summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-10-21 07:08:36 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-10-21 07:08:36 +0000
commit48aff82709769b098321c738f3444b9bdaa694c6 (patch)
treee00c7c43e2d9b603a5a6af576b1685e400410dee /app
parent879f5329ee916a948223f8f43d77fba4da6cd028 (diff)
downloadgitlab-ce-48aff82709769b098321c738f3444b9bdaa694c6.tar.gz
Add latest changes from gitlab-org/gitlab@13-5-stable-eev13.5.0-rc42
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue6
-rw-r--r--app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue8
-rw-r--r--app/assets/javascripts/alert_handler.js22
-rw-r--r--app/assets/javascripts/alert_management/components/alert_details.vue137
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_empty_state.vue31
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue63
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_table.vue456
-rw-r--r--app/assets/javascripts/alert_management/components/alert_metrics.vue2
-rw-r--r--app/assets/javascripts/alert_management/components/alert_sidebar.vue1
-rw-r--r--app/assets/javascripts/alert_management/components/alert_status.vue38
-rw-r--r--app/assets/javascripts/alert_management/components/alert_summary_row.vue18
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue8
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue60
-rw-r--r--app/assets/javascripts/alert_management/components/system_notes/system_note.vue27
-rw-r--r--app/assets/javascripts/alert_management/constants.js2
-rw-r--r--app/assets/javascripts/alert_management/details.js6
-rw-r--r--app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql5
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql4
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql4
-rw-r--r--app/assets/javascripts/alert_management/list.js35
-rw-r--r--app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue8
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue109
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue179
-rw-r--r--app/assets/javascripts/alerts_settings/constants.js24
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/components/app.vue30
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue64
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue215
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue143
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/constants.js5
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql4
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql4
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql34
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql76
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql13
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/index.js24
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/utils.js69
-rw-r--r--app/assets/javascripts/analytics/shared/components/metric_card.vue80
-rw-r--r--app/assets/javascripts/api.js99
-rw-r--r--app/assets/javascripts/awards_handler.js2
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue4
-rw-r--r--app/assets/javascripts/badges/components/badge_list_row.vue36
-rw-r--r--app/assets/javascripts/badges/components/badge_settings.vue30
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue5
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_dropdown.vue111
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_item.vue17
-rw-r--r--app/assets/javascripts/batch_comments/components/publish_button.vue20
-rw-r--r--app/assets/javascripts/batch_comments/components/review_bar.vue47
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js22
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js7
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js16
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js2
-rw-r--r--app/assets/javascripts/behaviors/autosize.js2
-rw-r--r--app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js21
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js25
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji.js4
-rw-r--r--app/assets/javascripts/behaviors/index.js3
-rw-r--r--app/assets/javascripts/behaviors/load_startup_css.js15
-rw-r--r--app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js1
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js96
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js7
-rw-r--r--app/assets/javascripts/blob/components/blob_edit_content.vue17
-rw-r--r--app/assets/javascripts/blob/components/blob_header_filepath.vue1
-rw-r--r--app/assets/javascripts/blob/components/eventhub.js3
-rw-r--r--app/assets/javascripts/blob/pipeline_tour_success_modal.vue48
-rw-r--r--app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue50
-rw-r--r--app/assets/javascripts/blob/suggest_web_ide_ci/index.js20
-rw-r--r--app/assets/javascripts/blob/viewer/index.js7
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js27
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js109
-rw-r--r--app/assets/javascripts/boards/boards_util.js41
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.vue104
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue18
-rw-r--r--app/assets/javascripts/boards/components/board_configuration_options.vue65
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue7
-rw-r--r--app/assets/javascripts/boards/components/board_delete.js30
-rw-r--r--app/assets/javascripts/boards/components/board_extra_actions.vue57
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue25
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue14
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue49
-rw-r--r--app/assets/javascripts/boards/components/board_list_new.vue166
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue33
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue9
-rw-r--r--app/assets/javascripts/boards/components/modal/tabs.vue34
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js32
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue6
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_editable_item.vue8
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue120
-rw-r--r--app/assets/javascripts/boards/ee_functions.js2
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js3
-rw-r--r--app/assets/javascripts/boards/icons/fullscreen_collapse.svg1
-rw-r--r--app/assets/javascripts/boards/icons/fullscreen_expand.svg1
-rw-r--r--app/assets/javascripts/boards/index.js108
-rw-r--r--app/assets/javascripts/boards/mixins/is_wip_limits.js7
-rw-r--r--app/assets/javascripts/boards/models/list.js3
-rw-r--r--app/assets/javascripts/boards/queries/board.mutation.graphql11
-rw-r--r--app/assets/javascripts/boards/queries/board_list_create.mutation.graphql20
-rw-r--r--app/assets/javascripts/boards/queries/board_lists.query.graphql28
-rw-r--r--app/assets/javascripts/boards/queries/group_board.query.graphql13
-rw-r--r--app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql15
-rw-r--r--app/assets/javascripts/boards/queries/lists_issues.query.graphql28
-rw-r--r--app/assets/javascripts/boards/queries/project_board.query.graphql13
-rw-r--r--app/assets/javascripts/boards/stores/actions.js159
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js63
-rw-r--r--app/assets/javascripts/boards/stores/getters.js15
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js6
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js89
-rw-r--r--app/assets/javascripts/boards/stores/state.js5
-rw-r--r--app/assets/javascripts/boards/toggle_focus.js13
-rw-r--r--app/assets/javascripts/breadcrumb.js9
-rw-r--r--app/assets/javascripts/build_artifacts.js18
-rw-r--r--app/assets/javascripts/ci_lint/components/ci_lint.vue112
-rw-r--r--app/assets/javascripts/ci_lint/components/ci_lint_results.vue115
-rw-r--r--app/assets/javascripts/ci_lint/components/ci_lint_results_param.vue26
-rw-r--r--app/assets/javascripts/ci_lint/components/ci_lint_results_value.vue81
-rw-r--r--app/assets/javascripts/ci_lint/components/ci_lint_warnings.vue69
-rw-r--r--app/assets/javascripts/ci_lint/graphql/mutations/lint_ci.mutation.graphql22
-rw-r--r--app/assets/javascripts/ci_lint/index.js47
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue143
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/index.js36
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue42
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue16
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue5
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue4
-rw-r--r--app/assets/javascripts/clusters/components/crossplane_provider_stack.vue18
-rw-r--r--app/assets/javascripts/clusters/components/fluentd_output_settings.vue35
-rw-r--r--app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue42
-rw-r--r--app/assets/javascripts/clusters/components/knative_domain_editor.vue27
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue8
-rw-r--r--app/assets/javascripts/clusters/forms/components/integration_form.vue4
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue33
-rw-r--r--app/assets/javascripts/clusters_list/components/node_error_help_text.vue53
-rw-r--r--app/assets/javascripts/clusters_list/constants.js43
-rw-r--r--app/assets/javascripts/clusters_list/index.js18
-rw-r--r--app/assets/javascripts/clusters_list/load_clusters.js18
-rw-r--r--app/assets/javascripts/clusters_list/store/actions.js2
-rw-r--r--app/assets/javascripts/code_navigation/index.js10
-rw-r--r--app/assets/javascripts/code_navigation/store/index.js11
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js7
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue126
-rw-r--r--app/assets/javascripts/commons/index.js1
-rw-r--r--app/assets/javascripts/commons/jquery.js4
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/dropdown.vue31
-rw-r--r--app/assets/javascripts/confirm_danger_modal.js10
-rw-r--r--app/assets/javascripts/confirm_modal.js12
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue5
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue299
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/index.js2
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/actions.js5
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js3
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/state.js1
-rw-r--r--app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue8
-rw-r--r--app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue23
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue16
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_test_component.vue19
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js6
-rw-r--r--app/assets/javascripts/cycle_analytics/svg/icon_branch.svg1
-rw-r--r--app/assets/javascripts/cycle_analytics/svg/icon_build_status.svg1
-rw-r--r--app/assets/javascripts/cycle_analytics/svg/icon_commit.svg1
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue18
-rw-r--r--app/assets/javascripts/design_management/components/delete_button.vue2
-rw-r--r--app/assets/javascripts/design_management/components/design_note_pin.vue14
-rw-r--r--app/assets/javascripts/design_management/components/design_overlay.vue4
-rw-r--r--app/assets/javascripts/design_management/components/design_presentation.vue4
-rw-r--r--app/assets/javascripts/design_management/components/design_scaler.vue2
-rw-r--r--app/assets/javascripts/design_management/components/design_sidebar.vue13
-rw-r--r--app/assets/javascripts/design_management/components/image.vue4
-rw-r--r--app/assets/javascripts/design_management/components/list/item.vue9
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/design_navigation.vue4
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/index.vue8
-rw-r--r--app/assets/javascripts/design_management/components/upload/design_dropzone.vue10
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql1
-rw-r--r--app/assets/javascripts/design_management/mixins/all_designs.js26
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue23
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue53
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js14
-rw-r--r--app/assets/javascripts/design_management/utils/design_management_utils.js4
-rw-r--r--app/assets/javascripts/design_management/utils/tracking.js28
-rw-r--r--app/assets/javascripts/diff_notes/components/comment_resolve_btn.js65
-rw-r--r--app/assets/javascripts/diff_notes/components/diff_note_avatars.js189
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js210
-rw-r--r--app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js28
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_btn.js145
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_count.js28
-rw-r--r--app/assets/javascripts/diff_notes/diff_notes_bundle.js72
-rw-r--r--app/assets/javascripts/diff_notes/icons/collapse_icon.svg1
-rw-r--r--app/assets/javascripts/diff_notes/mixins/discussion.js37
-rw-r--r--app/assets/javascripts/diff_notes/models/discussion.js99
-rw-r--r--app/assets/javascripts/diff_notes/models/note.js14
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js86
-rw-r--r--app/assets/javascripts/diff_notes/stores/comments.js56
-rw-r--r--app/assets/javascripts/diffs/components/app.vue4
-rw-r--r--app/assets/javascripts/diffs/components/collapsed_files_warning.vue2
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue129
-rw-r--r--app/assets/javascripts/diffs/components/commit_widget.vue13
-rw-r--r--app/assets/javascripts/diffs/components/compare_dropdown_layout.vue2
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue222
-rw-r--r--app/assets/javascripts/diffs/components/diff_row_utils.js85
-rw-r--r--app/assets/javascripts/diffs/components/diff_stats.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_table_cell.vue206
-rw-r--r--app/assets/javascripts/diffs/components/edit_button.vue64
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue91
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue8
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_table_row.vue178
-rw-r--r--app/assets/javascripts/diffs/diff_file.js12
-rw-r--r--app/assets/javascripts/diffs/i18n.js5
-rw-r--r--app/assets/javascripts/diffs/store/actions.js9
-rw-r--r--app/assets/javascripts/diffs/store/getters.js50
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js4
-rw-r--r--app/assets/javascripts/editor/constants.js1
-rw-r--r--app/assets/javascripts/editor/editor_lite.js37
-rw-r--r--app/assets/javascripts/emoji/index.js239
-rw-r--r--app/assets/javascripts/emoji/support/index.js8
-rw-r--r--app/assets/javascripts/environments/components/enable_review_app_button.vue109
-rw-r--r--app/assets/javascripts/environments/components/enable_review_app_modal.vue98
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue8
-rw-r--r--app/assets/javascripts/environments/components/environment_delete.vue8
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue8
-rw-r--r--app/assets/javascripts/environments/components/environment_stop.vue6
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue2
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue144
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue1
-rw-r--r--app/assets/javascripts/environments/components/stop_environment_modal.vue49
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue26
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace_entry.vue4
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/app.vue18
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue15
-rw-r--r--app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue258
-rw-r--r--app/assets/javascripts/feature_flags/components/edit_feature_flag.vue144
-rw-r--r--app/assets/javascripts/feature_flags/components/environments_dropdown.vue181
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue326
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_tab.vue108
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue271
-rw-r--r--app/assets/javascripts/feature_flags/components/form.vue606
-rw-r--r--app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue100
-rw-r--r--app/assets/javascripts/feature_flags/components/new_feature_flag.vue101
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/default.vue10
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue121
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue63
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/parameter_form_group.vue22
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue69
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/users_with_id.vue47
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy.vue206
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy_parameters.vue54
-rw-r--r--app/assets/javascripts/feature_flags/components/user_lists_table.vue122
-rw-r--r--app/assets/javascripts/feature_flags/constants.js54
-rw-r--r--app/assets/javascripts/feature_flags/edit.js41
-rw-r--r--app/assets/javascripts/feature_flags/index.js49
-rw-r--r--app/assets/javascripts/feature_flags/new.js39
-rw-r--r--app/assets/javascripts/feature_flags/store/edit/actions.js61
-rw-r--r--app/assets/javascripts/feature_flags/store/edit/index.js11
-rw-r--r--app/assets/javascripts/feature_flags/store/edit/mutation_types.js9
-rw-r--r--app/assets/javascripts/feature_flags/store/edit/mutations.js39
-rw-r--r--app/assets/javascripts/feature_flags/store/edit/state.js18
-rw-r--r--app/assets/javascripts/feature_flags/store/helpers.js213
-rw-r--r--app/assets/javascripts/feature_flags/store/index/actions.js97
-rw-r--r--app/assets/javascripts/feature_flags/store/index/index.js11
-rw-r--r--app/assets/javascripts/feature_flags/store/index/mutation_types.js22
-rw-r--r--app/assets/javascripts/feature_flags/store/index/mutations.js113
-rw-r--r--app/assets/javascripts/feature_flags/store/index/state.js18
-rw-r--r--app/assets/javascripts/feature_flags/store/new/actions.js37
-rw-r--r--app/assets/javascripts/feature_flags/store/new/index.js11
-rw-r--r--app/assets/javascripts/feature_flags/store/new/mutation_types.js3
-rw-r--r--app/assets/javascripts/feature_flags/store/new/mutations.js15
-rw-r--r--app/assets/javascripts/feature_flags/store/new/state.js6
-rw-r--r--app/assets/javascripts/feature_flags/utils.js66
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js107
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js21
-rw-r--r--app/assets/javascripts/filtered_search/constants.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js2
-rw-r--r--app/assets/javascripts/flash.js2
-rw-r--r--app/assets/javascripts/frequent_items/index.js82
-rw-r--r--app/assets/javascripts/frequent_items/utils.js2
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js108
-rw-r--r--app/assets/javascripts/gl_form.js15
-rw-r--r--app/assets/javascripts/grafana_integration/components/grafana_integration.vue8
-rw-r--r--app/assets/javascripts/group_label_subscription.js9
-rw-r--r--app/assets/javascripts/group_settings/components/shared_runners_form.vue139
-rw-r--r--app/assets/javascripts/group_settings/constants.js11
-rw-r--r--app/assets/javascripts/group_settings/mount_shared_runners.js15
-rw-r--r--app/assets/javascripts/groups/components/group_folder.vue6
-rw-r--r--app/assets/javascripts/groups/components/item_actions.vue2
-rw-r--r--app/assets/javascripts/groups/components/item_stats_value.vue10
-rw-r--r--app/assets/javascripts/groups/members/components/app.vue33
-rw-r--r--app/assets/javascripts/groups/members/constants.js5
-rw-r--r--app/assets/javascripts/groups/members/index.js13
-rw-r--r--app/assets/javascripts/groups/members/utils.js44
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue18
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue25
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue12
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue15
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/message_field.vue19
-rw-r--r--app/assets/javascripts/ide/components/editor_mode_dropdown.vue6
-rw-r--r--app/assets/javascripts/ide/components/file_templates/bar.vue6
-rw-r--r--app/assets/javascripts/ide/components/file_templates/dropdown.vue5
-rw-r--r--app/assets/javascripts/ide/components/ide.vue30
-rw-r--r--app/assets/javascripts/ide/components/ide_review.vue30
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue18
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue11
-rw-r--r--app/assets/javascripts/ide/components/ide_tree.vue32
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue28
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail.vue9
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue7
-rw-r--r--app/assets/javascripts/ide/components/jobs/stage.vue7
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue1
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/upload.vue4
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue44
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue19
-rw-r--r--app/assets/javascripts/ide/constants.js6
-rw-r--r--app/assets/javascripts/ide/index.js12
-rw-r--r--app/assets/javascripts/ide/lib/editor.js6
-rw-r--r--app/assets/javascripts/ide/lib/errors.js38
-rw-r--r--app/assets/javascripts/ide/lib/languages/README.md4
-rw-r--r--app/assets/javascripts/ide/lib/languages/hcl.js192
-rw-r--r--app/assets/javascripts/ide/lib/languages/index.js3
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js4
-rw-r--r--app/assets/javascripts/ide/stores/getters.js6
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js18
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/mutations.js4
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js15
-rw-r--r--app/assets/javascripts/ide/stores/utils.js23
-rw-r--r--app/assets/javascripts/ide/utils.js54
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue463
-rw-r--r--app/assets/javascripts/incidents/constants.js34
-rw-r--r--app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql15
-rw-r--r--app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql6
-rw-r--r--app/assets/javascripts/incidents/list.js11
-rw-r--r--app/assets/javascripts/incidents_settings/components/alerts_form.vue22
-rw-r--r--app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue3
-rw-r--r--app/assets/javascripts/incidents_settings/components/pagerduty_form.vue41
-rw-r--r--app/assets/javascripts/incidents_settings/index.js8
-rw-r--r--app/assets/javascripts/init_issuable_sidebar.js8
-rw-r--r--app/assets/javascripts/integrations/edit/components/confirmation_modal.vue60
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue26
-rw-r--r--app/assets/javascripts/invite_member/components/invite_member_modal.vue64
-rw-r--r--app/assets/javascripts/invite_member/components/invite_member_trigger.vue37
-rw-r--r--app/assets/javascripts/invite_member/constants.js2
-rw-r--r--app/assets/javascripts/invite_member/event_hub.js3
-rw-r--r--app/assets/javascripts/invite_member/init_invite_member_modal.js21
-rw-r--r--app/assets/javascripts/invite_member/init_invite_member_trigger.js16
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue224
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue38
-rw-r--r--app/assets/javascripts/invite_members/event_hub.js3
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js25
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_trigger.js20
-rw-r--r--app/assets/javascripts/issuable_context.js1
-rw-r--r--app/assets/javascripts/issuable_create/components/issuable_form.vue1
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_body.vue103
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_description.vue31
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_edit_form.vue135
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_header.vue120
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_show_root.vue98
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_title.vue96
-rw-r--r--app/assets/javascripts/issuable_show/constants.js5
-rw-r--r--app/assets/javascripts/issuable_show/event_hub.js3
-rw-r--r--app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue88
-rw-r--r--app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue23
-rw-r--r--app/assets/javascripts/issuable_sidebar/sidebar_bundle.js27
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description_template.vue11
-rw-r--r--app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql1
-rw-r--r--app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue35
-rw-r--r--app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue20
-rw-r--r--app/assets/javascripts/issue_show/incident.js4
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js2
-rw-r--r--app/assets/javascripts/issue_show/utils/parse_data.js10
-rw-r--r--app/assets/javascripts/issues_list/components/issuable.vue2
-rw-r--r--app/assets/javascripts/jira_connect.js7
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_form.vue2
-rw-r--r--app/assets/javascripts/jira_import/index.js2
-rw-r--r--app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql1
-rw-r--r--app/assets/javascripts/jira_import/utils/cache_update.js21
-rw-r--r--app/assets/javascripts/jobs/components/commit_block.vue33
-rw-r--r--app/assets/javascripts/jobs/components/job_container_item.vue8
-rw-r--r--app/assets/javascripts/jobs/components/log/line.vue32
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue15
-rw-r--r--app/assets/javascripts/jobs/components/trigger_block.vue11
-rw-r--r--app/assets/javascripts/jobs/store/utils.js4
-rw-r--r--app/assets/javascripts/label_manager.js5
-rw-r--r--app/assets/javascripts/labels_select.js22
-rw-r--r--app/assets/javascripts/layout_nav.js9
-rw-r--r--app/assets/javascripts/lib/dompurify.js53
-rw-r--r--app/assets/javascripts/lib/graphql.js1
-rw-r--r--app/assets/javascripts/lib/utils/axios_startup_calls.js2
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js1
-rw-r--r--app/assets/javascripts/lib/utils/constants.js1
-rw-r--r--app/assets/javascripts/lib/utils/csrf.js8
-rw-r--r--app/assets/javascripts/lib/utils/css_utils.js19
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js49
-rw-r--r--app/assets/javascripts/lib/utils/experimentation.js3
-rw-r--r--app/assets/javascripts/lib/utils/highlight.js2
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js14
-rw-r--r--app/assets/javascripts/lib/utils/rails_ujs.js20
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js27
-rw-r--r--app/assets/javascripts/lib/utils/unit_format/index.js20
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js42
-rw-r--r--app/assets/javascripts/logs/components/environment_logs.vue62
-rw-r--r--app/assets/javascripts/logs/components/log_simple_filters.vue42
-rw-r--r--app/assets/javascripts/main.js13
-rw-r--r--app/assets/javascripts/members.js38
-rw-r--r--app/assets/javascripts/merge_request.js74
-rw-r--r--app/assets/javascripts/merge_request_tabs.js18
-rw-r--r--app/assets/javascripts/milestone.js11
-rw-r--r--app/assets/javascripts/milestones/project_milestone_combobox.vue1
-rw-r--r--app/assets/javascripts/milestones/stores/actions.js58
-rw-r--r--app/assets/javascripts/milestones/stores/getters.js2
-rw-r--r--app/assets/javascripts/milestones/stores/index.js (renamed from app/assets/javascripts/registry/settings/store/index.js)12
-rw-r--r--app/assets/javascripts/milestones/stores/mutation_types.js13
-rw-r--r--app/assets/javascripts/milestones/stores/mutations.js44
-rw-r--r--app/assets/javascripts/milestones/stores/state.js14
-rw-r--r--app/assets/javascripts/mirrors/mirror_repos.js3
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget_form.vue19
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboards_dropdown.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/group_empty_state.vue8
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js2
-rw-r--r--app/assets/javascripts/namespaces/leave_by_url.js3
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue2
-rw-r--r--app/assets/javascripts/notebook/cells/output/html.vue2
-rw-r--r--app/assets/javascripts/notes.js45
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue15
-rw-r--r--app/assets/javascripts/notes/components/diff_discussion_header.vue7
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue105
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue81
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue5
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue3
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue13
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue5
-rw-r--r--app/assets/javascripts/notes/components/sort_discussion.vue53
-rw-r--r--app/assets/javascripts/notes/components/timeline_toggle.vue60
-rw-r--r--app/assets/javascripts/notes/components/toggle_replies_widget.vue10
-rw-r--r--app/assets/javascripts/notes/constants.js3
-rw-r--r--app/assets/javascripts/notes/index.js12
-rw-r--r--app/assets/javascripts/notes/stores/actions.js8
-rw-r--r--app/assets/javascripts/notes/stores/getters.js24
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js9
-rw-r--r--app/assets/javascripts/notes/timeline.js16
-rw-r--r--app/assets/javascripts/notes/utils.js12
-rw-r--r--app/assets/javascripts/notifications_dropdown.js7
-rw-r--r--app/assets/javascripts/operation_settings/components/metrics_settings.vue8
-rw-r--r--app/assets/javascripts/packages/details/components/additional_metadata.vue6
-rw-r--r--app/assets/javascripts/packages/details/components/composer_installation.vue8
-rw-r--r--app/assets/javascripts/packages/details/components/package_title.vue10
-rw-r--r--app/assets/javascripts/packages/details/store/getters.js24
-rw-r--r--app/assets/javascripts/packages/details/utils.js13
-rw-r--r--app/assets/javascripts/packages/list/coming_soon/helpers.js55
-rw-r--r--app/assets/javascripts/packages/list/coming_soon/packages_coming_soon.vue172
-rw-r--r--app/assets/javascripts/packages/list/coming_soon/queries/issues.graphql20
-rw-r--r--app/assets/javascripts/packages/list/components/package_title.vue47
-rw-r--r--app/assets/javascripts/packages/list/components/packages_list_app.vue73
-rw-r--r--app/assets/javascripts/packages/list/components/packages_sort.vue2
-rw-r--r--app/assets/javascripts/packages/list/constants.js36
-rw-r--r--app/assets/javascripts/packages/list/stores/mutations.js9
-rw-r--r--app/assets/javascripts/packages/list/utils.js5
-rw-r--r--app/assets/javascripts/packages/shared/components/package_list_row.vue16
-rw-r--r--app/assets/javascripts/packages/shared/components/package_path.vue71
-rw-r--r--app/assets/javascripts/packages/shared/components/publish_method.vue3
-rw-r--r--app/assets/javascripts/packages/shared/constants.js3
-rw-r--r--app/assets/javascripts/packages/shared/utils.js2
-rw-r--r--app/assets/javascripts/pages/admin/credentials/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/instance_statistics/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/keys/index.js5
-rw-r--r--app/assets/javascripts/pages/admin/users/keys/index.js5
-rw-r--r--app/assets/javascripts/pages/dashboard/merge_requests/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js29
-rw-r--r--app/assets/javascripts/pages/groups/issues/index.js22
-rw-r--r--app/assets/javascripts/pages/groups/registry/repositories/index.js12
-rw-r--r--app/assets/javascripts/pages/groups/security/credentials/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js3
-rw-r--r--app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue31
-rw-r--r--app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js54
-rw-r--r--app/assets/javascripts/pages/profiles/keys/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js47
-rw-r--r--app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js42
-rw-r--r--app/assets/javascripts/pages/projects/ci/lints/new/index.js16
-rw-r--r--app/assets/javascripts/pages/projects/ci/lints/show/index.js18
-rw-r--r--app/assets/javascripts/pages/projects/clusters/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/commit/pipelines/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/environments/index/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js19
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js22
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js18
-rw-r--r--app/assets/javascripts/pages/projects/graphs/charts/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue34
-rw-r--r--app/assets/javascripts/pages/projects/incidents/show/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js26
-rw-r--r--app/assets/javascripts/pages/projects/issues/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/service_desk/index.js22
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js32
-rw-r--r--app/assets/javascripts/pages/projects/issues/show/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/index/index.js19
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js11
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js16
-rw-r--r--app/assets/javascripts/pages/projects/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/packages/packages/index/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/packages/packages/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue11
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg1
-rw-r--r--app/assets/javascripts/pages/projects/registry/repositories/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue20
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue38
-rw-r--r--app/assets/javascripts/pages/projects/shared/web_ide_link/index.js31
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js60
-rw-r--r--app/assets/javascripts/pages/projects/tags/index/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/tags/remove_tag.js16
-rw-r--r--app/assets/javascripts/pages/projects/tags/show/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/tree/show/index.js10
-rw-r--r--app/assets/javascripts/pages/search/show/index.js4
-rw-r--r--app/assets/javascripts/pages/search/show/search.js16
-rw-r--r--app/assets/javascripts/pages/shared/wikis/wikis.js2
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue2
-rw-r--r--app/assets/javascripts/performance_bar/performance_bar_log.js25
-rw-r--r--app/assets/javascripts/performance_constants.js23
-rw-r--r--app/assets/javascripts/performance_utils.js10
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue234
-rw-r--r--app/assets/javascripts/pipeline_new/index.js2
-rw-r--r--app/assets/javascripts/pipelines/components/dag/constants.js6
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag.vue13
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag_graph.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue40
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue25
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_name_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue166
-rw-r--r--app/assets/javascripts/pipelines/components/legacy_header_component.vue132
-rw-r--r--app/assets/javascripts/pipelines/components/parsing_utils.js (renamed from app/assets/javascripts/pipelines/components/dag/parsing_utils.js)0
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js96
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue46
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue177
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue28
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue28
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue30
-rw-r--r--app/assets/javascripts/pipelines/constants.js11
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql30
-rw-r--r--app/assets/javascripts/pipelines/mixins/graph_component_mixin.js54
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js16
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_header.js41
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/utils.js12
-rw-r--r--app/assets/javascripts/pipelines/utils.js51
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue10
-rw-r--r--app/assets/javascripts/profile/gl_crop.js4
-rw-r--r--app/assets/javascripts/profile/profile.js11
-rw-r--r--app/assets/javascripts/project_find_file.js8
-rw-r--r--app/assets/javascripts/project_label_subscription.js4
-rw-r--r--app/assets/javascripts/projects/commit_box/info/index.js18
-rw-r--r--app/assets/javascripts/projects/commit_box/info/load_branches.js20
-rw-r--r--app/assets/javascripts/projects/commits/components/author_select.vue1
-rw-r--r--app/assets/javascripts/projects/commits/store/actions.js2
-rw-r--r--app/assets/javascripts/projects/default_project_templates.js4
-rw-r--r--app/assets/javascripts/projects/default_sample_data_templates.js12
-rw-r--r--app/assets/javascripts/projects/project_new.js4
-rw-r--r--app/assets/javascripts/projects/settings/access_dropdown.js138
-rw-r--r--app/assets/javascripts/projects/settings/constants.js7
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue12
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue46
-rw-r--r--app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue7
-rw-r--r--app/assets/javascripts/protected_branches/constants.js2
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js4
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue1
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue38
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue14
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue18
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue79
-rw-r--r--app/assets/javascripts/registry/explorer/constants/expiration_policies.js7
-rw-r--r--app/assets/javascripts/registry/explorer/pages/details.vue20
-rw-r--r--app/assets/javascripts/registry/settings/components/registry_settings_app.vue50
-rw-r--r--app/assets/javascripts/registry/settings/components/settings_form.vue145
-rw-r--r--app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql8
-rw-r--r--app/assets/javascripts/registry/settings/graphql/index.js14
-rw-r--r--app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql10
-rw-r--r--app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql9
-rw-r--r--app/assets/javascripts/registry/settings/graphql/utils/cache_update.js22
-rw-r--r--app/assets/javascripts/registry/settings/registry_settings_bundle.js13
-rw-r--r--app/assets/javascripts/registry/settings/store/actions.js30
-rw-r--r--app/assets/javascripts/registry/settings/store/getters.js26
-rw-r--r--app/assets/javascripts/registry/settings/store/mutation_types.js5
-rw-r--r--app/assets/javascripts/registry/settings/store/mutations.js29
-rw-r--r--app/assets/javascripts/registry/settings/store/state.js42
-rw-r--r--app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue32
-rw-r--r--app/assets/javascripts/registry/shared/constants.js24
-rw-r--r--app/assets/javascripts/registry/shared/utils.js27
-rw-r--r--app/assets/javascripts/related_issues/components/add_issuable_form.vue3
-rw-r--r--app/assets/javascripts/related_issues/components/issue_token.vue2
-rw-r--r--app/assets/javascripts/related_issues/components/related_issuable_input.vue3
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue42
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue49
-rw-r--r--app/assets/javascripts/releases/components/app_show.vue6
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue3
-rw-r--r--app/assets/javascripts/releases/components/evidence_block.vue6
-rw-r--r--app/assets/javascripts/releases/components/release_block.vue17
-rw-r--r--app/assets/javascripts/releases/components/release_block_assets.vue137
-rw-r--r--app/assets/javascripts/releases/components/release_block_author.vue42
-rw-r--r--app/assets/javascripts/releases/components/release_block_header.vue8
-rw-r--r--app/assets/javascripts/releases/components/release_block_metadata.vue90
-rw-r--r--app/assets/javascripts/releases/components/release_block_milestones.vue50
-rw-r--r--app/assets/javascripts/releases/components/release_skeleton_loader.vue51
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination_graphql.vue6
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination_rest.vue8
-rw-r--r--app/assets/javascripts/releases/constants.js2
-rw-r--r--app/assets/javascripts/releases/mount_edit.js3
-rw-r--r--app/assets/javascripts/releases/mount_new.js3
-rw-r--r--app/assets/javascripts/releases/mount_show.js3
-rw-r--r--app/assets/javascripts/releases/queries/all_releases.query.graphql74
-rw-r--r--app/assets/javascripts/releases/queries/one_release.query.graphql9
-rw-r--r--app/assets/javascripts/releases/queries/release.fragment.graphql62
-rw-r--r--app/assets/javascripts/releases/stores/getters.js11
-rw-r--r--app/assets/javascripts/releases/stores/index.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/actions.js37
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/mutation_types.js1
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/mutations.js5
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/state.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/list/actions.js111
-rw-r--r--app/assets/javascripts/releases/stores/modules/list/mutations.js7
-rw-r--r--app/assets/javascripts/releases/stores/modules/list/state.js3
-rw-r--r--app/assets/javascripts/releases/util.js54
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue26
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue32
-rw-r--r--app/assets/javascripts/repository/components/preview/index.vue5
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue5
-rw-r--r--app/assets/javascripts/repository/index.js86
-rw-r--r--app/assets/javascripts/repository/log_tree.js22
-rw-r--r--app/assets/javascripts/repository/utils/icon.js98
-rw-r--r--app/assets/javascripts/right_sidebar.js19
-rw-r--r--app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue100
-rw-r--r--app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js36
-rw-r--r--app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js42
-rw-r--r--app/assets/javascripts/search/dropdown_filter/index.js38
-rw-r--r--app/assets/javascripts/search/index.js9
-rw-r--r--app/assets/javascripts/search/state_filter/components/state_filter.vue94
-rw-r--r--app/assets/javascripts/search/state_filter/constants.js39
-rw-r--r--app/assets/javascripts/search/state_filter/index.js34
-rw-r--r--app/assets/javascripts/search/store/index.js12
-rw-r--r--app/assets/javascripts/search/store/state.js4
-rw-r--r--app/assets/javascripts/self_monitor/components/self_monitor_form.vue7
-rw-r--r--app/assets/javascripts/sentry/sentry_config.js2
-rw-r--r--app/assets/javascripts/sentry/wrapper.js26
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue12
-rw-r--r--app/assets/javascripts/serverless/components/missing_prometheus.vue8
-rw-r--r--app/assets/javascripts/serverless/components/url.vue4
-rw-r--r--app/assets/javascripts/shared/milestones/form.js1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.vue17
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue20
-rw-r--r--app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue50
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue18
-rw-r--r--app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue24
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue107
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue43
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue84
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue65
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewers.vue72
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue112
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue103
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue14
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue7
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js141
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js12
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js51
-rw-r--r--app/assets/javascripts/single_file_diff.js10
-rw-r--r--app/assets/javascripts/snippet/snippet_bundle.js30
-rw-r--r--app/assets/javascripts/snippet/snippet_edit.js35
-rw-r--r--app/assets/javascripts/snippet/snippet_embed.js37
-rw-r--r--app/assets/javascripts/snippet/snippet_show.js28
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue23
-rw-r--r--app/assets/javascripts/snippets/components/embed_dropdown.vue2
-rw-r--r--app/assets/javascripts/snippets/components/show.vue13
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue15
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_edit.vue10
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_view.vue7
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_edit.vue1
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue2
-rw-r--r--app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql26
-rw-r--r--app/assets/javascripts/snippets/index.js14
-rw-r--r--app/assets/javascripts/snippets/mixins/snippets.js11
-rw-r--r--app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql10
-rw-r--r--app/assets/javascripts/snippets/queries/snippet.query.graphql12
-rw-r--r--app/assets/javascripts/snippets/utils/blob.js15
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue104
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue85
-rw-r--r--app/assets/javascripts/static_site_editor/components/publish_toolbar.vue4
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/index.js3
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql5
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql1
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js25
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js36
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/typedefs.graphql6
-rw-r--r--app/assets/javascripts/static_site_editor/index.js12
-rw-r--r--app/assets/javascripts/static_site_editor/pages/home.vue52
-rw-r--r--app/assets/javascripts/static_site_editor/pages/success.vue81
-rw-r--r--app/assets/javascripts/static_site_editor/services/front_matterify.js73
-rw-r--r--app/assets/javascripts/static_site_editor/services/parse_source_file.js17
-rw-r--r--app/assets/javascripts/static_site_editor/services/submit_content_changes.js18
-rw-r--r--app/assets/javascripts/static_site_editor/services/templater.js2
-rw-r--r--app/assets/javascripts/task_list.js9
-rw-r--r--app/assets/javascripts/test_utils/simulate_drag.js2
-rw-r--r--app/assets/javascripts/tooltips/index.js10
-rw-r--r--app/assets/javascripts/tracking.js2
-rw-r--r--app/assets/javascripts/user_lists/components/add_user_modal.vue72
-rw-r--r--app/assets/javascripts/user_lists/components/edit_user_list.vue74
-rw-r--r--app/assets/javascripts/user_lists/components/new_user_list.vue50
-rw-r--r--app/assets/javascripts/user_lists/components/user_list.vue142
-rw-r--r--app/assets/javascripts/user_lists/components/user_list_form.vue97
-rw-r--r--app/assets/javascripts/user_lists/constants/edit.js6
-rw-r--r--app/assets/javascripts/user_lists/constants/show.js8
-rw-r--r--app/assets/javascripts/user_lists/store/edit/actions.js22
-rw-r--r--app/assets/javascripts/user_lists/store/edit/index.js11
-rw-r--r--app/assets/javascripts/user_lists/store/edit/mutation_types.js5
-rw-r--r--app/assets/javascripts/user_lists/store/edit/mutations.js19
-rw-r--r--app/assets/javascripts/user_lists/store/edit/state.js9
-rw-r--r--app/assets/javascripts/user_lists/store/new/actions.js15
-rw-r--r--app/assets/javascripts/user_lists/store/new/index.js11
-rw-r--r--app/assets/javascripts/user_lists/store/new/mutation_types.js3
-rw-r--r--app/assets/javascripts/user_lists/store/new/mutations.js10
-rw-r--r--app/assets/javascripts/user_lists/store/new/state.js5
-rw-r--r--app/assets/javascripts/user_lists/store/show/actions.js32
-rw-r--r--app/assets/javascripts/user_lists/store/show/index.js11
-rw-r--r--app/assets/javascripts/user_lists/store/show/mutation_types.js8
-rw-r--r--app/assets/javascripts/user_lists/store/show/mutations.js29
-rw-r--r--app/assets/javascripts/user_lists/store/show/state.js9
-rw-r--r--app/assets/javascripts/user_lists/store/utils.js5
-rw-r--r--app/assets/javascripts/user_popovers.js3
-rw-r--r--app/assets/javascripts/users_select/index.js38
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue17
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue24
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue34
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue31
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/i18n.js7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/actions_button.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/alert_details_table.vue49
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js16
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_modal.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue92
-rw-r--r--app/assets/javascripts/vue_shared/components/editor_lite.vue99
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js121
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/index.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types.js26
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutations.js109
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/state.js47
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_mentions.vue55
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue62
-rw-r--r--app/assets/javascripts/vue_shared/components/local_storage_sync.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue59
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue59
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue36
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue57
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue41
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue61
-rw-r--r--app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue34
-rw-r--r--app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue91
-rw-r--r--app/assets/javascripts/vue_shared/components/members/constants.js70
-rw-r--r--app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue70
-rw-r--r--app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue69
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/created_at.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/expires_at.vue66
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue57
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/member_source.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/members_table.vue110
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue64
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue70
-rw-r--r--app/assets/javascripts/vue_shared/components/members/utils.js19
-rw-r--r--app/assets/javascripts/vue_shared/components/modal_copy_button.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/navigation_tabs.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js21
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue313
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/utils.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/list_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/title_area.vue78
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js15
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue91
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js31
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue41
-rw-r--r--app/assets/javascripts/vue_shared/components/split_button.vue30
-rw-r--r--app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/timezone_dropdown.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/todo_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/toggle_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue92
-rw-r--r--app/assets/javascripts/vue_shared/directives/tooltip.js5
-rw-r--r--app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js4
-rw-r--r--app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue107
-rw-r--r--app/assets/javascripts/vuex_shared/modules/members/actions.js25
-rw-r--r--app/assets/javascripts/vuex_shared/modules/members/index.js6
-rw-r--r--app/assets/javascripts/vuex_shared/modules/members/mutation_types.js7
-rw-r--r--app/assets/javascripts/vuex_shared/modules/members/mutations.js33
-rw-r--r--app/assets/javascripts/vuex_shared/modules/members/state.js16
-rw-r--r--app/assets/javascripts/vuex_shared/modules/members/utils.js1
-rw-r--r--app/assets/javascripts/whats_new/components/app.vue100
-rw-r--r--app/assets/javascripts/whats_new/components/skeleton_loader.vue25
-rw-r--r--app/assets/javascripts/whats_new/index.js2
-rw-r--r--app/assets/javascripts/whats_new/store/actions.js12
-rw-r--r--app/assets/javascripts/whats_new/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/whats_new/store/mutations.js3
-rw-r--r--app/assets/javascripts/whats_new/store/state.js1
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss19
-rw-r--r--app/assets/stylesheets/application.scss19
-rw-r--r--app/assets/stylesheets/application_utilities.scss12
-rw-r--r--app/assets/stylesheets/application_utilities_dark.scss3
-rw-r--r--app/assets/stylesheets/components/design_management/design.scss74
-rw-r--r--app/assets/stylesheets/components/rich_content_editor.scss8
-rw-r--r--app/assets/stylesheets/components/whats_new.scss25
-rw-r--r--app/assets/stylesheets/fontawesome_custom.scss65
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/animations.scss3
-rw-r--r--app/assets/stylesheets/framework/buttons.scss6
-rw-r--r--app/assets/stylesheets/framework/common.scss8
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss27
-rw-r--r--app/assets/stylesheets/framework/diffs.scss (renamed from app/assets/stylesheets/pages/diff.scss)142
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss109
-rw-r--r--app/assets/stylesheets/framework/editor-lite.scss6
-rw-r--r--app/assets/stylesheets/framework/files.scss4
-rw-r--r--app/assets/stylesheets/framework/filters.scss29
-rw-r--r--app/assets/stylesheets/framework/header.scss12
-rw-r--r--app/assets/stylesheets/framework/icons.scss1
-rw-r--r--app/assets/stylesheets/framework/mixins.scss2
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss30
-rw-r--r--app/assets/stylesheets/framework/snippets.scss27
-rw-r--r--app/assets/stylesheets/framework/tables.scss21
-rw-r--r--app/assets/stylesheets/framework/typography.scss4
-rw-r--r--app/assets/stylesheets/framework/variables.scss5
-rw-r--r--app/assets/stylesheets/framework/wells.scss6
-rw-r--r--app/assets/stylesheets/lazy_bundles/cropper.css378
-rw-r--r--app/assets/stylesheets/mailer.scss13
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss95
-rw-r--r--app/assets/stylesheets/page_bundles/_pipeline_mixins.scss233
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss (renamed from app/assets/stylesheets/pages/boards.scss)64
-rw-r--r--app/assets/stylesheets/page_bundles/cycle_analytics.scss (renamed from app/assets/stylesheets/pages/cycle_analytics.scss)44
-rw-r--r--app/assets/stylesheets/page_bundles/dev_ops_report.scss (renamed from app/assets/stylesheets/pages/dev_ops_report.scss)60
-rw-r--r--app/assets/stylesheets/page_bundles/environments.scss (renamed from app/assets/stylesheets/pages/environments.scss)37
-rw-r--r--app/assets/stylesheets/page_bundles/error_tracking_details.scss (renamed from app/assets/stylesheets/pages/error_details.scss)8
-rw-r--r--app/assets/stylesheets/page_bundles/error_tracking_index.scss (renamed from app/assets/stylesheets/pages/error_list.scss)8
-rw-r--r--app/assets/stylesheets/page_bundles/experimental_separate_sign_up.scss96
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/_dark.scss12
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss14
-rw-r--r--app/assets/stylesheets/page_bundles/issues_list.scss45
-rw-r--r--app/assets/stylesheets/page_bundles/jira_connect.scss77
-rw-r--r--app/assets/stylesheets/page_bundles/jira_connect_users.scss13
-rw-r--r--app/assets/stylesheets/page_bundles/merge_conflicts.scss (renamed from app/assets/stylesheets/pages/merge_conflicts.scss)19
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss96
-rw-r--r--app/assets/stylesheets/page_bundles/milestone.scss (renamed from app/assets/stylesheets/pages/milestone.scss)16
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline.scss484
-rw-r--r--app/assets/stylesheets/page_bundles/pipelines.scss195
-rw-r--r--app/assets/stylesheets/page_bundles/reports.scss (renamed from app/assets/stylesheets/pages/reports.scss)46
-rw-r--r--app/assets/stylesheets/page_bundles/terminal.scss3
-rw-r--r--app/assets/stylesheets/page_bundles/wiki.scss (renamed from app/assets/stylesheets/pages/wiki.scss)16
-rw-r--r--app/assets/stylesheets/pages/alert_management/details.scss2
-rw-r--r--app/assets/stylesheets/pages/builds.scss11
-rw-r--r--app/assets/stylesheets/pages/commits.scss1
-rw-r--r--app/assets/stylesheets/pages/error_tracking_list.scss5
-rw-r--r--app/assets/stylesheets/pages/experience_level.scss29
-rw-r--r--app/assets/stylesheets/pages/experimental_separate_sign_up.scss60
-rw-r--r--app/assets/stylesheets/pages/graph.scss74
-rw-r--r--app/assets/stylesheets/pages/groups.scss72
-rw-r--r--app/assets/stylesheets/pages/incident_management_list.scss20
-rw-r--r--app/assets/stylesheets/pages/issuable.scss13
-rw-r--r--app/assets/stylesheets/pages/issues.scss45
-rw-r--r--app/assets/stylesheets/pages/issues/issues_list.scss5
-rw-r--r--app/assets/stylesheets/pages/labels.scss91
-rw-r--r--app/assets/stylesheets/pages/members.scss21
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss31
-rw-r--r--app/assets/stylesheets/pages/notes.scss50
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss1005
-rw-r--r--app/assets/stylesheets/pages/profile.scss21
-rw-r--r--app/assets/stylesheets/pages/projects.scss102
-rw-r--r--app/assets/stylesheets/pages/search.scss22
-rw-r--r--app/assets/stylesheets/pages/serverless.scss3
-rw-r--r--app/assets/stylesheets/pages/settings_ci_cd.scss4
-rw-r--r--app/assets/stylesheets/pages/tags.scss3
-rw-r--r--app/assets/stylesheets/pages/ui_dev_kit.scss17
-rw-r--r--app/assets/stylesheets/themes/_dark.scss3
-rw-r--r--app/assets/stylesheets/utilities.scss48
-rw-r--r--app/channels/application_cable/connection.rb6
-rw-r--r--app/controllers/abuse_reports_controller.rb2
-rw-r--r--app/controllers/admin/abuse_reports_controller.rb2
-rw-r--r--app/controllers/admin/appearances_controller.rb2
-rw-r--r--app/controllers/admin/application_settings_controller.rb21
-rw-r--r--app/controllers/admin/applications_controller.rb2
-rw-r--r--app/controllers/admin/background_jobs_controller.rb1
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb2
-rw-r--r--app/controllers/admin/ci/variables_controller.rb2
-rw-r--r--app/controllers/admin/cohorts_controller.rb2
-rw-r--r--app/controllers/admin/dashboard_controller.rb2
-rw-r--r--app/controllers/admin/deploy_keys_controller.rb2
-rw-r--r--app/controllers/admin/dev_ops_report_controller.rb2
-rw-r--r--app/controllers/admin/gitaly_servers_controller.rb2
-rw-r--r--app/controllers/admin/groups_controller.rb2
-rw-r--r--app/controllers/admin/health_check_controller.rb2
-rw-r--r--app/controllers/admin/hook_logs_controller.rb2
-rw-r--r--app/controllers/admin/hooks_controller.rb4
-rw-r--r--app/controllers/admin/identities_controller.rb2
-rw-r--r--app/controllers/admin/impersonation_tokens_controller.rb2
-rw-r--r--app/controllers/admin/impersonations_controller.rb2
-rw-r--r--app/controllers/admin/instance_review_controller.rb39
-rw-r--r--app/controllers/admin/instance_statistics_controller.rb2
-rw-r--r--app/controllers/admin/integrations_controller.rb5
-rw-r--r--app/controllers/admin/jobs_controller.rb2
-rw-r--r--app/controllers/admin/keys_controller.rb2
-rw-r--r--app/controllers/admin/labels_controller.rb2
-rw-r--r--app/controllers/admin/plan_limits_controller.rb2
-rw-r--r--app/controllers/admin/projects_controller.rb3
-rw-r--r--app/controllers/admin/requests_profiles_controller.rb2
-rw-r--r--app/controllers/admin/runner_projects_controller.rb2
-rw-r--r--app/controllers/admin/runners_controller.rb10
-rw-r--r--app/controllers/admin/serverless/domains_controller.rb2
-rw-r--r--app/controllers/admin/services_controller.rb2
-rw-r--r--app/controllers/admin/sessions_controller.rb9
-rw-r--r--app/controllers/admin/spam_logs_controller.rb2
-rw-r--r--app/controllers/admin/system_info_controller.rb2
-rw-r--r--app/controllers/admin/users_controller.rb23
-rw-r--r--app/controllers/application_controller.rb12
-rw-r--r--app/controllers/autocomplete_controller.rb6
-rw-r--r--app/controllers/boards/issues_controller.rb2
-rw-r--r--app/controllers/boards/lists_controller.rb4
-rw-r--r--app/controllers/clusters/base_controller.rb2
-rw-r--r--app/controllers/clusters/clusters_controller.rb29
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb21
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb (renamed from app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb)19
-rw-r--r--app/controllers/concerns/boards_actions.rb4
-rw-r--r--app/controllers/concerns/controller_with_feature_category.rb29
-rw-r--r--app/controllers/concerns/controller_with_feature_category/config.rb38
-rw-r--r--app/controllers/concerns/hooks_execution.rb15
-rw-r--r--app/controllers/concerns/integrations_actions.rb2
-rw-r--r--app/controllers/concerns/issuable_collections.rb10
-rw-r--r--app/controllers/concerns/issuable_collections_action.rb5
-rw-r--r--app/controllers/concerns/membership_actions.rb18
-rw-r--r--app/controllers/concerns/milestone_actions.rb14
-rw-r--r--app/controllers/concerns/multiple_boards_actions.rb6
-rw-r--r--app/controllers/concerns/redis_tracking.rb14
-rw-r--r--app/controllers/concerns/runner_setup_scripts.rb25
-rw-r--r--app/controllers/concerns/show_inherited_labels_checker.rb11
-rw-r--r--app/controllers/concerns/snippets_actions.rb40
-rw-r--r--app/controllers/concerns/wiki_actions.rb8
-rw-r--r--app/controllers/confirmations_controller.rb2
-rw-r--r--app/controllers/dashboard/groups_controller.rb2
-rw-r--r--app/controllers/dashboard/labels_controller.rb6
-rw-r--r--app/controllers/dashboard/milestones_controller.rb2
-rw-r--r--app/controllers/dashboard/projects_controller.rb2
-rw-r--r--app/controllers/dashboard/snippets_controller.rb2
-rw-r--r--app/controllers/dashboard/todos_controller.rb2
-rw-r--r--app/controllers/dashboard_controller.rb4
-rw-r--r--app/controllers/explore/groups_controller.rb2
-rw-r--r--app/controllers/explore/projects_controller.rb2
-rw-r--r--app/controllers/explore/snippets_controller.rb2
-rw-r--r--app/controllers/google_api/authorizations_controller.rb2
-rw-r--r--app/controllers/graphql_controller.rb16
-rw-r--r--app/controllers/groups/avatars_controller.rb2
-rw-r--r--app/controllers/groups/boards_controller.rb2
-rw-r--r--app/controllers/groups/children_controller.rb2
-rw-r--r--app/controllers/groups/deploy_tokens_controller.rb2
-rw-r--r--app/controllers/groups/group_links_controller.rb11
-rw-r--r--app/controllers/groups/group_members_controller.rb6
-rw-r--r--app/controllers/groups/imports_controller.rb2
-rw-r--r--app/controllers/groups/labels_controller.rb30
-rw-r--r--app/controllers/groups/milestones_controller.rb4
-rw-r--r--app/controllers/groups/packages_controller.rb2
-rw-r--r--app/controllers/groups/registry/repositories_controller.rb6
-rw-r--r--app/controllers/groups/releases_controller.rb2
-rw-r--r--app/controllers/groups/runners_controller.rb2
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb8
-rw-r--r--app/controllers/groups/settings/integrations_controller.rb2
-rw-r--r--app/controllers/groups/settings/repository_controller.rb2
-rw-r--r--app/controllers/groups/shared_projects_controller.rb2
-rw-r--r--app/controllers/groups/uploads_controller.rb2
-rw-r--r--app/controllers/groups/variables_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb26
-rw-r--r--app/controllers/help_controller.rb57
-rw-r--r--app/controllers/ide_controller.rb2
-rw-r--r--app/controllers/import/available_namespaces_controller.rb2
-rw-r--r--app/controllers/import/base_controller.rb1
-rw-r--r--app/controllers/import/bulk_imports_controller.rb109
-rw-r--r--app/controllers/import/fogbugz_controller.rb2
-rw-r--r--app/controllers/import/github_controller.rb2
-rw-r--r--app/controllers/import/gitlab_groups_controller.rb2
-rw-r--r--app/controllers/import/manifest_controller.rb11
-rw-r--r--app/controllers/invites_controller.rb59
-rw-r--r--app/controllers/jira_connect/application_controller.rb2
-rw-r--r--app/controllers/jira_connect/events_controller.rb4
-rw-r--r--app/controllers/jira_connect/users_controller.rb11
-rw-r--r--app/controllers/jwt_controller.rb2
-rw-r--r--app/controllers/notification_settings_controller.rb2
-rw-r--r--app/controllers/oauth/jira/authorizations_controller.rb2
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb5
-rw-r--r--app/controllers/passwords_controller.rb2
-rw-r--r--app/controllers/profiles/accounts_controller.rb2
-rw-r--r--app/controllers/profiles/active_sessions_controller.rb2
-rw-r--r--app/controllers/profiles/avatars_controller.rb2
-rw-r--r--app/controllers/profiles/chat_names_controller.rb2
-rw-r--r--app/controllers/profiles/emails_controller.rb2
-rw-r--r--app/controllers/profiles/gpg_keys_controller.rb2
-rw-r--r--app/controllers/profiles/groups_controller.rb2
-rw-r--r--app/controllers/profiles/keys_controller.rb2
-rw-r--r--app/controllers/profiles/notifications_controller.rb2
-rw-r--r--app/controllers/profiles/passwords_controller.rb2
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb2
-rw-r--r--app/controllers/profiles/preferences_controller.rb2
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb7
-rw-r--r--app/controllers/profiles/u2f_registrations_controller.rb2
-rw-r--r--app/controllers/profiles/webauthn_registrations_controller.rb2
-rw-r--r--app/controllers/profiles_controller.rb5
-rw-r--r--app/controllers/projects/alert_management_controller.rb3
-rw-r--r--app/controllers/projects/alerting/notifications_controller.rb2
-rw-r--r--app/controllers/projects/artifacts_controller.rb2
-rw-r--r--app/controllers/projects/autocomplete_sources_controller.rb5
-rw-r--r--app/controllers/projects/avatars_controller.rb2
-rw-r--r--app/controllers/projects/badges_controller.rb2
-rw-r--r--app/controllers/projects/blame_controller.rb2
-rw-r--r--app/controllers/projects/blob_controller.rb5
-rw-r--r--app/controllers/projects/boards_controller.rb2
-rw-r--r--app/controllers/projects/branches_controller.rb2
-rw-r--r--app/controllers/projects/build_artifacts_controller.rb2
-rw-r--r--app/controllers/projects/builds_controller.rb2
-rw-r--r--app/controllers/projects/ci/daily_build_group_report_results_controller.rb9
-rw-r--r--app/controllers/projects/ci/lints_controller.rb2
-rw-r--r--app/controllers/projects/commit_controller.rb2
-rw-r--r--app/controllers/projects/commits_controller.rb2
-rw-r--r--app/controllers/projects/compare_controller.rb2
-rw-r--r--app/controllers/projects/confluences_controller.rb2
-rw-r--r--app/controllers/projects/cycle_analytics/events_controller.rb2
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb2
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb2
-rw-r--r--app/controllers/projects/deploy_tokens_controller.rb2
-rw-r--r--app/controllers/projects/deployments_controller.rb2
-rw-r--r--app/controllers/projects/design_management/designs_controller.rb2
-rw-r--r--app/controllers/projects/discussions_controller.rb2
-rw-r--r--app/controllers/projects/environments/prometheus_api_controller.rb2
-rw-r--r--app/controllers/projects/environments/sample_metrics_controller.rb2
-rw-r--r--app/controllers/projects/environments_controller.rb2
-rw-r--r--app/controllers/projects/error_tracking/base_controller.rb2
-rw-r--r--app/controllers/projects/error_tracking/projects_controller.rb2
-rw-r--r--app/controllers/projects/feature_flags_clients_controller.rb29
-rw-r--r--app/controllers/projects/feature_flags_controller.rb174
-rw-r--r--app/controllers/projects/feature_flags_user_lists_controller.rb23
-rw-r--r--app/controllers/projects/find_file_controller.rb2
-rw-r--r--app/controllers/projects/forks_controller.rb2
-rw-r--r--app/controllers/projects/grafana_api_controller.rb2
-rw-r--r--app/controllers/projects/graphs_controller.rb3
-rw-r--r--app/controllers/projects/group_links_controller.rb15
-rw-r--r--app/controllers/projects/hook_logs_controller.rb2
-rw-r--r--app/controllers/projects/hooks_controller.rb4
-rw-r--r--app/controllers/projects/import/jira_controller.rb2
-rw-r--r--app/controllers/projects/imports_controller.rb2
-rw-r--r--app/controllers/projects/incident_management/pager_duty_incidents_controller.rb2
-rw-r--r--app/controllers/projects/incidents_controller.rb39
-rw-r--r--app/controllers/projects/issue_links_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb24
-rw-r--r--app/controllers/projects/jobs_controller.rb55
-rw-r--r--app/controllers/projects/labels_controller.rb5
-rw-r--r--app/controllers/projects/logs_controller.rb2
-rw-r--r--app/controllers/projects/mattermosts_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests/application_controller.rb3
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb3
-rw-r--r--app/controllers/projects/merge_requests/drafts_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests_controller.rb29
-rw-r--r--app/controllers/projects/metrics/dashboards/builder_controller.rb2
-rw-r--r--app/controllers/projects/metrics_dashboard_controller.rb2
-rw-r--r--app/controllers/projects/milestones_controller.rb6
-rw-r--r--app/controllers/projects/mirrors_controller.rb2
-rw-r--r--app/controllers/projects/network_controller.rb2
-rw-r--r--app/controllers/projects/notes_controller.rb2
-rw-r--r--app/controllers/projects/packages/package_files_controller.rb2
-rw-r--r--app/controllers/projects/packages/packages_controller.rb9
-rw-r--r--app/controllers/projects/pages_controller.rb2
-rw-r--r--app/controllers/projects/pages_domains_controller.rb2
-rw-r--r--app/controllers/projects/performance_monitoring/dashboards_controller.rb2
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb2
-rw-r--r--app/controllers/projects/pipelines/application_controller.rb2
-rw-r--r--app/controllers/projects/pipelines_controller.rb18
-rw-r--r--app/controllers/projects/pipelines_settings_controller.rb2
-rw-r--r--app/controllers/projects/product_analytics_controller.rb2
-rw-r--r--app/controllers/projects/project_members_controller.rb6
-rw-r--r--app/controllers/projects/prometheus/alerts_controller.rb2
-rw-r--r--app/controllers/projects/prometheus/metrics_controller.rb2
-rw-r--r--app/controllers/projects/protected_refs_controller.rb4
-rw-r--r--app/controllers/projects/raw_controller.rb2
-rw-r--r--app/controllers/projects/refs_controller.rb2
-rw-r--r--app/controllers/projects/registry/application_controller.rb2
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb6
-rw-r--r--app/controllers/projects/registry/tags_controller.rb9
-rw-r--r--app/controllers/projects/releases/evidences_controller.rb2
-rw-r--r--app/controllers/projects/releases_controller.rb14
-rw-r--r--app/controllers/projects/repositories_controller.rb2
-rw-r--r--app/controllers/projects/runner_projects_controller.rb2
-rw-r--r--app/controllers/projects/runners_controller.rb6
-rw-r--r--app/controllers/projects/serverless/functions_controller.rb2
-rw-r--r--app/controllers/projects/service_desk_controller.rb2
-rw-r--r--app/controllers/projects/services_controller.rb4
-rw-r--r--app/controllers/projects/settings/access_tokens_controller.rb2
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb25
-rw-r--r--app/controllers/projects/settings/integrations_controller.rb2
-rw-r--r--app/controllers/projects/settings/operations_controller.rb37
-rw-r--r--app/controllers/projects/settings/repository_controller.rb5
-rw-r--r--app/controllers/projects/snippets/application_controller.rb2
-rw-r--r--app/controllers/projects/snippets_controller.rb25
-rw-r--r--app/controllers/projects/starrers_controller.rb2
-rw-r--r--app/controllers/projects/static_site_editor_controller.rb24
-rw-r--r--app/controllers/projects/tags/releases_controller.rb2
-rw-r--r--app/controllers/projects/tags_controller.rb26
-rw-r--r--app/controllers/projects/templates_controller.rb2
-rw-r--r--app/controllers/projects/todos_controller.rb2
-rw-r--r--app/controllers/projects/tracings_controller.rb28
-rw-r--r--app/controllers/projects/tree_controller.rb2
-rw-r--r--app/controllers/projects/triggers_controller.rb2
-rw-r--r--app/controllers/projects/uploads_controller.rb2
-rw-r--r--app/controllers/projects/usage_ping_controller.rb2
-rw-r--r--app/controllers/projects/variables_controller.rb2
-rw-r--r--app/controllers/projects/web_ide_schemas_controller.rb2
-rw-r--r--app/controllers/projects/web_ide_terminals_controller.rb2
-rw-r--r--app/controllers/projects/wikis_controller.rb2
-rw-r--r--app/controllers/projects_controller.rb23
-rw-r--r--app/controllers/registrations/experience_levels_controller.rb10
-rw-r--r--app/controllers/registrations_controller.rb52
-rw-r--r--app/controllers/repositories/git_http_client_controller.rb2
-rw-r--r--app/controllers/runner_setup_controller.rb9
-rw-r--r--app/controllers/search_controller.rb8
-rw-r--r--app/controllers/sent_notifications_controller.rb2
-rw-r--r--app/controllers/sessions_controller.rb9
-rw-r--r--app/controllers/snippets/application_controller.rb2
-rw-r--r--app/controllers/snippets/notes_controller.rb2
-rw-r--r--app/controllers/snippets_controller.rb27
-rw-r--r--app/controllers/uploads_controller.rb2
-rw-r--r--app/controllers/user_callouts_controller.rb2
-rw-r--r--app/controllers/users/terms_controller.rb2
-rw-r--r--app/controllers/users_controller.rb4
-rw-r--r--app/controllers/whats_new_controller.rb25
-rw-r--r--app/finders/README.md11
-rw-r--r--app/finders/alert_management/alerts_finder.rb14
-rw-r--r--app/finders/ci/jobs_finder.rb4
-rw-r--r--app/finders/concerns/time_frame_filter.rb7
-rw-r--r--app/finders/environment_names_finder.rb47
-rw-r--r--app/finders/group_labels_finder.rb29
-rw-r--r--app/finders/group_members_finder.rb28
-rw-r--r--app/finders/groups_finder.rb12
-rw-r--r--app/finders/issuable_finder.rb18
-rw-r--r--app/finders/keys_finder.rb2
-rw-r--r--app/finders/merge_requests/by_approvals_finder.rb93
-rw-r--r--app/finders/merge_requests_finder.rb63
-rw-r--r--app/finders/milestones_finder.rb3
-rw-r--r--app/finders/packages/generic/package_finder.rb22
-rw-r--r--app/finders/projects_finder.rb13
-rw-r--r--app/finders/releases_finder.rb10
-rw-r--r--app/graphql/batch_loaders/merge_request_diff_summary_batch_loader.rb29
-rw-r--r--app/graphql/mutations/award_emojis/base.rb7
-rw-r--r--app/graphql/mutations/base_mutation.rb1
-rw-r--r--app/graphql/mutations/boards/create.rb78
-rw-r--r--app/graphql/mutations/boards/lists/destroy.rb41
-rw-r--r--app/graphql/mutations/ci/base.rb7
-rw-r--r--app/graphql/mutations/concerns/mutations/spammable_mutation_fields.rb24
-rw-r--r--app/graphql/mutations/design_management/move.rb11
-rw-r--r--app/graphql/mutations/discussions/toggle_resolve.rb7
-rw-r--r--app/graphql/mutations/issues/common_mutation_arguments.rb28
-rw-r--r--app/graphql/mutations/issues/create.rb109
-rw-r--r--app/graphql/mutations/issues/move.rb33
-rw-r--r--app/graphql/mutations/issues/update.rb43
-rw-r--r--app/graphql/mutations/merge_requests/set_milestone.rb2
-rw-r--r--app/graphql/mutations/metrics/dashboard/annotations/create.rb19
-rw-r--r--app/graphql/mutations/notes/base.rb19
-rw-r--r--app/graphql/mutations/notes/create/base.rb11
-rw-r--r--app/graphql/mutations/notes/create/note.rb8
-rw-r--r--app/graphql/mutations/notes/destroy.rb8
-rw-r--r--app/graphql/mutations/notes/update/base.rb2
-rw-r--r--app/graphql/mutations/notes/update/image_diff_note.rb4
-rw-r--r--app/graphql/mutations/notes/update/note.rb4
-rw-r--r--app/graphql/mutations/snippets/create.rb29
-rw-r--r--app/graphql/mutations/snippets/update.rb22
-rw-r--r--app/graphql/mutations/todos/base.rb7
-rw-r--r--app/graphql/mutations/todos/mark_done.rb2
-rw-r--r--app/graphql/mutations/todos/restore.rb2
-rw-r--r--app/graphql/mutations/todos/restore_many.rb18
-rw-r--r--app/graphql/queries/repository/path_last_commit.query.graphql (renamed from app/assets/javascripts/repository/queries/path_last_commit.query.graphql)9
-rw-r--r--app/graphql/resolvers/alert_management/alert_resolver.rb4
-rw-r--r--app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb4
-rw-r--r--app/graphql/resolvers/assigned_merge_requests_resolver.rb2
-rw-r--r--app/graphql/resolvers/authored_merge_requests_resolver.rb2
-rw-r--r--app/graphql/resolvers/base_resolver.rb3
-rw-r--r--app/graphql/resolvers/board_list_issues_resolver.rb2
-rw-r--r--app/graphql/resolvers/board_lists_resolver.rb10
-rw-r--r--app/graphql/resolvers/board_resolver.rb27
-rw-r--r--app/graphql/resolvers/boards_resolver.rb2
-rw-r--r--app/graphql/resolvers/ci/runner_platforms_resolver.rb30
-rw-r--r--app/graphql/resolvers/concerns/group_issuable_resolver.rb14
-rw-r--r--app/graphql/resolvers/concerns/issue_resolver_arguments.rb6
-rw-r--r--app/graphql/resolvers/concerns/looks_ahead.rb4
-rw-r--r--app/graphql/resolvers/concerns/resolves_merge_requests.rb9
-rw-r--r--app/graphql/resolvers/concerns/time_frame_arguments.rb20
-rw-r--r--app/graphql/resolvers/group_issues_resolver.rb7
-rw-r--r--app/graphql/resolvers/group_merge_requests_resolver.rb25
-rw-r--r--app/graphql/resolvers/merge_requests_resolver.rb12
-rw-r--r--app/graphql/resolvers/milestones_resolver.rb27
-rw-r--r--app/graphql/resolvers/project_merge_requests_resolver.rb8
-rw-r--r--app/graphql/resolvers/projects/jira_projects_resolver.rb2
-rw-r--r--app/graphql/resolvers/projects_resolver.rb16
-rw-r--r--app/graphql/resolvers/snippets/blobs_resolver.rb40
-rw-r--r--app/graphql/resolvers/terraform/states_resolver.rb23
-rw-r--r--app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb16
-rw-r--r--app/graphql/types/alert_management/alert_status_counts_type.rb4
-rw-r--r--app/graphql/types/alert_management/alert_type.rb8
-rw-r--r--app/graphql/types/alert_management/status_enum.rb4
-rw-r--r--app/graphql/types/base_argument.rb14
-rw-r--r--app/graphql/types/base_field.rb2
-rw-r--r--app/graphql/types/board_list_type.rb7
-rw-r--r--app/graphql/types/ci/detailed_status_type.rb47
-rw-r--r--app/graphql/types/ci/group_type.rb3
-rw-r--r--app/graphql/types/ci/job_type.rb5
-rw-r--r--app/graphql/types/ci/runner_architecture_type.rb15
-rw-r--r--app/graphql/types/ci/runner_platform_type.rb17
-rw-r--r--app/graphql/types/ci/stage_type.rb3
-rw-r--r--app/graphql/types/ci/status_action_type.rb25
-rw-r--r--app/graphql/types/date_type.rb22
-rw-r--r--app/graphql/types/design_management/design_collection_copy_state_enum.rb27
-rw-r--r--app/graphql/types/design_management/design_collection_type.rb4
-rw-r--r--app/graphql/types/design_management/design_type.rb2
-rw-r--r--app/graphql/types/environment_type.rb8
-rw-r--r--app/graphql/types/global_id_type.rb16
-rw-r--r--app/graphql/types/group_type.rb10
-rw-r--r--app/graphql/types/issue_sort_enum.rb2
-rw-r--r--app/graphql/types/issue_state_event_enum.rb11
-rw-r--r--app/graphql/types/issue_type.rb21
-rw-r--r--app/graphql/types/label_type.rb2
-rw-r--r--app/graphql/types/merge_request_type.rb30
-rw-r--r--app/graphql/types/mutation_type.rb5
-rw-r--r--app/graphql/types/notes/noteable_type.rb32
-rw-r--r--app/graphql/types/package_type_enum.rb8
-rw-r--r--app/graphql/types/project_member_type.rb7
-rw-r--r--app/graphql/types/project_type.rb8
-rw-r--r--app/graphql/types/query_type.rb19
-rw-r--r--app/graphql/types/range_input_type.rb29
-rw-r--r--app/graphql/types/root_storage_statistics_type.rb1
-rw-r--r--app/graphql/types/snippet_type.rb19
-rw-r--r--app/graphql/types/sort_enum.rb15
-rw-r--r--app/graphql/types/terraform/state_type.rb37
-rw-r--r--app/graphql/types/timeframe_input_type.rb10
-rw-r--r--app/helpers/analytics/navbar_helper.rb2
-rw-r--r--app/helpers/analytics/unique_visits_helper.rb3
-rw-r--r--app/helpers/application_helper.rb24
-rw-r--r--app/helpers/application_settings_helper.rb14
-rw-r--r--app/helpers/avatars_helper.rb2
-rw-r--r--app/helpers/blob_helper.rb26
-rw-r--r--app/helpers/boards_helper.rb22
-rw-r--r--app/helpers/ci/runners_helper.rb8
-rw-r--r--app/helpers/clusters_helper.rb20
-rw-r--r--app/helpers/container_expiration_policies_helper.rb5
-rw-r--r--app/helpers/dropdowns_helper.rb2
-rw-r--r--app/helpers/emails_helper.rb20
-rw-r--r--app/helpers/events_helper.rb16
-rw-r--r--app/helpers/external_link_helper.rb2
-rw-r--r--app/helpers/feature_flags_helper.rb19
-rw-r--r--app/helpers/form_helper.rb26
-rw-r--r--app/helpers/gitlab_routing_helper.rb12
-rw-r--r--app/helpers/gitpod_helper.rb10
-rw-r--r--app/helpers/groups/group_members_helper.rb42
-rw-r--r--app/helpers/icons_helper.rb20
-rw-r--r--app/helpers/invite_members_helper.rb21
-rw-r--r--app/helpers/issuables_helper.rb58
-rw-r--r--app/helpers/issues_helper.rb15
-rw-r--r--app/helpers/labels_helper.rb31
-rw-r--r--app/helpers/merge_requests_helper.rb4
-rw-r--r--app/helpers/mirror_helper.rb6
-rw-r--r--app/helpers/namespaces_helper.rb2
-rw-r--r--app/helpers/nav_helper.rb3
-rw-r--r--app/helpers/operations_helper.rb2
-rw-r--r--app/helpers/packages_helper.rb22
-rw-r--r--app/helpers/page_layout_helper.rb8
-rw-r--r--app/helpers/pagination_helper.rb10
-rw-r--r--app/helpers/preferences_helper.rb4
-rw-r--r--app/helpers/projects/alert_management_helper.rb4
-rw-r--r--app/helpers/projects/incidents_helper.rb7
-rw-r--r--app/helpers/projects_helper.rb32
-rw-r--r--app/helpers/releases_helper.rb9
-rw-r--r--app/helpers/reminder_emails_helper.rb76
-rw-r--r--app/helpers/search_helper.rb51
-rw-r--r--app/helpers/services_helper.rb6
-rw-r--r--app/helpers/snippets_helper.rb25
-rw-r--r--app/helpers/ssh_keys_helper.rb18
-rw-r--r--app/helpers/startupjs_helper.rb17
-rw-r--r--app/helpers/suggest_pipeline_helper.rb2
-rw-r--r--app/helpers/system_note_helper.rb6
-rw-r--r--app/helpers/tags_helper.rb9
-rw-r--r--app/helpers/timeboxes_helper.rb4
-rw-r--r--app/helpers/todos_helper.rb9
-rw-r--r--app/helpers/tree_helper.rb45
-rw-r--r--app/helpers/user_callouts_helper.rb6
-rw-r--r--app/helpers/users_helper.rb13
-rw-r--r--app/helpers/visibility_level_helper.rb17
-rw-r--r--app/helpers/web_ide_button_helper.rb61
-rw-r--r--app/helpers/webpack_helper.rb14
-rw-r--r--app/helpers/whats_new_helper.rb27
-rw-r--r--app/helpers/wiki_helper.rb3
-rw-r--r--app/mailers/abuse_report_mailer.rb4
-rw-r--r--app/mailers/emails/issues.rb2
-rw-r--r--app/mailers/emails/members.rb32
-rw-r--r--app/mailers/emails/merge_requests.rb25
-rw-r--r--app/mailers/emails/projects.rb10
-rw-r--r--app/mailers/notify.rb2
-rw-r--r--app/models/alert_management/alert.rb80
-rw-r--r--app/models/alert_management/http_integration.rb41
-rw-r--r--app/models/analytics/instance_statistics/measurement.rb22
-rw-r--r--app/models/application_record.rb14
-rw-r--r--app/models/application_setting.rb16
-rw-r--r--app/models/application_setting/term.rb2
-rw-r--r--app/models/application_setting_implementation.rb2
-rw-r--r--app/models/audit_event.rb18
-rw-r--r--app/models/authentication_event.rb10
-rw-r--r--app/models/blob_viewer/balsamiq.rb2
-rw-r--r--app/models/blob_viewer/markup.rb10
-rw-r--r--app/models/blob_viewer/pdf.rb2
-rw-r--r--app/models/blob_viewer/sketch.rb2
-rw-r--r--app/models/bulk_import.rb16
-rw-r--r--app/models/bulk_imports/configuration.rb20
-rw-r--r--app/models/bulk_imports/entity.rb43
-rw-r--r--app/models/ci/bridge.rb30
-rw-r--r--app/models/ci/build.rb45
-rw-r--r--app/models/ci/build_pending_state.rb6
-rw-r--r--app/models/ci/build_trace_chunk.rb104
-rw-r--r--app/models/ci/build_trace_chunks/database.rb2
-rw-r--r--app/models/ci/deleted_object.rb37
-rw-r--r--app/models/ci/job_artifact.rb32
-rw-r--r--app/models/ci/pipeline.rb46
-rw-r--r--app/models/ci_platform_metric.rb2
-rw-r--r--app/models/clusters/agent.rb1
-rw-r--r--app/models/clusters/applications/fluentd.rb6
-rw-r--r--app/models/clusters/applications/ingress.rb7
-rw-r--r--app/models/clusters/applications/prometheus.rb8
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/platforms/kubernetes.rb3
-rw-r--r--app/models/commit.rb32
-rw-r--r--app/models/commit_status.rb10
-rw-r--r--app/models/concerns/approvable_base.rb24
-rw-r--r--app/models/concerns/avatarable.rb15
-rw-r--r--app/models/concerns/checksummable.rb8
-rw-r--r--app/models/concerns/counter_attribute.rb29
-rw-r--r--app/models/concerns/has_repository.rb10
-rw-r--r--app/models/concerns/has_user_type.rb6
-rw-r--r--app/models/concerns/integration.rb4
-rw-r--r--app/models/concerns/issuable.rb11
-rw-r--r--app/models/concerns/issue_available_features.rb23
-rw-r--r--app/models/concerns/mentionable.rb16
-rw-r--r--app/models/concerns/presentable.rb4
-rw-r--r--app/models/concerns/reactive_caching.rb5
-rw-r--r--app/models/concerns/reactive_service.rb1
-rw-r--r--app/models/concerns/referable.rb2
-rw-r--r--app/models/concerns/relative_positioning.rb43
-rw-r--r--app/models/concerns/shardable.rb4
-rw-r--r--app/models/concerns/timebox.rb26
-rw-r--r--app/models/concerns/update_project_statistics.rb5
-rw-r--r--app/models/container_expiration_policy.rb11
-rw-r--r--app/models/container_repository.rb8
-rw-r--r--app/models/data_list.rb10
-rw-r--r--app/models/deploy_token.rb14
-rw-r--r--app/models/deployment.rb34
-rw-r--r--app/models/deployment_merge_request.rb21
-rw-r--r--app/models/design_management/design.rb10
-rw-r--r--app/models/design_management/design_at_version.rb4
-rw-r--r--app/models/design_management/design_collection.rb1
-rw-r--r--app/models/diff_viewer/rich.rb2
-rw-r--r--app/models/environment.rb11
-rw-r--r--app/models/environment_status.rb8
-rw-r--r--app/models/event.rb14
-rw-r--r--app/models/global_label.rb32
-rw-r--r--app/models/group.rb120
-rw-r--r--app/models/group_import_state.rb3
-rw-r--r--app/models/incident_management/project_incident_management_setting.rb2
-rw-r--r--app/models/issuable_severity.rb7
-rw-r--r--app/models/issue.rb18
-rw-r--r--app/models/issue_assignee.rb1
-rw-r--r--app/models/issue_email_participant.rb13
-rw-r--r--app/models/iteration.rb16
-rw-r--r--app/models/member.rb13
-rw-r--r--app/models/merge_request.rb36
-rw-r--r--app/models/merge_request_context_commit.rb4
-rw-r--r--app/models/merge_request_diff.rb25
-rw-r--r--app/models/milestone.rb4
-rw-r--r--app/models/milestone_release.rb4
-rw-r--r--app/models/namespace.rb57
-rw-r--r--app/models/namespace_setting.rb19
-rw-r--r--app/models/note.rb2
-rw-r--r--app/models/notification_reason.rb2
-rw-r--r--app/models/notification_recipient.rb2
-rw-r--r--app/models/notification_setting.rb1
-rw-r--r--app/models/operations/feature_flags/strategy.rb33
-rw-r--r--app/models/packages/event.rb25
-rw-r--r--app/models/packages/package.rb14
-rw-r--r--app/models/pages_deployment.rb14
-rw-r--r--app/models/postgresql/replication_slot.rb4
-rw-r--r--app/models/preloaders/merge_request_diff_preloader.rb27
-rw-r--r--app/models/project.rb57
-rw-r--r--app/models/project_pages_metadatum.rb1
-rw-r--r--app/models/project_repository_storage_move.rb10
-rw-r--r--app/models/project_services/chat_message/deployment_message.rb10
-rw-r--r--app/models/project_services/chat_message/issue_message.rb10
-rw-r--r--app/models/project_services/confluence_service.rb2
-rw-r--r--app/models/project_services/drone_ci_service.rb4
-rw-r--r--app/models/project_services/packagist_service.rb2
-rw-r--r--app/models/project_statistics.rb31
-rw-r--r--app/models/project_tracing_setting.rb15
-rw-r--r--app/models/project_wiki.rb3
-rw-r--r--app/models/prometheus_alert.rb2
-rw-r--r--app/models/prometheus_metric.rb2
-rw-r--r--app/models/release.rb17
-rw-r--r--app/models/repository.rb24
-rw-r--r--app/models/resource_label_event.rb11
-rw-r--r--app/models/resource_state_event.rb25
-rw-r--r--app/models/resource_timebox_event.rb15
-rw-r--r--app/models/resource_weight_event.rb7
-rw-r--r--app/models/service.rb29
-rw-r--r--app/models/service_list.rb12
-rw-r--r--app/models/snippet.rb8
-rw-r--r--app/models/snippet_input_action_collection.rb6
-rw-r--r--app/models/snippet_repository.rb2
-rw-r--r--app/models/snippet_statistics.rb2
-rw-r--r--app/models/system_note_metadata.rb4
-rw-r--r--app/models/terraform/state.rb68
-rw-r--r--app/models/terraform/state_version.rb6
-rw-r--r--app/models/todo.rb2
-rw-r--r--app/models/u2f_registration.rb22
-rw-r--r--app/models/user.rb50
-rw-r--r--app/models/user_callout.rb2
-rw-r--r--app/models/user_interacted_project.rb2
-rw-r--r--app/models/user_preference.rb3
-rw-r--r--app/models/vulnerability.rb10
-rw-r--r--app/models/wiki.rb46
-rw-r--r--app/models/wiki_directory.rb39
-rw-r--r--app/models/wiki_page.rb23
-rw-r--r--app/policies/base_policy.rb5
-rw-r--r--app/policies/ci/bridge_policy.rb12
-rw-r--r--app/policies/ci/build_policy.rb2
-rw-r--r--app/policies/global_policy.rb1
-rw-r--r--app/policies/group_policy.rb37
-rw-r--r--app/policies/issue_policy.rb9
-rw-r--r--app/policies/project_policy.rb12
-rw-r--r--app/policies/releases/evidence_policy.rb1
-rw-r--r--app/policies/terraform/state_policy.rb9
-rw-r--r--app/policies/wiki_policy.rb6
-rw-r--r--app/presenters/alert_management/alert_presenter.rb33
-rw-r--r--app/presenters/clusterable_presenter.rb2
-rw-r--r--app/presenters/environment_presenter.rb13
-rw-r--r--app/presenters/event_presenter.rb22
-rw-r--r--app/presenters/instance_clusterable_presenter.rb2
-rw-r--r--app/presenters/issue_presenter.rb2
-rw-r--r--app/presenters/label_presenter.rb5
-rw-r--r--app/presenters/merge_request_presenter.rb2
-rw-r--r--app/presenters/packages/detail/package_presenter.rb6
-rw-r--r--app/presenters/packages/pypi/package_presenter.rb4
-rw-r--r--app/presenters/project_presenter.rb52
-rw-r--r--app/presenters/projects/prometheus/alert_presenter.rb179
-rw-r--r--app/presenters/release_presenter.rb2
-rw-r--r--app/presenters/sentry_error_presenter.rb2
-rw-r--r--app/presenters/snippet_blob_presenter.rb8
-rw-r--r--app/presenters/snippet_presenter.rb10
-rw-r--r--app/serializers/README.md4
-rw-r--r--app/serializers/build_details_entity.rb4
-rw-r--r--app/serializers/ci/trigger_entity.rb42
-rw-r--r--app/serializers/ci/trigger_serializer.rb7
-rw-r--r--app/serializers/cluster_entity.rb2
-rw-r--r--app/serializers/cluster_serializer.rb1
-rw-r--r--app/serializers/container_repository_entity.rb1
-rw-r--r--app/serializers/deployment_entity.rb1
-rw-r--r--app/serializers/diff_file_base_entity.rb14
-rw-r--r--app/serializers/diffs_entity.rb4
-rw-r--r--app/serializers/discussion_entity.rb5
-rw-r--r--app/serializers/feature_flag_entity.rb48
-rw-r--r--app/serializers/feature_flag_scope_entity.rb12
-rw-r--r--app/serializers/feature_flag_serializer.rb10
-rw-r--r--app/serializers/feature_flag_summary_entity.rb19
-rw-r--r--app/serializers/feature_flag_summary_serializer.rb5
-rw-r--r--app/serializers/feature_flags/scope_entity.rb8
-rw-r--r--app/serializers/feature_flags/strategy_entity.rb11
-rw-r--r--app/serializers/feature_flags/user_list_entity.rb10
-rw-r--r--app/serializers/feature_flags_client_entity.rb7
-rw-r--r--app/serializers/feature_flags_client_serializer.rb9
-rw-r--r--app/serializers/group_group_link_entity.rb24
-rw-r--r--app/serializers/import/bulk_import_entity.rb15
-rw-r--r--app/serializers/label_entity.rb2
-rw-r--r--app/serializers/label_serializer.rb2
-rw-r--r--app/serializers/merge_request_basic_entity.rb1
-rw-r--r--app/serializers/merge_request_poll_cached_widget_entity.rb12
-rw-r--r--app/serializers/merge_request_poll_widget_entity.rb16
-rw-r--r--app/serializers/merge_request_widget_entity.rb26
-rw-r--r--app/serializers/paginated_diff_entity.rb2
-rw-r--r--app/serializers/pipeline_entity.rb4
-rw-r--r--app/serializers/pipeline_serializer.rb10
-rw-r--r--app/serializers/test_case_entity.rb1
-rw-r--r--app/services/admin/propagate_integration_service.rb67
-rw-r--r--app/services/admin/propagate_service_template.rb6
-rw-r--r--app/services/alert_management/alerts/update_service.rb22
-rw-r--r--app/services/alert_management/process_prometheus_alert_service.rb2
-rw-r--r--app/services/audit_event_service.rb21
-rw-r--r--app/services/boards/create_service.rb18
-rw-r--r--app/services/boards/issues/list_service.rb5
-rw-r--r--app/services/boards/lists/destroy_service.rb8
-rw-r--r--app/services/bulk_create_integration_service.rb59
-rw-r--r--app/services/bulk_update_integration_service.rb32
-rw-r--r--app/services/ci/archive_trace_service.rb2
-rw-r--r--app/services/ci/build_report_result_service.rb29
-rw-r--r--app/services/ci/create_downstream_pipeline_service.rb15
-rw-r--r--app/services/ci/create_job_artifacts_service.rb9
-rw-r--r--app/services/ci/create_pipeline_service.rb5
-rw-r--r--app/services/ci/daily_build_group_report_result_service.rb2
-rw-r--r--app/services/ci/delete_objects_service.rb62
-rw-r--r--app/services/ci/destroy_expired_job_artifacts_service.rb7
-rw-r--r--app/services/ci/expire_pipeline_cache_service.rb13
-rw-r--r--app/services/ci/list_config_variables_service.rb13
-rw-r--r--app/services/ci/pipelines/create_artifact_service.rb1
-rw-r--r--app/services/ci/play_bridge_service.rb14
-rw-r--r--app/services/ci/play_build_service.rb4
-rw-r--r--app/services/ci/play_manual_stage_service.rb8
-rw-r--r--app/services/ci/process_pipeline_service.rb4
-rw-r--r--app/services/ci/retry_build_service.rb2
-rw-r--r--app/services/ci/update_build_queue_service.rb4
-rw-r--r--app/services/ci/update_build_state_service.rb142
-rw-r--r--app/services/clusters/kubernetes/create_or_update_service_account_service.rb4
-rw-r--r--app/services/concerns/admin/propagate_service.rb51
-rw-r--r--app/services/deployments/update_environment_service.rb (renamed from app/services/deployments/after_create_service.rb)4
-rw-r--r--app/services/design_management/copy_design_collection.rb6
-rw-r--r--app/services/design_management/copy_design_collection/copy_service.rb309
-rw-r--r--app/services/design_management/copy_design_collection/queue_service.rb42
-rw-r--r--app/services/design_management/delete_designs_service.rb1
-rw-r--r--app/services/design_management/design_service.rb1
-rw-r--r--app/services/design_management/generate_image_versions_service.rb3
-rw-r--r--app/services/design_management/runs_design_actions.rb11
-rw-r--r--app/services/design_management/save_designs_service.rb21
-rw-r--r--app/services/feature_flags/base_service.rb55
-rw-r--r--app/services/feature_flags/create_service.rb52
-rw-r--r--app/services/feature_flags/destroy_service.rb33
-rw-r--r--app/services/feature_flags/disable_service.rb46
-rw-r--r--app/services/feature_flags/enable_service.rb93
-rw-r--r--app/services/feature_flags/update_service.rb87
-rw-r--r--app/services/git/branch_hooks_service.rb8
-rw-r--r--app/services/git/wiki_push_service.rb4
-rw-r--r--app/services/groups/create_service.rb19
-rw-r--r--app/services/groups/import_export/import_service.rb2
-rw-r--r--app/services/groups/transfer_service.rb35
-rw-r--r--app/services/groups/update_service.rb58
-rw-r--r--app/services/groups/update_shared_runners_service.rb32
-rw-r--r--app/services/incident_management/incidents/create_service.rb6
-rw-r--r--app/services/incident_management/incidents/update_severity_service.rb38
-rw-r--r--app/services/incident_management/pager_duty/process_webhook_service.rb2
-rw-r--r--app/services/issuable/clone/attributes_rewriter.rb13
-rw-r--r--app/services/issuable_base_service.rb3
-rw-r--r--app/services/issues/base_service.rb12
-rw-r--r--app/services/issues/build_service.rb20
-rw-r--r--app/services/issues/move_service.rb23
-rw-r--r--app/services/issues/reopen_service.rb8
-rw-r--r--app/services/jira/requests/base.rb7
-rw-r--r--app/services/lfs/push_service.rb16
-rw-r--r--app/services/members/create_service.rb2
-rw-r--r--app/services/members/invitation_reminder_email_service.rb54
-rw-r--r--app/services/merge_requests/base_service.rb9
-rw-r--r--app/services/merge_requests/cleanup_refs_service.rb2
-rw-r--r--app/services/merge_requests/export_csv_service.rb55
-rw-r--r--app/services/merge_requests/ff_merge_service.rb2
-rw-r--r--app/services/merge_requests/merge_service.rb2
-rw-r--r--app/services/merge_requests/merge_to_ref_service.rb9
-rw-r--r--app/services/merge_requests/mergeability_check_service.rb17
-rw-r--r--app/services/merge_requests/refresh_service.rb17
-rw-r--r--app/services/merge_requests/update_service.rb2
-rw-r--r--app/services/metrics/dashboard/custom_dashboard_service.rb6
-rw-r--r--app/services/metrics/dashboard/dynamic_embed_service.rb2
-rw-r--r--app/services/namespace_settings/update_service.rb25
-rw-r--r--app/services/notes/create_service.rb2
-rw-r--r--app/services/notification_recipients/build_service.rb8
-rw-r--r--app/services/notification_recipients/builder/default.rb3
-rw-r--r--app/services/notification_service.rb31
-rw-r--r--app/services/packages/composer/composer_json_service.rb6
-rw-r--r--app/services/packages/create_event_service.rb37
-rw-r--r--app/services/packages/create_package_service.rb1
-rw-r--r--app/services/packages/generic/create_package_file_service.rb38
-rw-r--r--app/services/packages/generic/find_or_create_package_service.rb15
-rw-r--r--app/services/personal_access_tokens/revoke_service.rb7
-rw-r--r--app/services/pod_logs/base_service.rb2
-rw-r--r--app/services/pod_logs/elasticsearch_service.rb1
-rw-r--r--app/services/pod_logs/kubernetes_service.rb1
-rw-r--r--app/services/projects/alerting/notify_service.rb75
-rw-r--r--app/services/projects/container_repository/cleanup_tags_service.rb5
-rw-r--r--app/services/projects/container_repository/delete_tags_service.rb4
-rw-r--r--app/services/projects/create_from_template_service.rb18
-rw-r--r--app/services/projects/create_service.rb22
-rw-r--r--app/services/projects/gitlab_projects_import_service.rb1
-rw-r--r--app/services/projects/operations/update_service.rb10
-rw-r--r--app/services/projects/overwrite_project_service.rb12
-rw-r--r--app/services/projects/prometheus/alerts/notify_service.rb14
-rw-r--r--app/services/projects/transfer_service.rb4
-rw-r--r--app/services/projects/update_pages_service.rb16
-rw-r--r--app/services/projects/update_remote_mirror_service.rb11
-rw-r--r--app/services/quick_actions/target_service.rb4
-rw-r--r--app/services/releases/base_service.rb89
-rw-r--r--app/services/releases/concerns.rb77
-rw-r--r--app/services/releases/create_evidence_service.rb2
-rw-r--r--app/services/releases/create_service.rb4
-rw-r--r--app/services/releases/destroy_service.rb4
-rw-r--r--app/services/releases/update_service.rb20
-rw-r--r--app/services/repository_archive_clean_up_service.rb24
-rw-r--r--app/services/resource_access_tokens/create_service.rb18
-rw-r--r--app/services/resource_access_tokens/revoke_service.rb27
-rw-r--r--app/services/search/global_service.rb10
-rw-r--r--app/services/search/group_service.rb3
-rw-r--r--app/services/search/project_service.rb19
-rw-r--r--app/services/search_service.rb4
-rw-r--r--app/services/snippets/base_service.rb18
-rw-r--r--app/services/snippets/create_service.rb9
-rw-r--r--app/services/snippets/repository_validation_service.rb2
-rw-r--r--app/services/snippets/update_service.rb39
-rw-r--r--app/services/spam/spam_action_service.rb2
-rw-r--r--app/services/static_site_editor/config_service.rb66
-rw-r--r--app/services/system_note_service.rb8
-rw-r--r--app/services/system_notes/incident_service.rb29
-rw-r--r--app/services/system_notes/issuables_service.rb82
-rw-r--r--app/services/system_notes/time_tracking_service.rb12
-rw-r--r--app/services/todos/destroy/entity_leave_service.rb74
-rw-r--r--app/services/users/approve_service.rb37
-rw-r--r--app/services/users/block_service.rb2
-rw-r--r--app/services/users/build_service.rb3
-rw-r--r--app/services/users/destroy_service.rb2
-rw-r--r--app/services/users/validate_otp_service.rb25
-rw-r--r--app/services/web_hooks/destroy_service.rb78
-rw-r--r--app/services/webauthn/authenticate_service.rb12
-rw-r--r--app/uploaders/deleted_object_uploader.rb11
-rw-r--r--app/uploaders/pages/deployment_uploader.rb51
-rw-r--r--app/uploaders/terraform/versioned_state_uploader.rb14
-rw-r--r--app/validators/addressable_url_validator.rb2
-rw-r--r--app/validators/ip_address_validator.rb39
-rw-r--r--app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json6
-rw-r--r--app/views/admin/abuse_reports/_abuse_report.html.haml13
-rw-r--r--app/views/admin/abuse_reports/index.html.haml2
-rw-r--r--app/views/admin/application_settings/_abuse.html.haml6
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml3
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml2
-rw-r--r--app/views/admin/application_settings/_diff_limits.html.haml3
-rw-r--r--app/views/admin/application_settings/_eks.html.haml2
-rw-r--r--app/views/admin/application_settings/_email.html.haml2
-rw-r--r--app/views/admin/application_settings/_external_authorization_service_form.html.haml3
-rw-r--r--app/views/admin/application_settings/_gitaly.html.haml2
-rw-r--r--app/views/admin/application_settings/_gitpod.html.haml5
-rw-r--r--app/views/admin/application_settings/_grafana.html.haml2
-rw-r--r--app/views/admin/application_settings/_help_page.html.haml7
-rw-r--r--app/views/admin/application_settings/_import_export_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_initial_branch_name.html.haml3
-rw-r--r--app/views/admin/application_settings/_ip_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_issue_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_localization.html.haml2
-rw-r--r--app/views/admin/application_settings/_outbound.html.haml2
-rw-r--r--app/views/admin/application_settings/_pages.html.haml2
-rw-r--r--app/views/admin/application_settings/_performance.html.haml2
-rw-r--r--app/views/admin/application_settings/_performance_bar.html.haml2
-rw-r--r--app/views/admin/application_settings/_plantuml.html.haml2
-rw-r--r--app/views/admin/application_settings/_prometheus.html.haml2
-rw-r--r--app/views/admin/application_settings/_protected_paths.html.haml2
-rw-r--r--app/views/admin/application_settings/_realtime.html.haml2
-rw-r--r--app/views/admin/application_settings/_registry.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_check.html.haml6
-rw-r--r--app/views/admin/application_settings/_repository_mirrors_form.html.haml3
-rw-r--r--app/views/admin/application_settings/_repository_static_objects.html.haml3
-rw-r--r--app/views/admin/application_settings/_repository_storage.html.haml3
-rw-r--r--app/views/admin/application_settings/_signin.html.haml3
-rw-r--r--app/views/admin/application_settings/_signup.html.haml11
-rw-r--r--app/views/admin/application_settings/_snowplow.html.haml2
-rw-r--r--app/views/admin/application_settings/_sourcegraph.html.haml2
-rw-r--r--app/views/admin/application_settings/_spam.html.haml2
-rw-r--r--app/views/admin/application_settings/_terminal.html.haml3
-rw-r--r--app/views/admin/application_settings/_terms.html.haml3
-rw-r--r--app/views/admin/application_settings/_third_party_offers.html.haml2
-rw-r--r--app/views/admin/application_settings/_usage.html.haml4
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml3
-rw-r--r--app/views/admin/application_settings/general.html.haml3
-rw-r--r--app/views/admin/applications/_delete_form.html.haml2
-rw-r--r--app/views/admin/applications/_form.html.haml4
-rw-r--r--app/views/admin/applications/index.html.haml4
-rw-r--r--app/views/admin/applications/show.html.haml6
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml6
-rw-r--r--app/views/admin/dashboard/_billable_users_text.html.haml1
-rw-r--r--app/views/admin/dashboard/index.html.haml10
-rw-r--r--app/views/admin/dashboard/stats.html.haml2
-rw-r--r--app/views/admin/deploy_keys/edit.html.haml4
-rw-r--r--app/views/admin/deploy_keys/index.html.haml6
-rw-r--r--app/views/admin/deploy_keys/new.html.haml4
-rw-r--r--app/views/admin/dev_ops_report/show.html.haml5
-rw-r--r--app/views/admin/groups/_form.html.haml10
-rw-r--r--app/views/admin/groups/_group.html.haml2
-rw-r--r--app/views/admin/groups/index.html.haml4
-rw-r--r--app/views/admin/groups/show.html.haml4
-rw-r--r--app/views/admin/health_check/show.html.haml2
-rw-r--r--app/views/admin/hook_logs/show.html.haml2
-rw-r--r--app/views/admin/hooks/edit.html.haml4
-rw-r--r--app/views/admin/hooks/index.html.haml2
-rw-r--r--app/views/admin/identities/_form.html.haml2
-rw-r--r--app/views/admin/identities/_identity.html.haml4
-rw-r--r--app/views/admin/identities/index.html.haml2
-rw-r--r--app/views/admin/jobs/index.html.haml2
-rw-r--r--app/views/admin/labels/_form.html.haml4
-rw-r--r--app/views/admin/labels/_label.html.haml4
-rw-r--r--app/views/admin/labels/index.html.haml2
-rw-r--r--app/views/admin/projects/index.html.haml4
-rw-r--r--app/views/admin/projects/show.html.haml9
-rw-r--r--app/views/admin/runners/_runner.html.haml8
-rw-r--r--app/views/admin/runners/index.html.haml10
-rw-r--r--app/views/admin/runners/show.html.haml4
-rw-r--r--app/views/admin/serverless/domains/_form.html.haml12
-rw-r--r--app/views/admin/sessions/_new_base.html.haml2
-rw-r--r--app/views/admin/sessions/_two_factor_otp.html.haml2
-rw-r--r--app/views/admin/spam_logs/_spam_log.html.haml8
-rw-r--r--app/views/admin/users/_approve_user.html.haml7
-rw-r--r--app/views/admin/users/_block_user.html.haml11
-rw-r--r--app/views/admin/users/_form.html.haml2
-rw-r--r--app/views/admin/users/_head.html.haml17
-rw-r--r--app/views/admin/users/_modals.html.haml2
-rw-r--r--app/views/admin/users/_user.html.haml24
-rw-r--r--app/views/admin/users/_user_approve_effects.html.haml11
-rw-r--r--app/views/admin/users/_user_detail.html.haml2
-rw-r--r--app/views/admin/users/index.html.haml10
-rw-r--r--app/views/admin/users/show.html.haml84
-rw-r--r--app/views/ci/status/_dropdown_graph_badge.html.haml4
-rw-r--r--app/views/ci/variables/_index.html.haml2
-rw-r--r--app/views/clusters/clusters/_advanced_settings.html.haml21
-rw-r--r--app/views/clusters/clusters/_buttons.html.haml6
-rw-r--r--app/views/clusters/clusters/_cluster.html.haml19
-rw-r--r--app/views/clusters/clusters/_cluster_list.html.haml12
-rw-r--r--app/views/clusters/clusters/_empty_state.html.haml8
-rw-r--r--app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml18
-rw-r--r--app/views/clusters/clusters/_provider_details_form.html.haml12
-rw-r--r--app/views/clusters/clusters/aws/_new.html.haml1
-rw-r--r--app/views/clusters/clusters/gcp/_form.html.haml8
-rw-r--r--app/views/clusters/clusters/index.html.haml46
-rw-r--r--app/views/clusters/clusters/user/_form.html.haml8
-rw-r--r--app/views/dashboard/merge_requests.html.haml2
-rw-r--r--app/views/dashboard/milestones/index.html.haml1
-rw-r--r--app/views/dashboard/todos/_todo.html.haml10
-rw-r--r--app/views/dashboard/todos/index.html.haml4
-rw-r--r--app/views/devise/mailer/confirmation_instructions.text.erb2
-rw-r--r--app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml15
-rw-r--r--app/views/devise/shared/_signup_box.html.haml20
-rw-r--r--app/views/devise/shared/_terms_of_service_notice.html.haml5
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml2
-rw-r--r--app/views/discussions/_jump_to_next.html.haml9
-rw-r--r--app/views/discussions/_new_issue_for_all_discussions.html.haml8
-rw-r--r--app/views/discussions/_new_issue_for_discussion.html.haml10
-rw-r--r--app/views/discussions/_notes.html.haml22
-rw-r--r--app/views/doorkeeper/applications/_delete_form.html.haml2
-rw-r--r--app/views/doorkeeper/authorized_applications/_delete_form.html.haml2
-rw-r--r--app/views/events/event/_common.html.haml2
-rw-r--r--app/views/groups/_invite_members_modal.html.haml6
-rw-r--r--app/views/groups/_invite_members_side_nav_link.html.haml3
-rw-r--r--app/views/groups/group_members/index.html.haml9
-rw-r--r--app/views/groups/issues.html.haml6
-rw-r--r--app/views/groups/labels/destroy.js.haml2
-rw-r--r--app/views/groups/labels/index.html.haml4
-rw-r--r--app/views/groups/milestones/index.html.haml1
-rw-r--r--app/views/groups/milestones/show.html.haml1
-rw-r--r--app/views/groups/projects.html.haml2
-rw-r--r--app/views/groups/registry/repositories/index.html.haml2
-rw-r--r--app/views/groups/runners/_index.html.haml2
-rw-r--r--app/views/groups/runners/_shared_runners.html.haml3
-rw-r--r--app/views/groups/settings/_advanced.html.haml8
-rw-r--r--app/views/groups/settings/_export.html.haml5
-rw-r--r--app/views/groups/settings/_general.html.haml3
-rw-r--r--app/views/groups/settings/_permanent_deletion.html.haml3
-rw-r--r--app/views/groups/settings/_permissions.html.haml5
-rw-r--r--app/views/groups/settings/_two_factor_auth.html.haml9
-rw-r--r--app/views/groups/show.html.haml2
-rw-r--r--app/views/ide/_show.html.haml3
-rw-r--r--app/views/import/bitbucket/status.html.haml5
-rw-r--r--app/views/import/bitbucket_server/new.html.haml6
-rw-r--r--app/views/import/bitbucket_server/status.html.haml5
-rw-r--r--app/views/import/bulk_imports/status.html.haml1
-rw-r--r--app/views/import/github/new.html.haml4
-rw-r--r--app/views/import/github/status.html.haml6
-rw-r--r--app/views/import/google_code/new.html.haml5
-rw-r--r--app/views/import/google_code/new_user_map.html.haml5
-rw-r--r--app/views/import/google_code/status.html.haml5
-rw-r--r--app/views/import/shared/_errors.html.haml8
-rw-r--r--app/views/invites/decline.html.haml8
-rw-r--r--app/views/invites/show.html.haml4
-rw-r--r--app/views/jira_connect/subscriptions/index.html.haml85
-rw-r--r--app/views/jira_connect/users/show.html.haml12
-rw-r--r--app/views/layouts/_head.html.haml25
-rw-r--r--app/views/layouts/_loading_hints.html.haml1
-rw-r--r--app/views/layouts/_page.html.haml1
-rw-r--r--app/views/layouts/_startup_css.haml2
-rw-r--r--app/views/layouts/_startup_css_activation.haml1
-rw-r--r--app/views/layouts/_startup_js.html.haml22
-rw-r--r--app/views/layouts/devise.html.haml5
-rw-r--r--app/views/layouts/devise_experimental_onboarding_issues.html.haml1
-rw-r--r--app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml1
-rw-r--r--app/views/layouts/group.html.haml4
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml2
-rw-r--r--app/views/layouts/header/_default.html.haml10
-rw-r--r--app/views/layouts/header/_help_dropdown.html.haml2
-rw-r--r--app/views/layouts/header/_new_dropdown.haml2
-rw-r--r--app/views/layouts/jira_connect.html.haml1
-rw-r--r--app/views/layouts/nav/_classification_level_banner.html.haml2
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml10
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml9
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml59
-rw-r--r--app/views/layouts/nav/sidebar/_tracing_link.html.haml7
-rw-r--r--app/views/layouts/project.html.haml4
-rw-r--r--app/views/notify/_failed_builds.html.haml2
-rw-r--r--app/views/notify/_issuable_csv_export.html.haml6
-rw-r--r--app/views/notify/autodevops_disabled_email.html.haml2
-rw-r--r--app/views/notify/autodevops_disabled_email.text.erb2
-rw-r--r--app/views/notify/changed_reviewer_of_merge_request_email.html.haml2
-rw-r--r--app/views/notify/changed_reviewer_of_merge_request_email.text.erb1
-rw-r--r--app/views/notify/issue_status_changed_email.text.erb1
-rw-r--r--app/views/notify/issues_csv_email.html.haml7
-rw-r--r--app/views/notify/member_invited_reminder_email.html.haml9
-rw-r--r--app/views/notify/member_invited_reminder_email.text.erb6
-rw-r--r--app/views/notify/merge_requests_csv_email.html.haml1
-rw-r--r--app/views/notify/merge_requests_csv_email.text.erb5
-rw-r--r--app/views/notify/pipeline_failed_email.html.haml2
-rw-r--r--app/views/notify/pipeline_failed_email.text.erb2
-rw-r--r--app/views/notify/prometheus_alert_fired_email.html.haml7
-rw-r--r--app/views/notify/prometheus_alert_fired_email.text.erb6
-rw-r--r--app/views/profiles/accounts/show.html.haml10
-rw-r--r--app/views/profiles/active_sessions/_active_session.html.haml2
-rw-r--r--app/views/profiles/chat_names/_chat_name.html.haml2
-rw-r--r--app/views/profiles/chat_names/new.html.haml4
-rw-r--r--app/views/profiles/emails/index.html.haml6
-rw-r--r--app/views/profiles/gpg_keys/_form.html.haml2
-rw-r--r--app/views/profiles/gpg_keys/_key.html.haml4
-rw-r--r--app/views/profiles/keys/_form.html.haml8
-rw-r--r--app/views/profiles/keys/_key.html.haml11
-rw-r--r--app/views/profiles/keys/_key_details.html.haml2
-rw-r--r--app/views/profiles/notifications/show.html.haml2
-rw-r--r--app/views/profiles/passwords/edit.html.haml2
-rw-r--r--app/views/profiles/passwords/new.html.haml2
-rw-r--r--app/views/profiles/preferences/_gitpod.html.haml4
-rw-r--r--app/views/profiles/preferences/show.html.haml4
-rw-r--r--app/views/profiles/show.html.haml14
-rw-r--r--app/views/profiles/two_factor_auths/_codes.html.haml4
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml19
-rw-r--r--app/views/projects/_export.html.haml5
-rw-r--r--app/views/projects/_files.html.haml2
-rw-r--r--app/views/projects/_home_panel.html.haml12
-rw-r--r--app/views/projects/_import_project_pane.html.haml17
-rw-r--r--app/views/projects/_project_templates.html.haml18
-rw-r--r--app/views/projects/_stat_anchor_list.html.haml3
-rw-r--r--app/views/projects/_visibility_modal.html.haml2
-rw-r--r--app/views/projects/artifacts/_artifact.html.haml8
-rw-r--r--app/views/projects/blob/_content.html.haml4
-rw-r--r--app/views/projects/blob/_editor.html.haml5
-rw-r--r--app/views/projects/blob/_header.html.haml7
-rw-r--r--app/views/projects/blob/_new_dir.html.haml4
-rw-r--r--app/views/projects/blob/_remove.html.haml4
-rw-r--r--app/views/projects/blob/_upload.html.haml6
-rw-r--r--app/views/projects/blob/_viewer_switcher.html.haml4
-rw-r--r--app/views/projects/blob/edit.html.haml3
-rw-r--r--app/views/projects/blob/new.html.haml3
-rw-r--r--app/views/projects/blob/viewers/_markup.html.haml3
-rw-r--r--app/views/projects/branches/_branch.html.haml8
-rw-r--r--app/views/projects/branches/index.html.haml2
-rw-r--r--app/views/projects/buttons/_fork.html.haml4
-rw-r--r--app/views/projects/buttons/_remove_tag.html.haml6
-rw-r--r--app/views/projects/buttons/_star.html.haml6
-rw-r--r--app/views/projects/ci/builds/_build.html.haml22
-rw-r--r--app/views/projects/ci/lints/show.html.haml14
-rw-r--r--app/views/projects/cleanup/_show.html.haml3
-rw-r--r--app/views/projects/commit/_commit_box.html.haml12
-rw-r--r--app/views/projects/commit/pipelines.html.haml1
-rw-r--r--app/views/projects/commit/show.html.haml1
-rw-r--r--app/views/projects/commits/_commits.html.haml3
-rw-r--r--app/views/projects/commits/show.html.haml6
-rw-r--r--app/views/projects/compare/_form.html.haml8
-rw-r--r--app/views/projects/confluences/show.html.haml1
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml1
-rw-r--r--app/views/projects/default_branch/_show.html.haml3
-rw-r--r--app/views/projects/deployments/_actions.haml2
-rw-r--r--app/views/projects/deployments/_commit.html.haml2
-rw-r--r--app/views/projects/deployments/_confirm_rollback_modal.html.haml4
-rw-r--r--app/views/projects/deployments/_rollback.haml2
-rw-r--r--app/views/projects/diffs/_diffs.html.haml2
-rw-r--r--app/views/projects/diffs/_file_header.html.haml2
-rw-r--r--app/views/projects/diffs/_line.html.haml3
-rw-r--r--app/views/projects/diffs/_parallel_view.html.haml6
-rw-r--r--app/views/projects/diffs/_stats.html.haml4
-rw-r--r--app/views/projects/diffs/_text_file.html.haml2
-rw-r--r--app/views/projects/diffs/_warning.html.haml23
-rw-r--r--app/views/projects/edit.html.haml23
-rw-r--r--app/views/projects/empty.html.haml10
-rw-r--r--app/views/projects/environments/edit.html.haml1
-rw-r--r--app/views/projects/environments/folder.html.haml1
-rw-r--r--app/views/projects/environments/index.html.haml1
-rw-r--r--app/views/projects/environments/new.html.haml1
-rw-r--r--app/views/projects/environments/show.html.haml7
-rw-r--r--app/views/projects/environments/terminal.html.haml4
-rw-r--r--app/views/projects/error_tracking/details.html.haml1
-rw-r--r--app/views/projects/error_tracking/index.html.haml1
-rw-r--r--app/views/projects/feature_flags/_errors.html.haml4
-rw-r--r--app/views/projects/feature_flags/edit.html.haml4
-rw-r--r--app/views/projects/feature_flags/index.html.haml2
-rw-r--r--app/views/projects/feature_flags/new.html.haml2
-rw-r--r--app/views/projects/forks/_fork_button.html.haml4
-rw-r--r--app/views/projects/forks/index.html.haml4
-rw-r--r--app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml2
-rw-r--r--app/views/projects/group_links/update.js.haml4
-rw-r--r--app/views/projects/hook_logs/show.html.haml2
-rw-r--r--app/views/projects/hooks/edit.html.haml4
-rw-r--r--app/views/projects/hooks/index.html.haml3
-rw-r--r--app/views/projects/incidents/_new_branch.html.haml1
-rw-r--r--app/views/projects/incidents/index.html.haml2
-rw-r--r--app/views/projects/incidents/show.html.haml1
-rw-r--r--app/views/projects/issues/_discussion.html.haml1
-rw-r--r--app/views/projects/issues/_issue.html.haml4
-rw-r--r--app/views/projects/issues/_issues.html.haml8
-rw-r--r--app/views/projects/issues/_new_branch.html.haml2
-rw-r--r--app/views/projects/issues/export_csv/_button.html.haml2
-rw-r--r--app/views/projects/issues/export_csv/_modal.html.haml13
-rw-r--r--app/views/projects/issues/index.html.haml1
-rw-r--r--app/views/projects/issues/service_desk.html.haml2
-rw-r--r--app/views/projects/issues/show.html.haml14
-rw-r--r--app/views/projects/jobs/show.html.haml4
-rw-r--r--app/views/projects/jobs/terminal.html.haml5
-rw-r--r--app/views/projects/labels/index.html.haml4
-rw-r--r--app/views/projects/merge_requests/_description.html.haml3
-rw-r--r--app/views/projects/merge_requests/_discussion.html.haml11
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml5
-rw-r--r--app/views/projects/merge_requests/_merge_requests.html.haml2
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml9
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml1
-rw-r--r--app/views/projects/merge_requests/creations/new.html.haml1
-rw-r--r--app/views/projects/merge_requests/diffs/_commit_widget.html.haml11
-rw-r--r--app/views/projects/merge_requests/diffs/_different_base.html.haml11
-rw-r--r--app/views/projects/merge_requests/diffs/_diffs.html.haml21
-rw-r--r--app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml17
-rw-r--r--app/views/projects/merge_requests/diffs/_version_controls.html.haml73
-rw-r--r--app/views/projects/merge_requests/invalid.html.haml15
-rw-r--r--app/views/projects/merge_requests/show.html.haml8
-rw-r--r--app/views/projects/milestones/index.html.haml3
-rw-r--r--app/views/projects/milestones/show.html.haml15
-rw-r--r--app/views/projects/milestones/update.js.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml6
-rw-r--r--app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml4
-rw-r--r--app/views/projects/mirrors/_ssh_host_keys.html.haml4
-rw-r--r--app/views/projects/network/show.html.haml8
-rw-r--r--app/views/projects/no_repo.html.haml2
-rw-r--r--app/views/projects/notes/_actions.html.haml2
-rw-r--r--app/views/projects/packages/packages/show.html.haml3
-rw-r--r--app/views/projects/pages/_destroy.haml2
-rw-r--r--app/views/projects/pages/_list.html.haml4
-rw-r--r--app/views/projects/pages/_pages_settings.html.haml7
-rw-r--r--app/views/projects/pages/show.html.haml2
-rw-r--r--app/views/projects/pages_domains/_certificate.html.haml2
-rw-r--r--app/views/projects/pages_domains/_form.html.haml3
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/index.html.haml2
-rw-r--r--app/views/projects/pipelines/_info.html.haml6
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml2
-rw-r--r--app/views/projects/pipelines/index.html.haml1
-rw-r--r--app/views/projects/pipelines/new.html.haml12
-rw-r--r--app/views/projects/pipelines/show.html.haml5
-rw-r--r--app/views/projects/project_members/_team.html.haml2
-rw-r--r--app/views/projects/project_members/import.html.haml4
-rw-r--r--app/views/projects/project_templates/_built_in_templates.html.haml17
-rw-r--r--app/views/projects/project_templates/_template.html.haml16
-rw-r--r--app/views/projects/protected_branches/_create_protected_branch.html.haml4
-rw-r--r--app/views/projects/protected_tags/shared/_create_protected_tag.html.haml2
-rw-r--r--app/views/projects/registry/repositories/index.html.haml3
-rw-r--r--app/views/projects/registry/settings/_index.haml3
-rw-r--r--app/views/projects/releases/show.html.haml3
-rw-r--r--app/views/projects/runners/_shared_runners.html.haml25
-rw-r--r--app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml2
-rw-r--r--app/views/projects/services/prometheus/_configuration_banner.html.haml4
-rw-r--r--app/views/projects/services/prometheus/_custom_metrics.html.haml2
-rw-r--r--app/views/projects/settings/_archive.html.haml7
-rw-r--r--app/views/projects/settings/_general.html.haml3
-rw-r--r--app/views/projects/settings/ci_cd/_badge.html.haml6
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml13
-rw-r--r--app/views/projects/settings/operations/_alert_management.html.haml2
-rw-r--r--app/views/projects/settings/operations/_tracing.html.haml33
-rw-r--r--app/views/projects/settings/operations/show.html.haml2
-rw-r--r--app/views/projects/snippets/_actions.html.haml36
-rw-r--r--app/views/projects/snippets/show.html.haml8
-rw-r--r--app/views/projects/starrers/index.html.haml2
-rw-r--r--app/views/projects/tags/_tag.html.haml8
-rw-r--r--app/views/projects/tags/destroy.js.haml4
-rw-r--r--app/views/projects/tags/show.html.haml3
-rw-r--r--app/views/projects/tracings/_tracing_button.html.haml2
-rw-r--r--app/views/projects/tracings/show.html.haml33
-rw-r--r--app/views/projects/tree/_tree_header.html.haml12
-rw-r--r--app/views/projects/tree/show.html.haml2
-rw-r--r--app/views/projects/triggers/_index.html.haml35
-rw-r--r--app/views/projects/triggers/_trigger.html.haml4
-rw-r--r--app/views/projects/wikis/git_access.html.haml1
-rw-r--r--app/views/registrations/welcome.html.haml2
-rw-r--r--app/views/search/_category.html.haml1
-rw-r--r--app/views/search/_filter.html.haml24
-rw-r--r--app/views/search/_form.html.haml4
-rw-r--r--app/views/search/_results.html.haml10
-rw-r--r--app/views/search/results/_blob.html.haml2
-rw-r--r--app/views/search/results/_blob_data.html.haml2
-rw-r--r--app/views/search/results/_empty.html.haml2
-rw-r--r--app/views/search/results/_filters.html.haml7
-rw-r--r--app/views/search/results/_issue.html.haml5
-rw-r--r--app/views/search/show.html.haml7
-rw-r--r--app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml18
-rw-r--r--app/views/shared/_confirm_fork_modal.html.haml2
-rw-r--r--app/views/shared/_confirm_modal.html.haml2
-rw-r--r--app/views/shared/_delete_label_modal.html.haml2
-rw-r--r--app/views/shared/_issuable_meta_data.html.haml14
-rw-r--r--app/views/shared/_label.html.haml3
-rw-r--r--app/views/shared/_label_full_path.html.haml4
-rw-r--r--app/views/shared/_label_row.html.haml31
-rw-r--r--app/views/shared/_new_project_item_select.html.haml8
-rw-r--r--app/views/shared/_user_dropdown_instance_review.html.haml6
-rw-r--r--app/views/shared/_web_ide_button.html.haml10
-rw-r--r--app/views/shared/boards/_show.html.haml3
-rw-r--r--app/views/shared/deploy_keys/_project_group_form.html.haml2
-rw-r--r--app/views/shared/deploy_tokens/_form.html.haml2
-rw-r--r--app/views/shared/icons/_next_discussion.svg1
-rw-r--r--app/views/shared/issuable/_approved_by_dropdown.html.haml16
-rw-r--r--app/views/shared/issuable/_assignees.html.haml5
-rw-r--r--app/views/shared/issuable/_close_reopen_button.html.haml13
-rw-r--r--app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml37
-rw-r--r--app/views/shared/issuable/_close_reopen_report_toggle.html.haml2
-rw-r--r--app/views/shared/issuable/_reviewers.html.haml11
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml11
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml349
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml19
-rw-r--r--app/views/shared/issuable/_sidebar_reviewers.html.haml55
-rw-r--r--app/views/shared/issuable/form/_type_selector.html.haml6
-rw-r--r--app/views/shared/labels/_form.html.haml6
-rw-r--r--app/views/shared/labels/_nav.html.haml8
-rw-r--r--app/views/shared/members/_access_request_links.html.haml6
-rw-r--r--app/views/shared/members/_group.html.haml13
-rw-r--r--app/views/shared/members/_member.html.haml11
-rw-r--r--app/views/shared/members/update.js.haml6
-rw-r--r--app/views/shared/milestones/_delete_button.html.haml6
-rw-r--r--app/views/shared/milestones/_issuable.html.haml2
-rw-r--r--app/views/shared/milestones/_issuables.html.haml2
-rw-r--r--app/views/shared/milestones/_issues_tab.html.haml3
-rw-r--r--app/views/shared/milestones/_merge_requests_tab.haml3
-rw-r--r--app/views/shared/milestones/_milestone.html.haml4
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml3
-rw-r--r--app/views/shared/milestones/_tabs.html.haml17
-rw-r--r--app/views/shared/milestones/_top.html.haml2
-rw-r--r--app/views/shared/notes/_edit.html.haml2
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml2
-rw-r--r--app/views/shared/notifications/_button.html.haml2
-rw-r--r--app/views/shared/projects/_search_form.html.haml2
-rw-r--r--app/views/shared/runners/_shared_runners_description.html.haml11
-rw-r--r--app/views/shared/snippets/_blob.html.haml13
-rw-r--r--app/views/shared/snippets/_form.html.haml58
-rw-r--r--app/views/shared/snippets/_header.html.haml47
-rw-r--r--app/views/shared/snippets/_snippet.html.haml2
-rw-r--r--app/views/shared/web_hooks/_form.html.haml2
-rw-r--r--app/views/shared/wikis/_form.html.haml26
-rw-r--r--app/views/shared/wikis/_main_links.html.haml9
-rw-r--r--app/views/shared/wikis/_pages_wiki_page.html.haml2
-rw-r--r--app/views/shared/wikis/_sidebar.html.haml11
-rw-r--r--app/views/shared/wikis/_wiki_directory.html.haml4
-rw-r--r--app/views/shared/wikis/diff.html.haml3
-rw-r--r--app/views/shared/wikis/edit.html.haml3
-rw-r--r--app/views/shared/wikis/empty.html.haml1
-rw-r--r--app/views/shared/wikis/history.html.haml1
-rw-r--r--app/views/shared/wikis/pages.html.haml5
-rw-r--r--app/views/shared/wikis/show.html.haml11
-rw-r--r--app/views/sherlock/file_samples/show.html.haml4
-rw-r--r--app/views/snippets/_actions.html.haml35
-rw-r--r--app/views/snippets/show.html.haml8
-rw-r--r--app/views/users/_overview.html.haml31
-rw-r--r--app/views/users/show.html.haml91
-rw-r--r--app/workers/all_queues.yml167
-rw-r--r--app/workers/analytics/instance_statistics/count_job_trigger_worker.rb5
-rw-r--r--app/workers/archive_trace_worker.rb2
-rw-r--r--app/workers/authorized_project_update/periodic_recalculate_worker.rb4
-rw-r--r--app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb4
-rw-r--r--app/workers/background_migration_worker.rb4
-rw-r--r--app/workers/build_coverage_worker.rb2
-rw-r--r--app/workers/build_finished_worker.rb17
-rw-r--r--app/workers/build_trace_sections_worker.rb2
-rw-r--r--app/workers/chat_notification_worker.rb1
-rw-r--r--app/workers/ci/build_report_result_worker.rb2
-rw-r--r--app/workers/ci/build_trace_chunk_flush_worker.rb4
-rw-r--r--app/workers/ci/delete_objects_worker.rb38
-rw-r--r--app/workers/ci/schedule_delete_objects_cron_worker.rb18
-rw-r--r--app/workers/cleanup_container_repository_worker.rb12
-rw-r--r--app/workers/concerns/limited_capacity/job_tracker.rb74
-rw-r--r--app/workers/concerns/limited_capacity/worker.rb164
-rw-r--r--app/workers/container_expiration_policy_worker.rb16
-rw-r--r--app/workers/deployments/drop_older_deployments_worker.rb14
-rw-r--r--app/workers/deployments/execute_hooks_worker.rb17
-rw-r--r--app/workers/deployments/finished_worker.rb2
-rw-r--r--app/workers/deployments/forward_deployment_worker.rb2
-rw-r--r--app/workers/deployments/link_merge_request_worker.rb18
-rw-r--r--app/workers/deployments/success_worker.rb4
-rw-r--r--app/workers/deployments/update_environment_worker.rb20
-rw-r--r--app/workers/design_management/copy_design_collection_worker.rb26
-rw-r--r--app/workers/design_management/new_version_worker.rb4
-rw-r--r--app/workers/disallow_two_factor_for_group_worker.rb19
-rw-r--r--app/workers/disallow_two_factor_for_subgroups_worker.rb30
-rw-r--r--app/workers/expire_build_instance_artifacts_worker.rb1
-rw-r--r--app/workers/export_csv_worker.rb4
-rw-r--r--app/workers/git_garbage_collect_worker.rb6
-rw-r--r--app/workers/group_destroy_worker.rb1
-rw-r--r--app/workers/group_export_worker.rb2
-rw-r--r--app/workers/group_import_worker.rb4
-rw-r--r--app/workers/incident_management/add_severity_system_note_worker.rb22
-rw-r--r--app/workers/issuable_export_csv_worker.rb53
-rw-r--r--app/workers/issue_placement_worker.rb8
-rw-r--r--app/workers/issue_rebalancing_worker.rb3
-rw-r--r--app/workers/member_invitation_reminder_emails_worker.rb19
-rw-r--r--app/workers/metrics/dashboard/sync_dashboards_worker.rb22
-rw-r--r--app/workers/pages_domain_ssl_renewal_worker.rb1
-rw-r--r--app/workers/pages_domain_verification_worker.rb1
-rw-r--r--app/workers/pages_worker.rb1
-rw-r--r--app/workers/post_receive.rb2
-rw-r--r--app/workers/project_destroy_worker.rb1
-rw-r--r--app/workers/project_export_worker.rb2
-rw-r--r--app/workers/propagate_integration_group_worker.rb25
-rw-r--r--app/workers/propagate_integration_inherit_worker.rb19
-rw-r--r--app/workers/propagate_integration_project_worker.rb22
-rw-r--r--app/workers/propagate_integration_worker.rb3
-rw-r--r--app/workers/repository_import_worker.rb3
-rw-r--r--app/workers/stuck_ci_jobs_worker.rb2
-rw-r--r--app/workers/web_hooks/destroy_worker.rb27
2184 files changed, 34508 insertions, 14680 deletions
diff --git a/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue b/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue
index 2ea55d44420..bc2d96832fa 100644
--- a/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue
+++ b/app/assets/javascripts/admin/cohorts/components/usage_ping_disabled.vue
@@ -9,13 +9,13 @@ export default {
},
inject: {
svgPath: {
- type: String,
+ default: '',
},
docsLink: {
- type: String,
+ default: '',
},
primaryButtonPath: {
- type: String,
+ default: '',
},
},
};
diff --git a/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue b/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue
index 5429ec403d3..316827e1b07 100644
--- a/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue
+++ b/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue
@@ -10,16 +10,16 @@ export default {
},
inject: {
isAdmin: {
- type: Boolean,
+ default: false,
},
svgPath: {
- type: String,
+ default: '',
},
docsLink: {
- type: String,
+ default: '',
},
primaryButtonPath: {
- type: String,
+ default: '',
},
},
};
diff --git a/app/assets/javascripts/alert_handler.js b/app/assets/javascripts/alert_handler.js
index 8fffb61d1dd..26b0142f6a2 100644
--- a/app/assets/javascripts/alert_handler.js
+++ b/app/assets/javascripts/alert_handler.js
@@ -1,13 +1,21 @@
-// This allows us to dismiss alerts that we've migrated from bootstrap
-// Note: This ONLY works on alerts that are created on page load
+// This allows us to dismiss alerts and banners that we've migrated from bootstrap
+// Note: This ONLY works on elements that are created on page load
// You can follow this effort in the following epic
// https://gitlab.com/groups/gitlab-org/-/epics/4070
export default function initAlertHandler() {
- const ALERT_SELECTOR = '.gl-alert';
- const CLOSE_SELECTOR = '.gl-alert-dismiss';
+ const DISMISSIBLE_SELECTORS = ['.gl-alert', '.gl-banner'];
+ const DISMISS_LABEL = '[aria-label="Dismiss"]';
+ const DISMISS_CLASS = '.gl-alert-dismiss';
- const dismissAlert = ({ target }) => target.closest(ALERT_SELECTOR).remove();
- const closeButtons = document.querySelectorAll(`${ALERT_SELECTOR} ${CLOSE_SELECTOR}`);
- closeButtons.forEach(alert => alert.addEventListener('click', dismissAlert));
+ DISMISSIBLE_SELECTORS.forEach(selector => {
+ const elements = document.querySelectorAll(selector);
+ elements.forEach(element => {
+ const button = element.querySelector(DISMISS_LABEL) || element.querySelector(DISMISS_CLASS);
+ if (!button) {
+ return;
+ }
+ button.addEventListener('click', () => element.remove());
+ });
+ });
}
diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue
index c6605452616..f7a5d31b835 100644
--- a/app/assets/javascripts/alert_management/components/alert_details.vue
+++ b/app/assets/javascripts/alert_management/components/alert_details.vue
@@ -1,16 +1,17 @@
<script>
-/* eslint-disable vue/no-v-html */
-import * as Sentry from '@sentry/browser';
import {
GlAlert,
GlBadge,
GlIcon,
+ GlLink,
GlLoadingIcon,
GlSprintf,
GlTabs,
GlTab,
GlButton,
+ GlSafeHtmlDirective,
} from '@gitlab/ui';
+import * as Sentry from '~/sentry/wrapper';
import { s__ } from '~/locale';
import alertQuery from '../graphql/queries/details.query.graphql';
import sidebarStatusQuery from '../graphql/queries/sidebar_status.query.graphql';
@@ -28,6 +29,8 @@ import SystemNote from './system_notes/system_note.vue';
import AlertSidebar from './alert_sidebar.vue';
import AlertMetrics from './alert_metrics.vue';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
+import AlertSummaryRow from './alert_summary_row.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const containerEl = document.querySelector('.page-with-contextual-sidebar');
@@ -39,6 +42,9 @@ export default {
reportedAt: s__('AlertManagement|Reported %{when}'),
reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'),
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
severityLabels: ALERTS_SEVERITY_LABELS,
tabsConfig: [
{
@@ -56,9 +62,11 @@ export default {
],
components: {
AlertDetailsTable,
+ AlertSummaryRow,
GlBadge,
GlAlert,
GlIcon,
+ GlLink,
GlLoadingIcon,
GlSprintf,
GlTab,
@@ -69,20 +77,18 @@ export default {
SystemNote,
AlertMetrics,
},
+ mixins: [glFeatureFlagsMixin()],
inject: {
projectPath: {
default: '',
},
alertId: {
- type: String,
default: '',
},
projectId: {
- type: String,
default: '',
},
projectIssuesPath: {
- type: String,
default: '',
},
},
@@ -143,6 +149,15 @@ export default {
this.$router.replace({ name: 'tab', params: { tabId } });
},
},
+ environmentName() {
+ return this.shouldDisplayEnvironment && this.alert?.environment?.name;
+ },
+ environmentPath() {
+ return this.shouldDisplayEnvironment && this.alert?.environment?.path;
+ },
+ shouldDisplayEnvironment() {
+ return this.glFeatures.exposeEnvironmentPathInAlertDetails;
+ },
},
mounted() {
this.trackPageViews();
@@ -211,7 +226,7 @@ export default {
<template>
<div>
<gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError">
- <p v-html="sidebarErrorMessage || $options.i18n.errorMsg"></p>
+ <p v-safe-html="sidebarErrorMessage || $options.i18n.errorMsg"></p>
</gl-alert>
<gl-alert
v-if="createIncidentError"
@@ -270,10 +285,9 @@ export default {
variant="default"
class="d-sm-none gl-absolute toggle-sidebar-mobile-button"
type="button"
+ icon="chevron-double-lg-left"
@click="toggleSidebar"
- >
- <i class="fa fa-angle-double-left"></i>
- </gl-button>
+ />
</div>
<div
v-if="alert"
@@ -283,54 +297,65 @@ export default {
</div>
<gl-tabs v-if="alert" v-model="currentTabIndex" data-testid="alertDetailsTabs">
<gl-tab :data-testid="$options.tabsConfig[0].id" :title="$options.tabsConfig[0].title">
- <div v-if="alert.severity" class="gl-mt-3 gl-mb-5 gl-display-flex">
- <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Severity') }}:
- </div>
- <div class="gl-pl-2" data-testid="severity">
- <span>
- <gl-icon
- class="gl-vertical-align-middle"
- :size="12"
- :name="`severity-${alert.severity.toLowerCase()}`"
- :class="`icon-${alert.severity.toLowerCase()}`"
- />
- </span>
+ <alert-summary-row v-if="alert.severity" :label="`${s__('AlertManagement|Severity')}:`">
+ <span data-testid="severity">
+ <gl-icon
+ class="gl-vertical-align-middle"
+ :size="12"
+ :name="`severity-${alert.severity.toLowerCase()}`"
+ :class="`icon-${alert.severity.toLowerCase()}`"
+ />
{{ $options.severityLabels[alert.severity] }}
- </div>
- </div>
- <div v-if="alert.startedAt" class="gl-my-5 gl-display-flex">
- <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Start time') }}:
- </div>
- <div class="gl-pl-2">
- <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" />
- </div>
- </div>
- <div v-if="alert.eventCount" class="gl-my-5 gl-display-flex">
- <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Events') }}:
- </div>
- <div class="gl-pl-2" data-testid="eventCount">{{ alert.eventCount }}</div>
- </div>
- <div v-if="alert.monitoringTool" class="gl-my-5 gl-display-flex">
- <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Tool') }}:
- </div>
- <div class="gl-pl-2" data-testid="monitoringTool">{{ alert.monitoringTool }}</div>
- </div>
- <div v-if="alert.service" class="gl-my-5 gl-display-flex">
- <div class="bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Service') }}:
- </div>
- <div class="gl-pl-2" data-testid="service">{{ alert.service }}</div>
- </div>
- <div v-if="alert.runbook" class="gl-my-5 gl-display-flex">
- <div class="bold gl-w-13 gl-text-right gl-pr-3">
- {{ s__('AlertManagement|Runbook') }}:
- </div>
- <div class="gl-pl-2" data-testid="runbook">{{ alert.runbook }}</div>
- </div>
+ </span>
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="environmentName"
+ :label="`${s__('AlertManagement|Environment')}:`"
+ >
+ <gl-link
+ v-if="environmentPath"
+ class="gl-display-inline-block"
+ data-testid="environmentPath"
+ :href="environmentPath"
+ >
+ {{ environmentName }}
+ </gl-link>
+ <span v-else data-testid="environmentName">{{ environmentName }}</span>
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.startedAt"
+ :label="`${s__('AlertManagement|Start time')}:`"
+ >
+ <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" />
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.eventCount"
+ :label="`${s__('AlertManagement|Events')}:`"
+ data-testid="eventCount"
+ >
+ {{ alert.eventCount }}
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.monitoringTool"
+ :label="`${s__('AlertManagement|Tool')}:`"
+ data-testid="monitoringTool"
+ >
+ {{ alert.monitoringTool }}
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.service"
+ :label="`${s__('AlertManagement|Service')}:`"
+ data-testid="service"
+ >
+ {{ alert.service }}
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.runbook"
+ :label="`${s__('AlertManagement|Runbook')}:`"
+ data-testid="runbook"
+ >
+ {{ alert.runbook }}
+ </alert-summary-row>
<alert-details-table :alert="alert" :loading="loading" />
</gl-tab>
<gl-tab :data-testid="$options.tabsConfig[1].id" :title="$options.tabsConfig[1].title">
diff --git a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue
index 68443166f40..c5ff2dc0d11 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue
@@ -33,30 +33,13 @@ export default {
query: alertsHelpUrlQuery,
},
},
- props: {
- enableAlertManagementPath: {
- type: String,
- required: true,
- },
- userCanEnableAlertManagement: {
- type: Boolean,
- required: true,
- },
- emptyAlertSvgPath: {
- type: String,
- required: true,
- },
- opsgenieMvcEnabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- opsgenieMvcTargetUrl: {
- type: String,
- required: false,
- default: '',
- },
- },
+ inject: [
+ 'enableAlertManagementPath',
+ 'userCanEnableAlertManagement',
+ 'emptyAlertSvgPath',
+ 'opsgenieMvcEnabled',
+ 'opsgenieMvcTargetUrl',
+ ],
data() {
return {
alertsHelpUrl: '',
diff --git a/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue b/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue
index 094f33fed3b..5e9cdfb3fed 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_list_wrapper.vue
@@ -1,6 +1,4 @@
<script>
-import Tracking from '~/tracking';
-import { trackAlertListViewsOptions } from '../constants';
import AlertManagementEmptyState from './alert_management_empty_state.vue';
import AlertManagementTable from './alert_management_table.vue';
@@ -9,67 +7,12 @@ export default {
AlertManagementEmptyState,
AlertManagementTable,
},
- props: {
- projectPath: {
- type: String,
- required: true,
- },
- alertManagementEnabled: {
- type: Boolean,
- required: true,
- },
- enableAlertManagementPath: {
- type: String,
- required: true,
- },
- populatingAlertsHelpUrl: {
- type: String,
- required: true,
- },
- userCanEnableAlertManagement: {
- type: Boolean,
- required: true,
- },
- emptyAlertSvgPath: {
- type: String,
- required: true,
- },
- opsgenieMvcEnabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- opsgenieMvcTargetUrl: {
- type: String,
- required: false,
- default: '',
- },
- },
- mounted() {
- this.trackPageViews();
- },
- methods: {
- trackPageViews() {
- const { category, action } = trackAlertListViewsOptions;
- Tracking.event(category, action);
- },
- },
+ inject: ['alertManagementEnabled'],
};
</script>
<template>
<div>
- <alert-management-table
- v-if="alertManagementEnabled"
- :populating-alerts-help-url="populatingAlertsHelpUrl"
- :project-path="projectPath"
- />
- <alert-management-empty-state
- v-else
- :empty-alert-svg-path="emptyAlertSvgPath"
- :enable-alert-management-path="enableAlertManagementPath"
- :user-can-enable-alert-management="userCanEnableAlertManagement"
- :opsgenie-mvc-enabled="opsgenieMvcEnabled"
- :opsgenie-mvc-target-url="opsgenieMvcTargetUrl"
- />
+ <alert-management-table v-if="alertManagementEnabled" />
+ <alert-management-empty-state v-else />
</div>
</template>
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 0fd00fe90eb..f287b425826 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_table.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue
@@ -1,58 +1,43 @@
<script>
-/* eslint-disable vue/no-v-html */
import {
+ GlAlert,
GlLoadingIcon,
GlTable,
- GlAlert,
GlAvatarsInline,
GlAvatarLink,
GlAvatar,
GlIcon,
GlLink,
- GlTabs,
- GlTab,
- GlBadge,
- GlPagination,
- GlSearchBoxByType,
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
-import { debounce, trim } from 'lodash';
-import { __, s__ } from '~/locale';
-import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
+import { s__, __ } from '~/locale';
import { fetchPolicies } from '~/lib/graphql';
+import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
+import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue';
+import {
+ tdClass,
+ thClass,
+ bodyTrClass,
+ initialPaginationState,
+} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
-import Tracking from '~/tracking';
import getAlerts from '../graphql/queries/get_alerts.query.graphql';
import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
import {
ALERTS_STATUS_TABS,
ALERTS_SEVERITY_LABELS,
- DEFAULT_PAGE_SIZE,
trackAlertListViewsOptions,
- trackAlertStatusUpdateOptions,
} from '../constants';
import AlertStatus from './alert_status.vue';
-const tdClass =
- 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap';
-const thClass = 'gl-hover-bg-blue-50';
-const bodyTrClass =
- 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-bg-blue-50 gl-hover-cursor-pointer gl-hover-border-b-solid gl-hover-border-blue-200';
const TH_TEST_ID = { 'data-testid': 'alert-management-severity-sort' };
-const initialPaginationState = {
- currentPage: 1,
- prevPageCursor: '',
- nextPageCursor: '',
- firstPageSize: DEFAULT_PAGE_SIZE,
- lastPageSize: null,
-};
-
const TWELVE_HOURS_IN_MS = 12 * 60 * 60 * 1000;
export default {
+ trackAlertListViewsOptions,
i18n: {
noAlertsMsg: s__(
'AlertManagement|No alerts available to display. See %{linkStart}enabling alert management%{linkEnd} for more information on adding alerts to the list.',
@@ -60,7 +45,6 @@ export default {
errorMsg: s__(
"AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.",
),
- searchPlaceholder: __('Search or filter results...'),
unassigned: __('Unassigned'),
},
fields: [
@@ -94,7 +78,7 @@ export default {
},
{
key: 'issue',
- label: s__('AlertManagement|Issue'),
+ label: s__('AlertManagement|Incident'),
thClass: 'gl-w-12 gl-pointer-events-none',
tdClass,
},
@@ -115,36 +99,23 @@ export default {
severityLabels: ALERTS_SEVERITY_LABELS,
statusTabs: ALERTS_STATUS_TABS,
components: {
+ GlAlert,
GlLoadingIcon,
GlTable,
- GlAlert,
GlAvatarsInline,
GlAvatarLink,
GlAvatar,
TimeAgo,
GlIcon,
GlLink,
- GlTabs,
- GlTab,
- GlBadge,
- GlPagination,
- GlSearchBoxByType,
GlSprintf,
AlertStatus,
+ PaginatedTableWithSearchAndTabs,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- props: {
- projectPath: {
- type: String,
- required: true,
- },
- populatingAlertsHelpUrl: {
- type: String,
- required: true,
- },
- },
+ inject: ['projectPath', 'textQuery', 'assigneeUsernameQuery', 'populatingAlertsHelpUrl'],
apollo: {
alerts: {
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
@@ -152,6 +123,7 @@ export default {
variables() {
return {
searchTerm: this.searchTerm,
+ assigneeUsername: this.assigneeUsername,
projectPath: this.projectPath,
statuses: this.statusFilter,
sort: this.sort,
@@ -182,14 +154,16 @@ export default {
};
},
error() {
- this.hasError = true;
+ this.errored = true;
},
},
alertsCount: {
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
query: getAlertsCountByStatus,
variables() {
return {
searchTerm: this.searchTerm,
+ assigneeUsername: this.assigneeUsername,
projectPath: this.projectPath,
};
},
@@ -200,83 +174,53 @@ export default {
},
data() {
return {
- searchTerm: '',
- hasError: false,
- errorMessage: '',
- isAlertDismissed: false,
+ errored: false,
+ serverErrorMessage: '',
+ isErrorAlertDismissed: false,
sort: 'STARTED_AT_DESC',
statusFilter: [],
filteredByStatus: '',
- pagination: initialPaginationState,
+ alerts: {},
+ alertsCount: {},
sortBy: 'startedAt',
sortDesc: true,
sortDirection: 'desc',
+ searchTerm: this.textQuery,
+ assigneeUsername: this.assigneeUsernameQuery,
+ pagination: initialPaginationState,
};
},
computed: {
+ showErrorMsg() {
+ return this.errored && !this.isErrorAlertDismissed;
+ },
showNoAlertsMsg() {
return (
- !this.hasError &&
+ !this.errored &&
!this.loading &&
this.alertsCount?.all === 0 &&
!this.searchTerm &&
- !this.isAlertDismissed
+ !this.assigneeUsername &&
+ !this.isErrorAlertDismissed
);
},
loading() {
return this.$apollo.queries.alerts.loading;
},
- hasAlerts() {
- return this.alerts?.list?.length;
- },
- showPaginationControls() {
- return Boolean(this.prevPage || this.nextPage);
- },
- alertsForCurrentTab() {
- return this.alertsCount ? this.alertsCount[this.filteredByStatus.toLowerCase()] : 0;
- },
- prevPage() {
- return Math.max(this.pagination.currentPage - 1, 0);
- },
- nextPage() {
- const nextPage = this.pagination.currentPage + 1;
- return nextPage > Math.ceil(this.alertsForCurrentTab / DEFAULT_PAGE_SIZE) ? null : nextPage;
+ isEmpty() {
+ return !this.alerts?.list?.length;
},
},
- mounted() {
- this.trackPageViews();
- },
methods: {
- filterAlertsByStatus(tabIndex) {
- this.resetPagination();
- const { filters, status } = this.$options.statusTabs[tabIndex];
- this.statusFilter = filters;
- this.filteredByStatus = status;
- },
fetchSortedData({ sortBy, sortDesc }) {
const sortingDirection = sortDesc ? 'DESC' : 'ASC';
const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
- this.resetPagination();
+ this.pagination = initialPaginationState;
this.sort = `${sortingColumn}_${sortingDirection}`;
},
- onInputChange: debounce(function debounceSearch(input) {
- const trimmedInput = trim(input);
- if (trimmedInput !== this.searchTerm) {
- this.resetPagination();
- this.searchTerm = trimmedInput;
- }
- }, 500),
- navigateToAlertDetails({ iid }) {
- return visitUrl(joinPaths(window.location.pathname, iid, 'details'));
- },
- trackPageViews() {
- const { category, action } = trackAlertListViewsOptions;
- Tracking.event(category, action);
- },
- trackStatusUpdate(status) {
- const { category, action, label } = trackAlertStatusUpdateOptions;
- Tracking.event(category, action, { label, property: status });
+ navigateToAlertDetails({ iid }, index, { metaKey }) {
+ return visitUrl(joinPaths(window.location.pathname, iid, 'details'), metaKey);
},
hasAssignees(assignees) {
return Boolean(assignees.nodes?.length);
@@ -284,204 +228,180 @@ export default {
getIssueLink(item) {
return joinPaths('/', this.projectPath, '-', 'issues', item.issueIid);
},
- handlePageChange(page) {
- const { startCursor, endCursor } = this.alerts.pageInfo;
-
- if (page > this.pagination.currentPage) {
- this.pagination = {
- ...initialPaginationState,
- nextPageCursor: endCursor,
- currentPage: page,
- };
- } else {
- this.pagination = {
- lastPageSize: DEFAULT_PAGE_SIZE,
- firstPageSize: null,
- prevPageCursor: startCursor,
- nextPageCursor: '',
- currentPage: page,
- };
- }
- },
- resetPagination() {
- this.pagination = initialPaginationState;
- },
tbodyTrClass(item) {
return {
- [bodyTrClass]: !this.loading && this.hasAlerts,
+ [bodyTrClass]: !this.loading && !this.isEmpty,
'new-alert': item?.isNew,
};
},
handleAlertError(errorMessage) {
- this.hasError = true;
- this.errorMessage = errorMessage;
+ this.errored = true;
+ this.serverErrorMessage = errorMessage;
},
- dismissError() {
- this.hasError = false;
- this.errorMessage = '';
+ handleStatusUpdate() {
+ this.$apollo.queries.alerts.refetch();
+ this.$apollo.queries.alertsCount.refetch();
+ },
+ pageChanged(pagination) {
+ this.pagination = pagination;
+ },
+ statusChanged({ filters, status }) {
+ this.statusFilter = filters;
+ this.filteredByStatus = status;
+ },
+ filtersChanged({ searchTerm, assigneeUsername }) {
+ this.searchTerm = searchTerm;
+ this.assigneeUsername = assigneeUsername;
+ },
+ errorAlertDismissed() {
+ this.errored = false;
+ this.serverErrorMessage = '';
+ this.isErrorAlertDismissed = true;
},
},
};
</script>
<template>
<div>
- <div class="incident-management-list">
- <gl-alert v-if="showNoAlertsMsg" @dismiss="isAlertDismissed = true">
- <gl-sprintf :message="$options.i18n.noAlertsMsg">
- <template #link="{ content }">
- <gl-link
- class="gl-display-inline-block"
- :href="populatingAlertsHelpUrl"
- target="_blank"
- >
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </gl-alert>
- <gl-alert v-if="hasError" variant="danger" data-testid="alert-error" @dismiss="dismissError">
- <p v-html="errorMessage || $options.i18n.errorMsg"></p>
- </gl-alert>
-
- <gl-tabs
- content-class="gl-p-0 gl-border-b-solid gl-border-b-1 gl-border-gray-100"
- @input="filterAlertsByStatus"
- >
- <gl-tab v-for="tab in $options.statusTabs" :key="tab.status">
- <template slot="title">
- <span>{{ tab.title }}</span>
- <gl-badge v-if="alertsCount" pill size="sm" class="gl-tab-counter-badge">
- {{ alertsCount[tab.status.toLowerCase()] }}
- </gl-badge>
- </template>
- </gl-tab>
- </gl-tabs>
+ <gl-alert v-if="showNoAlertsMsg" @dismiss="errorAlertDismissed">
+ <gl-sprintf :message="$options.i18n.noAlertsMsg">
+ <template #link="{ content }">
+ <gl-link class="gl-display-inline-block" :href="populatingAlertsHelpUrl" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
- <div class="gl-bg-gray-10 gl-p-5 gl-border-b-solid gl-border-b-1 gl-border-gray-100">
- <gl-search-box-by-type
- class="gl-bg-white"
- :placeholder="$options.i18n.searchPlaceholder"
- @input="onInputChange"
- />
- </div>
+ <paginated-table-with-search-and-tabs
+ :show-error-msg="showErrorMsg"
+ :i18n="$options.i18n"
+ :items="alerts.list || []"
+ :page-info="alerts.pageInfo"
+ :items-count="alertsCount"
+ :status-tabs="$options.statusTabs"
+ :track-views-options="$options.trackAlertListViewsOptions"
+ :server-error-message="serverErrorMessage"
+ :filter-search-tokens="['assignee_username']"
+ filter-search-key="alerts"
+ @page-changed="pageChanged"
+ @tabs-changed="statusChanged"
+ @filters-changed="filtersChanged"
+ @error-alert-dismissed="errorAlertDismissed"
+ >
+ <template #header-actions></template>
- <h4 class="d-block d-md-none my-3">
+ <template #title>
{{ s__('AlertManagement|Alerts') }}
- </h4>
- <gl-table
- class="alert-management-table"
- :items="alerts ? alerts.list : []"
- :fields="$options.fields"
- :show-empty="true"
- :busy="loading"
- stacked="md"
- :tbody-tr-class="tbodyTrClass"
- :no-local-sorting="true"
- :sort-direction="sortDirection"
- :sort-desc.sync="sortDesc"
- :sort-by.sync="sortBy"
- sort-icon-left
- fixed
- @row-clicked="navigateToAlertDetails"
- @sort-changed="fetchSortedData"
- >
- <template #cell(severity)="{ item }">
- <div
- class="d-inline-flex align-items-center justify-content-between"
- data-testid="severityField"
- >
- <gl-icon
- class="mr-2"
- :size="12"
- :name="`severity-${item.severity.toLowerCase()}`"
- :class="`icon-${item.severity.toLowerCase()}`"
- />
- {{ $options.severityLabels[item.severity] }}
- </div>
- </template>
+ </template>
- <template #cell(startedAt)="{ item }">
- <time-ago v-if="item.startedAt" :time="item.startedAt" />
- </template>
+ <template #table>
+ <gl-table
+ class="alert-management-table"
+ :items="alerts ? alerts.list : []"
+ :fields="$options.fields"
+ :show-empty="true"
+ :busy="loading"
+ stacked="md"
+ :tbody-tr-class="tbodyTrClass"
+ :no-local-sorting="true"
+ :sort-direction="sortDirection"
+ :sort-desc.sync="sortDesc"
+ :sort-by.sync="sortBy"
+ sort-icon-left
+ fixed
+ @row-clicked="navigateToAlertDetails"
+ @sort-changed="fetchSortedData"
+ >
+ <template #cell(severity)="{ item }">
+ <div
+ class="d-inline-flex align-items-center justify-content-between"
+ data-testid="severityField"
+ >
+ <gl-icon
+ class="mr-2"
+ :size="12"
+ :name="`severity-${item.severity.toLowerCase()}`"
+ :class="`icon-${item.severity.toLowerCase()}`"
+ />
+ {{ $options.severityLabels[item.severity] }}
+ </div>
+ </template>
- <template #cell(eventCount)="{ item }">
- {{ item.eventCount }}
- </template>
+ <template #cell(startedAt)="{ item }">
+ <time-ago v-if="item.startedAt" :time="item.startedAt" />
+ </template>
- <template #cell(alertLabel)="{ item }">
- <div
- class="gl-max-w-full text-truncate"
- :title="`${item.iid} - ${item.title}`"
- data-testid="idField"
- >
- #{{ item.iid }} {{ item.title }}
- </div>
- </template>
+ <template #cell(eventCount)="{ item }">
+ {{ item.eventCount }}
+ </template>
- <template #cell(issue)="{ item }">
- <gl-link v-if="item.issueIid" data-testid="issueField" :href="getIssueLink(item)">
- #{{ item.issueIid }}
- </gl-link>
- <div v-else data-testid="issueField">{{ s__('AlertManagement|None') }}</div>
- </template>
+ <template #cell(alertLabel)="{ item }">
+ <div
+ class="gl-max-w-full text-truncate"
+ :title="`${item.iid} - ${item.title}`"
+ data-testid="idField"
+ >
+ #{{ item.iid }} {{ item.title }}
+ </div>
+ </template>
- <template #cell(assignees)="{ item }">
- <div data-testid="assigneesField">
- <template v-if="hasAssignees(item.assignees)">
- <gl-avatars-inline
- :avatars="item.assignees.nodes"
- :collapsed="true"
- :max-visible="4"
- :avatar-size="24"
- badge-tooltip-prop="name"
- :badge-tooltip-max-chars="100"
- >
- <template #avatar="{ avatar }">
- <gl-avatar-link
- :key="avatar.username"
- v-gl-tooltip
- target="_blank"
- :href="avatar.webUrl"
- :title="avatar.name"
- >
- <gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="24" />
- </gl-avatar-link>
- </template>
- </gl-avatars-inline>
- </template>
- <template v-else>
- {{ $options.i18n.unassigned }}
- </template>
- </div>
- </template>
+ <template #cell(issue)="{ item }">
+ <gl-link v-if="item.issueIid" data-testid="issueField" :href="getIssueLink(item)">
+ #{{ item.issueIid }}
+ </gl-link>
+ <div v-else data-testid="issueField">{{ s__('AlertManagement|None') }}</div>
+ </template>
- <template #cell(status)="{ item }">
- <alert-status
- :alert="item"
- :project-path="projectPath"
- :is-sidebar="false"
- @alert-error="handleAlertError"
- />
- </template>
+ <template #cell(assignees)="{ item }">
+ <div data-testid="assigneesField">
+ <template v-if="hasAssignees(item.assignees)">
+ <gl-avatars-inline
+ :avatars="item.assignees.nodes"
+ :collapsed="true"
+ :max-visible="4"
+ :avatar-size="24"
+ badge-tooltip-prop="name"
+ :badge-tooltip-max-chars="100"
+ >
+ <template #avatar="{ avatar }">
+ <gl-avatar-link
+ :key="avatar.username"
+ v-gl-tooltip
+ target="_blank"
+ :href="avatar.webUrl"
+ :title="avatar.name"
+ >
+ <gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="24" />
+ </gl-avatar-link>
+ </template>
+ </gl-avatars-inline>
+ </template>
+ <template v-else>
+ {{ $options.i18n.unassigned }}
+ </template>
+ </div>
+ </template>
- <template #empty>
- {{ s__('AlertManagement|No alerts to display.') }}
- </template>
+ <template #cell(status)="{ item }">
+ <alert-status
+ :alert="item"
+ :project-path="projectPath"
+ :is-sidebar="false"
+ @alert-error="handleAlertError"
+ @hide-dropdown="handleStatusUpdate"
+ />
+ </template>
- <template #table-busy>
- <gl-loading-icon size="lg" color="dark" class="mt-3" />
- </template>
- </gl-table>
+ <template #empty>
+ {{ s__('AlertManagement|No alerts to display.') }}
+ </template>
- <gl-pagination
- v-if="showPaginationControls"
- :value="pagination.currentPage"
- :prev-page="prevPage"
- :next-page="nextPage"
- align="center"
- class="gl-pagination gl-mt-3"
- @input="handlePageChange"
- />
- </div>
+ <template #table-busy>
+ <gl-loading-icon size="lg" color="dark" class="mt-3" />
+ </template>
+ </gl-table>
+ </template>
+ </paginated-table-with-search-and-tabs>
</div>
</template>
diff --git a/app/assets/javascripts/alert_management/components/alert_metrics.vue b/app/assets/javascripts/alert_management/components/alert_metrics.vue
index c5b40edc672..8a6490ecd5c 100644
--- a/app/assets/javascripts/alert_management/components/alert_metrics.vue
+++ b/app/assets/javascripts/alert_management/components/alert_metrics.vue
@@ -1,7 +1,7 @@
<script>
import Vue from 'vue';
import Vuex from 'vuex';
-import * as Sentry from '@sentry/browser';
+import * as Sentry from '~/sentry/wrapper';
Vue.use(Vuex);
diff --git a/app/assets/javascripts/alert_management/components/alert_sidebar.vue b/app/assets/javascripts/alert_management/components/alert_sidebar.vue
index 64e4089c85a..41d77716592 100644
--- a/app/assets/javascripts/alert_management/components/alert_sidebar.vue
+++ b/app/assets/javascripts/alert_management/components/alert_sidebar.vue
@@ -18,7 +18,6 @@ export default {
default: '',
},
projectId: {
- type: String,
default: '',
},
},
diff --git a/app/assets/javascripts/alert_management/components/alert_status.vue b/app/assets/javascripts/alert_management/components/alert_status.vue
index ff71b348cc9..3083a85cbd9 100644
--- a/app/assets/javascripts/alert_management/components/alert_status.vue
+++ b/app/assets/javascripts/alert_management/components/alert_status.vue
@@ -1,9 +1,9 @@
<script>
-import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlButton } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
import { trackAlertStatusUpdateOptions } from '../constants';
-import updateAlertStatus from '../graphql/mutations/update_alert_status.mutation.graphql';
+import updateAlertStatusMutation from '../graphql/mutations/update_alert_status.mutation.graphql';
export default {
i18n: {
@@ -18,9 +18,8 @@ export default {
RESOLVED: s__('AlertManagement|Resolved'),
},
components: {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
- GlButton,
+ GlDropdown,
+ GlDropdownItem,
},
props: {
projectPath: {
@@ -51,7 +50,7 @@ export default {
this.$emit('handle-updating', true);
this.$apollo
.mutate({
- mutation: updateAlertStatus,
+ mutation: updateAlertStatusMutation,
variables: {
iid: this.alert.iid,
status: status.toUpperCase(),
@@ -60,8 +59,6 @@ export default {
})
.then(resp => {
this.trackStatusUpdate(status);
- this.$emit('hide-dropdown');
-
const errors = resp.data?.updateAlertStatus?.errors || [];
if (errors[0]) {
@@ -70,6 +67,8 @@ export default {
`${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${errors[0]}`,
);
}
+
+ this.$emit('hide-dropdown');
})
.catch(() => {
this.$emit(
@@ -91,39 +90,30 @@ export default {
<template>
<div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
- <gl-deprecated-dropdown
+ <gl-dropdown
ref="dropdown"
right
:text="$options.statuses[alert.status]"
class="w-100"
toggle-class="dropdown-menu-toggle"
- variant="outline-default"
@keydown.esc.native="$emit('hide-dropdown')"
@hide="$emit('hide-dropdown')"
>
- <div v-if="isSidebar" class="dropdown-title gl-display-flex">
- <span class="alert-title gl-ml-auto">{{ s__('AlertManagement|Assign status') }}</span>
- <gl-button
- :aria-label="__('Close')"
- variant="link"
- class="dropdown-title-button dropdown-menu-close gl-ml-auto gl-text-black-normal!"
- icon="close"
- @click="$emit('hide-dropdown')"
- />
- </div>
+ <p v-if="isSidebar" class="gl-new-dropdown-header-top" data-testid="dropdown-header">
+ {{ s__('AlertManagement|Assign status') }}
+ </p>
<div class="dropdown-content dropdown-body">
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
v-for="(label, field) in $options.statuses"
:key="field"
data-testid="statusDropdownItem"
- class="gl-vertical-align-middle"
:active="label.toUpperCase() === alert.status"
:active-class="'is-active'"
@click="updateAlertStatus(label)"
>
{{ label }}
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
</div>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/alert_management/components/alert_summary_row.vue b/app/assets/javascripts/alert_management/components/alert_summary_row.vue
new file mode 100644
index 00000000000..13835b7e2fa
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/alert_summary_row.vue
@@ -0,0 +1,18 @@
+<script>
+export default {
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-my-5 gl-display-flex">
+ <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">{{ label }}</div>
+ <div class="gl-pl-2">
+ <slot></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue
index 0a1478ef5fe..df07038151e 100644
--- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue
+++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue
@@ -1,9 +1,9 @@
<script>
-import { GlDeprecatedDropdownItem } from '@gitlab/ui';
+import { GlDropdownItem } from '@gitlab/ui';
export default {
components: {
- GlDeprecatedDropdownItem,
+ GlDropdownItem,
},
props: {
user: {
@@ -24,7 +24,7 @@ export default {
</script>
<template>
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
:key="user.username"
data-testid="assigneeDropdownItem"
class="assignee-dropdown-item gl-vertical-align-middle"
@@ -47,5 +47,5 @@ export default {
</strong>
<span class="dropdown-menu-user-username"> {{ user.username }}</span>
</span>
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue
index 0f354e85e96..5e4fd56738b 100644
--- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue
+++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue
@@ -1,10 +1,11 @@
<script>
import {
GlIcon,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownHeader,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ GlSearchBoxByType,
GlLoadingIcon,
GlTooltip,
GlButton,
@@ -33,10 +34,11 @@ export default {
},
components: {
GlIcon,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownHeader,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
GlLoadingIcon,
GlTooltip,
GlButton,
@@ -216,48 +218,32 @@ export default {
</p>
<div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
- <gl-deprecated-dropdown
+ <gl-dropdown
ref="dropdown"
:text="userName"
class="w-100"
toggle-class="dropdown-menu-toggle"
- variant="outline-default"
@keydown.esc.native="hideDropdown"
@hide="hideDropdown"
>
- <div class="dropdown-title gl-display-flex">
- <span class="alert-title gl-ml-auto">{{ __('Assign To') }}</span>
- <gl-button
- :aria-label="__('Close')"
- variant="link"
- class="dropdown-title-button dropdown-menu-close gl-ml-auto gl-text-black-normal!"
- icon="close"
- @click="hideDropdown"
- />
- </div>
- <div class="dropdown-input">
- <input
- v-model.trim="search"
- class="dropdown-input-field"
- type="search"
- :placeholder="__('Search users')"
- />
- <gl-icon name="search" class="dropdown-input-search ic-search" data-hidden="true" />
- </div>
+ <p class="gl-new-dropdown-header-top">
+ {{ __('Assign To') }}
+ </p>
+ <gl-search-box-by-type v-model.trim="search" :placeholder="__('Search users')" />
<div class="dropdown-content dropdown-body">
<template v-if="userListValid">
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
:active="!userName"
active-class="is-active"
@click="updateAlertAssignees('')"
>
{{ __('Unassigned') }}
- </gl-deprecated-dropdown-item>
- <gl-deprecated-dropdown-divider />
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
- <gl-deprecated-dropdown-header class="mt-0">
+ <gl-dropdown-section-header>
{{ __('Assignee') }}
- </gl-deprecated-dropdown-header>
+ </gl-dropdown-section-header>
<sidebar-assignee
v-for="user in sortedUsers"
:key="user.username"
@@ -266,12 +252,12 @@ export default {
@update-alert-assignees="updateAlertAssignees"
/>
</template>
- <gl-deprecated-dropdown-item v-else-if="userListEmpty">
+ <p v-else-if="userListEmpty" class="mx-3 my-2">
{{ __('No Matching Results') }}
- </gl-deprecated-dropdown-item>
+ </p>
<gl-loading-icon v-else />
</div>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
</div>
<gl-loading-icon v-if="isUpdating" :inline="true" />
diff --git a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue
index 0b206ce42f4..3705e36a579 100644
--- a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue
+++ b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue
@@ -1,11 +1,12 @@
<script>
/* eslint-disable vue/no-v-html */
+import { GlIcon } from '@gitlab/ui';
import NoteHeader from '~/notes/components/note_header.vue';
-import { spriteIcon } from '~/lib/utils/common_utils';
export default {
components: {
NoteHeader,
+ GlIcon,
},
props: {
note: {
@@ -24,23 +25,23 @@ export default {
} = this.note;
return { ...author, id: id?.split('/').pop() };
},
- iconHtml() {
- return spriteIcon(this.note?.systemNoteIconName);
- },
},
};
</script>
<template>
- <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-px-0!">
- <div class="timeline-entry-inner">
- <div class="timeline-icon" v-html="iconHtml"></div>
- <div class="timeline-content">
- <div class="note-header">
- <note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id">
- <span v-html="note.bodyHtml"></span>
- </note-header>
- </div>
+ <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-p-0!">
+ <div class="gl-display-inline-flex gl-align-items-center">
+ <div
+ class="gl-display-inline gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-box-sizing-content-box gl-p-3 gl-mt-n2 gl-mr-6"
+ >
+ <gl-icon :name="note.systemNoteIconName" />
+ </div>
+
+ <div class="note-header">
+ <note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id">
+ <span v-html="note.bodyHtml"></span>
+ </note-header>
</div>
</div>
</li>
diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js
index 73cb5ecdf98..b79a64646eb 100644
--- a/app/assets/javascripts/alert_management/constants.js
+++ b/app/assets/javascripts/alert_management/constants.js
@@ -63,5 +63,3 @@ export const trackAlertStatusUpdateOptions = {
action: 'update_alert_status',
label: 'Status',
};
-
-export const DEFAULT_PAGE_SIZE = 20;
diff --git a/app/assets/javascripts/alert_management/details.js b/app/assets/javascripts/alert_management/details.js
index c2020dfcbe3..cbbdecae390 100644
--- a/app/assets/javascripts/alert_management/details.js
+++ b/app/assets/javascripts/alert_management/details.js
@@ -1,11 +1,11 @@
+import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import produce from 'immer';
-import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql';
-import createRouter from './router';
import AlertDetails from './components/alert_details.vue';
import sidebarStatusQuery from './graphql/queries/sidebar_status.query.graphql';
+import createRouter from './router';
Vue.use(VueApollo);
diff --git a/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql
index 0712ff12c23..406dfe97ce0 100644
--- a/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql
+++ b/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql
@@ -10,6 +10,11 @@ fragment AlertDetailItem on AlertManagementAlert {
description
updatedAt
endedAt
+ hosts
+ environment {
+ name
+ path
+ }
details
runbook
todos {
diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql
index 8ac00bbc6b5..bc7e51a2e90 100644
--- a/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql
+++ b/app/assets/javascripts/alert_management/graphql/queries/get_alerts.query.graphql
@@ -1,7 +1,6 @@
#import "../fragments/list_item.fragment.graphql"
query getAlerts(
- $searchTerm: String
$projectPath: ID!
$statuses: [AlertManagementStatus!]
$sort: AlertManagementAlertSort
@@ -9,10 +8,13 @@ query getAlerts(
$lastPageSize: Int
$prevPageCursor: String = ""
$nextPageCursor: String = ""
+ $searchTerm: String = ""
+ $assigneeUsername: String = ""
) {
project(fullPath: $projectPath) {
alertManagementAlerts(
search: $searchTerm
+ assigneeUsername: $assigneeUsername
statuses: $statuses
sort: $sort
first: $firstPageSize
diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql
index 5a6faea5cd8..40ec4c56171 100644
--- a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql
+++ b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql
@@ -1,6 +1,6 @@
-query getAlertsCount($searchTerm: String, $projectPath: ID!) {
+query getAlertsCount($searchTerm: String, $projectPath: ID!, $assigneeUsername: String = "") {
project(fullPath: $projectPath) {
- alertManagementAlertStatusCounts(search: $searchTerm) {
+ alertManagementAlertStatusCounts(search: $searchTerm, assigneeUsername: $assigneeUsername) {
all
open
acknowledged
diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js
index e180ab5f7e3..e34450204fb 100644
--- a/app/assets/javascripts/alert_management/list.js
+++ b/app/assets/javascripts/alert_management/list.js
@@ -18,12 +18,12 @@ export default () => {
populatingAlertsHelpUrl,
alertsHelpUrl,
opsgenieMvcTargetUrl,
+ textQuery,
+ assigneeUsernameQuery,
+ alertManagementEnabled,
+ userCanEnableAlertManagement,
+ opsgenieMvcEnabled,
} = domEl.dataset;
- let { alertManagementEnabled, userCanEnableAlertManagement, opsgenieMvcEnabled } = domEl.dataset;
-
- alertManagementEnabled = parseBoolean(alertManagementEnabled);
- userCanEnableAlertManagement = parseBoolean(userCanEnableAlertManagement);
- opsgenieMvcEnabled = parseBoolean(opsgenieMvcEnabled);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
@@ -50,23 +50,24 @@ export default () => {
return new Vue({
el: selector,
+ provide: {
+ projectPath,
+ textQuery,
+ assigneeUsernameQuery,
+ enableAlertManagementPath,
+ populatingAlertsHelpUrl,
+ emptyAlertSvgPath,
+ opsgenieMvcTargetUrl,
+ alertManagementEnabled: parseBoolean(alertManagementEnabled),
+ userCanEnableAlertManagement: parseBoolean(userCanEnableAlertManagement),
+ opsgenieMvcEnabled: parseBoolean(opsgenieMvcEnabled),
+ },
apolloProvider,
components: {
AlertManagementList,
},
render(createElement) {
- return createElement('alert-management-list', {
- props: {
- projectPath,
- enableAlertManagementPath,
- populatingAlertsHelpUrl,
- emptyAlertSvgPath,
- alertManagementEnabled,
- userCanEnableAlertManagement,
- opsgenieMvcTargetUrl,
- opsgenieMvcEnabled,
- },
- });
+ return createElement('alert-management-list');
},
});
};
diff --git a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
index c5e213d7dc9..f2394ce385f 100644
--- a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
+++ b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
@@ -180,11 +180,9 @@ export default {
/>
</span>
</div>
- <span class="gl-display-flex gl-justify-content-end">
- <gl-button v-gl-modal.authKeyModal class="gl-mt-2" :disabled="isDisabled">{{
- $options.RESET_KEY
- }}</gl-button>
- </span>
+ <gl-button v-gl-modal.authKeyModal class="gl-mt-2" :disabled="isDisabled">{{
+ $options.RESET_KEY
+ }}</gl-button>
<gl-modal
modal-id="authKeyModal"
:title="$options.RESET_KEY"
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
new file mode 100644
index 00000000000..217442e6131
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
@@ -0,0 +1,109 @@
+<script>
+import { GlTable, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import Tracking from '~/tracking';
+import { trackAlertIntergrationsViewsOptions } from '../constants';
+
+export const i18n = {
+ title: s__('AlertsIntegrations|Current integrations'),
+ emptyState: s__('AlertsIntegrations|No integrations have been added yet'),
+ status: {
+ enabled: {
+ name: __('Enabled'),
+ tooltip: s__('AlertsIntegrations|Alerts will be created through this integration'),
+ },
+ disabled: {
+ name: __('Disabled'),
+ tooltip: s__('AlertsIntegrations|Alerts will not be created through this integration'),
+ },
+ },
+};
+
+const bodyTrClass =
+ 'gl-border-1 gl-border-t-solid gl-border-b-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-blue-50 gl-hover-border-blue-200';
+
+export default {
+ i18n,
+ components: {
+ GlTable,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ integrations: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ fields: [
+ {
+ key: 'activated',
+ label: __('Status'),
+ },
+ {
+ key: 'name',
+ label: s__('AlertsIntegrations|Integration Name'),
+ },
+ {
+ key: 'type',
+ label: __('Type'),
+ },
+ ],
+ computed: {
+ tbodyTrClass() {
+ return {
+ [bodyTrClass]: this.integrations.length,
+ };
+ },
+ },
+ mounted() {
+ this.trackPageViews();
+ },
+ methods: {
+ trackPageViews() {
+ const { category, action } = trackAlertIntergrationsViewsOptions;
+ Tracking.event(category, action);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="incident-management-list">
+ <h5 class="gl-font-lg">{{ $options.i18n.title }}</h5>
+ <gl-table
+ :empty-text="$options.i18n.emptyState"
+ :items="integrations"
+ :fields="$options.fields"
+ stacked="md"
+ :tbody-tr-class="tbodyTrClass"
+ show-empty
+ >
+ <template #cell(activated)="{ item }">
+ <span v-if="item.activated" data-testid="integration-activated-status">
+ <gl-icon
+ v-gl-tooltip
+ name="check-circle-filled"
+ :size="16"
+ class="gl-text-green-500 gl-hover-cursor-pointer gl-mr-3"
+ :title="$options.i18n.status.enabled.tooltip"
+ />
+ {{ $options.i18n.status.enabled.name }}
+ </span>
+ <span v-else data-testid="integration-activated-status">
+ <gl-icon
+ v-gl-tooltip
+ name="warning-solid"
+ :size="16"
+ class="gl-text-red-600 gl-hover-cursor-pointer gl-mr-3"
+ :title="$options.i18n.status.disabled.tooltip"
+ />
+ {{ $options.i18n.status.disabled.name }}
+ </span>
+ </template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
index f0bb8b0a90f..f885afae378 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
@@ -14,9 +14,11 @@ import {
GlFormSelect,
} from '@gitlab/ui';
import { debounce } from 'lodash';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { s__ } from '~/locale';
+import { doesHashExistInUrl } from '~/lib/utils/url_utility';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ToggleButton from '~/vue_shared/components/toggle_button.vue';
+import IntegrationsList from './alerts_integrations_list.vue';
import csrf from '~/lib/utils/csrf';
import service from '../services';
import {
@@ -25,7 +27,9 @@ import {
JSON_VALIDATE_DELAY,
targetPrometheusUrlPlaceholder,
targetOpsgenieUrlPlaceholder,
+ sectionHash,
} from '../constants';
+import createFlash, { FLASH_TYPES } from '~/flash';
export default {
i18n,
@@ -46,11 +50,11 @@ export default {
GlSprintf,
ClipboardButton,
ToggleButton,
+ IntegrationsList,
},
directives: {
'gl-modal': GlModalDirective,
},
- mixins: [glFeatureFlagsMixin()],
inject: ['prometheus', 'generic', 'opsgenie'],
data() {
return {
@@ -148,6 +152,20 @@ export default {
? this.$options.targetOpsgenieUrlPlaceholder
: this.$options.targetPrometheusUrlPlaceholder;
},
+ integrations() {
+ return [
+ {
+ name: s__('AlertSettings|HTTP endpoint'),
+ type: s__('AlertsIntegrations|HTTP endpoint'),
+ activated: this.generic.activated,
+ },
+ {
+ name: s__('AlertSettings|External Prometheus'),
+ type: s__('AlertsIntegrations|Prometheus'),
+ activated: this.prometheus.activated,
+ },
+ ];
+ },
},
watch: {
'testAlert.json': debounce(function debouncedJsonValidate() {
@@ -173,9 +191,12 @@ export default {
this.authKey = this.selectedService.authKey ?? '';
},
methods: {
- createUserErrorMessage(errors = { error: [''] }) {
- // eslint-disable-next-line prefer-destructuring
- this.serverError = errors.error[0];
+ createUserErrorMessage(errors = {}) {
+ const error = Object.entries(errors)?.[0];
+ if (error) {
+ const [field, [msg]] = error;
+ this.serverError = `${field} ${msg}`;
+ }
},
setOpsgenieAsDefault() {
this.options = this.options.map(el => {
@@ -245,29 +266,11 @@ export default {
? { service: { opsgenie_mvc_target_url: this.targetUrl, opsgenie_mvc_enabled: value } }
: { service: { active: value } },
})
- .then(() => {
- this.active = value;
- this.toggleSuccess(value);
-
- if (!this.isOpsgenie && value) {
- if (!this.selectedService.authKey) {
- return window.location.reload();
- }
-
- return this.removeOpsGenieOption();
- }
-
- if (this.isOpsgenie && value) {
- return this.setOpsgenieAsDefault();
- }
-
- // eslint-disable-next-line no-return-assign
- return (this.options = serviceOptions);
- })
+ .then(() => this.notifySuccessAndReload())
.catch(({ response: { data: { errors } = {} } = {} }) => {
this.createUserErrorMessage(errors);
this.setFeedback({
- feedbackMessage: `${this.$options.i18n.errorMsg}.`,
+ feedbackMessage: this.$options.i18n.errorMsg,
variant: 'danger',
});
})
@@ -276,6 +279,12 @@ export default {
this.canSaveForm = false;
});
},
+ reload() {
+ if (!doesHashExistInUrl(sectionHash)) {
+ window.location.hash = sectionHash;
+ }
+ window.location.reload();
+ },
togglePrometheusActive(value) {
this.loading = true;
return service
@@ -288,15 +297,11 @@ export default {
redirect: window.location,
},
})
- .then(() => {
- this.active = value;
- this.toggleSuccess(value);
- this.removeOpsGenieOption();
- })
+ .then(() => this.notifySuccessAndReload())
.catch(({ response: { data: { errors } = {} } = {} }) => {
this.createUserErrorMessage(errors);
this.setFeedback({
- feedbackMessage: `${this.$options.i18n.errorMsg}.`,
+ feedbackMessage: this.$options.i18n.errorMsg,
variant: 'danger',
});
})
@@ -305,18 +310,9 @@ export default {
this.canSaveForm = false;
});
},
- toggleSuccess(value) {
- if (value) {
- this.setFeedback({
- feedbackMessage: this.$options.i18n.endPointActivated,
- variant: 'info',
- });
- } else {
- this.setFeedback({
- feedbackMessage: this.$options.i18n.changesSaved,
- variant: 'info',
- });
- }
+ notifySuccessAndReload() {
+ createFlash({ message: this.$options.i18n.changesSaved, type: FLASH_TYPES.NOTICE });
+ setTimeout(() => this.reload(), 1000);
},
setFeedback({ feedbackMessage, variant }) {
this.feedback = { feedbackMessage, variant };
@@ -375,47 +371,50 @@ export default {
<template>
<div>
- <gl-alert v-if="showFeedbackMsg" :variant="feedback.variant" @dismiss="dismissFeedback">
- {{ feedback.feedbackMessage }}
- <br />
- <i v-if="serverError">{{ __('Error message:') }} {{ serverError }}</i>
- <gl-button
- v-if="showAlertSave"
- variant="danger"
- category="primary"
- class="gl-display-block gl-mt-3"
- @click="toggle(active)"
- >
- {{ __('Save anyway') }}
- </gl-button>
- </gl-alert>
- <div data-testid="alert-settings-description" class="gl-mt-5">
- <p v-for="section in sections" :key="section.text">
- <gl-sprintf :message="section.text">
- <template #link="{ content }">
- <gl-link :href="section.url" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
- </div>
+ <integrations-list :integrations="integrations" />
+
<gl-form @submit.prevent="onSubmit" @reset.prevent="onReset">
- <gl-form-group
- :label="$options.i18n.integrationsLabel"
- label-for="integrations"
- label-class="label-bold"
- >
+ <h5 class="gl-font-lg gl-my-5">{{ $options.i18n.integrationsLabel }}</h5>
+
+ <gl-alert v-if="showFeedbackMsg" :variant="feedback.variant" @dismiss="dismissFeedback">
+ {{ feedback.feedbackMessage }}
+ <br />
+ <i v-if="serverError">{{ __('Error message:') }} {{ serverError }}</i>
+ <gl-button
+ v-if="showAlertSave"
+ variant="danger"
+ category="primary"
+ class="gl-display-block gl-mt-3"
+ @click="toggle(active)"
+ >
+ {{ __('Save anyway') }}
+ </gl-button>
+ </gl-alert>
+
+ <div data-testid="alert-settings-description">
+ <p v-for="section in sections" :key="section.text">
+ <gl-sprintf :message="section.text">
+ <template #link="{ content }">
+ <gl-link :href="section.url" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+
+ <gl-form-group label-for="integration-type" :label="$options.i18n.integration">
<gl-form-select
+ id="integration-type"
v-model="selectedEndpoint"
:options="options"
data-testid="alert-settings-select"
@change="resetFormValues"
/>
- <span class="gl-text-gray-200">
+ <span class="gl-text-gray-500">
<gl-sprintf :message="$options.i18n.integrationsInfo">
<template #link="{ content }">
<gl-link
class="gl-display-inline-block"
- href="https://gitlab.com/groups/gitlab-org/-/epics/3362"
+ href="https://gitlab.com/groups/gitlab-org/-/epics/4390"
target="_blank"
>{{ content }}</gl-link
>
@@ -423,11 +422,7 @@ export default {
</gl-sprintf>
</span>
</gl-form-group>
- <gl-form-group
- :label="$options.i18n.activeLabel"
- label-for="activated"
- label-class="label-bold"
- >
+ <gl-form-group :label="$options.i18n.activeLabel" label-for="activated">
<toggle-button
id="activated"
:disabled-input="loading"
@@ -440,7 +435,6 @@ export default {
v-if="isOpsgenie || isPrometheus"
:label="$options.i18n.apiBaseUrlLabel"
label-for="api-url"
- label-class="label-bold"
>
<gl-form-input
id="api-url"
@@ -449,12 +443,12 @@ export default {
:placeholder="baseUrlPlaceholder"
:disabled="!active"
/>
- <span class="gl-text-gray-200">
+ <span class="gl-text-gray-500">
{{ $options.i18n.apiBaseUrlHelpText }}
</span>
</gl-form-group>
<template v-if="!isOpsgenie">
- <gl-form-group :label="$options.i18n.urlLabel" label-for="url" label-class="label-bold">
+ <gl-form-group :label="$options.i18n.urlLabel" label-for="url">
<gl-form-input-group id="url" readonly :value="selectedService.url">
<template #append>
<clipboard-button
@@ -464,15 +458,11 @@ export default {
/>
</template>
</gl-form-input-group>
- <span class="gl-text-gray-200">
+ <span class="gl-text-gray-500">
{{ prometheusInfo }}
</span>
</gl-form-group>
- <gl-form-group
- :label="$options.i18n.authKeyLabel"
- label-for="authorization-key"
- label-class="label-bold"
- >
+ <gl-form-group :label="$options.i18n.authKeyLabel" label-for="authorization-key">
<gl-form-input-group id="authorization-key" class="gl-mb-2" readonly :value="authKey">
<template #append>
<clipboard-button
@@ -498,7 +488,6 @@ export default {
<gl-form-group
:label="$options.i18n.alertJson"
label-for="alert-json"
- label-class="label-bold"
:invalid-feedback="testAlert.error"
>
<gl-form-textarea
@@ -511,16 +500,11 @@ export default {
max-rows="10"
/>
</gl-form-group>
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{
- $options.i18n.testAlertInfo
- }}</gl-button>
- </div>
+ <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{
+ $options.i18n.testAlertInfo
+ }}</gl-button>
</template>
<div class="footer-block row-content-block gl-display-flex gl-justify-content-space-between">
- <gl-button category="primary" :disabled="!canSaveConfig" @click="onReset">
- {{ __('Cancel') }}
- </gl-button>
<gl-button
variant="success"
category="primary"
@@ -529,6 +513,9 @@ export default {
>
{{ __('Save changes') }}
</gl-button>
+ <gl-button category="primary" :disabled="!canSaveConfig" @click="onReset">
+ {{ __('Cancel') }}
+ </gl-button>
</div>
</gl-form>
</div>
diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js
index fc669995875..4220dbde0c7 100644
--- a/app/assets/javascripts/alerts_settings/constants.js
+++ b/app/assets/javascripts/alerts_settings/constants.js
@@ -7,22 +7,21 @@ export const i18n = {
setupSection: s__(
"AlertSettings|Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.",
),
- errorMsg: s__('AlertSettings|There was an error updating the alert settings'),
+ errorMsg: s__('AlertSettings|There was an error updating the alert settings.'),
errorKeyMsg: s__(
'AlertSettings|There was an error while trying to reset the key. Please refresh the page to try again.',
),
restKeyInfo: s__(
'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
),
- endPointActivated: s__('AlertSettings|Alerts endpoint successfully activated.'),
- changesSaved: s__('AlertSettings|Your changes were successfully updated.'),
+ changesSaved: s__('AlertSettings|Your integration was successfully updated.'),
prometheusInfo: s__('AlertSettings|Add URL and auth key to your Prometheus config file'),
integrationsInfo: s__(
- 'AlertSettings|Learn more about our %{linkStart}upcoming integrations%{linkEnd}',
+ 'AlertSettings|Learn more about our improvements for %{linkStart}integrations%{linkEnd}',
),
resetKey: s__('AlertSettings|Reset key'),
copyToClipboard: s__('AlertSettings|Copy'),
- integrationsLabel: s__('AlertSettings|Integrations'),
+ integrationsLabel: s__('AlertSettings|Add new integrations'),
apiBaseUrlLabel: s__('AlertSettings|API URL'),
authKeyLabel: s__('AlertSettings|Authorization key'),
urlLabel: s__('AlertSettings|Webhook URL'),
@@ -38,10 +37,11 @@ export const i18n = {
authKeyRest: s__(
'AlertSettings|Authorization key has been successfully reset. Please save your changes now.',
),
+ integration: s__('AlertSettings|Integration'),
};
export const serviceOptions = [
- { value: 'generic', text: s__('AlertSettings|Generic') },
+ { value: 'generic', text: s__('AlertSettings|HTTP Endpoint') },
{ value: 'prometheus', text: s__('AlertSettings|External Prometheus') },
{ value: 'opsgenie', text: s__('AlertSettings|Opsgenie') },
];
@@ -50,3 +50,15 @@ export const JSON_VALIDATE_DELAY = 250;
export const targetPrometheusUrlPlaceholder = 'http://prometheus.example.com/';
export const targetOpsgenieUrlPlaceholder = 'https://app.opsgenie.com/alert/list/';
+
+export const sectionHash = 'js-alert-management-settings';
+
+/* eslint-disable @gitlab/require-i18n-strings */
+
+/**
+ * Tracks snowplow event when user views alerts intergration list
+ */
+export const trackAlertIntergrationsViewsOptions = {
+ category: 'Alert Intergrations',
+ action: 'view_alert_integrations_list',
+};
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/app.vue b/app/assets/javascripts/analytics/instance_statistics/components/app.vue
new file mode 100644
index 00000000000..7aa5c98aa0b
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/components/app.vue
@@ -0,0 +1,30 @@
+<script>
+import InstanceCounts from './instance_counts.vue';
+import PipelinesChart from './pipelines_chart.vue';
+import UsersChart from './users_chart.vue';
+import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants';
+
+export default {
+ name: 'InstanceStatisticsApp',
+ components: {
+ InstanceCounts,
+ PipelinesChart,
+ UsersChart,
+ },
+ TOTAL_DAYS_TO_SHOW,
+ START_DATE,
+ TODAY,
+};
+</script>
+
+<template>
+ <div>
+ <instance-counts />
+ <users-chart
+ :start-date="$options.START_DATE"
+ :end-date="$options.TODAY"
+ :total-data-points="$options.TOTAL_DAYS_TO_SHOW"
+ />
+ <pipelines-chart />
+ </div>
+</template>
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue b/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue
new file mode 100644
index 00000000000..4fbfb4daf22
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue
@@ -0,0 +1,64 @@
+<script>
+import * as Sentry from '~/sentry/wrapper';
+import { s__ } from '~/locale';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
+import MetricCard from '~/analytics/shared/components/metric_card.vue';
+import instanceStatisticsCountQuery from '../graphql/queries/instance_statistics_count.query.graphql';
+
+const defaultPrecision = 0;
+
+export default {
+ name: 'InstanceCounts',
+ components: {
+ MetricCard,
+ },
+ data() {
+ return {
+ counts: [],
+ };
+ },
+ apollo: {
+ counts: {
+ query: instanceStatisticsCountQuery,
+ update(data) {
+ return Object.entries(data).map(([key, obj]) => {
+ const label = this.$options.i18n.labels[key];
+ const formatter = getFormatter(SUPPORTED_FORMATS.number);
+ const value = obj.nodes?.length ? formatter(obj.nodes[0].count, defaultPrecision) : null;
+
+ return {
+ key,
+ value,
+ label,
+ };
+ });
+ },
+ error(error) {
+ createFlash(this.$options.i18n.loadCountsError);
+ Sentry.captureException(error);
+ },
+ },
+ },
+ i18n: {
+ labels: {
+ users: s__('InstanceStatistics|Users'),
+ projects: s__('InstanceStatistics|Projects'),
+ groups: s__('InstanceStatistics|Groups'),
+ issues: s__('InstanceStatistics|Issues'),
+ mergeRequests: s__('InstanceStatistics|Merge Requests'),
+ pipelines: s__('InstanceStatistics|Pipelines'),
+ },
+ loadCountsError: s__('Could not load instance counts. Please refresh the page to try again.'),
+ },
+};
+</script>
+
+<template>
+ <metric-card
+ :title="__('Instance Statistics')"
+ :metrics="counts"
+ :is-loading="$apollo.queries.counts.loading"
+ class="gl-mt-4"
+ />
+</template>
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue
new file mode 100644
index 00000000000..b16d960402b
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue
@@ -0,0 +1,215 @@
+<script>
+import { GlLineChart } from '@gitlab/ui/dist/charts';
+import { GlAlert } from '@gitlab/ui';
+import { mapKeys, mapValues, pick, some, sum } from 'lodash';
+import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
+import { s__ } from '~/locale';
+import {
+ differenceInMonths,
+ formatDateAsMonth,
+ getDayDifference,
+} from '~/lib/utils/datetime_utility';
+import { getAverageByMonth, sortByDate, extractValues } from '../utils';
+import pipelineStatsQuery from '../graphql/queries/pipeline_stats.query.graphql';
+import { TODAY, START_DATE } from '../constants';
+
+const DATA_KEYS = [
+ 'pipelinesTotal',
+ 'pipelinesSucceeded',
+ 'pipelinesFailed',
+ 'pipelinesCanceled',
+ 'pipelinesSkipped',
+];
+const PREFIX = 'pipelines';
+
+export default {
+ name: 'PipelinesChart',
+ components: {
+ GlLineChart,
+ GlAlert,
+ ChartSkeletonLoader,
+ },
+ startDate: START_DATE,
+ endDate: TODAY,
+ i18n: {
+ loadPipelineChartError: s__(
+ 'InstanceAnalytics|Could not load the pipelines chart. Please refresh the page to try again.',
+ ),
+ noDataMessage: s__('InstanceAnalytics|There is no data available.'),
+ total: s__('InstanceAnalytics|Total'),
+ succeeded: s__('InstanceAnalytics|Succeeded'),
+ failed: s__('InstanceAnalytics|Failed'),
+ canceled: s__('InstanceAnalytics|Canceled'),
+ skipped: s__('InstanceAnalytics|Skipped'),
+ chartTitle: s__('InstanceAnalytics|Pipelines'),
+ yAxisTitle: s__('InstanceAnalytics|Items'),
+ xAxisTitle: s__('InstanceAnalytics|Month'),
+ },
+ data() {
+ return {
+ loading: true,
+ loadingError: null,
+ };
+ },
+ apollo: {
+ pipelineStats: {
+ query: pipelineStatsQuery,
+ variables() {
+ return {
+ firstTotal: this.totalDaysToShow,
+ firstSucceeded: this.totalDaysToShow,
+ firstFailed: this.totalDaysToShow,
+ firstCanceled: this.totalDaysToShow,
+ firstSkipped: this.totalDaysToShow,
+ };
+ },
+ update(data) {
+ const allData = extractValues(data, DATA_KEYS, PREFIX, 'nodes');
+ const allPageInfo = extractValues(data, DATA_KEYS, PREFIX, 'pageInfo');
+
+ return {
+ ...mapValues(allData, sortByDate),
+ ...allPageInfo,
+ };
+ },
+ result() {
+ if (this.hasNextPage) {
+ this.fetchNextPage();
+ }
+ },
+ error() {
+ this.handleError();
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.pipelineStats.loading;
+ },
+ totalDaysToShow() {
+ return getDayDifference(this.$options.startDate, this.$options.endDate);
+ },
+ firstVariables() {
+ const allData = pick(this.pipelineStats, [
+ 'nodesTotal',
+ 'nodesSucceeded',
+ 'nodesFailed',
+ 'nodesCanceled',
+ 'nodesSkipped',
+ ]);
+ const allDayDiffs = mapValues(allData, data => {
+ const firstdataPoint = data[0];
+ if (!firstdataPoint) {
+ return 0;
+ }
+
+ return Math.max(
+ 0,
+ getDayDifference(this.$options.startDate, new Date(firstdataPoint.recordedAt)),
+ );
+ });
+
+ return mapKeys(allDayDiffs, (value, key) => key.replace('nodes', 'first'));
+ },
+ cursorVariables() {
+ const pageInfoKeys = [
+ 'pageInfoTotal',
+ 'pageInfoSucceeded',
+ 'pageInfoFailed',
+ 'pageInfoCanceled',
+ 'pageInfoSkipped',
+ ];
+
+ return extractValues(this.pipelineStats, pageInfoKeys, 'pageInfo', 'endCursor');
+ },
+ hasNextPage() {
+ return (
+ sum(Object.values(this.firstVariables)) > 0 &&
+ some(this.pipelineStats, ({ hasNextPage }) => hasNextPage)
+ );
+ },
+ hasEmptyDataSet() {
+ return this.chartData.every(({ data }) => data.length === 0);
+ },
+ chartData() {
+ const allData = pick(this.pipelineStats, [
+ 'nodesTotal',
+ 'nodesSucceeded',
+ 'nodesFailed',
+ 'nodesCanceled',
+ 'nodesSkipped',
+ ]);
+ const options = { shouldRound: true };
+ return Object.keys(allData).map(key => {
+ const i18nName = key.slice('nodes'.length).toLowerCase();
+ return {
+ name: this.$options.i18n[i18nName],
+ data: getAverageByMonth(allData[key], options),
+ };
+ });
+ },
+ range() {
+ return {
+ min: this.$options.startDate,
+ max: this.$options.endDate,
+ };
+ },
+ chartOptions() {
+ const { endDate, startDate, i18n } = this.$options;
+ return {
+ xAxis: {
+ ...this.range,
+ name: i18n.xAxisTitle,
+ type: 'time',
+ splitNumber: differenceInMonths(startDate, endDate) + 1,
+ axisLabel: {
+ interval: 0,
+ showMinLabel: false,
+ showMaxLabel: false,
+ align: 'right',
+ formatter: formatDateAsMonth,
+ },
+ },
+ yAxis: {
+ name: i18n.yAxisTitle,
+ },
+ };
+ },
+ },
+ methods: {
+ handleError() {
+ this.loadingError = true;
+ },
+ fetchNextPage() {
+ this.$apollo.queries.pipelineStats
+ .fetchMore({
+ variables: {
+ ...this.firstVariables,
+ ...this.cursorVariables,
+ },
+ updateQuery: (previousResult, { fetchMoreResult }) => {
+ return Object.keys(fetchMoreResult).reduce((memo, key) => {
+ const { nodes, ...rest } = fetchMoreResult[key];
+ const previousNodes = previousResult[key].nodes;
+ return { ...memo, [key]: { ...rest, nodes: [...previousNodes, ...nodes] } };
+ }, {});
+ },
+ })
+ .catch(this.handleError);
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <h3>{{ $options.i18n.chartTitle }}</h3>
+ <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">
+ {{ this.$options.i18n.loadPipelineChartError }}
+ </gl-alert>
+ <chart-skeleton-loader v-else-if="isLoading" />
+ <gl-alert v-else-if="hasEmptyDataSet" variant="info" :dismissible="false" class="gl-mt-3">
+ {{ $options.i18n.noDataMessage }}
+ </gl-alert>
+ <gl-line-chart v-else :option="chartOptions" :include-legend-avg-max="true" :data="chartData" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue
new file mode 100644
index 00000000000..a4a1d40b70b
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue
@@ -0,0 +1,143 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import produce from 'immer';
+import { sortBy } from 'lodash';
+import * as Sentry from '~/sentry/wrapper';
+import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
+import { __ } from '~/locale';
+import { formatDateAsMonth } from '~/lib/utils/datetime_utility';
+import usersQuery from '../graphql/queries/users.query.graphql';
+import { getAverageByMonth } from '../utils';
+
+const sortByDate = data => sortBy(data, item => new Date(item[0]).getTime());
+
+export default {
+ name: 'UsersChart',
+ components: { GlAlert, GlAreaChart, ChartSkeletonLoader },
+ props: {
+ startDate: {
+ type: Date,
+ required: true,
+ },
+ endDate: {
+ type: Date,
+ required: true,
+ },
+ totalDataPoints: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ loadingError: null,
+ users: [],
+ pageInfo: null,
+ };
+ },
+ apollo: {
+ users: {
+ query: usersQuery,
+ variables() {
+ return {
+ first: this.totalDataPoints,
+ after: null,
+ };
+ },
+ update(data) {
+ return data.users?.nodes || [];
+ },
+ result({ data }) {
+ const {
+ users: { pageInfo },
+ } = data;
+ this.pageInfo = pageInfo;
+ this.fetchNextPage();
+ },
+ error(error) {
+ this.handleError(error);
+ },
+ },
+ },
+ i18n: {
+ yAxisTitle: __('Total users'),
+ xAxisTitle: __('Month'),
+ loadUserChartError: __('Could not load the user chart. Please refresh the page to try again.'),
+ noDataMessage: __('There is no data available.'),
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.users.loading || this.pageInfo?.hasNextPage;
+ },
+ chartUserData() {
+ const averaged = getAverageByMonth(
+ this.users.length > this.totalDataPoints
+ ? this.users.slice(0, this.totalDataPoints)
+ : this.users,
+ { shouldRound: true },
+ );
+ return sortByDate(averaged);
+ },
+ options() {
+ return {
+ xAxis: {
+ name: this.$options.i18n.xAxisTitle,
+ type: 'category',
+ axisLabel: {
+ formatter: formatDateAsMonth,
+ },
+ },
+ yAxis: {
+ name: this.$options.i18n.yAxisTitle,
+ },
+ };
+ },
+ },
+ methods: {
+ handleError(error) {
+ this.loadingError = true;
+ this.users = [];
+ Sentry.captureException(error);
+ },
+ fetchNextPage() {
+ if (this.pageInfo?.hasNextPage) {
+ this.$apollo.queries.users
+ .fetchMore({
+ variables: { first: this.totalDataPoints, after: this.pageInfo.endCursor },
+ updateQuery: (previousResult, { fetchMoreResult }) => {
+ return produce(fetchMoreResult, newUsers => {
+ // eslint-disable-next-line no-param-reassign
+ newUsers.users.nodes = [...previousResult.users.nodes, ...newUsers.users.nodes];
+ });
+ },
+ })
+ .catch(this.handleError);
+ }
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <h3>{{ $options.i18n.yAxisTitle }}</h3>
+ <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">
+ {{ this.$options.i18n.loadUserChartError }}
+ </gl-alert>
+ <chart-skeleton-loader v-else-if="isLoading" />
+ <gl-alert v-else-if="!chartUserData.length" variant="info" :dismissible="false" class="gl-mt-3">
+ {{ $options.i18n.noDataMessage }}
+ </gl-alert>
+ <gl-area-chart
+ v-else
+ :option="options"
+ :include-legend-avg-max="true"
+ :data="[
+ {
+ name: $options.i18n.yAxisTitle,
+ data: chartUserData,
+ },
+ ]"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/analytics/instance_statistics/constants.js b/app/assets/javascripts/analytics/instance_statistics/constants.js
new file mode 100644
index 00000000000..846c0ef408b
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/constants.js
@@ -0,0 +1,5 @@
+import { getDateInPast } from '~/lib/utils/datetime_utility';
+
+export const TOTAL_DAYS_TO_SHOW = 365;
+export const TODAY = new Date();
+export const START_DATE = getDateInPast(TODAY, TOTAL_DAYS_TO_SHOW);
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql
new file mode 100644
index 00000000000..40cef95c2e7
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql
@@ -0,0 +1,4 @@
+fragment Count on InstanceStatisticsMeasurement {
+ count
+ recordedAt
+}
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql
new file mode 100644
index 00000000000..40cef95c2e7
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql
@@ -0,0 +1,4 @@
+fragment Count on InstanceStatisticsMeasurement {
+ count
+ recordedAt
+}
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql
new file mode 100644
index 00000000000..f14c2658674
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql
@@ -0,0 +1,34 @@
+#import "../fragments/count.fragment.graphql"
+
+query getInstanceCounts {
+ projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+ groups: instanceStatisticsMeasurements(identifier: GROUPS, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+ users: instanceStatisticsMeasurements(identifier: USERS, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+ issues: instanceStatisticsMeasurements(identifier: ISSUES, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+ mergeRequests: instanceStatisticsMeasurements(identifier: MERGE_REQUESTS, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+ pipelines: instanceStatisticsMeasurements(identifier: PIPELINES, first: 1) {
+ nodes {
+ ...Count
+ }
+ }
+}
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql
new file mode 100644
index 00000000000..3bf40403f91
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql
@@ -0,0 +1,76 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "./count.fragment.graphql"
+
+query pipelineStats(
+ $firstTotal: Int
+ $firstSucceeded: Int
+ $firstFailed: Int
+ $firstCanceled: Int
+ $firstSkipped: Int
+ $endCursorTotal: String
+ $endCursorSucceeded: String
+ $endCursorFailed: String
+ $endCursorCanceled: String
+ $endCursorSkipped: String
+) {
+ pipelinesTotal: instanceStatisticsMeasurements(
+ identifier: PIPELINES
+ first: $firstTotal
+ after: $endCursorTotal
+ ) {
+ nodes {
+ ...Count
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ pipelinesSucceeded: instanceStatisticsMeasurements(
+ identifier: PIPELINES_SUCCEEDED
+ first: $firstSucceeded
+ after: $endCursorSucceeded
+ ) {
+ nodes {
+ ...Count
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ pipelinesFailed: instanceStatisticsMeasurements(
+ identifier: PIPELINES_FAILED
+ first: $firstFailed
+ after: $endCursorFailed
+ ) {
+ nodes {
+ ...Count
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ pipelinesCanceled: instanceStatisticsMeasurements(
+ identifier: PIPELINES_CANCELED
+ first: $firstCanceled
+ after: $endCursorCanceled
+ ) {
+ nodes {
+ ...Count
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ pipelinesSkipped: instanceStatisticsMeasurements(
+ identifier: PIPELINES_SKIPPED
+ first: $firstSkipped
+ after: $endCursorSkipped
+ ) {
+ nodes {
+ ...Count
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+}
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql
new file mode 100644
index 00000000000..6235e36eb89
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql
@@ -0,0 +1,13 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "../fragments/count.fragment.graphql"
+
+query getUsersCount($first: Int, $after: String) {
+ users: instanceStatisticsMeasurements(identifier: USERS, first: $first, after: $after) {
+ nodes {
+ ...Count
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+}
diff --git a/app/assets/javascripts/analytics/instance_statistics/index.js b/app/assets/javascripts/analytics/instance_statistics/index.js
new file mode 100644
index 00000000000..0d7dcf6ace8
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/index.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import InstanceStatisticsApp from './components/app.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export default () => {
+ const el = document.getElementById('js-instance-statistics-app');
+
+ if (!el) return false;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(InstanceStatisticsApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/analytics/instance_statistics/utils.js b/app/assets/javascripts/analytics/instance_statistics/utils.js
new file mode 100644
index 00000000000..907482c0c72
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/utils.js
@@ -0,0 +1,69 @@
+import { masks } from 'dateformat';
+import { mapKeys, mapValues, pick, sortBy } from 'lodash';
+import { formatDate } from '~/lib/utils/datetime_utility';
+
+const { isoDate } = masks;
+
+/**
+ * Takes an array of items and returns one item per month with the average of the `count`s from that month
+ * @param {Array} items
+ * @param {Number} items[index].count value to be averaged
+ * @param {String} items[index].recordedAt item dateTime time stamp to be collected into a month
+ * @param {Object} options
+ * @param {Object} options.shouldRound an option to specify whether the retuned averages should be rounded
+ * @return {Array} items collected into [month, average],
+ * where month is a dateTime string representing the first of the given month
+ * and average is the average of the count
+ */
+export function getAverageByMonth(items = [], options = {}) {
+ const { shouldRound = false } = options;
+ const itemsMap = items.reduce((memo, item) => {
+ const { count, recordedAt } = item;
+ const date = new Date(recordedAt);
+ const month = formatDate(new Date(date.getFullYear(), date.getMonth(), 1), isoDate);
+ if (memo[month]) {
+ const { sum, recordCount } = memo[month];
+ return { ...memo, [month]: { sum: sum + count, recordCount: recordCount + 1 } };
+ }
+
+ return { ...memo, [month]: { sum: count, recordCount: 1 } };
+ }, {});
+
+ return Object.keys(itemsMap).map(month => {
+ const { sum, recordCount } = itemsMap[month];
+ const avg = sum / recordCount;
+ if (shouldRound) {
+ return [month, Math.round(avg)];
+ }
+
+ return [month, avg];
+ });
+}
+
+/**
+ * Extracts values given a data set and a set of keys
+ * @example
+ * const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' };
+ * extractValues(data, ['fooBar'], 'foo', 'baz') => { bazBar: 'quis' }
+ * @param {Object} data set to extract values from
+ * @param {Array} dataKeys keys describing where to look for values in the data set
+ * @param {String} replaceKey name key to be replaced in the data set
+ * @param {String} nestedKey key nested in the data set to be extracted,
+ * this is also used to rename the newly created data set
+ * @return {Object} the newly created data set with the extracted values
+ */
+export function extractValues(data, dataKeys = [], replaceKey, nestedKey) {
+ return mapKeys(pick(mapValues(data, nestedKey), dataKeys), (value, key) =>
+ key.replace(replaceKey, nestedKey),
+ );
+}
+
+/**
+ * Creates a new array of items sorted by the date string of each item
+ * @param {Array} items [description]
+ * @param {String} items[0] date string
+ * @return {Array} the new sorted array.
+ */
+export function sortByDate(items = []) {
+ return sortBy(items, ({ recordedAt }) => new Date(recordedAt).getTime());
+}
diff --git a/app/assets/javascripts/analytics/shared/components/metric_card.vue b/app/assets/javascripts/analytics/shared/components/metric_card.vue
new file mode 100644
index 00000000000..cee186c057c
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/components/metric_card.vue
@@ -0,0 +1,80 @@
+<script>
+import {
+ GlCard,
+ GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+ GlLink,
+ GlIcon,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+
+export default {
+ name: 'MetricCard',
+ components: {
+ GlCard,
+ GlSkeletonLoading,
+ GlLink,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ metrics: {
+ type: Array,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ methods: {
+ valueText(metric) {
+ const { value = null, unit = null } = metric;
+ if (!value || value === '-') return '-';
+ return unit && value ? `${value} ${unit}` : value;
+ },
+ },
+};
+</script>
+<template>
+ <gl-card>
+ <template #header>
+ <strong ref="title">{{ title }}</strong>
+ </template>
+ <template #default>
+ <gl-skeleton-loading v-if="isLoading" class="gl-h-auto gl-py-3" />
+ <div v-else ref="metricsWrapper" class="gl-display-flex">
+ <div
+ v-for="metric in metrics"
+ :key="metric.key"
+ ref="metricItem"
+ class="js-metric-card-item gl-flex-grow-1 gl-text-center"
+ >
+ <gl-link v-if="metric.link" :href="metric.link">
+ <h3 class="gl-my-2 gl-text-blue-700">{{ valueText(metric) }}</h3>
+ </gl-link>
+ <h3 v-else class="gl-my-2">{{ valueText(metric) }}</h3>
+ <p class="text-secondary gl-font-sm gl-mb-2">
+ {{ metric.label }}
+ <span v-if="metric.tooltipText">
+ &nbsp;
+ <gl-icon
+ v-gl-tooltip="{ title: metric.tooltipText }"
+ :size="14"
+ class="gl-vertical-align-middle"
+ name="question"
+ data-testid="tooltip"
+ />
+ </span>
+ </p>
+ </div>
+ </div>
+ </template>
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index dbc7ff67d9d..63b75cdb734 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -30,6 +30,7 @@ const Api = {
projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches',
projectSearchPath: '/api/:version/projects/:id/search',
projectMilestonesPath: '/api/:version/projects/:id/milestones',
+ projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid',
mergeRequestsPath: '/api/:version/merge_requests',
groupLabelsPath: '/groups/:namespace_path/-/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
@@ -54,6 +55,7 @@ const Api = {
releaseLinkPath: '/api/:version/projects/:id/releases/:tag_name/assets/links/:link_id',
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
adminStatisticsPath: '/api/:version/application/statistics',
+ pipelineJobsPath: '/api/:version/projects/:id/pipelines/:pipeline_id/jobs',
pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id',
pipelinesPath: '/api/:version/projects/:id/pipelines/',
createPipelinePath: '/api/:version/projects/:id/pipeline',
@@ -64,6 +66,10 @@ const Api = {
issuePath: '/api/:version/projects/:id/issues/:issue_iid',
tagsPath: '/api/:version/projects/:id/repository/tags',
freezePeriodsPath: '/api/:version/projects/:id/freeze_periods',
+ usageDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users',
+ featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists',
+ featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid',
+ billableGroupMembersPath: '/api/:version/groups/:id/billable_members',
group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@@ -111,6 +117,12 @@ const Api = {
});
},
+ inviteGroupMember(id, data) {
+ const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
+
+ return axios.post(url, data);
+ },
+
groupMilestones(id, options) {
const url = Api.buildUrl(this.groupMilestonesPath).replace(':id', encodeURIComponent(id));
@@ -317,6 +329,14 @@ const Api = {
});
},
+ addProjectIssueAsTodo(projectId, issueIid) {
+ const url = Api.buildUrl(Api.projectIssuePath)
+ .replace(':id', encodeURIComponent(projectId))
+ .replace(':issue_iid', encodeURIComponent(issueIid));
+
+ return axios.post(`${url}/todo`);
+ },
+
mergeRequests(params = {}) {
const url = Api.buildUrl(Api.mergeRequestsPath);
@@ -366,7 +386,7 @@ const Api = {
},
commitMultiple(id, data) {
- // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions
+ // see https://docs.gitlab.com/ee/api/commits.html#create-a-commit-with-multiple-files-and-actions
const url = Api.buildUrl(Api.commitsPath).replace(':id', encodeURIComponent(id));
return axios.post(url, JSON.stringify(data), {
headers: {
@@ -590,6 +610,14 @@ const Api = {
return axios.get(url);
},
+ pipelineJobs(projectId, pipelineId) {
+ const url = Api.buildUrl(this.pipelineJobsPath)
+ .replace(':id', encodeURIComponent(projectId))
+ .replace(':pipeline_id', encodeURIComponent(pipelineId));
+
+ return axios.get(url);
+ },
+
// Return all pipelines for a project or filter by query params
pipelines(id, options = {}) {
const url = Api.buildUrl(this.pipelinesPath).replace(':id', encodeURIComponent(id));
@@ -686,9 +714,78 @@ const Api = {
return axios.post(url, freezePeriod);
},
+ trackRedisHllUserEvent(event) {
+ if (!gon.features?.usageDataApi) {
+ return null;
+ }
+
+ const url = Api.buildUrl(this.usageDataIncrementUniqueUsersPath);
+ const headers = {
+ 'Content-Type': 'application/json',
+ };
+
+ return axios.post(url, { event }, { headers });
+ },
+
buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
+
+ fetchFeatureFlagUserLists(id, page) {
+ const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id);
+
+ return axios.get(url, { params: { page } });
+ },
+
+ createFeatureFlagUserList(id, list) {
+ const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id);
+
+ return axios.post(url, list);
+ },
+
+ fetchFeatureFlagUserList(id, listIid) {
+ const url = Api.buildUrl(this.featureFlagUserList)
+ .replace(':id', id)
+ .replace(':list_iid', listIid);
+
+ return axios.get(url);
+ },
+
+ updateFeatureFlagUserList(id, list) {
+ const url = Api.buildUrl(this.featureFlagUserList)
+ .replace(':id', id)
+ .replace(':list_iid', list.iid);
+
+ return axios.put(url, list);
+ },
+
+ deleteFeatureFlagUserList(id, listIid) {
+ const url = Api.buildUrl(this.featureFlagUserList)
+ .replace(':id', id)
+ .replace(':list_iid', listIid);
+
+ return axios.delete(url);
+ },
+
+ fetchBillableGroupMembersList(namespaceId, options = {}, callback = () => {}) {
+ const url = Api.buildUrl(this.billableGroupMembersPath).replace(':id', namespaceId);
+ const defaults = {
+ per_page: DEFAULT_PER_PAGE,
+ page: 1,
+ };
+
+ return axios
+ .get(url, {
+ params: {
+ ...defaults,
+ ...options,
+ },
+ })
+ .then(({ data, headers }) => {
+ callback(data);
+ return { data, headers };
+ });
+ },
};
export default Api;
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index cb71047e00c..7055cd42978 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -572,7 +572,7 @@ export class AwardsHandler {
}
findMatchingEmojiElements(query) {
- const emojiMatches = this.emoji.filterEmojiNamesByAlias(query);
+ const emojiMatches = this.emoji.searchEmoji(query, { match: 'fuzzy' }).map(({ name }) => name);
const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]');
const $matchingElements = $emojiElements.filter(
(i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0,
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
index 6afb10dd2ad..0a8479519f1 100644
--- a/app/assets/javascripts/badges/components/badge_form.vue
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -218,7 +218,7 @@ export default {
</p>
</div>
- <div v-if="isEditing" class="row-content-block gl-display-flex gl-justify-content-end">
+ <div v-if="isEditing" class="row-content-block">
<gl-button class="btn-cancel gl-mr-4" data-testid="cancelEditing" @click="onCancel">
{{ __('Cancel') }}
</gl-button>
@@ -232,7 +232,7 @@ export default {
{{ s__('Badges|Save changes') }}
</gl-button>
</div>
- <div v-else class="gl-display-flex gl-justify-content-end form-group">
+ <div v-else class="form-group">
<gl-button :loading="isSaving" type="submit" variant="success" category="primary">
{{ s__('Badges|Add badge') }}
</gl-button>
diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue
index 3343634ecad..4ca91e7a589 100644
--- a/app/assets/javascripts/badges/components/badge_list_row.vue
+++ b/app/assets/javascripts/badges/components/badge_list_row.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapState } from 'vuex';
-import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import { PROJECT_BADGE } from '../constants';
import Badge from './badge.vue';
@@ -9,8 +9,11 @@ export default {
name: 'BadgeListRow',
components: {
Badge,
- GlIcon,
GlLoadingIcon,
+ GlButton,
+ },
+ directives: {
+ GlModal: GlModalDirective,
},
props: {
badge: {
@@ -51,24 +54,25 @@ export default {
<span class="table-section section-30 str-truncated">{{ badge.linkUrl }}</span>
<div class="table-section section-10 table-button-footer">
<div v-if="canEditBadge" class="table-action-buttons">
- <button
+ <gl-button
:disabled="badge.isDeleting"
- class="btn btn-default gl-mr-3"
- type="button"
+ class="gl-mr-3"
+ variant="default"
+ icon="pencil"
+ size="medium"
+ :aria-label="__('Edit')"
@click="editBadge(badge)"
- >
- <gl-icon :size="16" :aria-label="__('Edit')" name="pencil" />
- </button>
- <button
+ />
+ <gl-button
+ v-gl-modal.delete-badge-modal
:disabled="badge.isDeleting"
- class="btn btn-danger"
- type="button"
- data-toggle="modal"
- data-target="#delete-badge-modal"
+ variant="danger"
+ icon="remove"
+ size="medium"
+ :aria-label="__('Delete')"
+ data-testid="delete-badge"
@click="updateBadgeInModal(badge)"
- >
- <gl-icon :size="16" :aria-label="__('Delete')" name="remove" />
- </button>
+ />
<gl-loading-icon v-show="badge.isDeleting" :inline="true" />
</div>
</div>
diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue
index 531742e49e3..19781783100 100644
--- a/app/assets/javascripts/badges/components/badge_settings.vue
+++ b/app/assets/javascripts/badges/components/badge_settings.vue
@@ -1,9 +1,8 @@
<script>
import { mapState, mapActions } from 'vuex';
-import { GlSprintf } from '@gitlab/ui';
+import { GlSprintf, GlModal } from '@gitlab/ui';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__ } from '~/locale';
-import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import Badge from './badge.vue';
import BadgeForm from './badge_form.vue';
import BadgeList from './badge_list.vue';
@@ -14,7 +13,7 @@ export default {
Badge,
BadgeForm,
BadgeList,
- GlModal: DeprecatedModal2,
+ GlModal,
GlSprintf,
},
i18n: {
@@ -24,6 +23,17 @@ export default {
},
computed: {
...mapState(['badgeInModal', 'isEditing']),
+ primaryProps() {
+ return {
+ text: s__('Delete badge'),
+ attributes: [{ category: 'primary' }, { variant: 'danger' }],
+ };
+ },
+ cancelProps() {
+ return {
+ text: s__('Cancel'),
+ };
+ },
},
methods: {
...mapActions(['deleteBadge']),
@@ -44,11 +54,11 @@ export default {
<template>
<div class="badge-settings">
<gl-modal
- id="delete-badge-modal"
- :header-title-text="s__('Badges|Delete badge?')"
- :footer-primary-button-text="s__('Badges|Delete badge')"
- footer-primary-button-variant="danger"
- @submit="onSubmitModal"
+ modal-id="delete-badge-modal"
+ :title="s__('Badges|Delete badge?')"
+ :action-primary="primaryProps"
+ :action-cancel="cancelProps"
+ @primary="onSubmitModal"
>
<div class="well">
<badge
@@ -65,9 +75,9 @@ export default {
</p>
</gl-modal>
- <badge-form v-show="isEditing" :is-editing="true" />
+ <badge-form v-show="isEditing" :is-editing="true" data-testid="edit-badge" />
- <badge-form v-show="!isEditing" :is-editing="false" />
+ <badge-form v-show="!isEditing" :is-editing="false" data-testid="add-new-badge" />
<badge-list v-show="!isEditing" />
</div>
</template>
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index a6cd36caede..74069b61f07 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -18,11 +18,6 @@ export default {
type: Object,
required: true,
},
- diffFile: {
- type: Object,
- required: false,
- default: () => ({}),
- },
line: {
type: Object,
required: false,
diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
index 2b37ed19176..e18dc344cd7 100644
--- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
@@ -1,114 +1,43 @@
<script>
-import { mapActions, mapGetters, mapState } from 'vuex';
-import { GlButton, GlLoadingIcon, GlIcon } from '@gitlab/ui';
-import { sprintf, n__ } from '~/locale';
-import DraftsCount from './drafts_count.vue';
-import PublishButton from './publish_button.vue';
+import { mapActions, mapGetters } from 'vuex';
+import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import PreviewItem from './preview_item.vue';
export default {
components: {
- GlButton,
- GlLoadingIcon,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
- DraftsCount,
- PublishButton,
PreviewItem,
},
computed: {
- ...mapGetters(['isNotesFetched']),
...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']),
- ...mapState('batchComments', ['showPreviewDropdown']),
- dropdownTitle() {
- return sprintf(
- n__('%{count} pending comment', '%{count} pending comments', this.draftsCount),
- { count: this.draftsCount },
- );
- },
- },
- watch: {
- showPreviewDropdown() {
- if (this.showPreviewDropdown && this.$refs.dropdown) {
- this.$nextTick(() => this.$refs.dropdown.$el.focus());
- }
- },
- },
- mounted() {
- document.addEventListener('click', this.onClickDocument);
- },
- beforeDestroy() {
- document.removeEventListener('click', this.onClickDocument);
},
methods: {
- ...mapActions('batchComments', ['toggleReviewDropdown']),
+ ...mapActions('batchComments', ['scrollToDraft']),
isLast(index) {
return index === this.sortedDrafts.length - 1;
},
- onClickDocument({ target }) {
- if (
- this.showPreviewDropdown &&
- !target.closest('.review-preview-dropdown, .js-publish-draft-button')
- ) {
- this.toggleReviewDropdown();
- }
- },
},
};
</script>
<template>
- <div
- class="dropdown float-right review-preview-dropdown"
- :class="{
- show: showPreviewDropdown,
- }"
+ <gl-dropdown
+ :header-text="n__('%d pending comment', '%d pending comments', draftsCount)"
+ dropup
+ toggle-class="qa-review-preview-toggle"
>
- <gl-button
- ref="dropdown"
- type="button"
- category="primary"
- variant="success"
- class="review-preview-dropdown-toggle qa-review-preview-toggle"
- @click="toggleReviewDropdown"
- >
- {{ __('Finish review') }}
- <drafts-count />
- <gl-icon name="angle-up" />
- </gl-button>
- <div
- class="dropdown-menu dropdown-menu-large dropdown-menu-right dropdown-open-top"
- :class="{
- show: showPreviewDropdown,
- }"
+ <template #button-content>
+ {{ __('Pending comments') }}
+ <gl-icon class="dropdown-chevron" name="chevron-up" />
+ </template>
+ <gl-dropdown-item
+ v-for="(draft, index) in sortedDrafts"
+ :key="draft.id"
+ @click="scrollToDraft(draft)"
>
- <div class="dropdown-title gl-display-flex gl-align-items-center">
- <span class="gl-ml-auto">{{ dropdownTitle }}</span>
- <gl-button
- :aria-label="__('Close')"
- type="button"
- category="tertiary"
- size="small"
- class="dropdown-title-button gl-ml-auto gl-p-0!"
- icon="close"
- @click="toggleReviewDropdown"
- />
- </div>
- <div class="dropdown-content">
- <ul v-if="isNotesFetched">
- <li v-for="(draft, index) in sortedDrafts" :key="draft.id">
- <preview-item :draft="draft" :is-last="isLast(index)" />
- </li>
- </ul>
- <gl-loading-icon v-else size="lg" class="gl-mt-3 gl-mb-3" />
- </div>
- <div class="dropdown-footer">
- <publish-button
- :show-count="false"
- :should-publish="true"
- :label="__('Submit review')"
- class="float-right gl-mr-3"
- />
- </div>
- </div>
- </div>
+ <preview-item :draft="draft" :is-last="isLast(index)" />
+ </gl-dropdown-item>
+ </gl-dropdown>
</template>
diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue
index c89a6b537ef..dca6d90fbcb 100644
--- a/app/assets/javascripts/batch_comments/components/preview_item.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_item.vue
@@ -1,5 +1,5 @@
<script>
-import { mapActions, mapGetters } from 'vuex';
+import { mapGetters } from 'vuex';
import { GlSprintf, GlIcon } from '@gitlab/ui';
import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
import { sprintf, __ } from '~/locale';
@@ -78,7 +78,6 @@ export default {
},
},
methods: {
- ...mapActions('batchComments', ['scrollToDraft']),
getLineClasses(lineNumber) {
return getLineClasses(lineNumber);
},
@@ -88,17 +87,7 @@ export default {
</script>
<template>
- <button
- type="button"
- class="review-preview-item menu-item"
- :class="[
- componentClasses,
- {
- 'is-last': isLast,
- },
- ]"
- @click="scrollToDraft(draft)"
- >
+ <span>
<span class="review-preview-item-header">
<gl-icon class="flex-shrink-0" :name="iconName" />
<span
@@ -139,5 +128,5 @@ export default {
>
<gl-icon class="gl-mr-3" name="status_success" /> {{ resolvedStatusMessage }}
</span>
- </button>
+ </span>
</template>
diff --git a/app/assets/javascripts/batch_comments/components/publish_button.vue b/app/assets/javascripts/batch_comments/components/publish_button.vue
index 0c79e185f06..ecced36771e 100644
--- a/app/assets/javascripts/batch_comments/components/publish_button.vue
+++ b/app/assets/javascripts/batch_comments/components/publish_button.vue
@@ -1,7 +1,6 @@
<script>
import { mapActions, mapState } from 'vuex';
import { GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
import DraftsCount from './drafts_count.vue';
export default {
@@ -15,11 +14,6 @@ export default {
required: false,
default: false,
},
- label: {
- type: String,
- required: false,
- default: __('Finish review'),
- },
category: {
type: String,
required: false,
@@ -30,22 +24,14 @@ export default {
required: false,
default: 'success',
},
- shouldPublish: {
- type: Boolean,
- required: true,
- },
},
computed: {
...mapState('batchComments', ['isPublishing']),
},
methods: {
- ...mapActions('batchComments', ['publishReview', 'toggleReviewDropdown']),
+ ...mapActions('batchComments', ['publishReview']),
onClick() {
- if (this.shouldPublish) {
- this.publishReview();
- } else {
- this.toggleReviewDropdown();
- }
+ this.publishReview();
},
},
};
@@ -59,7 +45,7 @@ export default {
:variant="variant"
@click="onClick"
>
- {{ label }}
+ {{ __('Submit review') }}
<drafts-count v-if="showCount" />
</gl-button>
</template>
diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue
index e51888eabc1..035d6f4e0ab 100644
--- a/app/assets/javascripts/batch_comments/components/review_bar.vue
+++ b/app/assets/javascripts/batch_comments/components/review_bar.vue
@@ -1,22 +1,15 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { mapActions, mapState, mapGetters } from 'vuex';
-import { GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
+import { mapActions, mapGetters } from 'vuex';
import PreviewDropdown from './preview_dropdown.vue';
+import PublishButton from './publish_button.vue';
export default {
components: {
- GlButton,
- GlModal,
PreviewDropdown,
- },
- directives: {
- 'gl-modal': GlModalDirective,
+ PublishButton,
},
computed: {
...mapGetters(['isNotesFetched']),
- ...mapState('batchComments', ['isDiscarding']),
...mapGetters('batchComments', ['draftsCount']),
},
watch: {
@@ -27,45 +20,17 @@ export default {
},
},
methods: {
- ...mapActions('batchComments', ['discardReview', 'expandAllDiscussions']),
+ ...mapActions('batchComments', ['expandAllDiscussions']),
},
- modalId: 'discard-draft-review',
- text: sprintf(
- s__(
- `BatchComments|You're about to discard your review which will delete all of your pending comments.
- The deleted comments %{strong_start}cannot%{strong_end} be restored.`,
- ),
- {
- strong_start: '<strong>',
- strong_end: '</strong>',
- },
- false,
- ),
};
</script>
<template>
<div v-show="draftsCount > 0">
<nav class="review-bar-component">
- <div class="review-bar-content qa-review-bar">
+ <div class="review-bar-content qa-review-bar d-flex gl-justify-content-end">
<preview-dropdown />
- <gl-button
- v-gl-modal="$options.modalId"
- :loading="isDiscarding"
- class="qa-discard-review float-right"
- >
- {{ __('Discard review') }}
- </gl-button>
+ <publish-button class="gl-ml-3" show-count />
</div>
</nav>
- <gl-modal
- :title="s__('BatchComments|Discard review?')"
- :ok-title="s__('BatchComments|Delete all pending comments')"
- :modal-id="$options.modalId"
- title-tag="h4"
- ok-variant="danger qa-modal-delete-pending-comments"
- @ok="discardReview"
- >
- <p v-html="$options.text"></p>
- </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
index d9b92113103..ebd821125fb 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
@@ -75,15 +75,6 @@ export const updateDiscussionsAfterPublish = ({ dispatch, getters, rootGetters }
}),
);
-export const discardReview = ({ commit, getters }) => {
- commit(types.REQUEST_DISCARD_REVIEW);
-
- return service
- .discard(getters.getNotesData.draftsDiscardPath)
- .then(() => commit(types.RECEIVE_DISCARD_REVIEW_SUCCESS))
- .catch(() => commit(types.RECEIVE_DISCARD_REVIEW_ERROR));
-};
-
export const updateDraft = (
{ commit, getters },
{ note, noteText, resolveDiscussion, position, callback },
@@ -108,8 +99,6 @@ export const scrollToDraft = ({ dispatch, rootGetters }, draft) => {
const draftID = `note_${draft.id}`;
const el = document.querySelector(`#${tabEl} #${draftID}`);
- dispatch('closeReviewDropdown');
-
window.location.hash = draftID;
if (window.mrTabs.currentAction !== tab) {
@@ -125,17 +114,6 @@ export const scrollToDraft = ({ dispatch, rootGetters }, draft) => {
}
};
-export const toggleReviewDropdown = ({ dispatch, state }) => {
- if (state.showPreviewDropdown) {
- dispatch('closeReviewDropdown');
- } else {
- dispatch('openReviewDropdown');
- }
-};
-
-export const openReviewDropdown = ({ commit }) => commit(types.OPEN_REVIEW_DROPDOWN);
-export const closeReviewDropdown = ({ commit }) => commit(types.CLOSE_REVIEW_DROPDOWN);
-
export const expandAllDiscussions = ({ dispatch, state }) =>
state.drafts
.filter(draft => draft.discussion_id)
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
index c8f0658c21c..df523a692d3 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
@@ -11,13 +11,6 @@ export const REQUEST_PUBLISH_REVIEW = 'REQUEST_PUBLISH_REVIEW';
export const RECEIVE_PUBLISH_REVIEW_SUCCESS = 'RECEIVE_PUBLISH_REVIEW_SUCCESS';
export const RECEIVE_PUBLISH_REVIEW_ERROR = 'RECEIVE_PUBLISH_REVIEW_ERROR';
-export const REQUEST_DISCARD_REVIEW = 'REQUEST_DISCARD_REVIEW';
-export const RECEIVE_DISCARD_REVIEW_SUCCESS = 'RECEIVE_DISCARD_REVIEW_SUCCESS';
-export const RECEIVE_DISCARD_REVIEW_ERROR = 'RECEIVE_DISCARD_REVIEW_ERROR';
-
export const RECEIVE_DRAFT_UPDATE_SUCCESS = 'RECEIVE_DRAFT_UPDATE_SUCCESS';
-export const OPEN_REVIEW_DROPDOWN = 'OPEN_REVIEW_DROPDOWN';
-export const CLOSE_REVIEW_DROPDOWN = 'CLOSE_REVIEW_DROPDOWN';
-
export const TOGGLE_RESOLVE_DISCUSSION = 'TOGGLE_RESOLVE_DISCUSSION';
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
index 81ceef7b160..731f4b6d12a 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
@@ -43,16 +43,6 @@ export default {
[types.RECEIVE_PUBLISH_REVIEW_ERROR](state) {
state.isPublishing = false;
},
- [types.REQUEST_DISCARD_REVIEW](state) {
- state.isDiscarding = true;
- },
- [types.RECEIVE_DISCARD_REVIEW_SUCCESS](state) {
- state.isDiscarding = false;
- state.drafts = [];
- },
- [types.RECEIVE_DISCARD_REVIEW_ERROR](state) {
- state.isDiscarding = false;
- },
[types.RECEIVE_DRAFT_UPDATE_SUCCESS](state, data) {
const index = state.drafts.findIndex(draft => draft.id === data.id);
@@ -60,12 +50,6 @@ export default {
state.drafts.splice(index, 1, processDraft(data));
}
},
- [types.OPEN_REVIEW_DROPDOWN](state) {
- state.showPreviewDropdown = true;
- },
- [types.CLOSE_REVIEW_DROPDOWN](state) {
- state.showPreviewDropdown = false;
- },
[types.TOGGLE_RESOLVE_DISCUSSION](state, draftId) {
state.drafts = state.drafts.map(draft => {
if (draft.id === draftId) {
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
index 80c710deab0..6b97fc242c8 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
@@ -4,6 +4,4 @@ export default () => ({
drafts: [],
isPublishing: false,
currentlyPublishingDrafts: [],
- isDiscarding: false,
- showPreviewDropdown: false,
});
diff --git a/app/assets/javascripts/behaviors/autosize.js b/app/assets/javascripts/behaviors/autosize.js
index d61797b7ae4..3e9d77cdf6b 100644
--- a/app/assets/javascripts/behaviors/autosize.js
+++ b/app/assets/javascripts/behaviors/autosize.js
@@ -1,5 +1,5 @@
import Autosize from 'autosize';
-import { waitForCSSLoaded } from '../helpers/startup_css_helper';
+import { waitForCSSLoaded } from '~/helpers/startup_css_helper';
document.addEventListener('DOMContentLoaded', () => {
waitForCSSLoaded(() => {
diff --git a/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js b/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js
index d9164f6204a..c4af34b848b 100644
--- a/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js
+++ b/app/assets/javascripts/behaviors/collapse_sidebar_on_window_resize.js
@@ -8,7 +8,6 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
* @sentrify
*/
export default () => {
- const $sidebarGutterToggle = $('.js-sidebar-toggle');
let bootstrapBreakpoint = bp.getBreakpointSize();
$(window).on('resize.app', () => {
@@ -19,8 +18,13 @@ export default () => {
const breakpointSizes = ['md', 'sm', 'xs'];
if (breakpointSizes.includes(bootstrapBreakpoint)) {
- const $gutterIcon = $sidebarGutterToggle.find('i');
- if ($gutterIcon.hasClass('fa-angle-double-right')) {
+ const $toggleContainer = $('.js-sidebar-toggle-container');
+ const isExpanded = $toggleContainer.data('is-expanded');
+ const $expandIcon = $('.js-sidebar-expand');
+
+ if (isExpanded) {
+ const $sidebarGutterToggle = $expandIcon.closest('.js-sidebar-toggle');
+
$sidebarGutterToggle.trigger('click');
}
@@ -28,11 +32,12 @@ export default () => {
// Sidebar has an icon which corresponds to collapsing the sidebar
// only then trigger the click.
- if (sidebarGutterVueToggleEl) {
- const collapseIcon = sidebarGutterVueToggleEl.querySelector('i.fa-angle-double-right');
-
- if (collapseIcon) {
- collapseIcon.click();
+ if (
+ sidebarGutterVueToggleEl &&
+ !sidebarGutterVueToggleEl.classList.contains('js-sidebar-collapsed')
+ ) {
+ if (sidebarGutterVueToggleEl) {
+ sidebarGutterVueToggleEl.click();
}
}
}
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
index 48bcba7bcca..430a8c38387 100644
--- a/app/assets/javascripts/behaviors/copy_to_clipboard.js
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -1,19 +1,24 @@
import $ from 'jquery';
import Clipboard from 'clipboard';
import { sprintf, __ } from '~/locale';
+import { fixTitle, show } from '~/tooltips';
function showTooltip(target, title) {
- const $target = $(target);
- const originalTitle = $target.data('originalTitle');
+ const { originalTitle } = target.dataset;
+ const hideTooltip = () => {
+ target.removeEventListener('mouseout', hideTooltip);
+ setTimeout(() => {
+ target.setAttribute('title', originalTitle);
+ fixTitle(target);
+ }, 100);
+ };
- if (!$target.data('hideTooltip')) {
- $target
- .attr('title', title)
- .tooltip('_fixTitle')
- .tooltip('show')
- .attr('title', originalTitle)
- .tooltip('_fixTitle');
- }
+ target.setAttribute('title', title);
+
+ fixTitle(target);
+ show(target);
+
+ target.addEventListener('mouseout', hideTooltip);
}
function genericSuccess(e) {
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js
index bcf732e9522..16373b523b2 100644
--- a/app/assets/javascripts/behaviors/gl_emoji.js
+++ b/app/assets/javascripts/behaviors/gl_emoji.js
@@ -3,9 +3,7 @@ import isEmojiUnicodeSupported from '../emoji/support';
import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji';
class GlEmoji extends HTMLElement {
- constructor() {
- super();
-
+ connectedCallback() {
this.initialize();
}
initialize() {
diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js
index fd12c282b62..613309a1c5a 100644
--- a/app/assets/javascripts/behaviors/index.js
+++ b/app/assets/javascripts/behaviors/index.js
@@ -13,6 +13,9 @@ import './toggler_behavior';
import './preview_markdown';
import initCollapseSidebarOnWindowResize from './collapse_sidebar_on_window_resize';
import initSelect2Dropdowns from './select2';
+import { loadStartupCSS } from './load_startup_css';
+
+loadStartupCSS();
installGlEmojiElement();
diff --git a/app/assets/javascripts/behaviors/load_startup_css.js b/app/assets/javascripts/behaviors/load_startup_css.js
new file mode 100644
index 00000000000..1d7bf716475
--- /dev/null
+++ b/app/assets/javascripts/behaviors/load_startup_css.js
@@ -0,0 +1,15 @@
+export const loadStartupCSS = () => {
+ // We need to fallback to dispatching `load` in case our event listener was added too late
+ // or the browser environment doesn't load media=print.
+ // Do this on `window.load` so that the default deferred behavior takes precedence.
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/239357
+ window.addEventListener(
+ 'load',
+ () => {
+ document
+ .querySelectorAll('link[media=print]')
+ .forEach(x => x.dispatchEvent(new Event('load')));
+ },
+ { once: true },
+ );
+};
diff --git a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
index d712c90242c..83f2ca0bdc2 100644
--- a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
+++ b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
@@ -14,7 +14,6 @@ export default function initGFMInput($els) {
milestones: enableGFM,
mergeRequests: enableGFM,
labels: enableGFM,
- vulnerabilities: enableGFM,
});
});
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
new file mode 100644
index 00000000000..bbcc40ab9fe
--- /dev/null
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -0,0 +1,96 @@
+import { flatten } from 'lodash';
+import { s__ } from '~/locale';
+import AccessorUtilities from '~/lib/utils/accessor';
+import { shouldDisableShortcuts } from './shortcuts_toggle';
+
+export const LOCAL_STORAGE_KEY = 'gl-keyboard-shortcuts-customizations';
+
+let parsedCustomizations = {};
+const localStorageIsSafe = AccessorUtilities.isLocalStorageAccessSafe();
+
+if (localStorageIsSafe) {
+ try {
+ parsedCustomizations = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '{}');
+ } catch (e) {
+ /* do nothing */
+ }
+}
+
+/**
+ * A map of command => keys of all keyboard shortcuts
+ * that have been customized by the user.
+ *
+ * @example
+ * { "globalShortcuts.togglePerformanceBar": ["p e r f"] }
+ *
+ * @type { Object.<string, string[]> }
+ */
+export const customizations = parsedCustomizations;
+
+// All available commands
+export const TOGGLE_PERFORMANCE_BAR = 'globalShortcuts.togglePerformanceBar';
+
+/** All keybindings, grouped and ordered with descriptions */
+export const keybindingGroups = [
+ {
+ groupId: 'globalShortcuts',
+ name: s__('KeyboardShortcuts|Global Shortcuts'),
+ keybindings: [
+ {
+ description: s__('KeyboardShortcuts|Toggle the Performance Bar'),
+ command: TOGGLE_PERFORMANCE_BAR,
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ defaultKeys: ['p b'],
+ },
+ ],
+ },
+]
+
+ // For each keybinding object, add a `customKeys` property populated with the
+ // user's custom keybindings (if the command has been customized).
+ // `customKeys` will be `undefined` if the command hasn't been customized.
+ .map(group => {
+ return {
+ ...group,
+ keybindings: group.keybindings.map(binding => ({
+ ...binding,
+ customKeys: customizations[binding.command],
+ })),
+ };
+ });
+
+/**
+ * A simple map of command => keys. All user customizations are included in this map.
+ * This mapping is used to simplify `keysFor` below.
+ *
+ * @example
+ * { "globalShortcuts.togglePerformanceBar": ["p e r f"] }
+ */
+const commandToKeys = flatten(keybindingGroups.map(group => group.keybindings)).reduce(
+ (acc, binding) => {
+ acc[binding.command] = binding.customKeys || binding.defaultKeys;
+ return acc;
+ },
+ {},
+);
+
+/**
+ * Gets keyboard shortcuts associated with a command
+ *
+ * @param {string} command The command string. All command
+ * strings are available as imports from this file.
+ *
+ * @returns {string[]} An array of keyboard shortcut strings bound to the command
+ *
+ * @example
+ * import { keysFor, TOGGLE_PERFORMANCE_BAR } from '~/behaviors/shortcuts/keybindings'
+ *
+ * Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), handler);
+ */
+export const keysFor = command => {
+ if (shouldDisableShortcuts()) {
+ return [];
+ }
+
+ return commandToKeys[command];
+};
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index 8a8b61a57cd..a53150f8d61 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -9,6 +9,7 @@ import axios from '../../lib/utils/axios_utils';
import { refreshCurrentPage, visitUrl } from '../../lib/utils/url_utility';
import findAndFollowLink from '../../lib/utils/navigation_utility';
import { parseBoolean, getCspNonceValue } from '~/lib/utils/common_utils';
+import { keysFor, TOGGLE_PERFORMANCE_BAR } from './keybindings';
const defaultStopCallback = Mousetrap.prototype.stopCallback;
Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) {
@@ -70,7 +71,7 @@ export default class Shortcuts {
Mousetrap.bind('s', Shortcuts.focusSearch);
Mousetrap.bind('/', Shortcuts.focusSearch);
Mousetrap.bind('f', this.focusFilter.bind(this));
- Mousetrap.bind('p b', Shortcuts.onTogglePerfBar);
+ Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), Shortcuts.onTogglePerfBar);
const findFileURL = document.body.dataset.findFile;
@@ -117,9 +118,9 @@ export default class Shortcuts {
e.preventDefault();
const performanceBarCookieName = 'perf_bar_enabled';
if (parseBoolean(Cookies.get(performanceBarCookieName))) {
- Cookies.set(performanceBarCookieName, 'false', { path: '/' });
+ Cookies.set(performanceBarCookieName, 'false', { expires: 365, path: '/' });
} else {
- Cookies.set(performanceBarCookieName, 'true', { path: '/' });
+ Cookies.set(performanceBarCookieName, 'true', { expires: 365, path: '/' });
}
refreshCurrentPage();
}
diff --git a/app/assets/javascripts/blob/components/blob_edit_content.vue b/app/assets/javascripts/blob/components/blob_edit_content.vue
index 6293f3bed1c..a013d637c1d 100644
--- a/app/assets/javascripts/blob/components/blob_edit_content.vue
+++ b/app/assets/javascripts/blob/components/blob_edit_content.vue
@@ -1,12 +1,9 @@
<script>
import { debounce } from 'lodash';
import { initEditorLite } from '~/blob/utils';
-import {
- SNIPPET_MARK_BLOBS_CONTENT,
- SNIPPET_MARK_EDIT_APP_START,
- SNIPPET_MEASURE_BLOBS_CONTENT,
- SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP,
-} from '~/performance_constants';
+import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance_constants';
+
+import eventHub from './eventhub';
export default {
props: {
@@ -48,13 +45,7 @@ export default {
this.editor.onDidChangeModelContent(debounce(this.onFileChange.bind(this), 250));
- window.requestAnimationFrame(() => {
- if (!performance.getEntriesByName(SNIPPET_MARK_BLOBS_CONTENT).length) {
- performance.mark(SNIPPET_MARK_BLOBS_CONTENT);
- performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT);
- performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP, SNIPPET_MARK_EDIT_APP_START);
- }
- });
+ eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT);
},
beforeDestroy() {
this.editor.dispose();
diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue
index 601b694db87..f99ecba2324 100644
--- a/app/assets/javascripts/blob/components/blob_header_filepath.vue
+++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue
@@ -43,6 +43,7 @@ export default {
:text="blob.path"
:gfm="gfmCopyText"
:title="__('Copy file path')"
+ category="tertiary"
css-class="btn-clipboard btn-transparent lh-100 position-static"
/>
</div>
diff --git a/app/assets/javascripts/blob/components/eventhub.js b/app/assets/javascripts/blob/components/eventhub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/blob/components/eventhub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
index 411241b72d5..1412e49836d 100644
--- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
+++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
@@ -1,8 +1,7 @@
<script>
import { GlModal, GlSprintf, GlLink, GlButton } from '@gitlab/ui';
import Cookies from 'js-cookie';
-import { sprintf, s__, __ } from '~/locale';
-import { glEmojiTag } from '~/emoji';
+import { s__ } from '~/locale';
import Tracking from '~/tracking';
const trackingMixin = Tracking.mixin();
@@ -12,21 +11,6 @@ export default {
'https://about.gitlab.com/blog/2018/01/22/a-beginners-guide-to-continuous-integration/',
exampleLink: 'https://docs.gitlab.com/ee/ci/examples/',
codeQualityLink: 'https://docs.gitlab.com/ee/user/project/merge_requests/code_quality.html',
- bodyMessage: s__(
- `MR widget|The pipeline will test your code on every commit. A %{codeQualityLinkStart}code quality report%{codeQualityLinkEnd} will appear in your merge requests to warn you about potential code degradations.`,
- ),
- helpMessage: s__(
- `MR widget|Take a look at our %{beginnerLinkStart}Beginner's Guide to Continuous Integration%{beginnerLinkEnd} and our %{exampleLinkStart}examples of GitLab CI/CD%{exampleLinkEnd} to learn more.`,
- ),
- pipelinesButton: s__('MR widget|See your pipeline in action'),
- mergeRequestButton: s__('MR widget|Back to the Merge request'),
- modalTitle: sprintf(
- __("That's it, well done!%{celebrate}"),
- {
- celebrate: glEmojiTag('tada'),
- },
- false,
- ),
goToTrackValuePipelines: 10,
goToTrackValueMergeRequest: 20,
trackEvent: 'click_button',
@@ -78,6 +62,17 @@ export default {
return '';
},
},
+ i18n: {
+ modalTitle: s__("That's it, well done!"),
+ pipelinesButton: s__('MR widget|See your pipeline in action'),
+ mergeRequestButton: s__('MR widget|Back to the Merge request'),
+ bodyMessage: s__(
+ `MR widget|The pipeline will test your code on every commit. A %{codeQualityLinkStart}code quality report%{codeQualityLinkEnd} will appear in your merge requests to warn you about potential code degradations.`,
+ ),
+ helpMessage: s__(
+ `MR widget|Take a look at our %{beginnerLinkStart}Beginner's Guide to Continuous Integration%{beginnerLinkEnd} and our %{exampleLinkStart}examples of GitLab CI/CD%{exampleLinkEnd} to learn more.`,
+ ),
+ },
mounted() {
this.track();
this.disableModalFromRenderingAgain();
@@ -90,14 +85,13 @@ export default {
};
</script>
<template>
- <gl-modal
- visible
- size="sm"
- :title="$options.modalTitle"
- modal-id="success-pipeline-modal-id-not-used"
- >
+ <gl-modal visible size="sm" modal-id="success-pipeline-modal-id-not-used">
+ <template #modal-title>
+ {{ $options.i18n.modalTitle }}
+ <gl-emoji class="gl-vertical-align-baseline font-size-inherit gl-mr-1" data-name="tada" />
+ </template>
<p>
- <gl-sprintf :message="$options.bodyMessage">
+ <gl-sprintf :message="$options.i18n.bodyMessage">
<template #codeQualityLink="{content}">
<gl-link :href="$options.codeQualityLink" target="_blank" class="font-size-inherit">{{
content
@@ -105,7 +99,7 @@ export default {
</template>
</gl-sprintf>
</p>
- <gl-sprintf :message="$options.helpMessage">
+ <gl-sprintf :message="$options.i18n.helpMessage">
<template #beginnerLink="{content}">
<gl-link :href="$options.beginnerLink" target="_blank">
{{ content }}
@@ -127,7 +121,7 @@ export default {
:data-track-event="$options.trackEvent"
:data-track-label="trackLabel"
>
- {{ $options.mergeRequestButton }}
+ {{ $options.i18n.mergeRequestButton }}
</gl-button>
<gl-button
ref="goToPipelines"
@@ -138,7 +132,7 @@ export default {
:data-track-event="$options.trackEvent"
:data-track-label="trackLabel"
>
- {{ $options.pipelinesButton }}
+ {{ $options.i18n.pipelinesButton }}
</gl-button>
</template>
</gl-modal>
diff --git a/app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue b/app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue
deleted file mode 100644
index 1308ca53e74..00000000000
--- a/app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue
+++ /dev/null
@@ -1,50 +0,0 @@
-<script>
-import { GlAlert, GlButton } from '@gitlab/ui';
-import axios from '~/lib/utils/axios_utils';
-
-export default {
- components: {
- GlAlert,
- GlButton,
- },
- props: {
- dismissEndpoint: {
- type: String,
- required: true,
- },
- featureId: {
- type: String,
- required: true,
- },
- editPath: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- showAlert: true,
- };
- },
- methods: {
- dismissAlert() {
- this.showAlert = false;
-
- return axios.post(this.dismissEndpoint, {
- feature_name: this.featureId,
- });
- },
- },
-};
-</script>
-
-<template>
- <gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissAlert">
- {{ __('The Web IDE offers advanced syntax highlighting capabilities and more.') }}
- <div class="gl-mt-5">
- <gl-button :href="editPath" category="primary" variant="info">{{
- __('Open Web IDE')
- }}</gl-button>
- </div>
- </gl-alert>
-</template>
diff --git a/app/assets/javascripts/blob/suggest_web_ide_ci/index.js b/app/assets/javascripts/blob/suggest_web_ide_ci/index.js
deleted file mode 100644
index eadf3cd6216..00000000000
--- a/app/assets/javascripts/blob/suggest_web_ide_ci/index.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Vue from 'vue';
-import WebIdeAlert from './components/web_ide_alert.vue';
-
-export default el => {
- const { dismissEndpoint, featureId, editPath } = el.dataset;
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- render(createElement) {
- return createElement(WebIdeAlert, {
- props: {
- dismissEndpoint,
- featureId,
- editPath,
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 05ee8e49eb1..aa76364c466 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -5,6 +5,7 @@ import { handleLocationHash } from '../../lib/utils/common_utils';
import axios from '../../lib/utils/axios_utils';
import eventHub from '../../notes/event_hub';
import { __ } from '~/locale';
+import { fixTitle } from '~/tooltips';
const loadRichBlobViewer = type => {
switch (type) {
@@ -124,7 +125,7 @@ export default class BlobViewer {
this.copySourceBtn.classList.add('disabled');
}
- $(this.copySourceBtn).tooltip('_fixTitle');
+ fixTitle($(this.copySourceBtn));
}
switchToViewer(name) {
@@ -179,9 +180,7 @@ export default class BlobViewer {
viewer.innerHTML = data.html;
viewer.setAttribute('data-loaded', 'true');
- if (window.gon?.features?.codeNavigation) {
- eventHub.$emit('showBlobInteractionZones', viewer.dataset.path);
- }
+ eventHub.$emit('showBlobInteractionZones', viewer.dataset.path);
return viewer;
});
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index c9972f0b43c..2d426ee663a 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -2,19 +2,17 @@
import $ from 'jquery';
import NewCommitForm from '../new_commit_form';
-import EditBlob from './edit_blob';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
import BlobFileDropzone from '../blob/blob_file_dropzone';
import initPopover from '~/blob/suggest_gitlab_ci_yml';
import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils';
import Tracking from '~/tracking';
-import initWebIdeAlert from '~/blob/suggest_web_ide_ci';
export default () => {
const editBlobForm = $('.js-edit-blob-form');
const uploadBlobForm = $('.js-upload-blob-form');
const deleteBlobForm = $('.js-delete-blob-form');
const suggestEl = document.querySelector('.js-suggest-gitlab-ci-yml');
- const alertEl = document.getElementById('js-suggest-web-ide-ci');
if (editBlobForm.length) {
const urlRoot = editBlobForm.data('relativeUrlRoot');
@@ -26,6 +24,18 @@ export default () => {
const commitButton = $('.js-commit-button');
const cancelLink = $('.btn.btn-cancel');
+ import('./edit_blob')
+ .then(({ default: EditBlob } = {}) => {
+ new EditBlob({
+ assetsPath: `${urlRoot}${assetsPath}`,
+ filePath,
+ currentAction,
+ projectId,
+ isMarkdown,
+ });
+ })
+ .catch(e => createFlash(e));
+
cancelLink.on('click', () => {
window.onbeforeunload = null;
});
@@ -34,13 +44,6 @@ export default () => {
window.onbeforeunload = null;
});
- new EditBlob({
- assetsPath: `${urlRoot}${assetsPath}`,
- filePath,
- currentAction,
- projectId,
- isMarkdown,
- });
new NewCommitForm(editBlobForm);
// returning here blocks page navigation
@@ -85,8 +88,4 @@ export default () => {
});
}
}
-
- if (alertEl) {
- initWebIdeAlert(alertEl);
- }
};
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index 2a4ab4b8827..e6b0a6fc1c5 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -1,91 +1,56 @@
-/* global ace */
-
import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants';
import TemplateSelectorMediator from '../blob/file_template_mediator';
-import getModeByFileExtension from '~/lib/utils/ace_utils';
import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
-
-const monacoEnabledGlobally = window.gon.features?.monacoBlobs;
+import EditorLite from '~/editor/editor_lite';
+import FileTemplateExtension from '~/editor/editor_file_template_ext';
export default class EditBlob {
// The options object has:
// assetsPath, filePath, currentAction, projectId, isMarkdown
constructor(options) {
this.options = options;
- this.options.monacoEnabled = this.options.monacoEnabled ?? monacoEnabledGlobally;
- const { isMarkdown, monacoEnabled } = this.options;
- return Promise.resolve()
- .then(() => {
- return monacoEnabled ? this.configureMonacoEditor() : this.configureAceEditor();
- })
- .then(() => {
- this.initModePanesAndLinks();
- this.initFileSelectors();
- this.initSoftWrap();
- if (isMarkdown) {
+ this.configureMonacoEditor();
+
+ if (this.options.isMarkdown) {
+ import('~/editor/editor_markdown_ext')
+ .then(MarkdownExtension => {
+ this.editor.use(MarkdownExtension.default);
addEditorMarkdownListeners(this.editor);
- }
- this.editor.focus();
- })
- .catch(() => createFlash(BLOB_EDITOR_ERROR));
+ })
+ .catch(() => createFlash(BLOB_EDITOR_ERROR));
+ }
+
+ this.initModePanesAndLinks();
+ this.initFileSelectors();
+ this.initSoftWrap();
+ this.editor.focus();
}
configureMonacoEditor() {
- const EditorPromise = import(
- /* webpackChunkName: 'monaco_editor_lite' */ '~/editor/editor_lite'
- );
- const MarkdownExtensionPromise = this.options.isMarkdown
- ? import('~/editor/editor_markdown_ext')
- : Promise.resolve(false);
- const FileTemplateExtensionPromise = import('~/editor/editor_file_template_ext');
-
- return Promise.all([EditorPromise, MarkdownExtensionPromise, FileTemplateExtensionPromise])
- .then(([EditorModule, MarkdownExtension, FileTemplateExtension]) => {
- const EditorLite = EditorModule.default;
- const editorEl = document.getElementById('editor');
- const fileNameEl =
- document.getElementById('file_path') || document.getElementById('file_name');
- const fileContentEl = document.getElementById('file-content');
- const form = document.querySelector('.js-edit-blob-form');
-
- const rootEditor = new EditorLite();
-
- this.editor = rootEditor.createInstance({
- el: editorEl,
- blobPath: fileNameEl.value,
- blobContent: editorEl.innerText,
- });
-
- rootEditor.use([MarkdownExtension.default, FileTemplateExtension.default], this.editor);
-
- fileNameEl.addEventListener('change', () => {
- this.editor.updateModelLanguage(fileNameEl.value);
- });
-
- form.addEventListener('submit', () => {
- fileContentEl.value = this.editor.getValue();
- });
- })
- .catch(() => createFlash(BLOB_EDITOR_ERROR));
- }
+ const editorEl = document.getElementById('editor');
+ const fileNameEl = document.getElementById('file_path') || document.getElementById('file_name');
+ const fileContentEl = document.getElementById('file-content');
+ const form = document.querySelector('.js-edit-blob-form');
- configureAceEditor() {
- const { filePath, assetsPath } = this.options;
- ace.config.set('modePath', `${assetsPath}/ace`);
- ace.config.loadModule('ace/ext/searchbox');
- ace.config.loadModule('ace/ext/modelist');
+ const rootEditor = new EditorLite();
- this.editor = ace.edit('editor');
+ this.editor = rootEditor.createInstance({
+ el: editorEl,
+ blobPath: fileNameEl.value,
+ blobContent: editorEl.innerText,
+ });
+ this.editor.use(FileTemplateExtension);
- // This prevents warnings re: automatic scrolling being logged
- this.editor.$blockScrolling = Infinity;
+ fileNameEl.addEventListener('change', () => {
+ this.editor.updateModelLanguage(fileNameEl.value);
+ });
- if (filePath) {
- this.editor.getSession().setMode(getModeByFileExtension(filePath));
- }
+ form.addEventListener('submit', () => {
+ fileContentEl.value = this.editor.getValue();
+ });
}
initFileSelectors() {
@@ -137,7 +102,7 @@ export default class EditBlob {
}
initSoftWrap() {
- this.isSoftWrapped = Boolean(this.options.monacoEnabled);
+ this.isSoftWrapped = true;
this.$toggleButton = $('.soft-wrap-toggle');
this.$toggleButton.toggleClass('soft-wrap-active', this.isSoftWrapped);
this.$toggleButton.on('click', () => this.toggleSoftWrap());
@@ -146,10 +111,6 @@ export default class EditBlob {
toggleSoftWrap() {
this.isSoftWrapped = !this.isSoftWrapped;
this.$toggleButton.toggleClass('soft-wrap-active', this.isSoftWrapped);
- if (this.options.monacoEnabled) {
- this.editor.updateOptions({ wordWrap: this.isSoftWrapped ? 'on' : 'off' });
- } else {
- this.editor.getSession().setUseWrapMode(this.isSoftWrapped);
- }
+ this.editor.updateOptions({ wordWrap: this.isSoftWrapped ? 'on' : 'off' });
}
}
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 5c8df94ca90..6b7b0c2e28d 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -2,11 +2,24 @@ import { sortBy } from 'lodash';
import ListIssue from 'ee_else_ce/boards/models/issue';
import { ListType } from './constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import boardsStore from '~/boards/stores/boards_store';
export function getMilestone() {
return null;
}
+export function formatBoardLists(lists) {
+ const formattedLists = lists.nodes.map(list =>
+ boardsStore.updateListPosition({ ...list, doNotFetchIssues: true }),
+ );
+ return formattedLists.reduce((map, list) => {
+ return {
+ ...map,
+ [list.id]: list,
+ };
+ }, {});
+}
+
export function formatIssue(issue) {
return new ListIssue({
...issue,
@@ -17,9 +30,15 @@ export function formatIssue(issue) {
export function formatListIssues(listIssues) {
const issues = {};
+ let listIssuesCount;
const listData = listIssues.nodes.reduce((map, list) => {
- const sortedIssues = sortBy(list.issues.nodes, 'relativePosition');
+ listIssuesCount = list.issues.count;
+ let sortedIssues = list.issues.edges.map(issueNode => ({
+ ...issueNode.node,
+ }));
+ sortedIssues = sortBy(sortedIssues, 'relativePosition');
+
return {
...map,
[list.id]: sortedIssues.map(i => {
@@ -39,13 +58,30 @@ export function formatListIssues(listIssues) {
};
}, {});
- return { listData, issues };
+ return { listData, issues, listIssuesCount };
+}
+
+export function formatListsPageInfo(lists) {
+ const listData = lists.nodes.reduce((map, list) => {
+ return {
+ ...map,
+ [list.id]: list.issues.pageInfo,
+ };
+ }, {});
+ return listData;
}
export function fullBoardId(boardId) {
return `gid://gitlab/Board/${boardId}`;
}
+export function fullLabelId(label) {
+ if (label.project_id !== null) {
+ return `gid://gitlab/ProjectLabel/${label.id}`;
+ }
+ return `gid://gitlab/GroupLabel/${label.id}`;
+}
+
export function moveIssueListHelper(issue, fromList, toList) {
if (toList.type === ListType.label) {
issue.addLabel(toList.label);
@@ -69,4 +105,5 @@ export default {
formatIssue,
formatListIssues,
fullBoardId,
+ fullLabelId,
};
diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue
deleted file mode 100644
index 55e3e4a6329..00000000000
--- a/app/assets/javascripts/boards/components/board_blank_state.vue
+++ /dev/null
@@ -1,104 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import Cookies from 'js-cookie';
-import { __ } from '~/locale';
-import ListLabel from '~/boards/models/label';
-import boardsStore from '../stores/boards_store';
-
-export default {
- components: {
- GlButton,
- },
- data() {
- return {
- predefinedLabels: [
- new ListLabel({ title: __('To Do'), color: '#F0AD4E' }),
- new ListLabel({ title: __('Doing'), color: '#5CB85C' }),
- ],
- };
- },
- methods: {
- addDefaultLists() {
- this.clearBlankState();
-
- this.predefinedLabels.forEach((label, i) => {
- boardsStore.addList({
- title: label.title,
- position: i,
- list_type: 'label',
- label: {
- title: label.title,
- color: label.color,
- },
- });
- });
-
- const loadListIssues = listObj => {
- const list = boardsStore.findList('title', listObj.title);
-
- if (!list) {
- return null;
- }
-
- list.id = listObj.id;
- list.label.id = listObj.label.id;
- return list.getIssues().catch(() => {
- // TODO: handle request error
- });
- };
-
- // Save the labels
- boardsStore
- .generateDefaultLists()
- .then(res => res.data)
- .then(data => Promise.all(data.map(loadListIssues)))
- .catch(() => {
- boardsStore.removeList(undefined, 'label');
- Cookies.remove('issue_board_welcome_hidden', {
- path: '',
- });
- boardsStore.addBlankState();
- });
- },
- clearBlankState: boardsStore.removeBlankState.bind(boardsStore),
- },
-};
-</script>
-
-<template>
- <div class="board-blank-state p-3">
- <p>
- {{
- s__('BoardBlankState|Add the following default lists to your Issue Board with one click:')
- }}
- </p>
- <ul class="list-unstyled board-blank-state-list">
- <li v-for="(label, index) in predefinedLabels" :key="index">
- <span
- :style="{ backgroundColor: label.color }"
- class="label-color position-relative d-inline-block rounded"
- ></span>
- {{ label.title }}
- </li>
- </ul>
- <p>
- {{
- s__(
- 'BoardBlankState|Starting out with the default set of lists will get you right on the way to making the most of your board.',
- )
- }}
- </p>
- <gl-button
- category="secondary"
- variant="success"
- block="block"
- class="gl-mb-0"
- @click.stop="addDefaultLists"
- >
- {{ s__('BoardBlankState|Add default lists') }}
- </gl-button>
- <gl-button category="secondary" variant="default" block="block" @click.stop="clearBlankState">
- {{ s__("BoardBlankState|Nevermind, I'll use my own") }}
- </gl-button>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index 6d216911798..9295065b7b7 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -1,13 +1,12 @@
<script>
import { mapGetters, mapActions } from 'vuex';
import Sortable from 'sortablejs';
-import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
import Tooltip from '~/vue_shared/directives/tooltip';
import EmptyComponent from '~/vue_shared/components/empty_component';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import BoardBlankState from './board_blank_state.vue';
import BoardList from './board_list.vue';
+import BoardListNew from './board_list_new.vue';
import boardsStore from '../stores/boards_store';
import eventHub from '../eventhub';
import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
@@ -16,14 +15,13 @@ import { ListType } from '../constants';
export default {
components: {
BoardPromotionState: EmptyComponent,
- BoardBlankState,
BoardListHeader,
- BoardList,
+ BoardList: gon.features?.graphqlBoardLists ? BoardListNew : BoardList,
},
directives: {
Tooltip,
},
- mixins: [isWipLimitsOn, glFeatureFlagMixin()],
+ mixins: [glFeatureFlagMixin()],
props: {
list: {
type: Object,
@@ -42,7 +40,7 @@ export default {
},
inject: {
boardId: {
- type: String,
+ default: '',
},
},
data() {
@@ -54,7 +52,7 @@ export default {
computed: {
...mapGetters(['getIssues']),
showBoardListAndBoardInfo() {
- return this.list.type !== ListType.blank && this.list.type !== ListType.promotion;
+ return this.list.type !== ListType.promotion;
},
uniqueKey() {
// eslint-disable-next-line @gitlab/require-i18n-strings
@@ -74,7 +72,7 @@ export default {
filter: {
handler() {
if (this.shouldFetchIssues) {
- this.fetchIssuesForList(this.list.id);
+ this.fetchIssuesForList({ listId: this.list.id });
} else {
this.list.page = 1;
this.list.getIssues(true).catch(() => {
@@ -87,7 +85,7 @@ export default {
},
mounted() {
if (this.shouldFetchIssues) {
- this.fetchIssuesForList(this.list.id);
+ this.fetchIssuesForList({ listId: this.list.id });
}
const instance = this;
@@ -146,9 +144,7 @@ export default {
:disabled="disabled"
:issues="listIssues"
:list="list"
- :loading="list.loading"
/>
- <board-blank-state v-if="canAdminList && list.id === 'blank'" />
<!-- Will be only available in EE -->
<board-promotion-state v-if="list.id === 'promotion'" />
diff --git a/app/assets/javascripts/boards/components/board_configuration_options.vue b/app/assets/javascripts/boards/components/board_configuration_options.vue
new file mode 100644
index 00000000000..ad3d653b905
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_configuration_options.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlFormCheckbox } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFormCheckbox,
+ },
+ props: {
+ currentBoard: {
+ type: Object,
+ required: true,
+ },
+ board: {
+ type: Object,
+ required: true,
+ },
+ isNewForm: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ const { hide_backlog_list: hideBacklogList, hide_closed_list: hideClosedList } = this.isNewForm
+ ? this.board
+ : this.currentBoard;
+
+ return {
+ hideClosedList,
+ hideBacklogList,
+ };
+ },
+ methods: {
+ changeClosedList(checked) {
+ this.board.hideClosedList = !checked;
+ },
+ changeBacklogList(checked) {
+ this.board.hideBacklogList = !checked;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="append-bottom-20">
+ <label class="form-section-title label-bold" for="board-new-name">
+ {{ __('List options') }}
+ </label>
+ <p class="text-secondary gl-mb-3">
+ {{ __('Configure which lists are shown for anyone who visits this board') }}
+ </p>
+ <gl-form-checkbox
+ :checked="!hideBacklogList"
+ data-testid="backlog-list-checkbox"
+ @change="changeBacklogList"
+ >{{ __('Show the Open list') }}
+ </gl-form-checkbox>
+ <gl-form-checkbox
+ :checked="!hideClosedList"
+ data-testid="closed-list-checkbox"
+ @change="changeClosedList"
+ >{{ __('Show the Closed list') }}
+ </gl-form-checkbox>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index c7b3da0e672..2515f471379 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -1,5 +1,6 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
+import { sortBy } from 'lodash';
import BoardColumn from 'ee_else_ce/boards/components/board_column.vue';
import { GlAlert } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -30,7 +31,9 @@ export default {
...mapState(['boardLists', 'error']),
...mapGetters(['isSwimlanesOn']),
boardListsToUse() {
- return this.glFeatures.graphqlBoardLists ? this.boardLists : this.lists;
+ const lists =
+ this.glFeatures.graphqlBoardLists || this.isSwimlanesOn ? this.boardLists : this.lists;
+ return sortBy([...Object.values(lists)], 'position');
},
},
mounted() {
@@ -68,7 +71,7 @@ export default {
<template v-else>
<epics-swimlanes
ref="swimlanes"
- :lists="boardLists"
+ :lists="boardListsToUse"
:can-admin-list="canAdminList"
:disabled="disabled"
/>
diff --git a/app/assets/javascripts/boards/components/board_delete.js b/app/assets/javascripts/boards/components/board_delete.js
deleted file mode 100644
index b74234a2e3c..00000000000
--- a/app/assets/javascripts/boards/components/board_delete.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import $ from 'jquery';
-import Vue from 'vue';
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default Vue.extend({
- components: {
- GlButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- list: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- },
- methods: {
- deleteBoard() {
- $(this.$el).tooltip('hide');
-
- // eslint-disable-next-line no-alert
- if (window.confirm(__('Are you sure you want to delete this list?'))) {
- this.list.destroy();
- }
- },
- },
-});
diff --git a/app/assets/javascripts/boards/components/board_extra_actions.vue b/app/assets/javascripts/boards/components/board_extra_actions.vue
new file mode 100644
index 00000000000..b802ccc7882
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_extra_actions.vue
@@ -0,0 +1,57 @@
+<script>
+import { GlTooltip, GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ name: 'BoardExtraActions',
+ components: {
+ GlTooltip,
+ GlButton,
+ },
+ props: {
+ canAdminList: {
+ type: Boolean,
+ required: true,
+ },
+ disabled: {
+ type: Boolean,
+ required: true,
+ },
+ openModal: {
+ type: Function,
+ required: true,
+ },
+ },
+ computed: {
+ tooltipTitle() {
+ if (this.disabled) {
+ return __('Please add a list to your board first');
+ }
+
+ return '';
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="board-extra-actions">
+ <span ref="addIssuesButtonTooltip" class="gl-ml-3">
+ <gl-button
+ v-if="canAdminList"
+ type="button"
+ data-placement="bottom"
+ data-track-event="click_button"
+ data-track-label="board_add_issues"
+ :disabled="disabled"
+ :aria-disabled="disabled"
+ @click="openModal"
+ >
+ {{ __('Add issues') }}
+ </gl-button>
+ </span>
+ <gl-tooltip v-if="disabled" :target="() => $refs.addIssuesButtonTooltip" placement="bottom">
+ {{ tooltipTitle }}
+ </gl-tooltip>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index 385dd5fdc71..793c594cf16 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -5,6 +5,8 @@ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import { visitUrl } from '~/lib/utils/url_utility';
import boardsStore from '~/boards/stores/boards_store';
+import BoardConfigurationOptions from './board_configuration_options.vue';
+
const boardDefaults = {
id: false,
name: '',
@@ -13,12 +15,15 @@ const boardDefaults = {
assignee: {},
assignee_id: undefined,
weight: null,
+ hide_backlog_list: false,
+ hide_closed_list: false,
};
export default {
components: {
BoardScope: () => import('ee_component/boards/components/board_scope.vue'),
DeprecatedModal,
+ BoardConfigurationOptions,
},
props: {
canAdminBoard: {
@@ -140,7 +145,17 @@ export default {
} else {
boardsStore
.createBoard(this.board)
- .then(resp => resp.data)
+ .then(resp => {
+ // This handles 2 use cases
+ // - In create call we only get one parameter, the new board
+ // - In update call, due to Promise.all, we get REST response in
+ // array index 0
+
+ if (Array.isArray(resp)) {
+ return resp[0].data;
+ }
+ return resp.data ? resp.data : resp;
+ })
.then(data => {
visitUrl(data.board_path);
})
@@ -182,7 +197,7 @@ export default {
<form v-else class="js-board-config-modal" @submit.prevent>
<div v-if="!readonly" class="append-bottom-20">
<label class="form-section-title label-bold" for="board-new-name">{{
- __('Board name')
+ __('Title')
}}</label>
<input
id="board-new-name"
@@ -196,6 +211,12 @@ export default {
/>
</div>
+ <board-configuration-options
+ :is-new-form="isNewForm"
+ :board="board"
+ :current-board="currentBoard"
+ />
+
<board-scope
v-if="scopedIssueBoardFeatureEnabled"
:collapse-scope="isNewForm"
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 25f8ffca633..d01df44e7e4 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -14,6 +14,8 @@ import {
sortableEnd,
} from '../mixins/sortable_default_options';
+// This component is being replaced in favor of './board_list_new.vue' for GraphQL boards
+
if (gon.features && gon.features.multiSelectBoard) {
Sortable.mount(new MultiDrag());
}
@@ -39,10 +41,6 @@ export default {
type: Array,
required: true,
},
- loading: {
- type: Boolean,
- required: true,
- },
},
data() {
return {
@@ -62,6 +60,9 @@ export default {
issuesSizeExceedsMax() {
return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount;
},
+ loading() {
+ return this.list.loading;
+ },
},
watch: {
filters: {
@@ -72,7 +73,6 @@ export default {
deep: true,
},
issues() {
- if (this.glFeatures.graphqlBoardLists) return;
this.$nextTick(() => {
if (
this.scrollHeight() <= this.listHeight() &&
@@ -98,6 +98,8 @@ export default {
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
+ // TODO: Use Draggable in ./board_list_new.vue to drag & drop issue
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/218164
const multiSelectOpts = {};
if (gon.features && gon.features.multiSelectBoard) {
multiSelectOpts.multiDrag = true;
@@ -403,8 +405,6 @@ export default {
this.showIssueForm = !this.showIssueForm;
},
onScroll() {
- if (this.glFeatures.graphqlBoardLists) return;
-
if (!this.list.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset) {
this.loadNextPage();
}
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index 361fe252afb..bb9a1b79d91 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -1,5 +1,5 @@
<script>
-import { mapActions } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import {
GlButton,
GlButtonGroup,
@@ -9,20 +9,18 @@ import {
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
-import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits';
import { n__, s__ } from '~/locale';
import AccessorUtilities from '../../lib/utils/accessor';
-import BoardDelete from './board_delete';
import IssueCount from './issue_count.vue';
import boardsStore from '../stores/boards_store';
import eventHub from '../eventhub';
-import { ListType } from '../constants';
+import sidebarEventHub from '~/sidebar/event_hub';
+import { inactiveId, LIST, ListType } from '../constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
- BoardDelete,
GlButtonGroup,
GlButton,
GlLabel,
@@ -34,7 +32,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [isWipLimitsOn, glFeatureFlagMixin()],
+ mixins: [glFeatureFlagMixin()],
props: {
list: {
type: Object,
@@ -45,11 +43,6 @@ export default {
type: Boolean,
required: true,
},
- canAdminList: {
- type: Boolean,
- required: false,
- default: false,
- },
isSwimlanesHeader: {
type: Boolean,
required: false,
@@ -58,7 +51,7 @@ export default {
},
inject: {
boardId: {
- type: String,
+ default: '',
},
},
data() {
@@ -67,6 +60,7 @@ export default {
};
},
computed: {
+ ...mapState(['activeId']),
isLoggedIn() {
return Boolean(gon.current_user_id);
},
@@ -114,10 +108,7 @@ export default {
},
isSettingsShown() {
return (
- this.listType !== ListType.backlog &&
- this.showListHeaderButton &&
- this.list.isExpanded &&
- this.isWipLimitsOn
+ this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded
);
},
showBoardListAndBoardInfo() {
@@ -135,7 +126,14 @@ export default {
},
},
methods: {
- ...mapActions(['updateList']),
+ ...mapActions(['updateList', 'setActiveId']),
+ openSidebarSettings() {
+ if (this.activeId === inactiveId) {
+ sidebarEventHub.$emit('sidebar.closeAll');
+ }
+
+ this.setActiveId({ id: this.list.id, sidebarType: LIST });
+ },
showScopedLabels(label) {
return boardsStore.scopedLabels.enabled && isScopedLabel(label);
},
@@ -176,7 +174,6 @@ export default {
<header
:class="{
'has-border': list.label && list.label.color,
- 'gl-relative': list.isExpanded,
'gl-h-full': !list.isExpanded,
'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader,
}"
@@ -279,22 +276,6 @@ export default {
</div>
</gl-tooltip>
- <board-delete
- v-if="canAdminList && !list.preset && list.id"
- :list="list"
- inline-template="true"
- >
- <gl-button
- v-gl-tooltip.hover.bottom
- :class="{ 'gl-display-none': !list.isExpanded }"
- :aria-label="__('Delete list')"
- class="board-delete no-drag gl-pr-0 gl-shadow-none! gl-mr-3"
- :title="__('Delete list')"
- icon="remove"
- size="small"
- @click.stop="deleteBoard"
- />
- </board-delete>
<div
v-if="showBoardListAndBoardInfo"
class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary"
diff --git a/app/assets/javascripts/boards/components/board_list_new.vue b/app/assets/javascripts/boards/components/board_list_new.vue
new file mode 100644
index 00000000000..0a495d05122
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_list_new.vue
@@ -0,0 +1,166 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
+import BoardNewIssue from './board_new_issue.vue';
+import BoardCard from './board_card.vue';
+import eventHub from '../eventhub';
+import boardsStore from '../stores/boards_store';
+import { sprintf, __ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+export default {
+ name: 'BoardList',
+ components: {
+ BoardCard,
+ BoardNewIssue,
+ GlLoadingIcon,
+ },
+ mixins: [glFeatureFlagMixin()],
+ props: {
+ disabled: {
+ type: Boolean,
+ required: true,
+ },
+ list: {
+ type: Object,
+ required: true,
+ },
+ issues: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ scrollOffset: 250,
+ filters: boardsStore.state.filters,
+ showCount: false,
+ showIssueForm: false,
+ };
+ },
+ computed: {
+ ...mapState(['pageInfoByListId', 'listsFlags']),
+ paginatedIssueText() {
+ return sprintf(__('Showing %{pageSize} of %{total} issues'), {
+ pageSize: this.issues.length,
+ total: this.list.issuesSize,
+ });
+ },
+ issuesSizeExceedsMax() {
+ return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount;
+ },
+ hasNextPage() {
+ return this.pageInfoByListId[this.list.id].hasNextPage;
+ },
+ loading() {
+ return this.listsFlags[this.list.id]?.isLoading;
+ },
+ },
+ watch: {
+ filters: {
+ handler() {
+ this.list.loadingMore = false;
+ this.$refs.list.scrollTop = 0;
+ },
+ deep: true,
+ },
+ issues() {
+ this.$nextTick(() => {
+ this.showCount = this.scrollHeight() > Math.ceil(this.listHeight());
+ });
+ },
+ },
+ created() {
+ eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
+ },
+ mounted() {
+ // Scroll event on list to load more
+ this.$refs.list.addEventListener('scroll', this.onScroll);
+ },
+ beforeDestroy() {
+ eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
+ this.$refs.list.removeEventListener('scroll', this.onScroll);
+ },
+ methods: {
+ ...mapActions(['fetchIssuesForList']),
+ listHeight() {
+ return this.$refs.list.getBoundingClientRect().height;
+ },
+ scrollHeight() {
+ return this.$refs.list.scrollHeight;
+ },
+ scrollTop() {
+ return this.$refs.list.scrollTop + this.listHeight();
+ },
+ scrollToTop() {
+ this.$refs.list.scrollTop = 0;
+ },
+ loadNextPage() {
+ const loadingDone = () => {
+ this.list.loadingMore = false;
+ };
+ this.list.loadingMore = true;
+ this.fetchIssuesForList({ listId: this.list.id, fetchNext: true })
+ .then(loadingDone)
+ .catch(loadingDone);
+ },
+ toggleForm() {
+ this.showIssueForm = !this.showIssueForm;
+ },
+ onScroll() {
+ window.requestAnimationFrame(() => {
+ if (
+ !this.list.loadingMore &&
+ this.scrollTop() > this.scrollHeight() - this.scrollOffset &&
+ this.hasNextPage
+ ) {
+ this.loadNextPage();
+ }
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-show="list.isExpanded"
+ class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column"
+ data-qa-selector="board_list_cards_area"
+ >
+ <div
+ v-if="loading"
+ class="gl-mt-4 gl-text-center"
+ :aria-label="__('Loading issues')"
+ data-testid="board_list_loading"
+ >
+ <gl-loading-icon />
+ </div>
+ <board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" />
+ <ul
+ v-show="!loading"
+ ref="list"
+ :data-board="list.id"
+ :data-board-type="list.type"
+ :class="{ 'bg-danger-100': issuesSizeExceedsMax }"
+ class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list"
+ >
+ <board-card
+ v-for="(issue, index) in issues"
+ ref="issue"
+ :key="issue.id"
+ :index="index"
+ :list="list"
+ :issue="issue"
+ :disabled="disabled"
+ />
+ <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1">
+ <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" />
+ <span v-if="issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
+ <span v-else>{{ paginatedIssueText }}</span>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index 348d485ff37..0a665b82880 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -22,11 +22,7 @@ export default {
required: true,
},
},
- inject: {
- groupId: {
- type: Number,
- },
- },
+ inject: ['groupId'],
data() {
return {
title: '',
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index e2600883e89..392e056dcbf 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDrawer, GlLabel } from '@gitlab/ui';
+import { GlButton, GlDrawer, GlLabel } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import { __ } from '~/locale';
import boardsStore from '~/boards/stores/boards_store';
@@ -17,6 +17,7 @@ export default {
label: 'label',
labelListText: __('Label'),
components: {
+ GlButton,
GlDrawer,
GlLabel,
BoardSettingsSidebarWipLimit: () =>
@@ -25,16 +26,23 @@ export default {
import('ee_component/boards/components/board_settings_list_types.vue'),
},
mixins: [glFeatureFlagMixin()],
+ props: {
+ canAdminList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
computed: {
- ...mapGetters(['isSidebarOpen']),
+ ...mapGetters(['isSidebarOpen', 'shouldUseGraphQL']),
...mapState(['activeId', 'sidebarType', 'boardLists']),
activeList() {
/*
Warning: Though a computed property it is not reactive because we are
referencing a List Model class. Reactivity only applies to plain JS objects
*/
- if (this.glFeatures.graphqlBoardLists) {
- return this.boardLists.find(({ id }) => id === this.activeId);
+ if (this.shouldUseGraphQL) {
+ return this.boardLists[this.activeId];
}
return boardsStore.state.lists.find(({ id }) => id === this.activeId);
},
@@ -62,6 +70,13 @@ export default {
showScopedLabels(label) {
return boardsStore.scopedLabels.enabled && isScopedLabel(label);
},
+ deleteBoard() {
+ // eslint-disable-next-line no-alert
+ if (window.confirm(__('Are you sure you want to delete this list?'))) {
+ this.activeList.destroy();
+ this.unsetActiveId();
+ }
+ },
},
};
</script>
@@ -91,6 +106,16 @@ export default {
:board-list-type="boardListType"
/>
<board-settings-sidebar-wip-limit :max-issue-count="activeList.maxIssueCount" />
+ <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-m-4">
+ <gl-button
+ variant="danger"
+ category="secondary"
+ icon="remove"
+ data-testid="remove-list"
+ @click.stop="deleteBoard"
+ >{{ __('Remove list') }}
+ </gl-button>
+ </div>
</template>
</gl-drawer>
</template>
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index 8658f51e5cf..a181ea51c4a 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -41,14 +41,7 @@ export default {
default: false,
},
},
- inject: {
- groupId: {
- type: Number,
- },
- rootPath: {
- type: String,
- },
- },
+ inject: ['groupId', 'rootPath'],
data() {
return {
limitBeforeCounter: 2,
diff --git a/app/assets/javascripts/boards/components/modal/tabs.vue b/app/assets/javascripts/boards/components/modal/tabs.vue
index a71fda9d7c5..b066fb25360 100644
--- a/app/assets/javascripts/boards/components/modal/tabs.vue
+++ b/app/assets/javascripts/boards/components/modal/tabs.vue
@@ -1,9 +1,15 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
+import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
import ModalStore from '../../stores/modal_store';
import modalMixin from '../../mixins/modal_mixins';
export default {
+ components: {
+ GlTabs,
+ GlTab,
+ GlBadge,
+ },
mixins: [modalMixin],
data() {
return ModalStore.store;
@@ -19,18 +25,18 @@ export default {
};
</script>
<template>
- <div class="top-area gl-mt-3 gl-mb-3">
- <ul class="nav-links issues-state-filters">
- <li :class="{ active: activeTab == 'all' }">
- <a href="#" role="button" @click.prevent="changeTab('all')">
- Open issues <span class="badge badge-pill"> {{ issuesCount }} </span>
- </a>
- </li>
- <li :class="{ active: activeTab == 'selected' }">
- <a href="#" role="button" @click.prevent="changeTab('selected')">
- Selected issues <span class="badge badge-pill"> {{ selectedCount }} </span>
- </a>
- </li>
- </ul>
- </div>
+ <gl-tabs class="gl-mt-3">
+ <gl-tab @click.prevent="changeTab('all')">
+ <template slot="title">
+ <span>Open issues</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ issuesCount }}</gl-badge>
+ </template>
+ </gl-tab>
+ <gl-tab @click.prevent="changeTab('selected')">
+ <template slot="title">
+ <span>Selected issues</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ selectedCount }}</gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
</template>
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index 2e356f1353a..c8926c5ef2a 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -6,8 +6,14 @@ import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as flash } from '~/flash';
import CreateLabelDropdown from '../../create_label';
import boardsStore from '../stores/boards_store';
+import { fullLabelId } from '../boards_util';
+import store from '~/boards/stores';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
+function shouldCreateListGraphQL(label) {
+ return store.getters.shouldUseGraphQL && !store.getters.getListByLabelId(fullLabelId(label));
+}
+
$(document)
.off('created.label')
.on('created.label', (e, label, addNewList) => {
@@ -15,16 +21,20 @@ $(document)
return;
}
- boardsStore.new({
- title: label.title,
- position: boardsStore.state.lists.length - 2,
- list_type: 'label',
- label: {
- id: label.id,
+ if (shouldCreateListGraphQL(label)) {
+ store.dispatch('createList', { labelId: fullLabelId(label) });
+ } else {
+ boardsStore.new({
title: label.title,
- color: label.color,
- },
- });
+ position: boardsStore.state.lists.length - 2,
+ list_type: 'label',
+ label: {
+ id: label.id,
+ title: label.title,
+ color: label.color,
+ },
+ });
+ }
});
export default function initNewListDropdown() {
@@ -74,7 +84,9 @@ export default function initNewListDropdown() {
const label = options.selectedObj;
e.preventDefault();
- if (!boardsStore.findListByLabelId(label.id)) {
+ if (shouldCreateListGraphQL(label)) {
+ store.dispatch('createList', { labelId: fullLabelId(label) });
+ } else if (!boardsStore.findListByLabelId(label.id)) {
boardsStore.new({
title: label.title,
position: boardsStore.state.lists.length - 2,
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
index 59e7620962a..566c0081b9d 100644
--- a/app/assets/javascripts/boards/components/project_select.vue
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -20,11 +20,7 @@ export default {
required: true,
},
},
- inject: {
- groupId: {
- type: Number,
- },
- },
+ inject: ['groupId'],
data() {
return {
loading: true,
diff --git a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
index 8df03ea581f..5fb7a9b210c 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
@@ -36,16 +36,18 @@ export default {
}
this.edit = true;
- this.$emit('changed', this.edit);
+ this.$emit('open');
window.addEventListener('click', this.collapseWhenOffClick);
},
- collapse() {
+ collapse({ emitEvent = true } = {}) {
if (!this.edit) {
return;
}
this.edit = false;
- this.$emit('changed', this.edit);
+ if (emitEvent) {
+ this.$emit('close');
+ }
window.removeEventListener('click', this.collapseWhenOffClick);
},
},
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue
new file mode 100644
index 00000000000..0f063c7582e
--- /dev/null
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue
@@ -0,0 +1,120 @@
+<script>
+import { mapGetters, mapActions } from 'vuex';
+import { GlLabel } from '@gitlab/ui';
+import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ BoardEditableItem,
+ LabelsSelect,
+ GlLabel,
+ },
+ data() {
+ return {
+ loading: false,
+ };
+ },
+ inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'],
+ computed: {
+ ...mapGetters({ issue: 'getActiveIssue' }),
+ selectedLabels() {
+ const { labels = [] } = this.issue;
+
+ return labels.map(label => ({
+ ...label,
+ id: getIdFromGraphQLId(label.id),
+ }));
+ },
+ issueLabels() {
+ const { labels = [] } = this.issue;
+
+ return labels.map(label => ({
+ ...label,
+ scoped: isScopedLabel(label),
+ }));
+ },
+ projectPath() {
+ const { referencePath = '' } = this.issue;
+ return referencePath.slice(0, referencePath.indexOf('#'));
+ },
+ },
+ methods: {
+ ...mapActions(['setActiveIssueLabels']),
+ async setLabels(payload) {
+ this.loading = true;
+ this.$refs.sidebarItem.collapse();
+
+ try {
+ const addLabelIds = payload.filter(label => label.set).map(label => label.id);
+ const removeLabelIds = this.selectedLabels
+ .filter(label => !payload.find(selected => selected.id === label.id))
+ .map(label => label.id);
+
+ const input = { addLabelIds, removeLabelIds, projectPath: this.projectPath };
+ await this.setActiveIssueLabels(input);
+ } catch (e) {
+ createFlash({ message: __('An error occurred while updating labels.') });
+ } finally {
+ this.loading = false;
+ }
+ },
+ async removeLabel(id) {
+ this.loading = true;
+
+ try {
+ const removeLabelIds = [getIdFromGraphQLId(id)];
+ const input = { removeLabelIds, projectPath: this.projectPath };
+ await this.setActiveIssueLabels(input);
+ } catch (e) {
+ createFlash({ message: __('An error occurred when removing the label.') });
+ } finally {
+ this.loading = false;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <board-editable-item ref="sidebarItem" :title="__('Labels')" :loading="loading">
+ <template #collapsed>
+ <gl-label
+ v-for="label in issueLabels"
+ :key="label.id"
+ :background-color="label.color"
+ :title="label.title"
+ :description="label.description"
+ :scoped="label.scoped"
+ :show-close-button="true"
+ :disabled="loading"
+ class="gl-mr-2 gl-mb-2"
+ @close="removeLabel(label.id)"
+ />
+ </template>
+ <template>
+ <labels-select
+ ref="labelsSelect"
+ :allow-label-edit="false"
+ :allow-label-create="false"
+ :allow-multiselect="true"
+ :allow-scoped-labels="true"
+ :selected-labels="selectedLabels"
+ :labels-fetch-path="labelsFetchPath"
+ :labels-manage-path="labelsManagePath"
+ :labels-filter-base-path="labelsFilterBasePath"
+ :labels-list-title="__('Select label')"
+ :dropdown-button-text="__('Choose labels')"
+ variant="embedded"
+ class="gl-display-block labels gl-w-full"
+ @updateSelectedLabels="setLabels"
+ >
+ {{ __('None') }}
+ </labels-select>
+ </template>
+ </board-editable-item>
+</template>
diff --git a/app/assets/javascripts/boards/ee_functions.js b/app/assets/javascripts/boards/ee_functions.js
index 583270fcae5..419a640d5c5 100644
--- a/app/assets/javascripts/boards/ee_functions.js
+++ b/app/assets/javascripts/boards/ee_functions.js
@@ -1,6 +1,6 @@
export const setPromotionState = () => {};
-export const setWeigthFetchingState = () => {};
+export const setWeightFetchingState = () => {};
export const setEpicFetchingState = () => {};
export const getMilestoneTitle = () => ({});
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index fff89832bf0..4fa78ecd5a4 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -25,7 +25,8 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
}
updateObject(path) {
- this.store.path = path.substr(1);
+ const groupByParam = new URLSearchParams(window.location.search).get('group_by');
+ this.store.path = `${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`;
if (gon.features.boardsWithSwimlanes || gon.features.graphqlBoardLists) {
boardsStore.updateFiltersUrl();
diff --git a/app/assets/javascripts/boards/icons/fullscreen_collapse.svg b/app/assets/javascripts/boards/icons/fullscreen_collapse.svg
deleted file mode 100644
index 6bd773dc4c5..00000000000
--- a/app/assets/javascripts/boards/icons/fullscreen_collapse.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="17" height="17" viewBox="0 0 17 17" xmlns="http://www.w3.org/2000/svg"><path d="M.147 15.496l2.146-2.146-1.286-1.286a.55.55 0 0 1-.125-.616c.101-.238.277-.357.527-.357h4a.55.55 0 0 1 .402.17.55.55 0 0 1 .17.401v4c0 .25-.12.426-.358.527-.232.101-.437.06-.616-.125l-1.286-1.286-2.146 2.146-1.428-1.428zM14.996.646l1.428 1.43-2.146 2.145 1.286 1.286c.185.179.226.384.125.616-.101.238-.277.357-.527.357h-4a.55.55 0 0 1-.402-.17.55.55 0 0 1-.17-.401v-4c0-.25.12-.426.358-.527a.553.553 0 0 1 .616.125l1.286 1.286L14.996.647zm-13.42 0L3.72 2.794l1.286-1.286a.55.55 0 0 1 .616-.125c.238.101.357.277.357.527v4a.55.55 0 0 1-.17.402.55.55 0 0 1-.401.17h-4c-.25 0-.426-.12-.527-.358-.101-.232-.06-.437.125-.616l1.286-1.286L.147 2.075 1.575.647zm14.848 14.85l-1.428 1.428-2.146-2.146-1.286 1.286c-.179.185-.384.226-.616.125-.238-.101-.357-.277-.357-.527v-4a.55.55 0 0 1 .17-.402.55.55 0 0 1 .401-.17h4c.25 0 .426.12.527.358a.553.553 0 0 1-.125.616l-1.286 1.286 2.146 2.146z" fill-rule="evenodd"/></svg>
diff --git a/app/assets/javascripts/boards/icons/fullscreen_expand.svg b/app/assets/javascripts/boards/icons/fullscreen_expand.svg
deleted file mode 100644
index 306073b8af2..00000000000
--- a/app/assets/javascripts/boards/icons/fullscreen_expand.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="15" height="15" viewBox="0 0 15 15" xmlns="http://www.w3.org/2000/svg"><path d="M8.591 5.056l2.147-2.146-1.286-1.286a.55.55 0 0 1-.125-.616c.101-.238.277-.357.527-.357h4a.55.55 0 0 1 .402.17.55.55 0 0 1 .17.401v4c0 .25-.12.426-.358.527-.232.101-.437.06-.616-.125l-1.286-1.286-2.146 2.147-1.429-1.43zM5.018 8.553l1.429 1.43L4.3 12.127l1.286 1.286c.185.179.226.384.125.616-.101.238-.277.357-.527.357h-4a.55.55 0 0 1-.402-.17.55.55 0 0 1-.17-.401v-4c0-.25.12-.426.358-.527a.553.553 0 0 1 .616.125L2.872 10.7l2.146-2.147zm4.964 0l2.146 2.147 1.286-1.286a.55.55 0 0 1 .616-.125c.238.101.357.277.357.527v4a.55.55 0 0 1-.17.402.55.55 0 0 1-.401.17h-4c-.25 0-.426-.12-.527-.358-.101-.232-.06-.437.125-.616l1.286-1.286-2.147-2.146 1.43-1.429zM6.447 5.018l-1.43 1.429L2.873 4.3 1.586 5.586c-.179.185-.384.226-.616.125-.238-.101-.357-.277-.357-.527v-4a.55.55 0 0 1 .17-.402.55.55 0 0 1 .401-.17h4c.25 0 .426.12.527.358a.553.553 0 0 1-.125.616L4.3 2.872l2.147 2.146z" fill-rule="evenodd"/></svg>
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 1173c6d0578..887abe79059 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import Vue from 'vue';
import { mapActions, mapState } from 'vuex';
@@ -11,7 +10,7 @@ import toggleLabels from 'ee_else_ce/boards/toggle_labels';
import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes';
import {
setPromotionState,
- setWeigthFetchingState,
+ setWeightFetchingState,
setEpicFetchingState,
getMilestoneTitle,
getBoardsModalData,
@@ -19,6 +18,7 @@ import {
import VueApollo from 'vue-apollo';
import BoardContent from '~/boards/components/board_content.vue';
+import BoardExtraActions from '~/boards/components/board_extra_actions.vue';
import createDefaultClient from '~/lib/graphql';
import { deprecatedCreateFlash as Flash } from '~/flash';
import { __ } from '~/locale';
@@ -84,8 +84,12 @@ export default () => {
},
provide: {
boardId: $boardApp.dataset.boardId,
- groupId: Number($boardApp.dataset.groupId) || null,
+ groupId: Number($boardApp.dataset.groupId),
rootPath: $boardApp.dataset.rootPath,
+ canUpdate: $boardApp.dataset.canUpdate,
+ labelsFetchPath: $boardApp.dataset.labelsFetchPath,
+ labelsManagePath: $boardApp.dataset.labelsManagePath,
+ labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath,
},
store,
apolloProvider,
@@ -131,6 +135,7 @@ export default () => {
eventHub.$on('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$on('toggleSubscription', this.toggleSubscription);
eventHub.$on('performSearch', this.performSearch);
+ eventHub.$on('initialBoardLoad', this.initialBoardLoad);
},
beforeDestroy() {
eventHub.$off('updateTokens', this.updateTokens);
@@ -138,6 +143,7 @@ export default () => {
eventHub.$off('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$off('toggleSubscription', this.toggleSubscription);
eventHub.$off('performSearch', this.performSearch);
+ eventHub.$off('initialBoardLoad', this.initialBoardLoad);
},
mounted() {
this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit);
@@ -148,6 +154,19 @@ export default () => {
boardsStore.disabled = this.disabled;
if (!gon.features.graphqlBoardLists) {
+ this.initialBoardLoad();
+ }
+ },
+ methods: {
+ ...mapActions([
+ 'setInitialBoardData',
+ 'setFilters',
+ 'fetchEpicsSwimlanes',
+ 'resetIssues',
+ 'resetEpics',
+ 'fetchLists',
+ ]),
+ initialBoardLoad() {
boardsStore
.all()
.then(res => res.data)
@@ -160,30 +179,26 @@ export default () => {
.catch(() => {
Flash(__('An error occurred while fetching the board lists. Please try again.'));
});
- }
- },
- methods: {
- ...mapActions([
- 'setInitialBoardData',
- 'setFilters',
- 'fetchEpicsSwimlanes',
- 'fetchIssuesForAllLists',
- ]),
+ },
updateTokens() {
this.filterManager.updateTokens();
},
performSearch() {
this.setFilters(convertObjectPropsToCamelCase(urlParamsToObject(window.location.search)));
if (gon.features.boardsWithSwimlanes && this.isShowingEpicsSwimlanes) {
- this.fetchEpicsSwimlanes(false);
- this.fetchIssuesForAllLists();
+ this.resetEpics();
+ this.resetIssues();
+ this.fetchEpicsSwimlanes({});
+ } else if (gon.features.graphqlBoardLists && !this.isShowingEpicsSwimlanes) {
+ this.fetchLists();
+ this.resetIssues();
}
},
updateDetailIssue(newIssue, multiSelect = false) {
const { sidebarInfoEndpoint } = newIssue;
if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
newIssue.setFetchingState('subscriptions', true);
- setWeigthFetchingState(newIssue, true);
+ setWeightFetchingState(newIssue, true);
setEpicFetchingState(newIssue, true);
boardsStore
.getIssueInfo(sidebarInfoEndpoint)
@@ -201,7 +216,7 @@ export default () => {
} = convertObjectPropsToCamelCase(data);
newIssue.setFetchingState('subscriptions', false);
- setWeigthFetchingState(newIssue, false);
+ setWeightFetchingState(newIssue, false);
setEpicFetchingState(newIssue, false);
newIssue.updateData({
humanTimeSpent: humanTotalTimeSpent,
@@ -216,7 +231,7 @@ export default () => {
})
.catch(() => {
newIssue.setFetchingState('subscriptions', false);
- setWeigthFetchingState(newIssue, false);
+ setWeightFetchingState(newIssue, false);
Flash(__('An error occurred while fetching sidebar data'));
});
}
@@ -300,63 +315,32 @@ export default () => {
}
return !this.store.lists.filter(list => !list.preset).length;
},
- tooltipTitle() {
- if (this.disabled) {
- return __('Please add a list to your board first');
- }
-
- return '';
- },
- },
- watch: {
- disabled() {
- this.updateTooltip();
- },
- },
- mounted() {
- this.updateTooltip();
},
methods: {
- updateTooltip() {
- const $tooltip = $(this.$refs.addIssuesButton);
-
- this.$nextTick(() => {
- if (this.disabled) {
- $tooltip.tooltip();
- } else {
- $tooltip.tooltip('dispose');
- }
- });
- },
openModal() {
if (!this.disabled) {
this.toggleModal(true);
}
},
},
- template: `
- <div class="board-extra-actions">
- <button
- class="btn btn-success gl-ml-3"
- type="button"
- data-placement="bottom"
- data-track-event="click_button"
- data-track-label="board_add_issues"
- ref="addIssuesButton"
- :class="{ 'disabled': disabled }"
- :title="tooltipTitle"
- :aria-disabled="disabled"
- v-if="canAdminList"
- @click="openModal">
- Add issues
- </button>
- </div>
- `,
+ render(createElement) {
+ return createElement(BoardExtraActions, {
+ props: {
+ canAdminList: this.$options.el.hasAttribute('data-can-admin-list'),
+ openModal: this.openModal,
+ disabled: this.disabled,
+ },
+ });
+ },
});
}
toggleFocusMode(ModalStore, boardsStore);
toggleLabels();
- toggleEpicsSwimlanes();
+
+ if (gon.features?.swimlanes) {
+ toggleEpicsSwimlanes();
+ }
+
mountMultipleBoardsSwitcher();
};
diff --git a/app/assets/javascripts/boards/mixins/is_wip_limits.js b/app/assets/javascripts/boards/mixins/is_wip_limits.js
deleted file mode 100644
index f172179d3c7..00000000000
--- a/app/assets/javascripts/boards/mixins/is_wip_limits.js
+++ /dev/null
@@ -1,7 +0,0 @@
-export default {
- computed: {
- isWipLimitsOn() {
- return false;
- },
- },
-};
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index 2f6caffbf84..09f5d5b4dd8 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-underscore-dangle, class-methods-use-this */
+/* eslint-disable class-methods-use-this */
import { __ } from '~/locale';
import ListLabel from './label';
import ListAssignee from './assignee';
@@ -34,7 +34,6 @@ const TYPES = {
class List {
constructor(obj) {
this.id = obj.id;
- this._uid = this.guid();
this.position = obj.position;
this.title = (obj.list_type || obj.listType) === 'backlog' ? __('Open') : obj.title;
this.type = obj.list_type || obj.listType;
diff --git a/app/assets/javascripts/boards/queries/board.mutation.graphql b/app/assets/javascripts/boards/queries/board.mutation.graphql
new file mode 100644
index 00000000000..ef2b81a7939
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/board.mutation.graphql
@@ -0,0 +1,11 @@
+mutation UpdateBoard($id: ID!, $hideClosedList: Boolean, $hideBacklogList: Boolean) {
+ updateBoard(
+ input: { id: $id, hideClosedList: $hideClosedList, hideBacklogList: $hideBacklogList }
+ ) {
+ board {
+ id
+ hideClosedList
+ hideBacklogList
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql b/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql
index dcfe69222a0..48420b349ae 100644
--- a/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql
+++ b/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql
@@ -1,7 +1,21 @@
-#import "./board_list.fragment.graphql"
+#import "ee_else_ce/boards/queries/board_list.fragment.graphql"
-mutation CreateBoardList($boardId: BoardID!, $backlog: Boolean) {
- boardListCreate(input: { boardId: $boardId, backlog: $backlog }) {
+mutation CreateBoardList(
+ $boardId: BoardID!
+ $backlog: Boolean
+ $labelId: LabelID
+ $milestoneId: MilestoneID
+ $assigneeId: UserID
+) {
+ boardListCreate(
+ input: {
+ boardId: $boardId
+ backlog: $backlog
+ labelId: $labelId
+ milestoneId: $milestoneId
+ assigneeId: $assigneeId
+ }
+ ) {
list {
...BoardListFragment
}
diff --git a/app/assets/javascripts/boards/queries/board_lists.query.graphql b/app/assets/javascripts/boards/queries/board_lists.query.graphql
new file mode 100644
index 00000000000..88425e9a9c1
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/board_lists.query.graphql
@@ -0,0 +1,28 @@
+#import "ee_else_ce/boards/queries/board_list.fragment.graphql"
+
+query ListIssues(
+ $fullPath: ID!
+ $boardId: ID!
+ $filters: BoardIssueInput
+ $isGroup: Boolean = false
+ $isProject: Boolean = false
+) {
+ group(fullPath: $fullPath) @include(if: $isGroup) {
+ board(id: $boardId) {
+ lists(issueFilters: $filters) {
+ nodes {
+ ...BoardListFragment
+ }
+ }
+ }
+ }
+ project(fullPath: $fullPath) @include(if: $isProject) {
+ board(id: $boardId) {
+ lists(issueFilters: $filters) {
+ nodes {
+ ...BoardListFragment
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/queries/group_board.query.graphql b/app/assets/javascripts/boards/queries/group_board.query.graphql
deleted file mode 100644
index cb42cb3f73d..00000000000
--- a/app/assets/javascripts/boards/queries/group_board.query.graphql
+++ /dev/null
@@ -1,13 +0,0 @@
-#import "ee_else_ce/boards/queries/board_list.fragment.graphql"
-
-query GroupBoard($fullPath: ID!, $boardId: ID!) {
- group(fullPath: $fullPath) {
- board(id: $boardId) {
- lists {
- nodes {
- ...BoardListFragment
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql b/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql
new file mode 100644
index 00000000000..3c5f4b3e3bd
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql
@@ -0,0 +1,15 @@
+mutation issueSetLabels($input: UpdateIssueInput!) {
+ updateIssue(input: $input) {
+ issue {
+ labels {
+ nodes {
+ id
+ title
+ color
+ description
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/boards/queries/lists_issues.query.graphql b/app/assets/javascripts/boards/queries/lists_issues.query.graphql
index c66cdf68cf4..5dbfe4675c6 100644
--- a/app/assets/javascripts/boards/queries/lists_issues.query.graphql
+++ b/app/assets/javascripts/boards/queries/lists_issues.query.graphql
@@ -7,15 +7,24 @@ query ListIssues(
$filters: BoardIssueInput
$isGroup: Boolean = false
$isProject: Boolean = false
+ $after: String
+ $first: Int
) {
group(fullPath: $fullPath) @include(if: $isGroup) {
board(id: $boardId) {
lists(id: $id) {
nodes {
id
- issues(filters: $filters) {
- nodes {
- ...IssueNode
+ issues(first: $first, filters: $filters, after: $after) {
+ count
+ edges {
+ node {
+ ...IssueNode
+ }
+ }
+ pageInfo {
+ endCursor
+ hasNextPage
}
}
}
@@ -27,9 +36,16 @@ query ListIssues(
lists(id: $id) {
nodes {
id
- issues(filters: $filters) {
- nodes {
- ...IssueNode
+ issues(first: $first, filters: $filters, after: $after) {
+ count
+ edges {
+ node {
+ ...IssueNode
+ }
+ }
+ pageInfo {
+ endCursor
+ hasNextPage
}
}
}
diff --git a/app/assets/javascripts/boards/queries/project_board.query.graphql b/app/assets/javascripts/boards/queries/project_board.query.graphql
deleted file mode 100644
index 4620a7e0fd5..00000000000
--- a/app/assets/javascripts/boards/queries/project_board.query.graphql
+++ /dev/null
@@ -1,13 +0,0 @@
-#import "ee_else_ce/boards/queries/board_list.fragment.graphql"
-
-query ProjectBoard($fullPath: ID!, $boardId: ID!) {
- project(fullPath: $fullPath) {
- board(id: $boardId) {
- lists {
- nodes {
- ...BoardListFragment
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 4b81d9c73ef..1fed1228106 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -1,28 +1,38 @@
import Cookies from 'js-cookie';
-import { sortBy, pick } from 'lodash';
-import createFlash from '~/flash';
+import { pick } from 'lodash';
+
+import boardListsQuery from 'ee_else_ce/boards/queries/board_lists.query.graphql';
import { __ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
-import createDefaultClient from '~/lib/graphql';
+import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { BoardType, ListType, inactiveId } from '~/boards/constants';
import * as types from './mutation_types';
-import { formatListIssues, fullBoardId } from '../boards_util';
+import {
+ formatBoardLists,
+ formatListIssues,
+ fullBoardId,
+ formatListsPageInfo,
+} from '../boards_util';
import boardStore from '~/boards/stores/boards_store';
import listsIssuesQuery from '../queries/lists_issues.query.graphql';
-import projectBoardQuery from '../queries/project_board.query.graphql';
-import groupBoardQuery from '../queries/group_board.query.graphql';
import createBoardListMutation from '../queries/board_list_create.mutation.graphql';
import updateBoardListMutation from '../queries/board_list_update.mutation.graphql';
import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql';
+import issueSetLabels from '../queries/issue_set_labels.mutation.graphql';
const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
throw new Error('Not implemented!');
};
-export const gqlClient = createDefaultClient();
+export const gqlClient = createGqClient(
+ {},
+ {
+ fetchPolicy: fetchPolicies.NO_CACHE,
+ },
+);
export default {
setInitialBoardData: ({ commit }, data) => {
@@ -50,62 +60,46 @@ export default {
},
fetchLists: ({ commit, state, dispatch }) => {
- const { endpoints, boardType } = state;
+ const { endpoints, boardType, filterParams } = state;
const { fullPath, boardId } = endpoints;
- let query;
- if (boardType === BoardType.group) {
- query = groupBoardQuery;
- } else if (boardType === BoardType.project) {
- query = projectBoardQuery;
- } else {
- createFlash(__('Invalid board'));
- return Promise.reject();
- }
-
const variables = {
fullPath,
boardId: fullBoardId(boardId),
+ filters: filterParams,
+ isGroup: boardType === BoardType.group,
+ isProject: boardType === BoardType.project,
};
return gqlClient
.query({
- query,
+ query: boardListsQuery,
variables,
})
.then(({ data }) => {
- let { lists } = data[boardType]?.board;
- // Temporarily using positioning logic from boardStore
- lists = lists.nodes.map(list =>
- boardStore.updateListPosition({
- ...list,
- doNotFetchIssues: true,
- }),
- );
- commit(types.RECEIVE_BOARD_LISTS_SUCCESS, sortBy(lists, 'position'));
- // Backlog list needs to be created if it doesn't exist
- if (!lists.find(l => l.type === ListType.backlog)) {
+ const { lists, hideBacklogList } = data[boardType]?.board;
+ commit(types.RECEIVE_BOARD_LISTS_SUCCESS, formatBoardLists(lists));
+ // Backlog list needs to be created if it doesn't exist and it's not hidden
+ if (!lists.nodes.find(l => l.listType === ListType.backlog) && !hideBacklogList) {
dispatch('createList', { backlog: true });
}
dispatch('showWelcomeList');
})
- .catch(() => {
- createFlash(
- __('An error occurred while fetching the board lists. Please reload the page.'),
- );
- });
+ .catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE));
},
- // This action only supports backlog list creation at this stage
- // Future iterations will add the ability to create other list types
- createList: ({ state, commit, dispatch }, { backlog = false }) => {
+ createList: ({ state, commit, dispatch }, { backlog, labelId, milestoneId, assigneeId }) => {
const { boardId } = state.endpoints;
+
gqlClient
.mutate({
mutation: createBoardListMutation,
variables: {
boardId: fullBoardId(boardId),
backlog,
+ labelId,
+ milestoneId,
+ assigneeId,
},
})
.then(({ data }) => {
@@ -116,16 +110,15 @@ export default {
dispatch('addList', list);
}
})
- .catch(() => {
- commit(types.CREATE_LIST_FAILURE);
- });
+ .catch(() => commit(types.CREATE_LIST_FAILURE));
},
- addList: ({ state, commit }, list) => {
- const lists = state.boardLists;
+ addList: ({ commit }, list) => {
// Temporarily using positioning logic from boardStore
- lists.push(boardStore.updateListPosition({ ...list, doNotFetchIssues: true }));
- commit(types.RECEIVE_BOARD_LISTS_SUCCESS, sortBy(lists, 'position'));
+ commit(
+ types.RECEIVE_ADD_LIST_SUCCESS,
+ boardStore.updateListPosition({ ...list, doNotFetchIssues: true }),
+ );
},
showWelcomeList: ({ state, dispatch }) => {
@@ -133,7 +126,9 @@ export default {
return;
}
if (
- state.boardLists.find(list => list.type !== ListType.backlog && list.type !== ListType.closed)
+ Object.entries(state.boardLists).find(
+ ([, list]) => list.type !== ListType.backlog && list.type !== ListType.closed,
+ )
) {
return;
}
@@ -155,13 +150,16 @@ export default {
notImplemented();
},
- moveList: ({ state, commit, dispatch }, { listId, newIndex, adjustmentValue }) => {
+ moveList: (
+ { state, commit, dispatch },
+ { listId, replacedListId, newIndex, adjustmentValue },
+ ) => {
const { boardLists } = state;
- const backupList = [...boardLists];
- const movedList = boardLists.find(({ id }) => id === listId);
+ const backupList = { ...boardLists };
+ const movedList = boardLists[listId];
const newPosition = newIndex - 1;
- const listAtNewIndex = boardLists[newIndex];
+ const listAtNewIndex = boardLists[replacedListId];
movedList.position = newPosition;
listAtNewIndex.position += adjustmentValue;
@@ -197,7 +195,9 @@ export default {
notImplemented();
},
- fetchIssuesForList: ({ state, commit }, listId) => {
+ fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => {
+ commit(types.REQUEST_ISSUES_FOR_LIST, { listId, fetchNext });
+
const { endpoints, boardType, filterParams } = state;
const { fullPath, boardId } = endpoints;
@@ -208,6 +208,8 @@ export default {
filters: filterParams,
isGroup: boardType === BoardType.group,
isProject: boardType === BoardType.project,
+ first: 20,
+ after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined,
};
return gqlClient
@@ -221,36 +223,14 @@ export default {
.then(({ data }) => {
const { lists } = data[boardType]?.board;
const listIssues = formatListIssues(lists);
- commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listId });
+ const listPageInfo = formatListsPageInfo(lists);
+ commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listPageInfo, listId });
})
.catch(() => commit(types.RECEIVE_ISSUES_FOR_LIST_FAILURE, listId));
},
- fetchIssuesForAllLists: ({ state, commit }) => {
- commit(types.REQUEST_ISSUES_FOR_ALL_LISTS);
-
- const { endpoints, boardType, filterParams } = state;
- const { fullPath, boardId } = endpoints;
-
- const variables = {
- fullPath,
- boardId: fullBoardId(boardId),
- filters: filterParams,
- isGroup: boardType === BoardType.group,
- isProject: boardType === BoardType.project,
- };
-
- return gqlClient
- .query({
- query: listsIssuesQuery,
- variables,
- })
- .then(({ data }) => {
- const { lists } = data[boardType]?.board;
- const listIssues = formatListIssues(lists);
- commit(types.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS, listIssues);
- })
- .catch(() => commit(types.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE));
+ resetIssues: ({ commit }) => {
+ commit(types.RESET_ISSUES);
},
moveIssue: (
@@ -303,6 +283,31 @@ export default {
commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issue });
},
+ setActiveIssueLabels: async ({ commit, getters }, input) => {
+ const activeIssue = getters.getActiveIssue;
+ const { data } = await gqlClient.mutate({
+ mutation: issueSetLabels,
+ variables: {
+ input: {
+ iid: String(activeIssue.iid),
+ addLabelIds: input.addLabelIds ?? [],
+ removeLabelIds: input.removeLabelIds ?? [],
+ projectPath: input.projectPath,
+ },
+ },
+ });
+
+ if (data.updateIssue?.errors?.length > 0) {
+ throw new Error(data.updateIssue.errors);
+ }
+
+ commit(types.UPDATE_ISSUE_BY_ID, {
+ issueId: activeIssue.id,
+ prop: 'labels',
+ value: data.updateIssue.issue.labels.nodes,
+ });
+ },
+
fetchBacklog: () => {
notImplemented();
},
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index faf4f9ebfd3..d1a5db1bcc5 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -2,7 +2,7 @@
/* global List */
/* global ListIssue */
import $ from 'jquery';
-import { sortBy } from 'lodash';
+import { sortBy, pick } from 'lodash';
import Vue from 'vue';
import Cookies from 'js-cookie';
import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee';
@@ -12,7 +12,7 @@ import {
parseBoolean,
convertObjectPropsToCamelCase,
} from '~/lib/utils/common_utils';
-import { __ } from '~/locale';
+import createDefaultClient from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -23,7 +23,11 @@ import ListLabel from '../models/label';
import ListAssignee from '../models/assignee';
import ListMilestone from '../models/milestone';
+import createBoardMutation from '../queries/board.mutation.graphql';
+
const PER_PAGE = 20;
+export const gqlClient = createDefaultClient();
+
const boardsStore = {
disabled: false,
timeTracking: {
@@ -114,7 +118,6 @@ const boardsStore = {
.catch(() => {
// https://gitlab.com/gitlab-org/gitlab-foss/issues/30821
});
- this.removeBlankState();
},
updateNewListDropdown(listId) {
$(`.js-board-list-${listId}`).removeClass('is-active');
@@ -124,22 +127,14 @@ const boardsStore = {
return !this.state.lists.filter(list => list.type !== 'backlog' && list.type !== 'closed')[0];
},
addBlankState() {
- if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return;
-
- this.addList({
- id: 'blank',
- list_type: 'blank',
- title: __('Welcome to your Issue Board!'),
- position: 0,
- });
- },
- removeBlankState() {
- this.removeList('blank');
+ if (!this.shouldAddBlankState() || this.welcomeIsHidden()) return;
- Cookies.set('issue_board_welcome_hidden', 'true', {
- expires: 365 * 10,
- path: '',
- });
+ this.generateDefaultLists()
+ .then(res => res.data)
+ .then(data => Promise.all(data.map(list => this.addList(list))))
+ .catch(() => {
+ this.removeList(undefined, 'label');
+ });
},
findIssueLabel(issue, findLabel) {
@@ -542,6 +537,10 @@ const boardsStore = {
this.timeTracking.limitToHours = parseBoolean(limitToHours);
},
+ generateBoardGid(boardId) {
+ return `gid://gitlab/Board/${boardId}`;
+ },
+
generateBoardsPath(id) {
return `${this.state.endpoints.boardsEndpoint}${id ? `/${id}` : ''}.json`;
},
@@ -800,9 +799,33 @@ const boardsStore = {
}
if (boardPayload.id) {
- return axios.put(this.generateBoardsPath(boardPayload.id), { board: boardPayload });
+ const input = {
+ ...pick(boardPayload, ['hideClosedList', 'hideBacklogList']),
+ id: this.generateBoardGid(boardPayload.id),
+ };
+
+ return Promise.all([
+ axios.put(this.generateBoardsPath(boardPayload.id), { board: boardPayload }),
+ gqlClient.mutate({
+ mutation: createBoardMutation,
+ variables: input,
+ }),
+ ]);
}
- return axios.post(this.generateBoardsPath(), { board: boardPayload });
+
+ return axios
+ .post(this.generateBoardsPath(), { board: boardPayload })
+ .then(resp => resp.data)
+ .then(data => {
+ gqlClient.mutate({
+ mutation: createBoardMutation,
+ variables: {
+ ...pick(boardPayload, ['hideClosedList', 'hideBacklogList']),
+ id: this.generateBoardGid(data.id),
+ },
+ });
+ return data;
+ });
},
deleteBoard({ id }) {
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
index 3688476dc5f..89a3b14b262 100644
--- a/app/assets/javascripts/boards/stores/getters.js
+++ b/app/assets/javascripts/boards/stores/getters.js
@@ -1,10 +1,11 @@
+import { find } from 'lodash';
import { inactiveId } from '../constants';
export default {
getLabelToggleState: state => (state.isShowingLabels ? 'on' : 'off'),
isSidebarOpen: state => state.activeId !== inactiveId,
isSwimlanesOn: state => {
- if (!gon?.features?.boardsWithSwimlanes) {
+ if (!gon?.features?.boardsWithSwimlanes && !gon?.features?.swimlanes) {
return false;
}
@@ -22,4 +23,16 @@ export default {
getActiveIssue: state => {
return state.issues[state.activeId] || {};
},
+
+ getListByLabelId: state => labelId => {
+ return find(state.boardLists, l => l.label?.id === labelId);
+ },
+
+ getListByTitle: state => title => {
+ return find(state.boardLists, l => l.title === title);
+ },
+
+ shouldUseGraphQL: () => {
+ return gon?.features?.graphqlBoardLists;
+ },
};
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index f0a283f6161..09ab08062df 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -3,6 +3,7 @@ export const SET_FILTERS = 'SET_FILTERS';
export const CREATE_LIST_SUCCESS = 'CREATE_LIST_SUCCESS';
export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE';
export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS';
+export const RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE';
export const SHOW_PROMOTION_LIST = 'SHOW_PROMOTION_LIST';
export const REQUEST_ADD_LIST = 'REQUEST_ADD_LIST';
export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS';
@@ -12,11 +13,9 @@ export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE';
export const REQUEST_REMOVE_LIST = 'REQUEST_REMOVE_LIST';
export const RECEIVE_REMOVE_LIST_SUCCESS = 'RECEIVE_REMOVE_LIST_SUCCESS';
export const RECEIVE_REMOVE_LIST_ERROR = 'RECEIVE_REMOVE_LIST_ERROR';
-export const REQUEST_ISSUES_FOR_ALL_LISTS = 'REQUEST_ISSUES_FOR_ALL_LISTS';
+export const REQUEST_ISSUES_FOR_LIST = 'REQUEST_ISSUES_FOR_LIST';
export const RECEIVE_ISSUES_FOR_LIST_FAILURE = 'RECEIVE_ISSUES_FOR_LIST_FAILURE';
export const RECEIVE_ISSUES_FOR_LIST_SUCCESS = 'RECEIVE_ISSUES_FOR_LIST_SUCCESS';
-export const RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS = 'RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS';
-export const RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE = 'RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE';
export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE';
export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS';
export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR';
@@ -32,3 +31,4 @@ export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE';
export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE';
export const SET_ACTIVE_ID = 'SET_ACTIVE_ID';
export const UPDATE_ISSUE_BY_ID = 'UPDATE_ISSUE_BY_ID';
+export const RESET_ISSUES = 'RESET_ISSUES';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index faeb3e25a71..0c7dbc0d2ef 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -1,8 +1,8 @@
import Vue from 'vue';
-import { sortBy, pull } from 'lodash';
+import { pull, union } from 'lodash';
import { formatIssue, moveIssueListHelper } from '../boards_util';
import * as mutationTypes from './mutation_types';
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
const notImplemented = () => {
@@ -10,11 +10,13 @@ const notImplemented = () => {
throw new Error('Not implemented!');
};
-const removeIssueFromList = (state, listId, issueId) => {
+export const removeIssueFromList = ({ state, listId, issueId }) => {
Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId));
+ const list = state.boardLists[listId];
+ Vue.set(state.boardLists, listId, { ...list, issuesSize: list.issuesSize - 1 });
};
-const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => {
+export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => {
const listIssues = state.issuesByListId[listId];
let newIndex = atIndex || 0;
if (moveBeforeId) {
@@ -24,6 +26,8 @@ const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atI
}
listIssues.splice(newIndex, 0, issueId);
Vue.set(state.issuesByListId, listId, listIssues);
+ const list = state.boardLists[listId];
+ Vue.set(state.boardLists, listId, { ...list, issuesSize: list.issuesSize + 1 });
};
export default {
@@ -39,6 +43,12 @@ export default {
state.boardLists = lists;
},
+ [mutationTypes.RECEIVE_BOARD_LISTS_FAILURE]: state => {
+ state.error = s__(
+ 'Boards|An error occurred while fetching the board lists. Please reload the page.',
+ );
+ },
+
[mutationTypes.SET_ACTIVE_ID](state, { id, sidebarType }) {
state.activeId = id;
state.sidebarType = sidebarType;
@@ -49,15 +59,15 @@ export default {
},
[mutationTypes.CREATE_LIST_FAILURE]: state => {
- state.error = __('An error occurred while creating the list. Please try again.');
+ state.error = s__('Boards|An error occurred while creating the list. Please try again.');
},
[mutationTypes.REQUEST_ADD_LIST]: () => {
notImplemented();
},
- [mutationTypes.RECEIVE_ADD_LIST_SUCCESS]: () => {
- notImplemented();
+ [mutationTypes.RECEIVE_ADD_LIST_SUCCESS]: (state, list) => {
+ Vue.set(state.boardLists, list.id, list);
},
[mutationTypes.RECEIVE_ADD_LIST_ERROR]: () => {
@@ -66,14 +76,12 @@ export default {
[mutationTypes.MOVE_LIST]: (state, { movedList, listAtNewIndex }) => {
const { boardLists } = state;
- const movedListIndex = state.boardLists.findIndex(l => l.id === movedList.id);
- Vue.set(boardLists, movedListIndex, movedList);
- Vue.set(boardLists, movedListIndex.position + 1, listAtNewIndex);
- Vue.set(state, 'boardLists', sortBy(boardLists, 'position'));
+ Vue.set(boardLists, movedList.id, movedList);
+ Vue.set(boardLists, listAtNewIndex.id, listAtNewIndex);
},
[mutationTypes.UPDATE_LIST_FAILURE]: (state, backupList) => {
- state.error = __('An error occurred while updating the list. Please try again.');
+ state.error = s__('Boards|An error occurred while updating the list. Please try again.');
Vue.set(state, 'boardLists', backupList);
},
@@ -89,28 +97,36 @@ export default {
notImplemented();
},
- [mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: (state, { listIssues, listId }) => {
+ [mutationTypes.REQUEST_ISSUES_FOR_LIST]: (state, { listId, fetchNext }) => {
+ Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true });
+ },
+
+ [mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: (
+ state,
+ { listIssues, listPageInfo, listId },
+ ) => {
const { listData, issues } = listIssues;
Vue.set(state, 'issues', { ...state.issues, ...issues });
- Vue.set(state.issuesByListId, listId, listData[listId]);
- const listIndex = state.boardLists.findIndex(l => l.id === listId);
- Vue.set(state.boardLists[listIndex], 'loading', false);
+ Vue.set(
+ state.issuesByListId,
+ listId,
+ union(state.issuesByListId[listId] || [], listData[listId]),
+ );
+ Vue.set(state.pageInfoByListId, listId, listPageInfo[listId]);
+ Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false });
},
[mutationTypes.RECEIVE_ISSUES_FOR_LIST_FAILURE]: (state, listId) => {
- state.error = __('An error occurred while fetching the board issues. Please reload the page.');
- const listIndex = state.boardLists.findIndex(l => l.id === listId);
- Vue.set(state.boardLists[listIndex], 'loading', false);
- },
-
- [mutationTypes.REQUEST_ISSUES_FOR_ALL_LISTS]: state => {
- state.isLoadingIssues = true;
+ state.error = s__(
+ 'Boards|An error occurred while fetching the board issues. Please reload the page.',
+ );
+ Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false });
},
- [mutationTypes.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS]: (state, { listData, issues }) => {
- state.issuesByListId = listData;
- state.issues = issues;
- state.isLoadingIssues = false;
+ [mutationTypes.RESET_ISSUES]: state => {
+ Object.keys(state.issuesByListId).forEach(listId => {
+ Vue.set(state.issuesByListId, listId, []);
+ });
},
[mutationTypes.UPDATE_ISSUE_BY_ID]: (state, { issueId, prop, value }) => {
@@ -122,11 +138,6 @@ export default {
Vue.set(state.issues[issueId], prop, value);
},
- [mutationTypes.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE]: state => {
- state.error = __('An error occurred while fetching the board issues. Please reload the page.');
- state.isLoadingIssues = false;
- },
-
[mutationTypes.REQUEST_ADD_ISSUE]: () => {
notImplemented();
},
@@ -143,13 +154,13 @@ export default {
state,
{ originalIssue, fromListId, toListId, moveBeforeId, moveAfterId },
) => {
- const fromList = state.boardLists.find(l => l.id === fromListId);
- const toList = state.boardLists.find(l => l.id === toListId);
+ const fromList = state.boardLists[fromListId];
+ const toList = state.boardLists[toListId];
const issue = moveIssueListHelper(originalIssue, fromList, toList);
Vue.set(state.issues, issue.id, issue);
- removeIssueFromList(state, fromListId, issue.id);
+ removeIssueFromList({ state, listId: fromListId, issueId: issue.id });
addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId });
},
@@ -162,9 +173,9 @@ export default {
state,
{ originalIssue, fromListId, toListId, originalIndex },
) => {
- state.error = __('An error occurred while moving the issue. Please try again.');
+ state.error = s__('Boards|An error occurred while moving the issue. Please try again.');
Vue.set(state.issues, originalIssue.id, originalIssue);
- removeIssueFromList(state, toListId, originalIssue.id);
+ removeIssueFromList({ state, listId: toListId, issueId: originalIssue.id });
addIssueToList({
state,
listId: fromListId,
@@ -193,8 +204,8 @@ export default {
},
[mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issue }) => {
- state.error = __('An error occurred while creating the issue. Please try again.');
- removeIssueFromList(state, list.id, issue.id);
+ state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
+ removeIssueFromList({ state, listId: list.id, issueId: issue.id });
},
[mutationTypes.SET_CURRENT_PAGE]: () => {
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index be937d68c6c..b91c09f8051 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -8,10 +8,11 @@ export default () => ({
isShowingLabels: true,
activeId: inactiveId,
sidebarType: '',
- boardLists: [],
+ boardLists: {},
+ listsFlags: {},
issuesByListId: {},
+ pageInfoByListId: {},
issues: {},
- isLoadingIssues: false,
filterParams: {},
error: undefined,
// TODO: remove after ce/ee split of board_content.vue
diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js
index e60e7059192..fa13d3a9e3c 100644
--- a/app/assets/javascripts/boards/toggle_focus.js
+++ b/app/assets/javascripts/boards/toggle_focus.js
@@ -1,13 +1,15 @@
import $ from 'jquery';
import Vue from 'vue';
-import collapseIcon from './icons/fullscreen_collapse.svg';
-import expandIcon from './icons/fullscreen_expand.svg';
+import { GlIcon } from '@gitlab/ui';
export default (ModalStore, boardsStore) => {
const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board');
return new Vue({
el: document.getElementById('js-toggle-focus-btn'),
+ components: {
+ GlIcon,
+ },
data: {
modal: ModalStore.store,
store: boardsStore.state,
@@ -32,12 +34,7 @@ export default (ModalStore, boardsStore) => {
title="Toggle focus mode"
ref="toggleFocusModeButton"
@click="toggleFocusMode">
- <span v-show="isFullscreen">
- ${collapseIcon}
- </span>
- <span v-show="!isFullscreen">
- ${expandIcon}
- </span>
+ <gl-icon :name="isFullscreen ? 'minimize' : 'maximize'" />
</a>
</div>
`,
diff --git a/app/assets/javascripts/breadcrumb.js b/app/assets/javascripts/breadcrumb.js
index a37838694ec..ff7f734f998 100644
--- a/app/assets/javascripts/breadcrumb.js
+++ b/app/assets/javascripts/breadcrumb.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { hide } from '~/tooltips';
export const addTooltipToEl = el => {
const textEl = el.querySelector('.js-breadcrumb-item-text');
@@ -23,9 +24,11 @@ export default () => {
topLevelLinks.forEach(el => addTooltipToEl(el));
$expander.closest('.dropdown').on('show.bs.dropdown hide.bs.dropdown', e => {
- $('.js-breadcrumbs-collapsed-expander', e.currentTarget)
- .toggleClass('open')
- .tooltip('hide');
+ const $el = $('.js-breadcrumbs-collapsed-expander', e.currentTarget);
+
+ $el.toggleClass('open');
+
+ hide($el);
});
}
};
diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js
index 2955f0f014b..8324c649538 100644
--- a/app/assets/javascripts/build_artifacts.js
+++ b/app/assets/javascripts/build_artifacts.js
@@ -3,6 +3,7 @@
import $ from 'jquery';
import { visitUrl } from './lib/utils/url_utility';
import { parseBoolean } from './lib/utils/common_utils';
+import { hide, initTooltips, show } from '~/tooltips';
export default class BuildArtifacts {
constructor() {
@@ -10,6 +11,7 @@ export default class BuildArtifacts {
this.setupEntryClick();
this.setupTooltips();
}
+
// eslint-disable-next-line class-methods-use-this
disablePropagation() {
$('.top-block').on('click', '.download', e => {
@@ -19,15 +21,17 @@ export default class BuildArtifacts {
e.stopImmediatePropagation();
});
}
+
// eslint-disable-next-line class-methods-use-this
setupEntryClick() {
return $('.tree-holder').on('click', 'tr[data-link]', function() {
visitUrl(this.dataset.link, parseBoolean(this.dataset.externalLink));
});
}
+
// eslint-disable-next-line class-methods-use-this
setupTooltips() {
- $('.js-artifact-tree-tooltip').tooltip({
+ initTooltips({
placement: 'bottom',
// Stop the tooltip from hiding when we stop hovering the element directly
// We handle all the showing/hiding below
@@ -38,14 +42,14 @@ export default class BuildArtifacts {
// But be placed below and in the middle of the file name
$('.js-artifact-tree-row')
.on('mouseenter', e => {
- $(e.currentTarget)
- .find('.js-artifact-tree-tooltip')
- .tooltip('show');
+ const $el = $(e.currentTarget).find('.js-artifact-tree-tooltip');
+
+ show($el);
})
.on('mouseleave', e => {
- $(e.currentTarget)
- .find('.js-artifact-tree-tooltip')
- .tooltip('hide');
+ const $el = $(e.currentTarget).find('.js-artifact-tree-tooltip');
+
+ hide($el);
});
}
}
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue
index 135d02e4f76..2532f4b86d2 100644
--- a/app/assets/javascripts/ci_lint/components/ci_lint.vue
+++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue
@@ -1,14 +1,120 @@
<script>
+import { GlButton, GlFormCheckbox, GlIcon, GlLink, GlAlert } from '@gitlab/ui';
+import EditorLite from '~/vue_shared/components/editor_lite.vue';
+import CiLintResults from './ci_lint_results.vue';
+import lintCIMutation from '../graphql/mutations/lint_ci.mutation.graphql';
+
export default {
+ components: {
+ GlButton,
+ GlFormCheckbox,
+ GlIcon,
+ GlLink,
+ GlAlert,
+ CiLintResults,
+ EditorLite,
+ },
props: {
endpoint: {
type: String,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ content: '',
+ valid: false,
+ errors: null,
+ warnings: null,
+ jobs: [],
+ dryRun: false,
+ showingResults: false,
+ apiError: null,
+ isErrorDismissed: false,
+ };
+ },
+ computed: {
+ shouldShowError() {
+ return this.apiError && !this.isErrorDismissed;
+ },
+ },
+ methods: {
+ async lint() {
+ try {
+ const {
+ data: {
+ lintCI: { valid, errors, warnings, jobs },
+ },
+ } = await this.$apollo.mutate({
+ mutation: lintCIMutation,
+ variables: { endpoint: this.endpoint, content: this.content, dry: this.dryRun },
+ });
+
+ this.showingResults = true;
+ this.valid = valid;
+ this.errors = errors;
+ this.warnings = warnings;
+ this.jobs = jobs;
+ } catch (error) {
+ this.apiError = error;
+ this.isErrorDismissed = false;
+ }
+ },
+ clear() {
+ this.content = '';
+ },
},
};
</script>
-<template
- ><div></div
-></template>
+<template>
+ <div class="row">
+ <div class="col-sm-12">
+ <gl-alert
+ v-if="shouldShowError"
+ class="gl-mb-3"
+ variant="danger"
+ @dismiss="isErrorDismissed = true"
+ >{{ apiError }}</gl-alert
+ >
+ <div class="file-holder gl-mb-3">
+ <div class="js-file-title file-title clearfix">
+ {{ __('Contents of .gitlab-ci.yml') }}
+ </div>
+ <editor-lite v-model="content" file-name="*.yml" />
+ </div>
+ </div>
+
+ <div class="col-sm-12 gl-display-flex gl-justify-content-space-between">
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-button
+ class="gl-mr-4"
+ category="primary"
+ variant="success"
+ data-testid="ci-lint-validate"
+ @click="lint"
+ >{{ __('Validate') }}</gl-button
+ >
+ <gl-form-checkbox v-model="dryRun"
+ >{{ __('Simulate a pipeline created for the default branch') }}
+ <gl-link :href="helpPagePath" target="_blank"
+ ><gl-icon class="gl-text-blue-600" name="question-o"/></gl-link
+ ></gl-form-checkbox>
+ </div>
+ <gl-button data-testid="ci-lint-clear" @click="clear">{{ __('Clear') }}</gl-button>
+ </div>
+
+ <ci-lint-results
+ v-if="showingResults"
+ :valid="valid"
+ :jobs="jobs"
+ :errors="errors"
+ :warnings="warnings"
+ :dry-run="dryRun"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results.vue b/app/assets/javascripts/ci_lint/components/ci_lint_results.vue
index 9fd1bd30c49..28b2a028b29 100644
--- a/app/assets/javascripts/ci_lint/components/ci_lint_results.vue
+++ b/app/assets/javascripts/ci_lint/components/ci_lint_results.vue
@@ -1,9 +1,116 @@
<script>
+import { GlAlert, GlTable } from '@gitlab/ui';
+import CiLintWarnings from './ci_lint_warnings.vue';
+import CiLintResultsValue from './ci_lint_results_value.vue';
+import CiLintResultsParam from './ci_lint_results_param.vue';
+import { __ } from '~/locale';
+
+const thBorderColor = 'gl-border-gray-100!';
+
export default {
- props: {},
+ correct: { variant: 'success', text: __('syntax is correct') },
+ incorrect: { variant: 'danger', text: __('syntax is incorrect') },
+ warningTitle: __('The form contains the following warning:'),
+ fields: [
+ {
+ key: 'parameter',
+ label: __('Parameter'),
+ thClass: thBorderColor,
+ },
+ {
+ key: 'value',
+ label: __('Value'),
+ thClass: thBorderColor,
+ },
+ ],
+ components: {
+ GlAlert,
+ GlTable,
+ CiLintWarnings,
+ CiLintResultsValue,
+ CiLintResultsParam,
+ },
+ props: {
+ valid: {
+ type: Boolean,
+ required: true,
+ },
+ jobs: {
+ type: Array,
+ required: true,
+ },
+ errors: {
+ type: Array,
+ required: true,
+ },
+ warnings: {
+ type: Array,
+ required: true,
+ },
+ dryRun: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isWarningDismissed: false,
+ };
+ },
+ computed: {
+ status() {
+ return this.valid ? this.$options.correct : this.$options.incorrect;
+ },
+ shouldShowTable() {
+ return this.errors.length === 0;
+ },
+ shouldShowError() {
+ return this.errors.length > 0;
+ },
+ shouldShowWarning() {
+ return this.warnings.length > 0 && !this.isWarningDismissed;
+ },
+ },
};
</script>
-<template
- ><div></div
-></template>
+<template>
+ <div class="col-sm-12 gl-mt-5">
+ <gl-alert
+ class="gl-mb-5"
+ :variant="status.variant"
+ :title="__('Status:')"
+ :dismissible="false"
+ data-testid="ci-lint-status"
+ >{{ status.text }}</gl-alert
+ >
+
+ <pre
+ v-if="shouldShowError"
+ class="gl-mb-5"
+ data-testid="ci-lint-errors"
+ ><div v-for="error in errors" :key="error">{{ error }}</div></pre>
+
+ <ci-lint-warnings
+ v-if="shouldShowWarning"
+ :warnings="warnings"
+ data-testid="ci-lint-warnings"
+ @dismiss="isWarningDismissed = true"
+ />
+
+ <gl-table
+ v-if="shouldShowTable"
+ :items="jobs"
+ :fields="$options.fields"
+ bordered
+ data-testid="ci-lint-table"
+ >
+ <template #cell(parameter)="{ item }">
+ <ci-lint-results-param :stage="item.stage" :job-name="item.name" />
+ </template>
+ <template #cell(value)="{ item }">
+ <ci-lint-results-value :item="item" :dry-run="dryRun" />
+ </template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results_param.vue b/app/assets/javascripts/ci_lint/components/ci_lint_results_param.vue
new file mode 100644
index 00000000000..23808bcb292
--- /dev/null
+++ b/app/assets/javascripts/ci_lint/components/ci_lint_results_param.vue
@@ -0,0 +1,26 @@
+<script>
+import { __ } from '~/locale';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+
+export default {
+ props: {
+ stage: {
+ type: String,
+ required: true,
+ },
+ jobName: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ formatParameter() {
+ return __(`${capitalizeFirstCharacter(this.stage)} Job - ${this.jobName}`);
+ },
+ },
+};
+</script>
+
+<template>
+ <span data-testid="ci-lint-parameter">{{ formatParameter }}</span>
+</template>
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results_value.vue b/app/assets/javascripts/ci_lint/components/ci_lint_results_value.vue
new file mode 100644
index 00000000000..4929c3206df
--- /dev/null
+++ b/app/assets/javascripts/ci_lint/components/ci_lint_results_value.vue
@@ -0,0 +1,81 @@
+<script>
+import { isEmpty } from 'lodash';
+
+export default {
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ dryRun: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ tagList() {
+ return this.item.tagList.join(', ');
+ },
+ onlyPolicy() {
+ return this.item.only ? this.item.only.refs.join(', ') : this.item.only;
+ },
+ exceptPolicy() {
+ return this.item.except ? this.item.except.refs.join(', ') : this.item.except;
+ },
+ scripts() {
+ return {
+ beforeScript: {
+ show: !isEmpty(this.item.beforeScript),
+ content: this.item.beforeScript.join('\n'),
+ },
+ script: {
+ show: !isEmpty(this.item.script),
+ content: this.item.script.join('\n'),
+ },
+ afterScript: {
+ show: !isEmpty(this.item.afterScript),
+ content: this.item.afterScript.join('\n'),
+ },
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <pre v-if="scripts.beforeScript.show" data-testid="ci-lint-before-script">{{
+ scripts.beforeScript.content
+ }}</pre>
+ <pre v-if="scripts.script.show" data-testid="ci-lint-script">{{ scripts.script.content }}</pre>
+ <pre v-if="scripts.afterScript.show" data-testid="ci-lint-after-script">{{
+ scripts.afterScript.content
+ }}</pre>
+
+ <ul class="gl-list-style-none gl-pl-0 gl-mb-0">
+ <li>
+ <b>{{ __('Tag list:') }}</b>
+ {{ tagList }}
+ </li>
+ <div v-if="!dryRun" data-testid="ci-lint-only-except">
+ <li>
+ <b>{{ __('Only policy:') }}</b>
+ {{ onlyPolicy }}
+ </li>
+ <li>
+ <b>{{ __('Except policy:') }}</b>
+ {{ exceptPolicy }}
+ </li>
+ </div>
+ <li>
+ <b>{{ __('Environment:') }}</b>
+ {{ item.environment }}
+ </li>
+ <li>
+ <b>{{ __('When:') }}</b>
+ {{ item.when }}
+ <b v-if="item.allowFailure">{{ __('Allowed to fail') }}</b>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_warnings.vue b/app/assets/javascripts/ci_lint/components/ci_lint_warnings.vue
new file mode 100644
index 00000000000..ac0332cb0bd
--- /dev/null
+++ b/app/assets/javascripts/ci_lint/components/ci_lint_warnings.vue
@@ -0,0 +1,69 @@
+<script>
+import { GlAlert, GlSprintf } from '@gitlab/ui';
+import { __, n__ } from '~/locale';
+
+export default {
+ maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
+ components: {
+ GlAlert,
+ GlSprintf,
+ },
+ props: {
+ warnings: {
+ type: Array,
+ required: true,
+ },
+ maxWarnings: {
+ type: Number,
+ required: false,
+ default: 25,
+ },
+ title: {
+ type: String,
+ required: false,
+ default: __('The form contains the following warning:'),
+ },
+ },
+ computed: {
+ totalWarnings() {
+ return this.warnings.length;
+ },
+ overMaxWarningsLimit() {
+ return this.totalWarnings > this.maxWarnings;
+ },
+ warningsSummary() {
+ return n__('%d warning found:', '%d warnings found:', this.totalWarnings);
+ },
+ summaryMessage() {
+ return this.overMaxWarningsLimit ? this.$options.maxWarningsSummary : this.warningsSummary;
+ },
+ limitWarnings() {
+ return this.warnings.slice(0, this.maxWarnings);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert class="gl-mb-4" :title="title" variant="warning" @dismiss="$emit('dismiss')">
+ <details>
+ <summary>
+ <gl-sprintf :message="summaryMessage">
+ <template #total>
+ {{ totalWarnings }}
+ </template>
+ <template #warningsDisplayed>
+ {{ maxWarnings }}
+ </template>
+ </gl-sprintf>
+ </summary>
+ <p
+ v-for="(warning, index) in limitWarnings"
+ :key="`warning-${index}`"
+ data-testid="ci-lint-warning"
+ >
+ {{ warning }}
+ </p>
+ </details>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/ci_lint/graphql/mutations/lint_ci.mutation.graphql b/app/assets/javascripts/ci_lint/graphql/mutations/lint_ci.mutation.graphql
new file mode 100644
index 00000000000..496036f690f
--- /dev/null
+++ b/app/assets/javascripts/ci_lint/graphql/mutations/lint_ci.mutation.graphql
@@ -0,0 +1,22 @@
+mutation lintCI($endpoint: String, $content: String, $dry: Boolean) {
+ lintCI(endpoint: $endpoint, content: $content, dry_run: $dry) @client {
+ valid
+ errors
+ warnings
+ jobs {
+ afterScript
+ allowFailure
+ beforeScript
+ environment
+ except
+ name
+ only {
+ refs
+ }
+ afterScript
+ stage
+ tagList
+ when
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci_lint/index.js b/app/assets/javascripts/ci_lint/index.js
index ed2cf1fe714..c41e6d47d75 100644
--- a/app/assets/javascripts/ci_lint/index.js
+++ b/app/assets/javascripts/ci_lint/index.js
@@ -1,16 +1,57 @@
import Vue from 'vue';
-import CILint from './components/ci_lint.vue';
+import VueApollo from 'vue-apollo';
+import axios from '~/lib/utils/axios_utils';
+import createDefaultClient from '~/lib/graphql';
+import CiLint from './components/ci_lint.vue';
+
+Vue.use(VueApollo);
+
+const resolvers = {
+ Mutation: {
+ lintCI: (_, { endpoint, content, dry_run }) => {
+ return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({
+ valid: data.valid,
+ errors: data.errors,
+ warnings: data.warnings,
+ jobs: data.jobs.map(job => ({
+ name: job.name,
+ stage: job.stage,
+ beforeScript: job.before_script,
+ script: job.script,
+ afterScript: job.after_script,
+ tagList: job.tag_list,
+ environment: job.environment,
+ when: job.when,
+ allowFailure: job.allow_failure,
+ only: {
+ refs: job.only.refs,
+ __typename: 'CiLintJobOnlyPolicy',
+ },
+ except: job.except,
+ __typename: 'CiLintJob',
+ })),
+ __typename: 'CiLintContent',
+ }));
+ },
+ },
+};
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(resolvers),
+});
export default (containerId = '#js-ci-lint') => {
const containerEl = document.querySelector(containerId);
- const { endpoint } = containerEl.dataset;
+ const { endpoint, helpPagePath } = containerEl.dataset;
return new Vue({
el: containerEl,
+ apolloProvider,
render(createElement) {
- return createElement(CILint, {
+ return createElement(CiLint, {
props: {
endpoint,
+ helpPagePath,
},
});
},
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
new file mode 100644
index 00000000000..ad07052a298
--- /dev/null
+++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
@@ -0,0 +1,143 @@
+<script>
+import { GlTable, GlButton, GlBadge, GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: {
+ GlTable,
+ GlButton,
+ GlBadge,
+ ClipboardButton,
+ TooltipOnTruncate,
+ UserAvatarLink,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ triggers: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ fields: [
+ {
+ key: 'token',
+ label: s__('Pipelines|Token'),
+ },
+ {
+ key: 'description',
+ label: s__('Pipelines|Description'),
+ },
+ {
+ key: 'owner',
+ label: s__('Pipelines|Owner'),
+ },
+ {
+ key: 'lastUsed',
+ label: s__('Pipelines|Last Used'),
+ },
+ {
+ key: 'actions',
+ label: '',
+ tdClass: 'gl-text-right gl-white-space-nowrap',
+ },
+ ],
+};
+</script>
+
+<template>
+ <div>
+ <gl-table
+ v-if="triggers.length"
+ :fields="$options.fields"
+ :items="triggers"
+ class="triggers-list"
+ responsive
+ >
+ <template #cell(token)="{item}">
+ {{ item.token }}
+ <clipboard-button
+ v-if="item.hasTokenExposed"
+ :text="item.token"
+ data-testid="clipboard-btn"
+ data-qa-selector="clipboard_button"
+ :title="s__('Pipelines|Copy trigger token')"
+ css-class="gl-border-none gl-py-0 gl-px-2"
+ />
+ <div class="label-container">
+ <gl-badge v-if="!item.canAccessProject" variant="danger">
+ <span
+ v-gl-tooltip.viewport
+ boundary="viewport"
+ :title="s__('Pipelines|Trigger user has insufficient permissions to project')"
+ >{{ s__('Pipelines|invalid') }}</span
+ >
+ </gl-badge>
+ </div>
+ </template>
+ <template #cell(description)="{item}">
+ <tooltip-on-truncate
+ :title="item.description"
+ truncate-target="child"
+ placement="top"
+ class="trigger-description gl-display-flex"
+ >
+ <div class="gl-flex-fill-1 gl-text-truncate">{{ item.description }}</div>
+ </tooltip-on-truncate>
+ </template>
+ <template #cell(owner)="{item}">
+ <span class="trigger-owner sr-only">{{ item.owner.name }}</span>
+ <user-avatar-link
+ v-if="item.owner"
+ :link-href="item.owner.path"
+ :img-src="item.owner.avatarUrl"
+ :tooltip-text="item.owner.name"
+ :img-alt="item.owner.name"
+ />
+ </template>
+ <template #cell(lastUsed)="{item}">
+ <time-ago-tooltip v-if="item.lastUsed" :time="item.lastUsed" />
+ <span v-else>{{ __('Never') }}</span>
+ </template>
+ <template #cell(actions)="{item}">
+ <gl-button
+ :title="s__('Pipelines|Edit')"
+ icon="pencil"
+ data-testid="edit-btn"
+ :href="item.editProjectTriggerPath"
+ />
+ <gl-button
+ :title="s__('Pipelines|Revoke')"
+ icon="remove"
+ variant="warning"
+ :data-confirm="
+ s__(
+ 'Pipelines|By revoking a trigger you will break any processes making use of it. Are you sure?',
+ )
+ "
+ data-method="delete"
+ rel="nofollow"
+ class="gl-ml-3"
+ data-testid="trigger_revoke_button"
+ data-qa-selector="trigger_revoke_button"
+ :href="item.projectTriggerPath"
+ />
+ </template>
+ </gl-table>
+ <div
+ v-else
+ data-testid="no_triggers_content"
+ data-qa-selector="no_triggers_content"
+ class="settings-message gl-text-center gl-mb-3"
+ >
+ {{ s__('Pipelines|No triggers have been created yet. Add one using the form above.') }}
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/index.js b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js
new file mode 100644
index 00000000000..182d5ca5ffb
--- /dev/null
+++ b/app/assets/javascripts/ci_settings_pipeline_triggers/index.js
@@ -0,0 +1,36 @@
+import Vue from 'vue';
+import TriggersList from './components/triggers_list.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+const parseJsonArray = triggers => {
+ try {
+ return convertObjectPropsToCamelCase(JSON.parse(triggers), { deep: true });
+ } catch {
+ return [];
+ }
+};
+
+export default (containerId = 'js-ci-pipeline-triggers-list') => {
+ const containerEl = document.getElementById(containerId);
+
+ // Note: Remove this check when FF `ci_pipeline_triggers_settings_vue_ui` is removed.
+ if (!containerEl) {
+ return null;
+ }
+
+ const triggers = parseJsonArray(containerEl.dataset.triggers);
+
+ return new Vue({
+ el: containerEl,
+ components: {
+ TriggersList,
+ },
+ render(h) {
+ return h(TriggersList, {
+ props: {
+ triggers,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
index 0e09ae108ea..ceb94b1f0f8 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
@@ -1,22 +1,15 @@
<script>
-import {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
- GlDeprecatedDropdownDivider,
- GlSearchBoxByType,
- GlIcon,
-} from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { __, sprintf } from '~/locale';
export default {
name: 'CiEnvironmentsDropdown',
components: {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
- GlDeprecatedDropdownDivider,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
GlSearchBoxByType,
- GlIcon,
},
props: {
value: {
@@ -66,28 +59,25 @@ export default {
};
</script>
<template>
- <gl-deprecated-dropdown :text="value">
- <gl-search-box-by-type v-model.trim="searchTerm" class="gl-m-3" />
- <gl-deprecated-dropdown-item
+ <gl-dropdown :text="value">
+ <gl-search-box-by-type v-model.trim="searchTerm" />
+ <gl-dropdown-item
v-for="environment in filteredResults"
:key="environment"
+ :is-checked="isSelected(environment)"
+ is-check-item
@click="selectEnvironment(environment)"
>
- <gl-icon
- :class="{ invisible: !isSelected(environment) }"
- name="mobile-issue-close"
- class="vertical-align-middle"
- />
{{ environment }}
- </gl-deprecated-dropdown-item>
- <gl-deprecated-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{
__('No matching results')
- }}</gl-deprecated-dropdown-item>
+ }}</gl-dropdown-item>
<template v-if="shouldRenderCreateButton">
- <gl-deprecated-dropdown-divider />
- <gl-deprecated-dropdown-item @click="createClicked">
+ <gl-dropdown-divider />
+ <gl-dropdown-item @click="createClicked">
{{ composedCreateButtonLabel }}
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
</template>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index fbf19847e9d..a2f4bea2f61 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -6,7 +6,6 @@ import {
GlFormCheckbox,
GlFormCombobox,
GlFormGroup,
- GlFormInput,
GlFormSelect,
GlFormTextarea,
GlIcon,
@@ -41,7 +40,6 @@ export default {
GlFormCheckbox,
GlFormCombobox,
GlFormGroup,
- GlFormInput,
GlFormSelect,
GlFormTextarea,
GlIcon,
@@ -122,11 +120,6 @@ export default {
return '';
},
tokenValidationState() {
- // If the feature flag is off, do not validate. Remove when flag is removed.
- if (!this.glFeatures.ciKeyAutocomplete) {
- return true;
- }
-
const validator = this.$options.tokens?.[this.variable.key]?.validation;
if (validator) {
@@ -204,21 +197,12 @@ export default {
>
<form>
<gl-form-combobox
- v-if="glFeatures.ciKeyAutocomplete"
v-model="key"
:token-list="$options.tokenList"
:label-text="__('Key')"
data-qa-selector="ci_variable_key_field"
/>
- <gl-form-group v-else :label="__('Key')" label-for="ci-variable-key">
- <gl-form-input
- id="ci-variable-key"
- v-model="key"
- data-qa-selector="ci_variable_key_field"
- />
- </gl-form-group>
-
<gl-form-group
:label="__('Value')"
label-for="ci-variable-value"
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
index 501c82b419e..07278bb442c 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
@@ -163,10 +163,7 @@ export default {
</p>
</template>
</gl-table>
- <div
- class="ci-variable-actions d-flex justify-content-end"
- :class="{ 'justify-content-center': !tableIsNotEmpty }"
- >
+ <div class="ci-variable-actions" :class="{ 'justify-content-center': !tableIsNotEmpty }">
<gl-button
v-if="tableIsNotEmpty"
ref="secret-value-reveal-button"
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index 039237042ea..b03cf6fc31b 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -360,7 +360,7 @@ export default {
>
<template #link="{ content }">
<gl-link
- href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html"
+ href="https://docs.gitlab.com/ee/user/project/integrations/prometheus.html"
target="_blank"
>{{ content }}</gl-link
>
@@ -481,7 +481,7 @@ export default {
type="text"
class="form-control js-hostname"
/>
- <span class="input-group-btn">
+ <span class="input-group-append">
<clipboard-button
:text="jupyterHostname"
:title="s__('ClusterIntegration|Copy Jupyter Hostname')"
diff --git a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue
index c816fc56d7a..6b99bb09504 100644
--- a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue
+++ b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue
@@ -1,12 +1,12 @@
<script>
-import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlIcon } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import { s__ } from '../../locale';
export default {
name: 'CrossplaneProviderStack',
components: {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
},
props: {
@@ -67,21 +67,17 @@ export default {
<label>
{{ s__('ClusterIntegration|Enabled stack') }}
</label>
- <gl-deprecated-dropdown
+ <gl-dropdown
:disabled="crossplane.installed"
:text="dropdownText"
toggle-class="dropdown-menu-toggle gl-field-error-outline"
class="w-100"
:class="{ 'gl-show-field-errors': validationError }"
>
- <gl-deprecated-dropdown-item
- v-for="stack in stacks"
- :key="stack.code"
- @click="selectStack(stack)"
- >
+ <gl-dropdown-item v-for="stack in stacks" :key="stack.code" @click="selectStack(stack)">
<span class="ml-1">{{ stack.name }}</span>
- </gl-deprecated-dropdown-item>
- </gl-deprecated-dropdown>
+ </gl-dropdown-item>
+ </gl-dropdown>
<span v-if="validationError" class="gl-field-error">{{ validationError }}</span>
<p class="form-text text-muted">
{{ s__(`You must select a stack for configuring your cloud provider. Learn more about`) }}
diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
index e6001b11296..b37fc3894f8 100644
--- a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
+++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
@@ -1,11 +1,5 @@
<script>
-import {
- GlAlert,
- GlDeprecatedButton,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
- GlFormCheckbox,
-} from '@gitlab/ui';
+import { GlAlert, GlButton, GlDropdown, GlDropdownItem, GlFormCheckbox } from '@gitlab/ui';
import { mapValues } from 'lodash';
import { __ } from '~/locale';
import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants';
@@ -16,9 +10,9 @@ const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_S
export default {
components: {
GlAlert,
- GlDeprecatedButton,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlFormCheckbox,
},
props: {
@@ -203,15 +197,15 @@ export default {
<label for="fluentd-protocol">
<strong>{{ s__('ClusterIntegration|SIEM Protocol') }}</strong>
</label>
- <gl-deprecated-dropdown :text="protocolName" class="w-100">
- <gl-deprecated-dropdown-item
+ <gl-dropdown :text="protocolName" class="w-100">
+ <gl-dropdown-item
v-for="(value, index) in protocols"
:key="index"
@click="selectProtocol(value.toLowerCase())"
>
{{ value }}
- </gl-deprecated-dropdown-item>
- </gl-deprecated-dropdown>
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
<div class="form-group flex flex-wrap">
<gl-form-checkbox :checked="wafLogEnabled" @input="wafLogChanged">
@@ -221,20 +215,21 @@ export default {
<strong>{{ s__('ClusterIntegration|Send Container Network Policies Logs') }}</strong>
</gl-form-checkbox>
</div>
- <div v-if="showButtons" class="mt-3">
- <gl-deprecated-button
+ <div v-if="showButtons" class="gl-mt-5 gl-display-flex">
+ <gl-button
ref="saveBtn"
- class="mr-1"
+ class="gl-mr-3"
variant="success"
+ category="primary"
:loading="isSaving"
:disabled="saveButtonDisabled"
@click="updateApplication"
>
{{ saveButtonLabel }}
- </gl-deprecated-button>
- <gl-deprecated-button ref="cancelBtn" :disabled="saveButtonDisabled" @click="resetStatus">
+ </gl-button>
+ <gl-button ref="cancelBtn" :disabled="saveButtonDisabled" @click="resetStatus">
{{ __('Cancel') }}
- </gl-deprecated-button>
+ </gl-button>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
index 5e8e1a76182..f05c8db5d56 100644
--- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
+++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
@@ -5,9 +5,9 @@ import {
GlSprintf,
GlLink,
GlToggle,
- GlDeprecatedButton,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
} from '@gitlab/ui';
import modSecurityLogo from 'images/cluster_app_logos/gitlab.png';
@@ -25,9 +25,9 @@ export default {
GlSprintf,
GlLink,
GlToggle,
- GlDeprecatedButton,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
},
props: {
@@ -221,29 +221,31 @@ export default {
</strong>
</p>
</div>
- <gl-deprecated-dropdown :text="modSecurityModeName" :disabled="saveButtonDisabled">
- <gl-deprecated-dropdown-item
- v-for="(mode, key) in modes"
- :key="key"
- @click="selectMode(key)"
- >
+ <gl-dropdown :text="modSecurityModeName" :disabled="saveButtonDisabled">
+ <gl-dropdown-item v-for="(mode, key) in modes" :key="key" @click="selectMode(key)">
{{ mode.name }}
- </gl-deprecated-dropdown-item>
- </gl-deprecated-dropdown>
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</div>
- <div v-if="showButtons" class="mt-3">
- <gl-deprecated-button
- class="btn-success inline mr-1"
+ <div v-if="showButtons" class="gl-mt-5 gl-display-flex">
+ <gl-button
+ variant="success"
+ category="primary"
+ data-qa-selector="save_ingress_modsecurity_settings"
:loading="saving"
:disabled="saveButtonDisabled"
@click="updateApplication"
>
{{ saveButtonLabel }}
- </gl-deprecated-button>
- <gl-deprecated-button :disabled="saveButtonDisabled" @click="resetStatus">
+ </gl-button>
+ <gl-button
+ data-qa-selector="cancel_ingress_modsecurity_settings"
+ :disabled="saveButtonDisabled"
+ @click="resetStatus"
+ >
{{ __('Cancel') }}
- </gl-deprecated-button>
+ </gl-button>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
index 2617ea0bdea..cb415d902e8 100644
--- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue
+++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
@@ -1,8 +1,8 @@
<script>
import {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
GlLoadingIcon,
GlSearchBoxByType,
GlSprintf,
@@ -20,9 +20,9 @@ export default {
GlButton,
ClipboardButton,
GlLoadingIcon,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
GlSearchBoxByType,
GlSprintf,
},
@@ -121,7 +121,7 @@ export default {
<strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong>
</label>
- <gl-deprecated-dropdown
+ <gl-dropdown
v-if="showDomainsDropdown"
:text="domainDropdownText"
toggle-class="dropdown-menu-toggle"
@@ -130,18 +130,17 @@ export default {
<gl-search-box-by-type
v-model.trim="searchQuery"
:placeholder="s__('ClusterIntegration|Search domains')"
- class="gl-m-3"
/>
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
v-for="domain in filteredDomains"
:key="domain.id"
@click="selectDomain(domain)"
>
<span class="ml-1">{{ domain.domain }}</span>
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
<template v-if="searchQuery">
- <gl-deprecated-dropdown-divider />
- <gl-deprecated-dropdown-item key="custom-domain" @click="selectCustomDomain(searchQuery)">
+ <gl-dropdown-divider />
+ <gl-dropdown-item key="custom-domain" @click="selectCustomDomain(searchQuery)">
<span class="ml-1">
<gl-sprintf :message="s__('ClusterIntegration|Use %{query}')">
<template #query>
@@ -149,9 +148,9 @@ export default {
</template>
</gl-sprintf>
</span>
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
</template>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
<input
v-else
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
index f82f4dd5012..477dd13db4f 100644
--- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
+++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
@@ -1,6 +1,5 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlModal } from '@gitlab/ui';
+import { GlModal, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uninstall_button_click';
import { sprintf, s__ } from '~/locale';
import {
@@ -45,6 +44,9 @@ export default {
components: {
GlModal,
},
+ directives: {
+ SafeHtml,
+ },
mixins: [trackUninstallButtonClickMixin],
props: {
application: {
@@ -94,6 +96,6 @@ export default {
:title="title"
@ok="confirmUninstall()"
>
- {{ warningText }} <span v-html="customAppWarningText"></span>
+ {{ warningText }} <span v-safe-html="customAppWarningText"></span>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/clusters/forms/components/integration_form.vue b/app/assets/javascripts/clusters/forms/components/integration_form.vue
index 53e004b4fc0..f0dafa7ef53 100644
--- a/app/assets/javascripts/clusters/forms/components/integration_form.vue
+++ b/app/assets/javascripts/clusters/forms/components/integration_form.vue
@@ -24,10 +24,10 @@ export default {
},
inject: {
autoDevopsHelpPath: {
- type: String,
+ default: '',
},
externalEndpointHelpPath: {
- type: String,
+ default: '',
},
},
data() {
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index 7b53020fc49..f8fb58cdca2 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -10,6 +10,7 @@ import {
GlTable,
} from '@gitlab/ui';
import AncestorNotice from './ancestor_notice.vue';
+import NodeErrorHelpText from './node_error_help_text.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import { CLUSTER_TYPES, STATUSES } from '../constants';
import { __, sprintf } from '~/locale';
@@ -26,6 +27,7 @@ export default {
GlSkeletonLoading,
GlSprintf,
GlTable,
+ NodeErrorHelpText,
},
directives: {
tooltip,
@@ -199,7 +201,13 @@ export default {
<section v-else>
<ancestor-notice />
- <gl-table :items="clusters" :fields="fields" stacked="md" class="qa-clusters-table">
+ <gl-table
+ :items="clusters"
+ :fields="fields"
+ stacked="md"
+ class="qa-clusters-table"
+ data-testid="cluster_list_table"
+ >
<template #cell(name)="{ item }">
<div :class="[contentAlignClasses, 'js-status']">
<img
@@ -231,9 +239,12 @@ export default {
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
- <small v-else class="gl-font-sm gl-font-style-italic gl-text-gray-200">{{
- __('Unknown')
- }}</small>
+ <NodeErrorHelpText
+ v-else-if="item.kubernetes_errors"
+ :class="contentAlignClasses"
+ :error-type="item.kubernetes_errors.connection_error"
+ :popover-id="`nodeSizeError${item.id}`"
+ />
</template>
<template #cell(total_cpu)="{ item }">
@@ -250,6 +261,13 @@ export default {
</span>
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
+
+ <NodeErrorHelpText
+ v-else-if="item.kubernetes_errors"
+ :class="contentAlignClasses"
+ :error-type="item.kubernetes_errors.node_connection_error"
+ :popover-id="`nodeCpuError${item.id}`"
+ />
</template>
<template #cell(total_memory)="{ item }">
@@ -266,6 +284,13 @@ export default {
</span>
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
+
+ <NodeErrorHelpText
+ v-else-if="item.kubernetes_errors"
+ :class="contentAlignClasses"
+ :error-type="item.kubernetes_errors.metrics_connection_error"
+ :popover-id="`nodeMemoryError${item.id}`"
+ />
</template>
<template #cell(cluster_type)="{value}">
diff --git a/app/assets/javascripts/clusters_list/components/node_error_help_text.vue b/app/assets/javascripts/clusters_list/components/node_error_help_text.vue
new file mode 100644
index 00000000000..1a396694bc8
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/node_error_help_text.vue
@@ -0,0 +1,53 @@
+<script>
+import { GlIcon, GlPopover } from '@gitlab/ui';
+import { CLUSTER_ERRORS } from '../constants';
+
+export default {
+ components: {
+ GlIcon,
+ GlPopover,
+ },
+ props: {
+ errorType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ popoverId: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ errorContent() {
+ return CLUSTER_ERRORS[this.errorType] || CLUSTER_ERRORS.default;
+ },
+ },
+};
+</script>
+
+<template>
+ <div :id="popoverId">
+ <span class="gl-font-style-italic">
+ {{ errorContent.tableText }}
+ </span>
+
+ <gl-icon name="status_warning" :size="24" class="gl-p-2" />
+
+ <gl-popover :container="popoverId" :target="popoverId" placement="top" triggers="hover focus">
+ <template #title>
+ <span class="gl-display-block gl-text-left">{{ errorContent.title }}</span>
+ </template>
+
+ <p class="gl-text-left">{{ errorContent.description }}</p>
+
+ <p class="gl-text-left">{{ s__('ClusterIntegration|Troubleshooting tips:') }}</p>
+
+ <ul class="gl-text-left">
+ <li v-for="tip in errorContent.troubleshootingTips" :key="tip">
+ {{ tip }}
+ </li>
+ </ul>
+ </gl-popover>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index 3e8ef3151a6..f39678b73dc 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -1,4 +1,45 @@
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
+
+export const CLUSTER_ERRORS = {
+ default: {
+ tableText: s__('ClusterIntegration|Unknown Error'),
+ title: s__('ClusterIntegration|Unknown Error'),
+ description: s__(
+ 'ClusterIntegration|An unknown error occurred while attempting to connect to Kubernetes.',
+ ),
+ troubleshootingTips: [
+ s__('ClusterIntegration|Check your cluster status'),
+ s__('ClusterIntegration|Make sure your API endpoint is correct'),
+ s__(
+ 'ClusterIntegration|Node calculations use the Kubernetes Metrics API. Make sure your cluster has metrics installed',
+ ),
+ ],
+ },
+ authentication_error: {
+ tableText: s__('ClusterIntegration|Unable to Authenticate'),
+ title: s__('ClusterIntegration|Authentication Error'),
+ description: s__('ClusterIntegration|GitLab failed to authenticate.'),
+ troubleshootingTips: [
+ s__('ClusterIntegration|Check your token'),
+ s__('ClusterIntegration|Check your CA certificate'),
+ ],
+ },
+ connection_error: {
+ tableText: s__('ClusterIntegration|Unable to Connect'),
+ title: s__('ClusterIntegration|Connection Error'),
+ description: s__('ClusterIntegration|GitLab failed to connect to the cluster.'),
+ troubleshootingTips: [
+ s__('ClusterIntegration|Check your cluster status'),
+ s__('ClusterIntegration|Make sure your API endpoint is correct'),
+ ],
+ },
+ http_error: {
+ tableText: s__('ClusterIntegration|Unable to Connect'),
+ title: s__('ClusterIntegration|HTTP Error'),
+ description: s__('ClusterIntegration|There was an HTTP error when connecting to your cluster.'),
+ troubleshootingTips: [s__('ClusterIntegration|Check your cluster status')],
+ },
+};
export const CLUSTER_TYPES = {
project_type: __('Project'),
diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js
index 51ad8769250..daa82892773 100644
--- a/app/assets/javascripts/clusters_list/index.js
+++ b/app/assets/javascripts/clusters_list/index.js
@@ -1,20 +1,6 @@
import Vue from 'vue';
-import Clusters from './components/clusters.vue';
-import { createStore } from './store';
+import loadClusters from './load_clusters';
export default () => {
- const entryPoint = document.querySelector('#js-clusters-list-app');
-
- if (!entryPoint) {
- return;
- }
-
- // eslint-disable-next-line no-new
- new Vue({
- el: '#js-clusters-list-app',
- store: createStore(entryPoint.dataset),
- render(createElement) {
- return createElement(Clusters);
- },
- });
+ loadClusters(Vue);
};
diff --git a/app/assets/javascripts/clusters_list/load_clusters.js b/app/assets/javascripts/clusters_list/load_clusters.js
new file mode 100644
index 00000000000..98bc5880898
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/load_clusters.js
@@ -0,0 +1,18 @@
+import Clusters from './components/clusters.vue';
+import { createStore } from './store';
+
+export default Vue => {
+ const el = document.querySelector('#js-clusters-list-app');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ store: createStore(el.dataset),
+ render(createElement) {
+ return createElement(Clusters);
+ },
+ });
+};
diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js
index ff711877621..1be82988db0 100644
--- a/app/assets/javascripts/clusters_list/store/actions.js
+++ b/app/assets/javascripts/clusters_list/store/actions.js
@@ -1,4 +1,4 @@
-import * as Sentry from '@sentry/browser';
+import * as Sentry from '~/sentry/wrapper';
import Poll from '~/lib/utils/poll';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as flash } from '~/flash';
diff --git a/app/assets/javascripts/code_navigation/index.js b/app/assets/javascripts/code_navigation/index.js
index 362c26ae065..fa5835245bc 100644
--- a/app/assets/javascripts/code_navigation/index.js
+++ b/app/assets/javascripts/code_navigation/index.js
@@ -1,13 +1,17 @@
import Vue from 'vue';
import Vuex from 'vuex';
-import store from './store';
+import createStore from './store';
import App from './components/app.vue';
-Vue.use(Vuex);
-
export default initialData => {
const el = document.getElementById('js-code-navigation');
+ if (!el) return null;
+
+ Vue.use(Vuex);
+
+ const store = createStore();
+
store.dispatch('setInitialData', initialData);
return new Vue({
diff --git a/app/assets/javascripts/code_navigation/store/index.js b/app/assets/javascripts/code_navigation/store/index.js
index fe48f3ac7f5..9b60fc337fe 100644
--- a/app/assets/javascripts/code_navigation/store/index.js
+++ b/app/assets/javascripts/code_navigation/store/index.js
@@ -3,8 +3,9 @@ import createState from './state';
import actions from './actions';
import mutations from './mutations';
-export default new Vuex.Store({
- actions,
- mutations,
- state: createState(),
-});
+export default () =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: createState(),
+ });
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index 340a93e4e66..c8168afbcb0 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -3,9 +3,10 @@ import commitPipelinesTable from './pipelines_table.vue';
/**
* Used in:
- * - Commit details View > Pipelines Tab > Pipelines Table.
- * - Merge Request details View > Pipelines Tab > Pipelines Table.
- * - New Merge Request View > Pipelines Tab > Pipelines Table.
+ * - Project Pipelines List (projects:pipelines:index)
+ * - Commit details View > Pipelines Tab > Pipelines Table (projects:commit:pipelines)
+ * - Merge Request details View > Pipelines Tab > Pipelines Table (projects:merge_requests:show)
+ * - New Merge Request View > Pipelines Tab > Pipelines Table (projects:merge_requests:creations:new)
*/
const CommitPipelinesTable = Vue.extend(commitPipelinesTable);
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index 2f4118c1717..fe32868e6d8 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -1,6 +1,5 @@
<script>
import { GlButton, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui';
-import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import PipelinesService from '~/pipelines/services/pipelines_service';
import PipelineStore from '~/pipelines/stores/pipelines_store';
import pipelinesMixin from '~/pipelines/mixins/pipelines';
@@ -126,16 +125,6 @@ export default {
(latest.flags.detached_merge_request_pipeline || latest.flags.merge_request_pipeline)
);
},
- /**
- * When we are on Desktop and the button is visible
- * we need to add a negative margin to the table
- * to make it inline with the button
- *
- * @returns {Boolean}
- */
- shouldAddNegativeMargin() {
- return this.canRenderPipelineButton && bp.isDesktop();
- },
},
created() {
this.service = new PipelinesService(this.endpoint);
@@ -204,66 +193,77 @@ export default {
"
/>
- <div v-else-if="shouldRenderTable" class="table-holder">
- <div v-if="canRenderPipelineButton" class="nav justify-content-end">
- <gl-button
- variant="success"
- class="js-run-mr-pipeline gl-mt-3 btn-wide-on-xs"
- :disabled="state.isRunningMergeRequestPipeline"
- @click="tryRunPipeline"
- >
- <gl-loading-icon v-if="state.isRunningMergeRequestPipeline" inline />
- {{ s__('Pipelines|Run Pipeline') }}
- </gl-button>
-
- <gl-modal
- :id="modalId"
- ref="modal"
- :modal-id="modalId"
- :title="s__('Pipelines|Are you sure you want to run this pipeline?')"
- :ok-title="s__('Pipelines|Run Pipeline')"
- ok-variant="danger"
- @ok="onClickRunPipeline"
- >
- <p>
- {{
- s__(
- 'Pipelines|This pipeline will run code originating from a forked project merge request. This means that the code can potentially have security considerations like exposing CI variables.',
- )
- }}
- </p>
- <p>
- {{
- s__(
- "Pipelines|It is recommended the code is reviewed thoroughly before running this pipeline with the parent project's CI resource.",
- )
- }}
- </p>
- <p>
- {{
- s__(
- 'Pipelines|If you are unsure, please ask a project maintainer to review it for you.',
- )
- }}
- </p>
- <gl-link
- href="/help/ci/merge_request_pipelines/index.html#run-pipelines-in-the-parent-project-for-merge-requests-from-a-forked-project"
- target="_blank"
- >
- {{ s__('Pipelines|More Information') }}
- </gl-link>
- </gl-modal>
- </div>
+ <div v-else-if="shouldRenderTable">
+ <gl-button
+ v-if="canRenderPipelineButton"
+ block
+ class="gl-mt-3 gl-mb-0 gl-display-md-none"
+ variant="success"
+ data-testid="run_pipeline_button_mobile"
+ :loading="state.isRunningMergeRequestPipeline"
+ @click="tryRunPipeline"
+ >
+ {{ s__('Pipelines|Run Pipeline') }}
+ </gl-button>
<pipelines-table-component
:pipelines="state.pipelines"
:update-graph-dropdown="updateGraphDropdown"
:auto-devops-help-path="autoDevopsHelpPath"
:view-type="viewType"
- :class="{ 'negative-margin-top': shouldAddNegativeMargin }"
- />
+ >
+ <template #table-header-actions>
+ <div v-if="canRenderPipelineButton" class="gl-text-right">
+ <gl-button
+ variant="success"
+ data-testid="run_pipeline_button"
+ :loading="state.isRunningMergeRequestPipeline"
+ @click="tryRunPipeline"
+ >
+ {{ s__('Pipelines|Run Pipeline') }}
+ </gl-button>
+ </div>
+ </template>
+ </pipelines-table-component>
</div>
+ <gl-modal
+ v-if="canRenderPipelineButton"
+ :id="modalId"
+ ref="modal"
+ :modal-id="modalId"
+ :title="s__('Pipelines|Are you sure you want to run this pipeline?')"
+ :ok-title="s__('Pipelines|Run Pipeline')"
+ ok-variant="danger"
+ @ok="onClickRunPipeline"
+ >
+ <p>
+ {{
+ s__(
+ 'Pipelines|This pipeline will run code originating from a forked project merge request. This means that the code can potentially have security considerations like exposing CI variables.',
+ )
+ }}
+ </p>
+ <p>
+ {{
+ s__(
+ "Pipelines|It is recommended the code is reviewed thoroughly before running this pipeline with the parent project's CI resource.",
+ )
+ }}
+ </p>
+ <p>
+ {{
+ s__('Pipelines|If you are unsure, please ask a project maintainer to review it for you.')
+ }}
+ </p>
+ <gl-link
+ href="/help/ci/merge_request_pipelines/index.html#run-pipelines-in-the-parent-project-for-merge-requests-from-a-forked-project"
+ target="_blank"
+ >
+ {{ s__('Pipelines|More Information') }}
+ </gl-link>
+ </gl-modal>
+
<table-pagination
v-if="shouldRenderPagination"
:change="onChangePage"
diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js
index e0d012cef23..77c85d85e27 100644
--- a/app/assets/javascripts/commons/index.js
+++ b/app/assets/javascripts/commons/index.js
@@ -1,5 +1,4 @@
import './polyfills';
-import './jquery';
import './bootstrap';
import './vue';
import '../lib/utils/axios_utils';
diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js
deleted file mode 100644
index 334f95bb27f..00000000000
--- a/app/assets/javascripts/commons/jquery.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import 'jquery';
-
-// common jQuery plugins
-import 'jquery-ujs';
diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
index 5b4bdca46e4..6bb654a434f 100644
--- a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
+++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
@@ -1,12 +1,11 @@
<script>
-import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlIcon } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
- GlIcon,
+ GlDropdown,
+ GlDropdownItem,
},
props: {
projects: {
@@ -37,25 +36,15 @@ export default {
</script>
<template>
- <gl-deprecated-dropdown toggle-class="d-flex align-items-center w-100" class="w-100">
- <template #button-content>
- <span class="str-truncated-100 mr-2">
- <gl-icon name="lock" />
- {{ dropdownText }}
- </span>
- <gl-icon name="chevron-down" class="ml-auto" />
- </template>
- <gl-deprecated-dropdown-item
+ <gl-dropdown block icon="lock" :text="dropdownText">
+ <gl-dropdown-item
v-for="project in projects"
:key="project.id"
+ :is-check-item="true"
+ :is-checked="project.id === selectedProject.id"
@click="selectProject(project)"
>
- <gl-icon
- name="mobile-issue-close"
- :class="{ icon: project.id !== selectedProject.id }"
- class="js-active-project-check"
- />
- <span class="ml-1">{{ project.name }}</span>
- </gl-deprecated-dropdown-item>
- </gl-deprecated-dropdown>
+ {{ project.name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</template>
diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js
index 262d501bfba..7321e4d18cc 100644
--- a/app/assets/javascripts/confirm_danger_modal.js
+++ b/app/assets/javascripts/confirm_danger_modal.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { Rails } from '~/lib/utils/rails_ujs';
import { rstrip } from './lib/utils/common_utils';
function openConfirmDangerModal($form, $modal, text) {
@@ -21,9 +22,16 @@ function openConfirmDangerModal($form, $modal, text) {
$submit.disable();
}
});
+
$('.js-confirm-danger-submit', $modal)
.off('click')
- .on('click', () => $form.submit());
+ .on('click', () => {
+ if ($form.data('remote')) {
+ Rails.fire($form[0], 'submit');
+ } else {
+ $form.submit();
+ }
+ });
}
function getModal($btn) {
diff --git a/app/assets/javascripts/confirm_modal.js b/app/assets/javascripts/confirm_modal.js
index 4b4fdf03873..bf2ea3ce38a 100644
--- a/app/assets/javascripts/confirm_modal.js
+++ b/app/assets/javascripts/confirm_modal.js
@@ -1,14 +1,16 @@
import Vue from 'vue';
import ConfirmModal from '~/vue_shared/components/confirm_modal.vue';
-const mountConfirmModal = () => {
- return new Vue({
+const mountConfirmModal = optionalProps =>
+ new Vue({
render(h) {
return h(ConfirmModal, {
- props: { selector: '.js-confirm-modal-button' },
+ props: {
+ selector: '.js-confirm-modal-button',
+ ...optionalProps,
+ },
});
},
}).$mount();
-};
-export default () => mountConfirmModal();
+export default (optionalProps = {}) => mountConfirmModal(optionalProps);
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue
index 3f7c2204b9f..eb195ad2b30 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue
@@ -13,6 +13,10 @@ export default {
type: String,
required: true,
},
+ namespacePerEnvironmentHelpPath: {
+ type: String,
+ required: true,
+ },
kubernetesIntegrationHelpPath: {
type: String,
required: true,
@@ -40,6 +44,7 @@ export default {
<eks-cluster-configuration-form
v-if="hasCredentials"
:gitlab-managed-cluster-help-path="gitlabManagedClusterHelpPath"
+ :namespace-per-environment-help-path="namespacePerEnvironmentHelpPath"
:kubernetes-integration-help-path="kubernetesIntegrationHelpPath"
:external-link-icon="externalLinkIcon"
/>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
index a653e228e3f..d403f370f9d 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
@@ -1,9 +1,7 @@
<script>
-/* eslint-disable vue/no-v-html */
import { createNamespacedHelpers, mapState, mapActions, mapGetters } from 'vuex';
-import { escape } from 'lodash';
-import { GlFormInput, GlFormCheckbox } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
+import { GlFormInput, GlFormCheckbox, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue';
import { KUBERNETES_VERSIONS } from '../constants';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
@@ -28,8 +26,11 @@ const { mapState: mapInstanceTypesState } = createNamespacedHelpers('instanceTyp
export default {
components: {
ClusterFormDropdown,
- GlFormInput,
GlFormCheckbox,
+ GlFormInput,
+ GlIcon,
+ GlLink,
+ GlSprintf,
LoadingButton,
},
props: {
@@ -37,6 +38,10 @@ export default {
type: String,
required: true,
},
+ namespacePerEnvironmentHelpPath: {
+ type: String,
+ required: true,
+ },
kubernetesIntegrationHelpPath: {
type: String,
required: true,
@@ -46,6 +51,49 @@ export default {
required: true,
},
},
+ i18n: {
+ kubernetesIntegrationHelpText: s__(
+ 'ClusterIntegration|Read our %{linkStart}help page%{linkEnd} on Kubernetes cluster integration.',
+ ),
+ roleDropdownHelpText: s__(
+ 'ClusterIntegration|Your service role is distinct from the provision role used when authenticating. It will allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role, first create one on %{linkStart}Amazon Web Services%{linkEnd}.',
+ ),
+ roleDropdownHelpPath:
+ 'https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html#create-service-role',
+ regionsDropdownHelpText: s__(
+ 'ClusterIntegration|Learn more about %{linkStart}Regions%{linkEnd}.',
+ ),
+ regionsDropdownHelpPath:
+ 'https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/',
+ keyPairDropdownHelpText: s__(
+ 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{linkStart}Amazon Web Services%{linkEnd}.',
+ ),
+ keyPairDropdownHelpPath:
+ 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair',
+ vpcDropdownHelpText: s__(
+ 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{linkStart}Amazon Web Services %{linkEnd}.',
+ ),
+ vpcDropdownHelpPath:
+ 'https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#vpc-create',
+ subnetDropdownHelpText: s__(
+ 'ClusterIntegration|Choose the %{linkStart}subnets %{linkEnd} in your VPC where your worker nodes will run.',
+ ),
+ subnetDropdownHelpPath: 'https://console.aws.amazon.com/vpc/home?#subnets',
+ securityGroupDropdownHelpText: s__(
+ 'ClusterIntegration|Choose the %{linkStart}security group %{linkEnd} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.',
+ ),
+ securityGroupDropdownHelpPath: 'https://console.aws.amazon.com/vpc/home?#securityGroups',
+ instanceTypesDropdownHelpText: s__(
+ 'ClusterIntegration|Choose the worker node %{linkStart}instance type%{linkEnd}.',
+ ),
+ instanceTypesDropdownHelpPath: 'https://aws.amazon.com/ec2/instance-types',
+ gitlabManagedClusterHelpText: s__(
+ 'ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{linkStart}More information%{linkEnd}',
+ ),
+ namespacePerEnvironmentHelpText: s__(
+ 'ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared. %{linkStart}More information%{linkEnd}',
+ ),
+ },
computed: {
...mapState([
'clusterName',
@@ -60,6 +108,7 @@ export default {
'selectedInstanceType',
'nodeCount',
'gitlabManagedCluster',
+ 'namespacePerEnvironment',
'isCreatingCluster',
]),
...mapGetters(['subnetValid']),
@@ -137,90 +186,6 @@ export default {
? s__('ClusterIntegration|Creating Kubernetes cluster')
: s__('ClusterIntegration|Create Kubernetes cluster');
},
- kubernetesIntegrationHelpText() {
- const escapedUrl = escape(this.kubernetesIntegrationHelpPath);
-
- return sprintf(
- s__(
- 'ClusterIntegration|Read our %{link_start}help page%{link_end} on Kubernetes cluster integration.',
- ),
- {
- link_start: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`,
- link_end: '</a>',
- },
- false,
- );
- },
- roleDropdownHelpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Your service role is distinct from the provision role used when authenticating. It will allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.',
- ),
- {
- startLink:
- '<a href="https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html#create-service-role" target="_blank" rel="noopener noreferrer">',
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
- },
- regionsDropdownHelpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Learn more about %{startLink}Regions %{externalLinkIcon}%{endLink}.',
- ),
- {
- startLink:
- '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">',
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
- },
- keyPairDropdownHelpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.',
- ),
- {
- startLink:
- '<a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair" target="_blank" rel="noopener noreferrer">',
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
- },
- vpcDropdownHelpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.',
- ),
- {
- startLink:
- '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#vpc-create" target="_blank" rel="noopener noreferrer">',
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
- },
- subnetDropdownHelpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Choose the %{startLink}subnets %{externalLinkIcon} %{endLink} in your VPC where your worker nodes will run.',
- ),
- {
- startLink:
- '<a href="https://console.aws.amazon.com/vpc/home?#subnets" target="_blank" rel="noopener noreferrer">',
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
- },
subnetValidationErrorText() {
if (this.loadingSubnetsError) {
return s__('ClusterIntegration|Could not load subnets for the selected VPC');
@@ -228,48 +193,6 @@ export default {
return s__('ClusterIntegration|You should select at least two subnets');
},
- securityGroupDropdownHelpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Choose the %{startLink}security group %{externalLinkIcon} %{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.',
- ),
- {
- startLink:
- '<a href="https://console.aws.amazon.com/vpc/home?#securityGroups" target="_blank" rel="noopener noreferrer">',
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
- },
- instanceTypesDropdownHelpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Choose the worker node %{startLink}instance type %{externalLinkIcon} %{endLink}.',
- ),
- {
- startLink:
- '<a href="https://aws.amazon.com/ec2/instance-types" target="_blank" rel="noopener noreferrer">',
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
- },
- gitlabManagedHelpText() {
- const escapedUrl = escape(this.gitlabManagedClusterHelpPath);
-
- return sprintf(
- s__(
- 'ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{startLink}More information%{endLink}',
- ),
- {
- startLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`,
- endLink: '</a>',
- },
- false,
- );
- },
},
mounted() {
this.fetchRegions();
@@ -290,6 +213,7 @@ export default {
'setInstanceType',
'setNodeCount',
'setGitlabManagedCluster',
+ 'setNamespacePerEnvironment',
]),
...mapRegionsActions({ fetchRegions: 'fetchItems' }),
...mapVpcActions({ fetchVpcs: 'fetchItems' }),
@@ -321,7 +245,15 @@ export default {
<h4>
{{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }}
</h4>
- <div class="mb-3" v-html="kubernetesIntegrationHelpText"></div>
+ <div class="mb-3">
+ <gl-sprintf :message="$options.i18n.kubernetesIntegrationHelpText">
+ <template #link="{ content }">
+ <gl-link :href="kubernetesIntegrationHelpPath">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
<div class="form-group">
<label class="label-bold" for="eks-cluster-name">{{
s__('ClusterIntegration|Kubernetes cluster name')
@@ -371,7 +303,16 @@ export default {
:error-message="s__('ClusterIntegration|Could not load IAM roles')"
@input="setRole({ role: $event })"
/>
- <p class="form-text text-muted" v-html="roleDropdownHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.roleDropdownHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.roleDropdownHelpPath" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Region') }}</label>
@@ -389,7 +330,16 @@ export default {
:error-message="s__('ClusterIntegration|Could not load regions from your AWS account')"
@input="setRegionAndFetchVpcsAndKeyPairs($event)"
/>
- <p class="form-text text-muted" v-html="regionsDropdownHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.regionsDropdownHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.regionsDropdownHelpPath" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<label class="label-bold" for="eks-key-pair">{{
@@ -411,7 +361,16 @@ export default {
:error-message="s__('ClusterIntegration|Could not load Key Pairs')"
@input="setKeyPair({ keyPair: $event })"
/>
- <p class="form-text text-muted" v-html="keyPairDropdownHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.keyPairDropdownHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.keyPairDropdownHelpPath" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<label class="label-bold" for="eks-vpc">{{ s__('ClusterIntegration|VPC') }}</label>
@@ -431,7 +390,16 @@ export default {
:error-message="s__('ClusterIntegration|Could not load VPCs for the selected region')"
@input="setVpcAndFetchSubnets($event)"
/>
- <p class="form-text text-muted" v-html="vpcDropdownHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.vpcDropdownHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.vpcDropdownHelpPath" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnets') }}</label>
@@ -452,7 +420,16 @@ export default {
:error-message="subnetValidationErrorText"
@input="setSubnet({ subnet: $event })"
/>
- <p class="form-text text-muted" v-html="subnetDropdownHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.subnetDropdownHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.subnetDropdownHelpPath" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<label class="label-bold" for="eks-security-group">{{
@@ -476,7 +453,16 @@ export default {
"
@input="setSecurityGroup({ securityGroup: $event })"
/>
- <p class="form-text text-muted" v-html="securityGroupDropdownHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.securityGroupDropdownHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.securityGroupDropdownHelpPath" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<label class="label-bold" for="eks-instance-type">{{
@@ -496,7 +482,16 @@ export default {
:error-message="s__('ClusterIntegration|Could not load instance types')"
@input="setInstanceType({ instanceType: $event })"
/>
- <p class="form-text text-muted" v-html="instanceTypesDropdownHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.instanceTypesDropdownHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.instanceTypesDropdownHelpPath" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<label class="label-bold" for="eks-node-count">{{
@@ -517,7 +512,31 @@ export default {
@input="setGitlabManagedCluster({ gitlabManagedCluster: $event })"
>{{ s__('ClusterIntegration|GitLab-managed cluster') }}</gl-form-checkbox
>
- <p class="form-text text-muted" v-html="gitlabManagedHelpText"></p>
+ <p class="form text text-muted">
+ <gl-sprintf :message="$options.i18n.gitlabManagedClusterHelpText">
+ <template #link="{ content }">
+ <gl-link :href="gitlabManagedClusterHelpPath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ <div class="form-group">
+ <gl-form-checkbox
+ :checked="namespacePerEnvironment"
+ @input="setNamespacePerEnvironment({ namespacePerEnvironment: $event })"
+ >{{ s__('ClusterIntegration|Namespace per environment') }}</gl-form-checkbox
+ >
+ <p class="form text text-muted">
+ <gl-sprintf :message="$options.i18n.namespacePerEnvironmentHelpText">
+ <template #link="{ content }">
+ <gl-link :href="namespacePerEnvironmentHelpPath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<div class="form-group">
<loading-button
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/index.js b/app/assets/javascripts/create_cluster/eks_cluster/index.js
index fb993a7aa59..6d1034b4a72 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/index.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/index.js
@@ -9,6 +9,7 @@ Vue.use(Vuex);
export default el => {
const {
gitlabManagedClusterHelpPath,
+ namespacePerEnvironmentHelpPath,
kubernetesIntegrationHelpPath,
accountAndExternalIdsHelpPath,
createRoleArnHelpPath,
@@ -42,6 +43,7 @@ export default el => {
return createElement('create-eks-cluster', {
props: {
gitlabManagedClusterHelpPath,
+ namespacePerEnvironmentHelpPath,
kubernetesIntegrationHelpPath,
accountAndExternalIdsHelpPath,
createRoleArnHelpPath,
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
index 5abff3c7831..48c85ff627f 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
@@ -55,6 +55,7 @@ export const createCluster = ({ dispatch, state }) => {
name: state.clusterName,
environment_scope: state.environmentScope,
managed: state.gitlabManagedCluster,
+ namespace_per_environment: state.namespacePerEnvironment,
provider_aws_attributes: {
kubernetes_version: state.kubernetesVersion,
region: state.selectedRegion,
@@ -114,6 +115,10 @@ export const setGitlabManagedCluster = ({ commit }, payload) => {
commit(types.SET_GITLAB_MANAGED_CLUSTER, payload);
};
+export const setNamespacePerEnvironment = ({ commit }, payload) => {
+ commit(types.SET_NAMESPACE_PER_ENVIRONMENT, payload);
+};
+
export const setInstanceType = ({ commit }, payload) => {
commit(types.SET_INSTANCE_TYPE, payload);
};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
index 9dee6abae5f..4a48195a27b 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
@@ -10,6 +10,7 @@ export const SET_SECURITY_GROUP = 'SET_SECURITY_GROUP';
export const SET_INSTANCE_TYPE = 'SET_INSTANCE_TYPE';
export const SET_NODE_COUNT = 'SET_NODE_COUNT';
export const SET_GITLAB_MANAGED_CLUSTER = 'SET_GITLAB_MANAGED_CLUSTER';
+export const SET_NAMESPACE_PER_ENVIRONMENT = 'SET_NAMESPACE_PER_ENVIRONMENT';
export const REQUEST_CREATE_ROLE = 'REQUEST_CREATE_ROLE';
export const CREATE_ROLE_SUCCESS = 'CREATE_ROLE_SUCCESS';
export const CREATE_ROLE_ERROR = 'CREATE_ROLE_ERROR';
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
index c331d27d255..f57236e0e31 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
@@ -37,6 +37,9 @@ export default {
[types.SET_GITLAB_MANAGED_CLUSTER](state, { gitlabManagedCluster }) {
state.gitlabManagedCluster = gitlabManagedCluster;
},
+ [types.SET_NAMESPACE_PER_ENVIRONMENT](state, { namespacePerEnvironment }) {
+ state.namespacePerEnvironment = namespacePerEnvironment;
+ },
[types.REQUEST_CREATE_ROLE](state) {
state.isCreatingRole = true;
state.createRoleError = null;
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
index ed51e95e434..c957eca1f7a 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
@@ -30,4 +30,5 @@ export default () => ({
createClusterError: false,
gitlabManagedCluster: true,
+ namespacePerEnvironment: true,
});
diff --git a/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue b/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue
index 34e4aeb290f..7c4117d7e8b 100644
--- a/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue
+++ b/app/assets/javascripts/custom_metrics/components/delete_custom_metric_modal.vue
@@ -1,11 +1,11 @@
<script>
-import { GlModal, GlModalDirective, GlDeprecatedButton } from '@gitlab/ui';
+import { GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
components: {
GlModal,
- GlDeprecatedButton,
+ GlButton,
},
directives: {
'gl-modal': GlModalDirective,
@@ -33,9 +33,9 @@ export default {
</script>
<template>
<div class="d-inline-block float-right mr-3">
- <gl-deprecated-button v-gl-modal="$options.modalId" variant="danger">
+ <gl-button v-gl-modal="$options.modalId" variant="danger" category="primary">
{{ __('Delete') }}
- </gl-deprecated-button>
+ </gl-button>
<gl-modal
:title="s__('Metrics|Delete metric?')"
:ok-title="s__('Metrics|Delete metric')"
diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
index ff0f352b333..b2c9cd4e597 100644
--- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
@@ -1,7 +1,10 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
+ components: {
+ GlIcon,
+ },
directives: {
GlTooltip: GlTooltipDirective,
},
@@ -15,15 +18,17 @@ export default {
</script>
<template>
<span v-if="count === 50" class="events-info float-right">
- <i
- v-gl-tooltip
- :title="
- n__('Limited to showing %d event at most', 'Limited to showing %d events at most', 50)
- "
- class="fa fa-warning"
+ <gl-icon
+ v-gl-tooltip="{
+ title: n__(
+ 'Limited to showing %d event at most',
+ 'Limited to showing %d events at most',
+ 50,
+ ),
+ }"
+ name="warning"
aria-hidden="true"
- >
- </i>
+ />
{{ n__('Showing %d event', 'Showing %d events', 50) }}
</span>
</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue
index ba2be2e5167..6d8f711c13b 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue
@@ -1,8 +1,6 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
-import iconBranch from '../svg/icon_branch.svg';
import limitWarning from './limit_warning_component.vue';
import totalTime from './total_time_component.vue';
@@ -13,6 +11,9 @@ export default {
limitWarning,
GlIcon,
},
+ directives: {
+ SafeHtml,
+ },
props: {
items: {
type: Array,
@@ -25,11 +26,6 @@ export default {
required: false,
},
},
- computed: {
- iconBranch() {
- return iconBranch;
- },
- },
};
</script>
<template>
@@ -47,7 +43,9 @@ export default {
<a :href="build.url" class="pipeline-id"> #{{ build.id }} </a>
<gl-icon :size="16" name="fork" />
<a :href="build.branch.url" class="ref-name"> {{ build.branch.name }} </a>
- <span class="icon-branch" v-html="iconBranch"> </span>
+ <span class="icon-branch gl-text-gray-400">
+ <gl-icon name="commit" :size="14" />
+ </span>
<a :href="build.commitUrl" class="commit-sha"> {{ build.shortSha }} </a>
</h5>
<span>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue
index cd49b3c5222..c165c8cee78 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue
@@ -1,8 +1,5 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlIcon } from '@gitlab/ui';
-import iconBuildStatus from '../svg/icon_build_status.svg';
-import iconBranch from '../svg/icon_branch.svg';
import limitWarning from './limit_warning_component.vue';
import totalTime from './total_time_component.vue';
@@ -24,14 +21,6 @@ export default {
required: false,
},
},
- computed: {
- iconBuildStatus() {
- return iconBuildStatus;
- },
- iconBranch() {
- return iconBranch;
- },
- },
};
</script>
<template>
@@ -44,12 +33,16 @@ export default {
<li v-for="(build, i) in items" :key="i" class="stage-event-item item-build-component">
<div class="item-details">
<h5 class="item-title">
- <span class="icon-build-status" v-html="iconBuildStatus"> </span>
+ <span class="icon-build-status gl-text-green-500">
+ <gl-icon name="status_success" :size="14" />
+ </span>
<a :href="build.url" class="item-build-name"> {{ build.name }} </a> &middot;
<a :href="build.url" class="pipeline-id"> #{{ build.id }} </a>
<gl-icon :size="16" name="fork" />
<a :href="build.branch.url" class="ref-name"> {{ build.branch.name }} </a>
- <span class="icon-branch" v-html="iconBranch"> </span>
+ <span class="icon-branch gl-text-gray-400">
+ <gl-icon name="commit" :size="14" />
+ </span>
<a :href="build.commitUrl" class="commit-sha"> {{ build.shortSha }} </a>
</h5>
<span>
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index f6bad5dce41..4cccabca28b 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -36,12 +36,6 @@ export default () => {
'stage-review-component': stageReviewComponent,
'stage-staging-component': stageStagingComponent,
'stage-production-component': stageComponent,
- GroupsDropdownFilter: () =>
- import('ee_component/analytics/shared/components/groups_dropdown_filter.vue'),
- ProjectsDropdownFilter: () =>
- import('ee_component/analytics/shared/components/projects_dropdown_filter.vue'),
- DateRangeDropdown: () =>
- import('ee_component/analytics/shared/components/date_range_dropdown.vue'),
'stage-nav-item': stageNavItem,
},
data() {
diff --git a/app/assets/javascripts/cycle_analytics/svg/icon_branch.svg b/app/assets/javascripts/cycle_analytics/svg/icon_branch.svg
deleted file mode 100644
index 9f547d3d744..00000000000
--- a/app/assets/javascripts/cycle_analytics/svg/icon_branch.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path fill="#8C8C8C" fill-rule="evenodd" d="M9.678 6.722C9.353 5.167 8.053 4 6.5 4S3.647 5.167 3.322 6.722h-2.6c-.397 0-.722.35-.722.778 0 .428.325.778.722.778h2.6C3.647 9.833 4.947 11 6.5 11s2.853-1.167 3.178-2.722h2.6c.397 0 .722-.35.722-.778 0-.428-.325-.778-.722-.778h-2.6zM4.694 7.5c0-1.09.795-1.944 1.806-1.944 1.01 0 1.806.855 1.806 1.944 0 1.09-.795 1.944-1.806 1.944-1.01 0-1.806-.855-1.806-1.944z"/></svg>
diff --git a/app/assets/javascripts/cycle_analytics/svg/icon_build_status.svg b/app/assets/javascripts/cycle_analytics/svg/icon_build_status.svg
deleted file mode 100644
index b932d90618a..00000000000
--- a/app/assets/javascripts/cycle_analytics/svg/icon_build_status.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"><g fill="#31AF64" fill-rule="evenodd"><path d="M12.5 7c0-3.038-2.462-5.5-5.5-5.5S1.5 3.962 1.5 7s2.462 5.5 5.5 5.5 5.5-2.462 5.5-5.5zM0 7c0-3.866 3.134-7 7-7s7 3.134 7 7-3.134 7-7 7-7-3.134-7-7z"/><path d="M6.28 7.697L5.045 6.464c-.117-.117-.305-.117-.42-.002l-.614.614c-.11.113-.11.303.007.42l1.91 1.91c.19.19.51.197.703.004l.264-.265L9.997 6.04c.108-.107.107-.293-.01-.408l-.612-.614c-.114-.113-.298-.12-.41-.01L6.28 7.7z"/></g></svg>
diff --git a/app/assets/javascripts/cycle_analytics/svg/icon_commit.svg b/app/assets/javascripts/cycle_analytics/svg/icon_commit.svg
deleted file mode 100644
index 6a517756058..00000000000
--- a/app/assets/javascripts/cycle_analytics/svg/icon_commit.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><path fill="#8F8F8F" fill-rule="evenodd" d="M28.777 18c-.91-4.008-4.494-7-8.777-7-4.283 0-7.868 2.992-8.777 7H4.01C2.9 18 2 18.895 2 20c0 1.112.9 2 2.01 2h7.213c.91 4.008 4.494 7 8.777 7 4.283 0 7.868-2.992 8.777-7h7.214C37.1 22 38 21.105 38 20c0-1.112-.9-2-2.01-2h-7.213zM20 25c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5z"/></svg>
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
index 159f5ddd755..0d6657973c3 100644
--- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
@@ -69,15 +69,13 @@ export default {
</p>
</template>
</gl-table>
- <div class="gl-display-flex gl-justify-content-center">
- <gl-button
- v-gl-modal.deploy-freeze-modal
- data-testid="add-deploy-freeze"
- category="primary"
- variant="success"
- >
- {{ $options.translations.addDeployFreeze }}
- </gl-button>
- </div>
+ <gl-button
+ v-gl-modal.deploy-freeze-modal
+ data-testid="add-deploy-freeze"
+ category="primary"
+ variant="success"
+ >
+ {{ $options.translations.addDeployFreeze }}
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue
index 970197ef41b..273fa3f6be2 100644
--- a/app/assets/javascripts/design_management/components/delete_button.vue
+++ b/app/assets/javascripts/design_management/components/delete_button.vue
@@ -63,7 +63,7 @@ export default {
title: s__('DesignManagement|Are you sure you want to archive the selected designs?'),
actionPrimary: {
text: s__('DesignManagement|Archive designs'),
- attributes: { variant: 'warning' },
+ attributes: { variant: 'warning', 'data-qa-selector': 'confirm_archiving_button' },
},
actionCancel: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/design_management/components/design_note_pin.vue b/app/assets/javascripts/design_management/components/design_note_pin.vue
index 2b5e62c2870..320e0654aab 100644
--- a/app/assets/javascripts/design_management/components/design_note_pin.vue
+++ b/app/assets/javascripts/design_management/components/design_note_pin.vue
@@ -17,19 +17,11 @@ export default {
required: false,
default: null,
},
- repositioning: {
- type: Boolean,
- required: false,
- default: false,
- },
},
computed: {
isNewNote() {
return this.label === null;
},
- pinStyle() {
- return this.repositioning ? { ...this.position, cursor: 'move' } : this.position;
- },
pinLabel() {
return this.isNewNote
? __('Comment form position')
@@ -41,13 +33,13 @@ export default {
<template>
<button
- :style="pinStyle"
+ :style="position"
:aria-label="pinLabel"
:class="{
- 'btn-transparent comment-indicator': isNewNote,
+ 'btn-transparent comment-indicator gl-p-0': isNewNote,
'js-image-badge badge badge-pill': !isNewNote,
}"
- class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0"
+ class="gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-font-lg gl-outline-0!"
type="button"
@mousedown="$emit('mousedown', $event)"
@mouseup="$emit('mouseup', $event)"
diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue
index 5c4a3ab5f94..88f3ce0b8ea 100644
--- a/app/assets/javascripts/design_management/components/design_overlay.vue
+++ b/app/assets/javascripts/design_management/components/design_overlay.vue
@@ -266,7 +266,7 @@ export default {
type="button"
role="button"
:aria-label="$options.i18n.newCommentButtonLabel"
- class="gl-absolute gl-w-full gl-h-full gl-p-0 gl-top-0 gl-left-0 gl-outline-0! btn-transparent design-detail-overlay-add-comment"
+ class="gl-absolute gl-w-full gl-h-full gl-p-0 gl-top-0 gl-left-0 gl-outline-0! btn-transparent gl-hover-cursor-crosshair"
data-qa-selector="design_image_button"
@mouseup="onAddCommentMouseup"
></button>
@@ -276,7 +276,6 @@ export default {
v-if="resolvedDiscussionsExpanded || !note.resolved"
:key="note.id"
:label="note.index"
- :repositioning="isMovingNote(note.id)"
:position="
isMovingNote(note.id) && movingNoteNewPosition
? getNotePositionStyle(movingNoteNewPosition)
@@ -290,7 +289,6 @@ export default {
<design-note-pin
v-if="currentCommentForm"
:position="currentCommentPositionStyle"
- :repositioning="isMovingCurrentComment"
@mousedown.stop="onNoteMousedown"
@mouseup.stop="onNoteMouseup"
/>
diff --git a/app/assets/javascripts/design_management/components/design_presentation.vue b/app/assets/javascripts/design_management/components/design_presentation.vue
index 84dbb2809d9..c4d904e0d91 100644
--- a/app/assets/javascripts/design_management/components/design_presentation.vue
+++ b/app/assets/javascripts/design_management/components/design_presentation.vue
@@ -286,7 +286,7 @@ export default {
<template>
<div
ref="presentationViewport"
- class="h-100 w-100 p-3 overflow-auto position-relative"
+ class="gl-h-full gl-w-full gl-p-5 overflow-auto gl-relative"
:style="presentationStyle"
@mousedown="onPresentationMousedown"
@mousemove="onPresentationMousemove"
@@ -297,7 +297,7 @@ export default {
@touchend="onPresentationMouseup"
@touchcancel="onPresentationMouseup"
>
- <div class="h-100 w-100 d-flex align-items-center position-relative">
+ <div class="gl-h-full gl-w-full gl-display-flex gl-align-items-center gl-relative">
<design-image
v-if="image"
:image="image"
diff --git a/app/assets/javascripts/design_management/components/design_scaler.vue b/app/assets/javascripts/design_management/components/design_scaler.vue
index 55dee74bef5..8d26f84641e 100644
--- a/app/assets/javascripts/design_management/components/design_scaler.vue
+++ b/app/assets/javascripts/design_management/components/design_scaler.vue
@@ -51,7 +51,7 @@ export default {
<template>
<div class="design-scaler btn-group" role="group">
<button class="btn" :disabled="disableDecrease" @click="decrementScale">
- <span class="d-flex-center gl-icon s16">
+ <span class="gl-display-flex gl-justify-content-center gl-align-items-center gl-icon s16">
</span>
</button>
diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue
index df425e3b96d..fb8e74c8c4c 100644
--- a/app/assets/javascripts/design_management/components/design_sidebar.vue
+++ b/app/assets/javascripts/design_management/components/design_sidebar.vue
@@ -71,14 +71,6 @@ export default {
resolvedCommentsToggleIcon() {
return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right';
},
- showTodoButton() {
- return this.glFeatures.designManagementTodoButton;
- },
- sidebarWrapperClass() {
- return {
- 'gl-pt-0': this.showTodoButton,
- };
- },
},
watch: {
isResolvedCommentsPopoverHidden(newVal) {
@@ -121,12 +113,11 @@ export default {
</script>
<template>
- <div class="image-notes" :class="sidebarWrapperClass" @click="handleSidebarClick">
+ <div class="image-notes gl-pt-0" @click="handleSidebarClick">
<div
- v-if="showTodoButton"
class="gl-py-4 gl-mb-4 gl-display-flex gl-justify-content-space-between gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
>
- <span>{{ __('To-Do') }}</span>
+ <span>{{ __('To Do') }}</span>
<design-todo-button :design="design" @error="$emit('todoError', $event)" />
</div>
<h2 class="gl-font-weight-bold gl-mt-0">
diff --git a/app/assets/javascripts/design_management/components/image.vue b/app/assets/javascripts/design_management/components/image.vue
index 91b7b576e0c..53062e6ebb0 100644
--- a/app/assets/javascripts/design_management/components/image.vue
+++ b/app/assets/javascripts/design_management/components/image.vue
@@ -93,8 +93,8 @@ export default {
</script>
<template>
- <div class="m-auto js-design-image">
- <gl-icon v-if="imageError" class="text-secondary-100" name="media-broken" :size="48" />
+ <div class="gl-mx-auto gl-my-auto js-design-image">
+ <gl-icon v-if="imageError" class="gl-text-gray-200" name="media-broken" :size="48" />
<img
v-show="!imageError"
ref="contentImg"
diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
index 36ea812d92e..fa09c7c15cc 100644
--- a/app/assets/javascripts/design_management/components/list/item.vue
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -132,7 +132,13 @@ export default {
>
<div v-if="icon.name" data-testid="designEvent" class="design-event gl-absolute">
<span :title="icon.tooltip" :aria-label="icon.tooltip">
- <gl-icon :name="icon.name" :size="18" :class="icon.classes" />
+ <gl-icon
+ :name="icon.name"
+ :size="18"
+ :class="icon.classes"
+ data-qa-selector="design_status_icon"
+ :data-qa-status="icon.name"
+ />
</span>
</div>
<gl-intersection-observer @appear="onAppear">
@@ -149,6 +155,7 @@ export default {
:alt="filename"
class="gl-display-block gl-mx-auto gl-max-w-full mh-100 design-img"
data-qa-selector="design_image"
+ :data-qa-filename="filename"
@load="onImageLoad"
@error="onImageError"
/>
diff --git a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
index afca8ed2c6f..2719d701c12 100644
--- a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
@@ -64,9 +64,9 @@ export default {
</script>
<template>
- <div v-if="designsCount" class="d-flex align-items-center">
+ <div v-if="designsCount" class="gl-display-flex gl-align-items-center">
{{ paginationText }}
- <gl-button-group class="ml-3 mr-3">
+ <gl-button-group class="gl-mx-5">
<gl-button
:disabled="!previousDesign"
:title="s__('DesignManagement|Go to previous design')"
diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue
index a1cb57123ab..8d25d467d59 100644
--- a/app/assets/javascripts/design_management/components/toolbar/index.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/index.vue
@@ -106,12 +106,12 @@ export default {
>
<gl-icon name="close" />
</router-link>
- <div class="overflow-hidden d-flex align-items-center">
- <h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2>
- <small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
+ <div class="gl-overflow-hidden gl-display-flex gl-align-items-center">
+ <h2 class="gl-m-0 str-truncated-100 gl-font-base">{{ filename }}</h2>
+ <small v-if="updatedAt" class="gl-text-gray-500">{{ updatedText }}</small>
</div>
</div>
- <design-navigation :id="id" class="ml-auto flex-shrink-0" />
+ <design-navigation :id="id" class="gl-ml-auto gl-flex-shrink-0" />
<gl-button :href="image" icon="download" />
<delete-button
v-if="isLatestVersion && canDeleteDesign"
diff --git a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue
index 7254b7cd16a..6694b0dab8d 100644
--- a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue
+++ b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue
@@ -83,7 +83,7 @@ export default {
<template>
<div
- class="w-100 position-relative"
+ class="gl-w-full gl-relative"
@dragstart.prevent.stop
@dragend.prevent.stop
@dragover.prevent.stop
@@ -93,7 +93,7 @@ export default {
>
<slot>
<button
- class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3"
+ class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
@click="openFileUpload"
>
<div
@@ -127,9 +127,9 @@ export default {
<transition name="design-dropzone-fade">
<div
v-show="dragging && !isDraggingDesign"
- class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
>
- <div v-show="!isDragDataValid" class="mw-50 text-center">
+ <div v-show="!isDragDataValid" class="mw-50 gl-text-center">
<h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Oh no!') }}</h3>
<span>{{
__(
@@ -137,7 +137,7 @@ export default {
)
}}</span>
</div>
- <div v-show="isDragDataValid" class="mw-50 text-center">
+ <div v-show="isDragDataValid" class="mw-50 gl-text-center">
<h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Incoming!') }}</h3>
<span>{{ __('Drop your designs to start your upload.') }}</span>
</div>
diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql
index 96efa8e8242..efa61edf51a 100644
--- a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql
+++ b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql
@@ -6,6 +6,7 @@ query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) {
id
issue(iid: $iid) {
designCollection {
+ copyState
designs(atVersion: $atVersion) {
nodes {
...DesignListItem
diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js
index 0c2858bb14b..62bcf216add 100644
--- a/app/assets/javascripts/design_management/mixins/all_designs.js
+++ b/app/assets/javascripts/design_management/mixins/all_designs.js
@@ -8,7 +8,7 @@ import { DESIGNS_ROUTE_NAME } from '../router/constants';
export default {
mixins: [allVersionsMixin],
apollo: {
- designs: {
+ designCollection: {
query: getDesignListQuery,
variables() {
return {
@@ -25,10 +25,11 @@ export default {
'designs',
'nodes',
]);
- if (designNodes) {
- return designNodes;
- }
- return [];
+ const copyState = propertyOf(data)(['project', 'issue', 'designCollection', 'copyState']);
+ return {
+ designs: designNodes,
+ copyState,
+ };
},
error() {
this.error = true;
@@ -42,13 +43,26 @@ export default {
);
this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } });
}
+ if (this.designCollection.copyState === 'ERROR') {
+ createFlash(
+ s__(
+ 'DesignManagement|There was an error moving your designs. Please upload your designs below.',
+ ),
+ 'warning',
+ );
+ }
},
},
},
data() {
return {
- designs: [],
+ designCollection: null,
error: false,
};
},
+ computed: {
+ designs() {
+ return this.designCollection?.designs || [];
+ },
+ },
};
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index c6225c516e2..6a96b06dcd8 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -40,6 +40,8 @@ import { trackDesignDetailView } from '../../utils/tracking';
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
+const DEFAULT_SCALE = 1;
+
export default {
components: {
ApolloMutation,
@@ -65,7 +67,7 @@ export default {
comment: '',
annotationCoordinates: null,
errorMessage: '',
- scale: 1,
+ scale: DEFAULT_SCALE,
resolvedDiscussionsExpanded: false,
};
},
@@ -157,6 +159,11 @@ export default {
beforeDestroy() {
Mousetrap.unbind('esc', this.closeDesign);
},
+ beforeRouteUpdate(to, from, next) {
+ // reset scale when the active design changes
+ this.scale = DEFAULT_SCALE;
+ next();
+ },
methods: {
addImageDiffNoteToStore(
store,
@@ -300,11 +307,13 @@ export default {
<template>
<div
- class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
+ 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"
>
- <gl-loading-icon v-if="isFirstLoading" size="xl" class="align-self-center" />
+ <gl-loading-icon v-if="isFirstLoading" size="xl" class="gl-align-self-center" />
<template v-else>
- <div class="d-flex overflow-hidden flex-grow-1 flex-column position-relative">
+ <div
+ class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative"
+ >
<design-destroyer
:filenames="[design.filename]"
:project-path="projectPath"
@@ -323,7 +332,7 @@ export default {
</template>
</design-destroyer>
- <div v-if="errorMessage" class="p-3">
+ <div v-if="errorMessage" class="gl-p-5">
<gl-alert variant="danger" @dismiss="errorMessage = null">
{{ errorMessage }}
</gl-alert>
@@ -340,7 +349,9 @@ export default {
@moveNote="onMoveNote"
/>
- <div class="design-scaler-wrapper position-absolute mb-4 d-flex-center">
+ <div
+ class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center"
+ >
<design-scaler @scale="scale = $event" />
</div>
</div>
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index 6c4c8c75054..6e71dca41e9 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -3,6 +3,7 @@ import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui';
import VueDraggable from 'vuedraggable';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__, sprintf } from '~/locale';
+import { getFilename } from '~/lib/utils/file_upload';
import UploadButton from '../components/upload/button.vue';
import DeleteButton from '../components/delete_button.vue';
import Design from '../components/list/item.vue';
@@ -31,7 +32,7 @@ import {
isValidDesignFile,
moveDesignOptimisticResponse,
} from '../utils/design_management_utils';
-import { getFilename } from '~/lib/utils/file_upload';
+import { trackDesignCreate, trackDesignUpdate } from '../utils/tracking';
import { DESIGNS_ROUTE_NAME } from '../router/constants';
const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
@@ -71,11 +72,14 @@ export default {
selectedDesigns: [],
isDraggingDesign: false,
reorderedDesigns: null,
+ isReorderingInProgress: false,
};
},
computed: {
isLoading() {
- return this.$apollo.queries.designs.loading || this.$apollo.queries.permissions.loading;
+ return (
+ this.$apollo.queries.designCollection.loading || this.$apollo.queries.permissions.loading
+ );
},
isSaving() {
return this.filesToBeSaved.length > 0;
@@ -109,6 +113,9 @@ export default {
isDesignListEmpty() {
return !this.isSaving && !this.hasDesigns;
},
+ isDesignCollectionCopying() {
+ return this.designCollection && this.designCollection.copyState === 'IN_PROGRESS';
+ },
designDropzoneWrapperClass() {
return this.isDesignListEmpty
? 'col-12'
@@ -180,6 +187,7 @@ export default {
updateStoreAfterUploadDesign(store, designManagementUpload, this.projectQueryBody);
},
onUploadDesignDone(res) {
+ // display any warnings, if necessary
const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || [];
const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles);
if (skippedWarningMessage) {
@@ -190,7 +198,19 @@ export default {
if (!this.isLatestVersion) {
this.$router.push({ name: DESIGNS_ROUTE_NAME });
}
+
+ // reset state
this.resetFilesToBeSaved();
+ this.trackUploadDesign(res);
+ },
+ trackUploadDesign(res) {
+ (res?.data?.designManagementUpload?.designs || []).forEach(design => {
+ if (design.event === 'CREATION') {
+ trackDesignCreate();
+ } else if (design.event === 'MODIFICATION') {
+ trackDesignUpdate();
+ }
+ });
},
onUploadDesignError() {
this.resetFilesToBeSaved();
@@ -277,6 +297,7 @@ export default {
return variables;
},
reorderDesigns({ moved: { newIndex, element } }) {
+ this.isReorderingInProgress = true;
this.$apollo
.mutate({
mutation: moveDesignMutation,
@@ -287,6 +308,9 @@ export default {
})
.catch(() => {
createFlash(MOVE_DESIGN_ERROR);
+ })
+ .finally(() => {
+ this.isReorderingInProgress = false;
});
},
onDesignMove(designs) {
@@ -311,7 +335,7 @@ export default {
@mouseenter="toggleOnPasteListener"
@mouseleave="toggleOffPasteListener"
>
- <header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex">
+ <header v-if="showToolbar" class="row-content-block gl-border-t-0 gl-p-3 gl-display-flex">
<div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full">
<div>
<span class="gl-font-weight-bold gl-mr-3">{{ s__('DesignManagement|Designs') }}</span>
@@ -339,6 +363,7 @@ export default {
button-category="secondary"
button-class="gl-mr-3"
button-size="small"
+ data-qa-selector="archive_button"
:loading="loading"
:has-selected-designs="hasSelectedDesigns"
@deleteSelectedDesigns="mutate()"
@@ -350,15 +375,30 @@ export default {
</div>
</div>
</header>
- <div class="mt-4">
+ <div class="gl-mt-6">
<gl-loading-icon v-if="isLoading" size="md" />
<gl-alert v-else-if="error" variant="danger" :dismissible="false">
{{ __('An error occurred while loading designs. Please try again.') }}
</gl-alert>
+ <header
+ v-else-if="isDesignCollectionCopying"
+ class="card"
+ data-testid="design-collection-is-copying"
+ >
+ <div class="card-header design-card-header gl-border-b-0">
+ <div class="card-title gl-display-flex gl-align-items-center gl-my-0 gl-h-7">
+ {{
+ s__(
+ 'DesignManagement|Your designs are being copied and are on their way… Please refresh to update.',
+ )
+ }}
+ </div>
+ </div>
+ </header>
<vue-draggable
v-else
:value="designs"
- :disabled="!isLatestVersion"
+ :disabled="!isLatestVersion || isReorderingInProgress"
v-bind="$options.dragOptions"
tag="ol"
draggable=".js-design-tile"
@@ -390,6 +430,8 @@ export default {
:checked="isDesignSelected(design.filename)"
type="checkbox"
class="design-checkbox"
+ data-qa-selector="design_checkbox"
+ :data-qa-design="design.filename"
@change="changeSelectedDesigns(design.filename)"
/>
</li>
@@ -399,6 +441,7 @@ export default {
:is-dragging-design="isDraggingDesign"
:class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }"
:has-designs="hasDesigns"
+ data-qa-selector="design_dropzone_content"
@change="onUploadDesign"
/>
</li>
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
index ff41136fd54..fc0530ff977 100644
--- a/app/assets/javascripts/design_management/utils/cache_update.js
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -1,6 +1,6 @@
/* eslint-disable @gitlab/require-i18n-strings */
-import { groupBy } from 'lodash';
+import { differenceBy } from 'lodash';
import produce from 'immer';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { extractCurrentDiscussion, extractDesign, extractDesigns } from './design_management_utils';
@@ -132,10 +132,13 @@ const addNewDesignToStore = (store, designManagementUpload, query) => {
const data = produce(sourceData, draftData => {
const currentDesigns = extractDesigns(draftData);
- const existingDesigns = groupBy(currentDesigns, 'filename');
- const newDesigns = currentDesigns.concat(
- designManagementUpload.designs.filter(d => !existingDesigns[d.filename]),
- );
+ const difference = differenceBy(designManagementUpload.designs, currentDesigns, 'filename');
+
+ const newDesigns = currentDesigns
+ .map(design => {
+ return designManagementUpload.designs.find(d => d.filename === design.filename) || design;
+ })
+ .concat(difference);
let newVersionNode;
const findNewVersions = designManagementUpload.designs.find(design => design.versions);
@@ -155,6 +158,7 @@ const addNewDesignToStore = (store, designManagementUpload, query) => {
const updatedDesigns = {
__typename: 'DesignCollection',
+ copyState: 'READY',
designs: {
__typename: 'DesignConnection',
nodes: newDesigns,
diff --git a/app/assets/javascripts/design_management/utils/design_management_utils.js b/app/assets/javascripts/design_management/utils/design_management_utils.js
index 93e4d6060c3..687e793d3df 100644
--- a/app/assets/javascripts/design_management/utils/design_management_utils.js
+++ b/app/assets/javascripts/design_management/utils/design_management_utils.js
@@ -65,6 +65,10 @@ export const designUploadOptimisticResponse = files => {
fullPath: '',
notesCount: 0,
event: 'NONE',
+ currentUserTodos: {
+ __typename: 'TodoConnection',
+ nodes: [],
+ },
diffRefs: {
__typename: 'DiffRefs',
baseSha: '',
diff --git a/app/assets/javascripts/design_management/utils/tracking.js b/app/assets/javascripts/design_management/utils/tracking.js
index 49fa306914c..4a39268c38b 100644
--- a/app/assets/javascripts/design_management/utils/tracking.js
+++ b/app/assets/javascripts/design_management/utils/tracking.js
@@ -1,9 +1,16 @@
import Tracking from '~/tracking';
// Tracking Constants
-const DESIGN_TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0';
-const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
-const DESIGN_TRACKING_EVENT_NAME = 'view_design';
+const DESIGN_TRACKING_CONTEXT_SCHEMAS = {
+ VIEW_DESIGN_SCHEMA: 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0',
+};
+const DESIGN_TRACKING_EVENTS = {
+ VIEW_DESIGN: 'view_design',
+ CREATE_DESIGN: 'create_design',
+ UPDATE_DESIGN: 'update_design',
+};
+
+export const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
export function trackDesignDetailView(
referer = '',
@@ -11,10 +18,11 @@ export function trackDesignDetailView(
designVersion = 1,
latestVersion = false,
) {
- Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENT_NAME, {
- label: DESIGN_TRACKING_EVENT_NAME,
+ const eventName = DESIGN_TRACKING_EVENTS.VIEW_DESIGN;
+ Tracking.event(DESIGN_TRACKING_PAGE_NAME, eventName, {
+ label: eventName,
context: {
- schema: DESIGN_TRACKING_CONTEXT_SCHEMA,
+ schema: DESIGN_TRACKING_CONTEXT_SCHEMAS.VIEW_DESIGN_SCHEMA,
data: {
'design-version-number': designVersion,
'design-is-current-version': latestVersion,
@@ -24,3 +32,11 @@ export function trackDesignDetailView(
},
});
}
+
+export function trackDesignCreate() {
+ return Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENTS.CREATE_DESIGN);
+}
+
+export function trackDesignUpdate() {
+ return Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENTS.UPDATE_DESIGN);
+}
diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
deleted file mode 100644
index dd60e2c7684..00000000000
--- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
+++ /dev/null
@@ -1,65 +0,0 @@
-/* global CommentsStore */
-
-import $ from 'jquery';
-import Vue from 'vue';
-import { __ } from '~/locale';
-
-const CommentAndResolveBtn = Vue.extend({
- props: {
- discussionId: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- textareaIsEmpty: true,
- discussion: {},
- };
- },
- computed: {
- showButton() {
- if (this.discussion) {
- return this.discussion.isResolvable();
- }
- return false;
- },
- isDiscussionResolved() {
- return this.discussion.isResolved();
- },
- buttonText() {
- if (this.textareaIsEmpty) {
- return this.isDiscussionResolved ? __('Unresolve thread') : __('Resolve thread');
- }
- return this.isDiscussionResolved
- ? __('Comment & unresolve thread')
- : __('Comment & resolve thread');
- },
- },
- created() {
- if (this.discussionId) {
- this.discussion = CommentsStore.state[this.discussionId];
- }
- },
- mounted() {
- if (!this.discussionId) return;
-
- const $textarea = $(
- `.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`,
- );
- this.textareaIsEmpty = $textarea.val() === '';
-
- $textarea.on('input.comment-and-resolve-btn', () => {
- this.textareaIsEmpty = $textarea.val() === '';
- });
- },
- destroyed() {
- if (!this.discussionId) return;
-
- $(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`).off(
- 'input.comment-and-resolve-btn',
- );
- },
-});
-
-Vue.component('comment-and-resolve-btn', CommentAndResolveBtn);
diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
deleted file mode 100644
index b5a781cbc92..00000000000
--- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
+++ /dev/null
@@ -1,189 +0,0 @@
-/* global CommentsStore */
-
-import $ from 'jquery';
-import Vue from 'vue';
-import collapseIcon from '../icons/collapse_icon.svg';
-import Notes from '../../notes';
-import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
-import { n__ } from '~/locale';
-
-const DiffNoteAvatars = Vue.extend({
- components: {
- userAvatarImage,
- },
- props: {
- discussionId: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- isVisible: false,
- lineType: '',
- storeState: CommentsStore.state,
- shownAvatars: 3,
- collapseIcon,
- };
- },
- computed: {
- discussionClassName() {
- return `js-diff-avatars-${this.discussionId}`;
- },
- notesSubset() {
- let notes = [];
-
- if (this.discussion) {
- notes = Object.keys(this.discussion.notes)
- .slice(0, this.shownAvatars)
- .map(noteId => this.discussion.notes[noteId]);
- }
-
- return notes;
- },
- extraNotesTitle() {
- if (this.discussion) {
- const extra = this.discussion.notesCount() - this.shownAvatars;
-
- return n__('%d more comment', '%d more comments', extra);
- }
-
- return '';
- },
- discussion() {
- return this.storeState[this.discussionId];
- },
- notesCount() {
- if (this.discussion) {
- return this.discussion.notesCount();
- }
-
- return 0;
- },
- moreText() {
- const plusSign = this.notesCount < 100 ? '+' : '';
-
- return `${plusSign}${this.notesCount - this.shownAvatars}`;
- },
- },
- watch: {
- storeState: {
- handler() {
- this.$nextTick(() => {
- $('.has-tooltip', this.$el).tooltip('_fixTitle');
-
- // We need to add/remove a class to an element that is outside the Vue instance
- this.addNoCommentClass();
- });
- },
- deep: true,
- },
- },
- mounted() {
- this.$nextTick(() => {
- this.addNoCommentClass();
- this.setDiscussionVisible();
-
- this.lineType = $(this.$el)
- .closest('.diff-line-num')
- .hasClass('old_line')
- ? 'old'
- : 'new';
- });
-
- $(document).on('toggle.comments', () => {
- this.$nextTick(() => {
- this.setDiscussionVisible();
- });
- });
- },
- beforeDestroy() {
- this.addNoCommentClass();
- $(document).off('toggle.comments');
- },
- methods: {
- clickedAvatar(e) {
- Notes.instance.onAddDiffNote(e);
-
- // Toggle the active state of the toggle all button
- this.toggleDiscussionsToggleState();
-
- this.$nextTick(() => {
- this.setDiscussionVisible();
-
- $('.has-tooltip', this.$el).tooltip('_fixTitle');
- $('.has-tooltip', this.$el).tooltip('hide');
- });
- },
- addNoCommentClass() {
- const { notesCount } = this;
-
- $(this.$el)
- .closest('.js-avatar-container')
- .toggleClass('no-comment-btn', notesCount > 0)
- .nextUntil('.js-avatar-container')
- .toggleClass('no-comment-btn', notesCount > 0);
- },
- toggleDiscussionsToggleState() {
- const $notesHolders = $(this.$el)
- .closest('.code')
- .find('.notes_holder');
- const $visibleNotesHolders = $notesHolders.filter(':visible');
- const $toggleDiffCommentsBtn = $(this.$el)
- .closest('.diff-file')
- .find('.js-toggle-diff-comments');
-
- $toggleDiffCommentsBtn.toggleClass(
- 'active',
- $notesHolders.length === $visibleNotesHolders.length,
- );
- },
- setDiscussionVisible() {
- this.isVisible = $(`.diffs .notes[data-discussion-id="${this.discussion.id}"]`).is(
- ':visible',
- );
- },
- getTooltipText(note) {
- return `${note.authorName}: ${note.noteTruncated}`;
- },
- },
- template: `
- <div class="diff-comment-avatar-holders"
- :class="discussionClassName"
- v-show="notesCount !== 0">
- <div v-if="!isVisible">
- <!-- FIXME: Pass an alt attribute here for accessibility -->
- <user-avatar-image
- v-for="note in notesSubset"
- :key="note.id"
- class="diff-comment-avatar js-diff-comment-avatar"
- @click.native="clickedAvatar($event)"
- :img-src="note.authorAvatar"
- :tooltip-text="getTooltipText(note)"
- :data-line-type="lineType"
- :size="19"
- data-html="true"
- />
- <span v-if="notesCount > shownAvatars"
- class="diff-comments-more-count has-tooltip js-diff-comment-avatar"
- data-container="body"
- data-placement="top"
- ref="extraComments"
- role="button"
- :data-line-type="lineType"
- :title="extraNotesTitle"
- @click="clickedAvatar($event)">{{ moreText }}</span>
- </div>
- <button class="diff-notes-collapse js-diff-comment-avatar"
- type="button"
- aria-label="Show comments"
- :data-line-type="lineType"
- @click="clickedAvatar($event)"
- v-if="isVisible"
- v-html="collapseIcon">
- </button>
- </div>
- `,
-});
-
-Vue.component('diff-note-avatars', DiffNoteAvatars);
diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
deleted file mode 100644
index 1de00c9f08b..00000000000
--- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
+++ /dev/null
@@ -1,210 +0,0 @@
-/* eslint-disable func-names, no-continue */
-/* global CommentsStore */
-
-import $ from 'jquery';
-import 'vendor/jquery.scrollTo';
-import Vue from 'vue';
-import { __ } from '~/locale';
-
-import DiscussionMixins from '../mixins/discussion';
-
-const JumpToDiscussion = Vue.extend({
- mixins: [DiscussionMixins],
- props: {
- discussionId: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- discussions: CommentsStore.state,
- discussion: {},
- };
- },
- computed: {
- buttonText() {
- if (this.discussionId) {
- return __('Jump to next unresolved thread');
- }
- return __('Jump to first unresolved thread');
- },
- allResolved() {
- return this.unresolvedDiscussionCount === 0;
- },
- showButton() {
- if (this.discussionId) {
- if (this.unresolvedDiscussionCount > 1) {
- return true;
- }
- return this.discussionId !== this.lastResolvedId;
- }
- return this.unresolvedDiscussionCount >= 1;
- },
- lastResolvedId() {
- let lastId;
- Object.keys(this.discussions).forEach(discussionId => {
- const discussion = this.discussions[discussionId];
-
- if (!discussion.isResolved()) {
- lastId = discussion.id;
- }
- });
- return lastId;
- },
- },
- created() {
- this.discussion = this.discussions[this.discussionId];
- },
- methods: {
- jumpToNextUnresolvedDiscussion() {
- let discussionsSelector;
- let discussionIdsInScope;
- let firstUnresolvedDiscussionId;
- let nextUnresolvedDiscussionId;
- let activeTab = window.mrTabs.currentAction;
- let hasDiscussionsToJumpTo = true;
- let jumpToFirstDiscussion = !this.discussionId;
-
- const discussionIdsForElements = function(elements) {
- return elements
- .map(function() {
- return $(this).attr('data-discussion-id');
- })
- .toArray();
- };
-
- const { discussions } = this;
-
- if (activeTab === 'diffs') {
- discussionsSelector = '.diffs .notes[data-discussion-id]';
- discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
-
- let unresolvedDiscussionCount = 0;
-
- for (let i = 0; i < discussionIdsInScope.length; i += 1) {
- const discussionId = discussionIdsInScope[i];
- const discussion = discussions[discussionId];
- if (discussion && !discussion.isResolved()) {
- unresolvedDiscussionCount += 1;
- }
- }
-
- if (this.discussionId && !this.discussion.isResolved()) {
- // If this is the last unresolved discussion on the diffs tab,
- // there are no discussions to jump to.
- if (unresolvedDiscussionCount === 1) {
- hasDiscussionsToJumpTo = false;
- }
- } else if (unresolvedDiscussionCount === 0) {
- // If there are no unresolved discussions on the diffs tab at all,
- // there are no discussions to jump to.
- hasDiscussionsToJumpTo = false;
- }
- } else if (activeTab !== 'show') {
- // If we are on the commits or builds tabs,
- // there are no discussions to jump to.
- hasDiscussionsToJumpTo = false;
- }
-
- if (!hasDiscussionsToJumpTo) {
- // If there are no discussions to jump to on the current page,
- // switch to the notes tab and jump to the first discussion there.
- window.mrTabs.activateTab('show');
- activeTab = 'show';
- jumpToFirstDiscussion = true;
- }
-
- if (activeTab === 'show') {
- discussionsSelector = '.discussion[data-discussion-id]';
- discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
- }
-
- let currentDiscussionFound = false;
- for (let i = 0; i < discussionIdsInScope.length; i += 1) {
- const discussionId = discussionIdsInScope[i];
- const discussion = discussions[discussionId];
-
- if (!discussion) {
- // Discussions for comments on commits in this MR don't have a resolved status.
- continue;
- }
-
- if (!firstUnresolvedDiscussionId && !discussion.isResolved()) {
- firstUnresolvedDiscussionId = discussionId;
-
- if (jumpToFirstDiscussion) {
- break;
- }
- }
-
- if (!jumpToFirstDiscussion) {
- if (currentDiscussionFound) {
- if (!discussion.isResolved()) {
- nextUnresolvedDiscussionId = discussionId;
- break;
- } else {
- continue;
- }
- }
-
- if (discussionId === this.discussionId) {
- currentDiscussionFound = true;
- }
- }
- }
-
- nextUnresolvedDiscussionId = nextUnresolvedDiscussionId || firstUnresolvedDiscussionId;
-
- if (!nextUnresolvedDiscussionId) {
- return;
- }
-
- let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`);
-
- if (activeTab === 'show') {
- $target = $target.closest('.note-discussion');
-
- // If the next discussion is closed, toggle it open.
- if ($target.find('.js-toggle-content').is(':hidden')) {
- $target.find('.js-toggle-button i').trigger('click');
- }
- } else if (activeTab === 'diffs') {
- // Resolved discussions are hidden in the diffs tab by default.
- // If they are marked unresolved on the notes tab, they will still be hidden on the diffs tab.
- // When jumping between unresolved discussions on the diffs tab, we show them.
- $target.closest('.content').show();
-
- const $notesHolder = $target.closest('tr.notes_holder');
-
- // Image diff discussions does not use notes_holder
- // so we should keep original $target value in those cases
- if ($notesHolder.length > 0) {
- $target = $notesHolder;
- }
-
- $target.show();
-
- // If we are on the diffs tab, we don't scroll to the discussion itself, but to
- // 4 diff lines above it: the line the discussion was in response to + 3 context
- let prevEl;
- for (let i = 0; i < 4; i += 1) {
- prevEl = $target.prev();
-
- // If the discussion doesn't have 4 lines above it, we'll have to do with fewer.
- if (!prevEl.hasClass('line_holder')) {
- break;
- }
-
- $target = prevEl;
- }
- }
-
- $.scrollTo($target, {
- offset: -150,
- });
- },
- },
-});
-
-Vue.component('jump-to-discussion', JumpToDiscussion);
diff --git a/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js b/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js
deleted file mode 100644
index e0c09aa0eee..00000000000
--- a/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/* global CommentsStore */
-
-import Vue from 'vue';
-
-const NewIssueForDiscussion = Vue.extend({
- props: {
- discussionId: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- discussions: CommentsStore.state,
- };
- },
- computed: {
- discussion() {
- return this.discussions[this.discussionId];
- },
- showButton() {
- if (this.discussion) return !this.discussion.isResolved();
- return false;
- },
- },
-});
-
-Vue.component('new-issue-for-discussion-btn', NewIssueForDiscussion);
diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js
deleted file mode 100644
index 0943712d0c5..00000000000
--- a/app/assets/javascripts/diff_notes/components/resolve_btn.js
+++ /dev/null
@@ -1,145 +0,0 @@
-/* global CommentsStore */
-/* global ResolveService */
-
-import $ from 'jquery';
-import Vue from 'vue';
-import { deprecatedCreateFlash as Flash } from '../../flash';
-import { sprintf, __ } from '~/locale';
-
-const ResolveBtn = Vue.extend({
- props: {
- noteId: {
- type: Number,
- required: true,
- },
- discussionId: {
- type: String,
- required: true,
- },
- resolved: {
- type: Boolean,
- required: true,
- },
- canResolve: {
- type: Boolean,
- required: true,
- },
- resolvedBy: {
- type: String,
- required: true,
- },
- authorName: {
- type: String,
- required: true,
- },
- authorAvatar: {
- type: String,
- required: true,
- },
- noteTruncated: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- discussions: CommentsStore.state,
- loading: false,
- };
- },
- computed: {
- discussion() {
- return this.discussions[this.discussionId];
- },
- note() {
- return this.discussion ? this.discussion.getNote(this.noteId) : {};
- },
- buttonText() {
- if (this.isResolved) {
- return sprintf(__('Resolved by %{resolvedByName}'), {
- resolvedByName: this.resolvedByName,
- });
- } else if (this.canResolve) {
- return __('Mark as resolved');
- }
-
- return __('Unable to resolve');
- },
- isResolved() {
- if (this.note) {
- return this.note.resolved;
- }
-
- return false;
- },
- resolvedByName() {
- return this.note.resolved_by;
- },
- },
- watch: {
- discussions: {
- handler: 'updateTooltip',
- deep: true,
- },
- },
- mounted() {
- $(this.$refs.button).tooltip({
- container: 'body',
- });
- },
- beforeDestroy() {
- CommentsStore.delete(this.discussionId, this.noteId);
- },
- created() {
- CommentsStore.create({
- discussionId: this.discussionId,
- noteId: this.noteId,
- canResolve: this.canResolve,
- resolved: this.resolved,
- resolvedBy: this.resolvedBy,
- authorName: this.authorName,
- authorAvatar: this.authorAvatar,
- noteTruncated: this.noteTruncated,
- });
- },
- methods: {
- updateTooltip() {
- this.$nextTick(() => {
- $(this.$refs.button)
- .tooltip('hide')
- .tooltip('_fixTitle');
- });
- },
- resolve() {
- if (!this.canResolve) return;
-
- let promise;
- this.loading = true;
-
- if (this.isResolved) {
- promise = ResolveService.unresolve(this.noteId);
- } else {
- promise = ResolveService.resolve(this.noteId);
- }
-
- promise
- .then(resp => resp.json())
- .then(data => {
- this.loading = false;
-
- const resolvedBy = data ? data.resolved_by : null;
-
- CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolvedBy);
- this.discussion.updateHeadline(data);
- gl.mrWidget.checkStatus();
- this.updateTooltip();
- })
- .catch(
- () =>
- new Flash(__('An error occurred when trying to resolve a comment. Please try again.')),
- );
- },
- },
-});
-
-Vue.component('resolve-btn', ResolveBtn);
diff --git a/app/assets/javascripts/diff_notes/components/resolve_count.js b/app/assets/javascripts/diff_notes/components/resolve_count.js
deleted file mode 100644
index f960853b25b..00000000000
--- a/app/assets/javascripts/diff_notes/components/resolve_count.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/* global CommentsStore */
-
-import Vue from 'vue';
-
-import DiscussionMixins from '../mixins/discussion';
-
-window.ResolveCount = Vue.extend({
- mixins: [DiscussionMixins],
- props: {
- loggedOut: {
- type: Boolean,
- required: true,
- },
- },
- data() {
- return {
- discussions: CommentsStore.state,
- };
- },
- computed: {
- allResolved() {
- return this.resolvedDiscussionCount === this.discussionCount;
- },
- resolvedCountText() {
- return this.discussionCount === 1 ? 'discussion' : 'discussions';
- },
- },
-});
diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
deleted file mode 100644
index 92862d4c933..00000000000
--- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js
+++ /dev/null
@@ -1,72 +0,0 @@
-/* eslint-disable func-names, new-cap */
-
-import $ from 'jquery';
-import Vue from 'vue';
-import './models/discussion';
-import './models/note';
-import './stores/comments';
-import './services/resolve';
-import './mixins/discussion';
-import './components/comment_resolve_btn';
-import './components/jump_to_discussion';
-import './components/resolve_btn';
-import './components/resolve_count';
-import './components/diff_note_avatars';
-import './components/new_issue_for_discussion';
-
-export default () => {
- const projectPathHolder =
- document.querySelector('.merge-request') || document.querySelector('.commit-box');
- const { projectPath } = projectPathHolder.dataset;
- const COMPONENT_SELECTOR =
- 'resolve-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn';
-
- window.gl = window.gl || {};
- window.gl.diffNoteApps = {};
-
- window.ResolveService = new gl.DiffNotesResolveServiceClass(projectPath);
-
- gl.diffNotesCompileComponents = () => {
- $('diff-note-avatars').each(function() {
- const tmp = Vue.extend({
- template: $(this).get(0).outerHTML,
- });
- const tmpApp = new tmp().$mount();
-
- $(this).replaceWith(tmpApp.$el);
- $(tmpApp.$el).one('remove.vue', () => {
- tmpApp.$destroy();
- tmpApp.$el.remove();
- });
- });
-
- const $components = $(COMPONENT_SELECTOR).filter(function() {
- return $(this).closest('resolve-count').length !== 1;
- });
-
- if ($components) {
- $components.each(function() {
- const $this = $(this);
- const noteId = $this.attr(':note-id');
- const discussionId = $this.attr(':discussion-id');
-
- if ($this.is('comment-and-resolve-btn') && !discussionId) return;
-
- const tmp = Vue.extend({
- template: $this.get(0).outerHTML,
- });
- const tmpApp = new tmp().$mount();
-
- if (noteId) {
- gl.diffNoteApps[`note_${noteId}`] = tmpApp;
- }
-
- $this.replaceWith(tmpApp.$el);
- });
- }
- };
-
- gl.diffNotesCompileComponents();
-
- $(window).trigger('resize.nav');
-};
diff --git a/app/assets/javascripts/diff_notes/icons/collapse_icon.svg b/app/assets/javascripts/diff_notes/icons/collapse_icon.svg
deleted file mode 100644
index bd4b393cfaa..00000000000
--- a/app/assets/javascripts/diff_notes/icons/collapse_icon.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="11" height="11" viewBox="0 0 9 13"><path d="M2.57568253,6.49866948 C2.50548852,6.57199715 2.44637866,6.59708255 2.39835118,6.57392645 C2.3503237,6.55077034 2.32631032,6.48902165 2.32631032,6.38867852 L2.32631032,-2.13272614 C2.32631032,-2.23306927 2.3503237,-2.29481796 2.39835118,-2.31797406 C2.44637866,-2.34113017 2.50548852,-2.31604477 2.57568253,-2.24271709 L6.51022184,1.86747129 C6.53977721,1.8983461 6.56379059,1.93500939 6.5822627,1.97746225 L6.5822627,2.27849013 C6.56379059,2.31708364 6.53977721,2.35374693 6.51022184,2.38848109 L2.57568253,6.49866948 Z" transform="translate(4.454287, 2.127976) rotate(90.000000) translate(-4.454287, -2.127976) "></path><path d="M3.74312342,2.09553332 C3.74312342,1.99519019 3.77821989,1.9083561 3.8484139,1.83502843 C3.91860791,1.76170075 4.00173115,1.72503747 4.09778611,1.72503747 L4.80711151,1.72503747 C4.90316647,1.72503747 4.98628971,1.76170075 5.05648372,1.83502843 C5.12667773,1.9083561 5.16177421,1.99519019 5.16177421,2.09553332 L5.16177421,10.2464421 C5.16177421,10.3467853 5.12667773,10.4336194 5.05648372,10.506947 C4.98628971,10.5802747 4.90316647,10.616938 4.80711151,10.616938 L4.09778611,10.616938 C4.00173115,10.616938 3.91860791,10.5802747 3.8484139,10.506947 C3.77821989,10.4336194 3.74312342,10.3467853 3.74312342,10.2464421 L3.74312342,2.09553332 Z" transform="translate(4.452449, 6.170988) rotate(-90.000000) translate(-4.452449, -6.170988) "></path><path d="M2.57568253,14.6236695 C2.50548852,14.6969971 2.44637866,14.7220826 2.39835118,14.6989264 C2.3503237,14.6757703 2.32631032,14.6140216 2.32631032,14.5136785 L2.32631032,5.99227386 C2.32631032,5.89193073 2.3503237,5.83018204 2.39835118,5.80702594 C2.44637866,5.78386983 2.50548852,5.80895523 2.57568253,5.88228291 L6.51022184,9.99247129 C6.53977721,10.0233461 6.56379059,10.0600094 6.5822627,10.1024622 L6.5822627,10.4034901 C6.56379059,10.4420836 6.53977721,10.4787469 6.51022184,10.5134811 L2.57568253,14.6236695 Z" transform="translate(4.454287, 10.252976) scale(1, -1) rotate(90.000000) translate(-4.454287, -10.252976) "></path></svg>
diff --git a/app/assets/javascripts/diff_notes/mixins/discussion.js b/app/assets/javascripts/diff_notes/mixins/discussion.js
deleted file mode 100644
index ef3001393cf..00000000000
--- a/app/assets/javascripts/diff_notes/mixins/discussion.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/* eslint-disable guard-for-in, no-restricted-syntax, */
-
-const DiscussionMixins = {
- computed: {
- discussionCount() {
- return Object.keys(this.discussions).length;
- },
- resolvedDiscussionCount() {
- let resolvedCount = 0;
-
- for (const discussionId in this.discussions) {
- const discussion = this.discussions[discussionId];
-
- if (discussion.isResolved()) {
- resolvedCount += 1;
- }
- }
-
- return resolvedCount;
- },
- unresolvedDiscussionCount() {
- let unresolvedCount = 0;
-
- for (const discussionId in this.discussions) {
- const discussion = this.discussions[discussionId];
-
- if (!discussion.isResolved()) {
- unresolvedCount += 1;
- }
- }
-
- return unresolvedCount;
- },
- },
-};
-
-export default DiscussionMixins;
diff --git a/app/assets/javascripts/diff_notes/models/discussion.js b/app/assets/javascripts/diff_notes/models/discussion.js
deleted file mode 100644
index 97296a40d6e..00000000000
--- a/app/assets/javascripts/diff_notes/models/discussion.js
+++ /dev/null
@@ -1,99 +0,0 @@
-/* eslint-disable guard-for-in, no-restricted-syntax */
-/* global NoteModel */
-
-import $ from 'jquery';
-import Vue from 'vue';
-import { localTimeAgo } from '../../lib/utils/datetime_utility';
-
-class DiscussionModel {
- constructor(discussionId) {
- this.id = discussionId;
- this.notes = {};
- this.loading = false;
- this.canResolve = false;
- }
-
- createNote(noteObj) {
- Vue.set(this.notes, noteObj.noteId, new NoteModel(this.id, noteObj));
- }
-
- deleteNote(noteId) {
- Vue.delete(this.notes, noteId);
- }
-
- getNote(noteId) {
- return this.notes[noteId];
- }
-
- notesCount() {
- return Object.keys(this.notes).length;
- }
-
- isResolved() {
- for (const noteId in this.notes) {
- const note = this.notes[noteId];
-
- if (!note.resolved) {
- return false;
- }
- }
- return true;
- }
-
- resolveAllNotes(resolvedBy) {
- for (const noteId in this.notes) {
- const note = this.notes[noteId];
-
- if (!note.resolved) {
- note.resolved = true;
- note.resolved_by = resolvedBy;
- }
- }
- }
-
- unResolveAllNotes() {
- for (const noteId in this.notes) {
- const note = this.notes[noteId];
-
- if (note.resolved) {
- note.resolved = false;
- note.resolved_by = null;
- }
- }
- }
-
- updateHeadline(data) {
- const discussionSelector = `.discussion[data-discussion-id="${this.id}"]`;
- const $discussionHeadline = $(`${discussionSelector} .js-discussion-headline`);
-
- if (data.discussion_headline_html) {
- if ($discussionHeadline.length) {
- $discussionHeadline.replaceWith(data.discussion_headline_html);
- } else {
- $(`${discussionSelector} .discussion-header`).append(data.discussion_headline_html);
- }
-
- localTimeAgo($('.js-timeago', `${discussionSelector}`));
- } else {
- $discussionHeadline.remove();
- }
- }
-
- isResolvable() {
- if (!this.canResolve) {
- return false;
- }
-
- for (const noteId in this.notes) {
- const note = this.notes[noteId];
-
- if (note.canResolve) {
- return true;
- }
- }
-
- return false;
- }
-}
-
-window.DiscussionModel = DiscussionModel;
diff --git a/app/assets/javascripts/diff_notes/models/note.js b/app/assets/javascripts/diff_notes/models/note.js
deleted file mode 100644
index 825a69deeec..00000000000
--- a/app/assets/javascripts/diff_notes/models/note.js
+++ /dev/null
@@ -1,14 +0,0 @@
-class NoteModel {
- constructor(discussionId, noteObj) {
- this.discussionId = discussionId;
- this.id = noteObj.noteId;
- this.canResolve = noteObj.canResolve;
- this.resolved = noteObj.resolved;
- this.resolved_by = noteObj.resolvedBy;
- this.authorName = noteObj.authorName;
- this.authorAvatar = noteObj.authorAvatar;
- this.noteTruncated = noteObj.noteTruncated;
- }
-}
-
-window.NoteModel = NoteModel;
diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js
deleted file mode 100644
index d6975963977..00000000000
--- a/app/assets/javascripts/diff_notes/services/resolve.js
+++ /dev/null
@@ -1,86 +0,0 @@
-/* global CommentsStore */
-
-import Vue from 'vue';
-import { deprecatedCreateFlash as Flash } from '../../flash';
-import { __ } from '~/locale';
-
-window.gl = window.gl || {};
-
-class ResolveServiceClass {
- constructor(root) {
- this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`);
- this.discussionResource = Vue.resource(
- `${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`,
- );
- }
-
- resolve(noteId) {
- return this.noteResource.save({ noteId }, {});
- }
-
- unresolve(noteId) {
- return this.noteResource.delete({ noteId }, {});
- }
-
- toggleResolveForDiscussion(mergeRequestId, discussionId) {
- const discussion = CommentsStore.state[discussionId];
- const isResolved = discussion.isResolved();
- let promise;
-
- if (isResolved) {
- promise = this.unResolveAll(mergeRequestId, discussionId);
- } else {
- promise = this.resolveAll(mergeRequestId, discussionId);
- }
-
- promise
- .then(resp => resp.json())
- .then(data => {
- discussion.loading = false;
- const resolvedBy = data ? data.resolved_by : null;
-
- if (isResolved) {
- discussion.unResolveAllNotes();
- } else {
- discussion.resolveAllNotes(resolvedBy);
- }
-
- if (gl.mrWidget) gl.mrWidget.checkStatus();
- discussion.updateHeadline(data);
- })
- .catch(
- () =>
- new Flash(__('An error occurred when trying to resolve a discussion. Please try again.')),
- );
- }
-
- resolveAll(mergeRequestId, discussionId) {
- const discussion = CommentsStore.state[discussionId];
-
- discussion.loading = true;
-
- return this.discussionResource.save(
- {
- mergeRequestId,
- discussionId,
- },
- {},
- );
- }
-
- unResolveAll(mergeRequestId, discussionId) {
- const discussion = CommentsStore.state[discussionId];
-
- discussion.loading = true;
-
- return this.discussionResource.delete(
- {
- mergeRequestId,
- discussionId,
- },
- {},
- );
- }
-}
-
-gl.DiffNotesResolveServiceClass = ResolveServiceClass;
diff --git a/app/assets/javascripts/diff_notes/stores/comments.js b/app/assets/javascripts/diff_notes/stores/comments.js
deleted file mode 100644
index 9bde18c4edf..00000000000
--- a/app/assets/javascripts/diff_notes/stores/comments.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/* eslint-disable no-restricted-syntax, guard-for-in */
-/* global DiscussionModel */
-
-import Vue from 'vue';
-
-window.CommentsStore = {
- state: {},
- get(discussionId, noteId) {
- return this.state[discussionId].getNote(noteId);
- },
- createDiscussion(discussionId, canResolve) {
- let discussion = this.state[discussionId];
- if (!this.state[discussionId]) {
- discussion = new DiscussionModel(discussionId);
- Vue.set(this.state, discussionId, discussion);
- }
-
- if (canResolve !== undefined) {
- discussion.canResolve = canResolve;
- }
-
- return discussion;
- },
- create(noteObj) {
- const discussion = this.createDiscussion(noteObj.discussionId);
-
- discussion.createNote(noteObj);
- },
- update(discussionId, noteId, resolved, resolvedBy) {
- const discussion = this.state[discussionId];
- const note = discussion.getNote(noteId);
- note.resolved = resolved;
- note.resolved_by = resolvedBy;
- },
- delete(discussionId, noteId) {
- const discussion = this.state[discussionId];
- discussion.deleteNote(noteId);
-
- if (discussion.notesCount() === 0) {
- Vue.delete(this.state, discussionId);
- }
- },
- unresolvedDiscussionIds() {
- const ids = [];
-
- for (const discussionId in this.state) {
- const discussion = this.state[discussionId];
-
- if (!discussion.isResolved()) {
- ids.push(discussion.id);
- }
- }
-
- return ids;
- },
-};
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index dd5addbf1e3..085f951147f 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -474,7 +474,7 @@ export default {
<div
v-if="showTreeList"
:style="{ width: `${treeWidth}px` }"
- class="diff-tree-list js-diff-tree-list mr-3"
+ class="diff-tree-list js-diff-tree-list px-3 pr-md-0"
>
<panel-resizer
:size.sync="treeWidth"
@@ -487,7 +487,7 @@ export default {
<tree-list :hide-file-stats="hideFileStats" />
</div>
<div
- class="diff-files-holder"
+ class="col-12 col-md-auto diff-files-holder"
:class="{
[CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer,
}"
diff --git a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
index dded3643115..270bbfb99b7 100644
--- a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
+++ b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
@@ -50,7 +50,7 @@ export default {
</script>
<template>
- <div v-if="!isDismissed" data-testid="root" :class="containerClasses">
+ <div v-if="!isDismissed" data-testid="root" :class="containerClasses" class="col-12">
<gl-alert
:dismissible="true"
:title="__('Some changes are not shown')"
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 23669eecce2..1b747fb7f20 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -1,7 +1,7 @@
<script>
/* eslint-disable vue/no-v-html */
import { mapActions } from 'vuex';
-import { GlButtonGroup, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlButtonGroup, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -122,74 +122,27 @@ export default {
</script>
<template>
- <li :class="{ 'js-toggle-container': collapsible }" class="commit flex-row">
- <div class="d-flex align-items-center align-self-start">
- <input
- v-if="isSelectable"
- class="mr-2"
- type="checkbox"
- :checked="checked"
- @change="$emit('handleCheckboxChange', $event.target.checked)"
- />
- <user-avatar-link
- :link-href="authorUrl"
- :img-src="authorAvatar"
- :img-alt="authorName"
- :img-size="40"
- class="avatar-cell d-none d-sm-block"
- />
- </div>
- <div class="commit-detail flex-list">
- <div class="commit-content qa-commit-content">
- <a
- :href="commit.commit_url"
- class="commit-row-message item-title"
- v-html="commit.title_html"
- ></a>
-
- <span class="commit-row-message d-block d-sm-none">&middot; {{ commit.short_id }}</span>
-
- <gl-button
- v-if="commit.description_html && collapsible"
- class="js-toggle-button"
- size="small"
- icon="ellipsis_h"
- :aria-label="__('Toggle commit description')"
- />
-
- <div class="committer">
- <a
- :href="authorUrl"
- :class="authorClass"
- :data-user-id="authorId"
- v-text="authorName"
- ></a>
- {{ s__('CommitWidget|authored') }}
- <time-ago-tooltip :time="commit.authored_date" />
- </div>
-
- <pre
- v-if="commit.description_html"
- :class="{ 'js-toggle-content': collapsible, 'd-block': !collapsible }"
- class="commit-row-description gl-mb-3 text-dark"
- v-html="commitDescription"
- ></pre>
- </div>
- <div class="commit-actions flex-row d-none d-sm-flex">
+ <li :class="{ 'js-toggle-container': collapsible }" class="commit">
+ <div
+ class="d-block d-sm-flex flex-row-reverse justify-content-between align-items-start flex-lg-row-reverse"
+ >
+ <div
+ class="commit-actions flex-row d-none d-sm-flex align-items-start flex-wrap justify-content-end"
+ >
<div v-if="commit.signature_html" v-html="commit.signature_html"></div>
<commit-pipeline-status
v-if="commit.pipeline_status_path"
:endpoint="commit.pipeline_status_path"
- class="d-inline-flex"
+ class="d-inline-flex mb-2"
/>
- <div class="commit-sha-group">
- <div class="label label-monospace monospace" v-text="commit.short_id"></div>
+ <gl-button-group class="gl-ml-4 gl-mb-4" data-testid="commit-sha-group">
+ <gl-button label class="gl-font-monospace" v-text="commit.short_id" />
<clipboard-button
:text="commit.id"
:title="__('Copy commit SHA')"
- class="btn btn-default"
+ class="input-group-text"
/>
- </div>
+ </gl-button-group>
<div
v-if="hasNeighborCommits && glFeatures.mrCommitNeighborNav"
class="commit-nav-buttons ml-3"
@@ -226,6 +179,62 @@ export default {
</gl-button-group>
</div>
</div>
+ <div>
+ <div class="d-flex float-left align-items-center align-self-start">
+ <input
+ v-if="isSelectable"
+ class="mr-2"
+ type="checkbox"
+ :checked="checked"
+ @change="$emit('handleCheckboxChange', $event.target.checked)"
+ />
+ <user-avatar-link
+ :link-href="authorUrl"
+ :img-src="authorAvatar"
+ :img-alt="authorName"
+ :img-size="40"
+ class="avatar-cell d-none d-sm-block"
+ />
+ </div>
+ <div class="commit-detail flex-list">
+ <div class="commit-content qa-commit-content">
+ <a
+ :href="commit.commit_url"
+ class="commit-row-message item-title"
+ v-html="commit.title_html"
+ ></a>
+
+ <span class="commit-row-message d-block d-sm-none">&middot; {{ commit.short_id }}</span>
+
+ <gl-button
+ v-if="commit.description_html && collapsible"
+ class="js-toggle-button"
+ size="small"
+ icon="ellipsis_h"
+ :aria-label="__('Toggle commit description')"
+ />
+
+ <div class="committer">
+ <a
+ :href="authorUrl"
+ :class="authorClass"
+ :data-user-id="authorId"
+ v-text="authorName"
+ ></a>
+ {{ s__('CommitWidget|authored') }}
+ <time-ago-tooltip :time="commit.authored_date" />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <pre
+ v-if="commit.description_html"
+ :class="{ 'js-toggle-content': collapsible, 'd-block': !collapsible }"
+ class="commit-row-description gl-mb-3 text-dark"
+ v-html="commitDescription"
+ ></pre>
</div>
</li>
</template>
diff --git a/app/assets/javascripts/diffs/components/commit_widget.vue b/app/assets/javascripts/diffs/components/commit_widget.vue
index 5c7e84bd87c..b1a2b2a72ea 100644
--- a/app/assets/javascripts/diffs/components/commit_widget.vue
+++ b/app/assets/javascripts/diffs/components/commit_widget.vue
@@ -1,19 +1,6 @@
<script>
import CommitItem from './commit_item.vue';
-/**
- * CommitWidget
- *
- * -----------------------------------------------------------------
- * WARNING: Please keep changes up-to-date with the following files:
- * - `views/projects/merge_requests/diffs/_commit_widget.html.haml`
- * -----------------------------------------------------------------
- *
- * This Component was cloned from a HAML view. For the time being,
- * they coexist, but there is an issue to remove the duplication.
- * https://gitlab.com/gitlab-org/gitlab-foss/issues/51613
- *
- */
export default {
components: {
CommitItem,
diff --git a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
index 8263e938e69..adef5d94624 100644
--- a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
+++ b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
@@ -32,7 +32,7 @@ export default {
<gl-icon :size="12" name="angle-down" class="position-absolute" />
</a>
<div class="dropdown-menu dropdown-select dropdown-menu-selectable">
- <div class="dropdown-content">
+ <div class="dropdown-content" data-qa-selector="dropdown_content">
<ul>
<li v-for="version in versions" :key="version.id">
<a :class="{ 'is-active': version.selected }" :href="version.href">
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index b94874c5644..b1ebd8e6ebc 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -100,6 +100,7 @@ export default {
<compare-dropdown-layout
:versions="diffCompareDropdownTargetVersions"
class="mr-version-compare-dropdown"
+ data-qa-selector="target_version_dropdown"
/>
</template>
<template #source>
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 9ecb9a44443..e68260b3e62 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -85,11 +85,9 @@ export default {
},
},
updated() {
- if (window.gon?.features?.codeNavigation) {
- this.$nextTick(() => {
- eventHub.$emit('showBlobInteractionZones', this.diffFile.new_path);
- });
- }
+ this.$nextTick(() => {
+ eventHub.$emit('showBlobInteractionZones', this.diffFile.new_path);
+ });
},
methods: {
...mapActions('diffs', ['saveDiffDiscussion', 'closeDiffFileCommentForm']),
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 02396a4ba1b..529723a349d 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -44,7 +44,7 @@ export default {
return {
isLoadingCollapsedDiff: false,
forkMessageVisible: false,
- isCollapsed: this.file.viewer.collapsed || false,
+ isCollapsed: this.file.viewer.automaticallyCollapsed || false,
};
},
computed: {
@@ -96,16 +96,16 @@ export default {
},
'file.file_hash': {
handler: function watchFileHash() {
- if (this.viewDiffsFileByFile && this.file.viewer.collapsed) {
+ if (this.viewDiffsFileByFile && this.file.viewer.automaticallyCollapsed) {
this.isCollapsed = false;
this.handleLoadCollapsedDiff();
} else {
- this.isCollapsed = this.file.viewer.collapsed || false;
+ this.isCollapsed = this.file.viewer.automaticallyCollapsed || false;
}
},
immediate: true,
},
- 'file.viewer.collapsed': function setIsCollapsed(newVal) {
+ 'file.viewer.automaticallyCollapsed': function setIsCollapsed(newVal) {
if (!this.viewDiffsFileByFile) {
this.isCollapsed = newVal;
}
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index fded391cc84..b08b9df13a4 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -1,39 +1,44 @@
<script>
-/* eslint-disable vue/no-v-html */
import { escape } from 'lodash';
import { mapActions, mapGetters } from 'vuex';
import {
- GlDeprecatedButton,
GlTooltipDirective,
GlSafeHtmlDirective,
- GlLoadingIcon,
GlIcon,
GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
} from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import { truncateSha } from '~/lib/utils/text_utility';
import { __, s__, sprintf } from '~/locale';
import { diffViewerModes } from '~/ide/constants';
-import EditButton from './edit_button.vue';
import DiffStats from './diff_stats.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
+import { DIFF_FILE_HEADER } from '../i18n';
export default {
components: {
- GlLoadingIcon,
- GlDeprecatedButton,
ClipboardButton,
- EditButton,
GlIcon,
FileIcon,
DiffStats,
GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml: GlSafeHtmlDirective,
},
+ i18n: {
+ ...DIFF_FILE_HEADER,
+ },
props: {
discussionPath: {
type: String,
@@ -69,6 +74,11 @@ export default {
default: false,
},
},
+ data() {
+ return {
+ moreActionsShown: false,
+ };
+ },
computed: {
...mapGetters('diffs', ['diffHasExpandedDiscussions', 'diffHasDiscussions']),
diffContentIDSelector() {
@@ -128,13 +138,9 @@ export default {
},
viewReplacedFileButtonText() {
const truncatedBaseSha = escape(truncateSha(this.diffFile.diff_refs.base_sha));
- return sprintf(
- s__('MergeRequests|View replaced file @ %{commitId}'),
- {
- commitId: `<span class="commit-sha">${truncatedBaseSha}</span>`,
- },
- false,
- );
+ return sprintf(s__('MergeRequests|View replaced file @ %{commitId}'), {
+ commitId: truncatedBaseSha,
+ });
},
gfmCopyText() {
return `\`${this.diffFile.file_path}\``;
@@ -151,6 +157,13 @@ export default {
}
return s__('MRDiff|Show full file');
},
+ showEditButton() {
+ return (
+ this.diffFile.blob?.readable_text &&
+ !this.diffFile.deleted_file &&
+ (this.diffFile.edit_path || this.diffFile.ide_edit_path)
+ );
+ },
},
methods: {
...mapActions('diffs', [
@@ -162,8 +175,11 @@ export default {
handleToggleFile() {
this.$emit('toggleFile');
},
- showForkMessage() {
- this.$emit('showForkMessage');
+ showForkMessage(e) {
+ if (this.canCurrentUserFork && !this.diffFile.can_modify_blob) {
+ e.preventDefault();
+ this.$emit('showForkMessage');
+ }
},
handleFileNameClick(e) {
const isLinkToOtherPage =
@@ -179,6 +195,9 @@ export default {
}
}
},
+ setMoreActionsShown(val) {
+ this.moreActionsShown = val;
+ },
},
};
</script>
@@ -186,10 +205,11 @@ export default {
<template>
<div
ref="header"
+ :class="{ 'gl-z-dropdown-menu!': moreActionsShown }"
class="js-file-title file-title file-title-flex-parent"
@click.self="handleToggleFile"
>
- <div class="file-header-content">
+ <div class="file-header-content gl-display-flex gl-align-items-center gl-pr-0!">
<gl-icon
v-if="collapsible"
ref="collapseIcon"
@@ -202,7 +222,7 @@ export default {
<a
ref="titleWrapper"
:v-once="!viewDiffsFileByFile"
- class="gl-mr-2"
+ class="gl-mr-2 gl-text-decoration-none!"
:href="titleLink"
@click="handleFileNameClick"
>
@@ -210,20 +230,27 @@ export default {
<span v-if="isFileRenamed">
<strong
v-gl-tooltip
+ v-safe-html="diffFile.old_path_html"
:title="diffFile.old_path"
class="file-title-name"
- v-html="diffFile.old_path_html"
></strong>
<strong
v-gl-tooltip
+ v-safe-html="diffFile.new_path_html"
:title="diffFile.new_path"
class="file-title-name"
- v-html="diffFile.new_path_html"
></strong>
</span>
- <strong v-else v-gl-tooltip :title="filePath" class="file-title-name" data-container="body">
+ <strong
+ v-else
+ v-gl-tooltip
+ :title="filePath"
+ class="file-title-name"
+ data-container="body"
+ data-qa-selector="file_name_content"
+ >
{{ filePath }}
</strong>
</a>
@@ -232,7 +259,8 @@ export default {
:title="__('Copy file path')"
:text="diffFile.file_path"
:gfm="gfmCopyText"
- css-class="btn-default btn-transparent btn-clipboard"
+ data-testid="diff-file-copy-clipboard"
+ category="tertiary"
data-track-event="click_copy_file_button"
data-track-label="diff_copy_file_path_button"
data-track-property="diff_copy_file"
@@ -247,93 +275,93 @@ export default {
<div
v-if="!diffFile.submodule && addMergeRequestButtons"
- class="file-actions d-none d-sm-flex align-items-center flex-wrap"
+ class="file-actions d-flex align-items-center flex-wrap"
>
<diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" />
- <div class="btn-group" role="group">
- <template v-if="diffFile.blob && diffFile.blob.readable_text">
- <span v-gl-tooltip.hover :title="s__('MergeRequests|Toggle comments for this file')">
- <gl-deprecated-button
- ref="toggleDiscussionsButton"
- :disabled="!diffHasDiscussions(diffFile)"
- :class="{ active: diffHasExpandedDiscussions(diffFile) }"
- class="js-btn-vue-toggle-comments btn"
- data-qa-selector="toggle_comments_button"
- data-track-event="click_toggle_comments_button"
- data-track-label="diff_toggle_comments_button"
- data-track-property="diff_toggle_comments"
- type="button"
- @click="toggleFileDiscussionWrappers(diffFile)"
- >
- <gl-icon name="comment" />
- </gl-deprecated-button>
- </span>
-
- <edit-button
- v-if="!diffFile.deleted_file"
- :can-current-user-fork="canCurrentUserFork"
- :edit-path="diffFile.edit_path"
- :can-modify-blob="diffFile.can_modify_blob"
- data-track-event="click_toggle_edit_button"
- data-track-label="diff_toggle_edit_button"
- data-track-property="diff_toggle_edit"
- @showForkMessage="showForkMessage"
- />
- </template>
-
- <a
- v-if="diffFile.replaced_view_path"
- ref="replacedFileButton"
- :href="diffFile.replaced_view_path"
- class="btn view-file"
- v-html="viewReplacedFileButtonText"
- >
- </a>
- <gl-deprecated-button
- v-if="!diffFile.is_fully_expanded"
- ref="expandDiffToFullFileButton"
- v-gl-tooltip.hover
- :title="expandDiffToFullFileTitle"
- class="expand-file"
- data-track-event="click_toggle_view_full_button"
- data-track-label="diff_toggle_view_full_button"
- data-track-property="diff_toggle_view_full"
- @click="toggleFullDiff(diffFile.file_path)"
- >
- <gl-loading-icon v-if="diffFile.isLoadingFullFile" color="dark" inline />
- <gl-icon v-else-if="diffFile.isShowingFullFile" name="doc-changes" />
- <gl-icon v-else name="doc-expand" />
- </gl-deprecated-button>
- <gl-deprecated-button
- ref="viewButton"
- v-gl-tooltip.hover
- :href="diffFile.view_path"
- target="_blank"
- class="view-file"
- data-track-event="click_toggle_view_sha_button"
- data-track-label="diff_toggle_view_sha_button"
- data-track-property="diff_toggle_view_sha"
- :title="viewFileButtonText"
- >
- <gl-icon name="doc-text" />
- </gl-deprecated-button>
-
- <a
+ <gl-button-group class="gl-pt-0!">
+ <gl-button
v-if="diffFile.external_url"
ref="externalLink"
v-gl-tooltip.hover
:href="diffFile.external_url"
:title="`View on ${diffFile.formatted_external_url}`"
target="_blank"
- rel="noopener noreferrer"
data-track-event="click_toggle_external_button"
data-track-label="diff_toggle_external_button"
data-track-property="diff_toggle_external"
- class="btn btn-file-option"
+ icon="external-link"
+ />
+ <gl-dropdown
+ v-gl-tooltip.hover.focus="$options.i18n.optionsDropdownTitle"
+ right
+ toggle-class="btn-icon js-diff-more-actions"
+ class="gl-pt-0!"
+ @show="setMoreActionsShown(true)"
+ @hidden="setMoreActionsShown(false)"
>
- <gl-icon name="external-link" />
- </a>
- </div>
+ <template #button-content>
+ <gl-icon name="ellipsis_v" class="mr-0" />
+ <span class="sr-only">{{ $options.i18n.optionsDropdownTitle }}</span>
+ </template>
+ <gl-dropdown-item
+ v-if="diffFile.replaced_view_path"
+ ref="replacedFileButton"
+ :href="diffFile.replaced_view_path"
+ target="_blank"
+ >
+ {{ viewReplacedFileButtonText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item ref="viewButton" :href="diffFile.view_path" target="_blank">
+ {{ viewFileButtonText }}
+ </gl-dropdown-item>
+ <template v-if="showEditButton">
+ <gl-dropdown-item
+ v-if="diffFile.edit_path"
+ ref="editButton"
+ :href="diffFile.edit_path"
+ class="js-edit-blob"
+ @click="showForkMessage"
+ >
+ {{ __('Edit in single-file editor') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="diffFile.edit_path"
+ ref="ideEditButton"
+ :href="diffFile.ide_edit_path"
+ class="js-ide-edit-blob"
+ >
+ {{ __('Edit in Web IDE') }}
+ </gl-dropdown-item>
+ </template>
+
+ <template v-if="!diffFile.viewer.automaticallyCollapsed">
+ <gl-dropdown-divider
+ v-if="!diffFile.is_fully_expanded || diffHasDiscussions(diffFile)"
+ />
+
+ <gl-dropdown-item
+ v-if="diffHasDiscussions(diffFile)"
+ ref="toggleDiscussionsButton"
+ data-qa-selector="toggle_comments_button"
+ @click="toggleFileDiscussionWrappers(diffFile)"
+ >
+ <template v-if="diffHasExpandedDiscussions(diffFile)">
+ {{ __('Hide comments on this file') }}
+ </template>
+ <template v-else>
+ {{ __('Show comments on this file') }}
+ </template>
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="!diffFile.is_fully_expanded"
+ ref="expandDiffToFullFileButton"
+ @click="toggleFullDiff(diffFile.file_path)"
+ >
+ {{ expandDiffToFullFileTitle }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+ </gl-button-group>
</div>
<div
diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js
new file mode 100644
index 00000000000..08b87a4bade
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_row_utils.js
@@ -0,0 +1,85 @@
+import { __ } from '~/locale';
+import {
+ MATCH_LINE_TYPE,
+ CONTEXT_LINE_TYPE,
+ LINE_HOVER_CLASS_NAME,
+ OLD_NO_NEW_LINE_TYPE,
+ NEW_NO_NEW_LINE_TYPE,
+ EMPTY_CELL_TYPE,
+} from '../constants';
+
+export const isHighlighted = (state, line, isCommented) => {
+ if (isCommented) return true;
+
+ const lineCode = line?.line_code;
+ return lineCode ? lineCode === state.diffs.highlightedRow : false;
+};
+
+export const isContextLine = type => type === CONTEXT_LINE_TYPE;
+
+export const isMatchLine = type => type === MATCH_LINE_TYPE;
+
+export const isMetaLine = type =>
+ [OLD_NO_NEW_LINE_TYPE, NEW_NO_NEW_LINE_TYPE, EMPTY_CELL_TYPE].includes(type);
+
+export const shouldRenderCommentButton = (isLoggedIn, isCommentButtonRendered) => {
+ return isCommentButtonRendered && isLoggedIn;
+};
+
+export const hasDiscussions = line => line?.discussions?.length > 0;
+
+export const lineHref = line => `#${line?.line_code || ''}`;
+
+export const lineCode = line => {
+ if (!line) return undefined;
+ return line.line_code || line.left?.line_code || line.right?.line_code;
+};
+
+export const classNameMapCell = (line, hll, isLoggedIn, isHover) => {
+ if (!line) return [];
+ const { type } = line;
+
+ return [
+ type,
+ {
+ hll,
+ [LINE_HOVER_CLASS_NAME]: isLoggedIn && isHover && !isContextLine(type) && !isMetaLine(type),
+ },
+ ];
+};
+
+export const addCommentTooltip = line => {
+ let tooltip;
+ if (!line) return tooltip;
+
+ tooltip = __('Add a comment to this line');
+ const brokenSymlinks = line.commentsDisabled;
+
+ if (brokenSymlinks) {
+ if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) {
+ tooltip = __(
+ 'Commenting on symbolic links that replace or are replaced by files is currently not supported.',
+ );
+ } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) {
+ tooltip = __(
+ 'Commenting on files that replace or are replaced by symbolic links is currently not supported.',
+ );
+ }
+ }
+
+ return tooltip;
+};
+
+export const parallelViewLeftLineType = (line, hll) => {
+ if (line?.right?.type === NEW_NO_NEW_LINE_TYPE) {
+ return OLD_NO_NEW_LINE_TYPE;
+ }
+
+ const lineTypeClass = line?.left ? line.left.type : EMPTY_CELL_TYPE;
+
+ return [lineTypeClass, { hll }];
+};
+
+export const shouldShowCommentButton = (hover, context, meta, discussions) => {
+ return hover && !context && !meta && !discussions;
+};
diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue
index 05fbbd39fae..f229fc4cf60 100644
--- a/app/assets/javascripts/diffs/components/diff_stats.vue
+++ b/app/assets/javascripts/diffs/components/diff_stats.vue
@@ -42,7 +42,7 @@ export default {
class="diff-stats"
:class="{
'is-compare-versions-header d-none d-lg-inline-flex': isCompareVersionsHeader,
- 'd-inline-flex': !isCompareVersionsHeader,
+ 'd-none d-sm-inline-flex': !isCompareVersionsHeader,
}"
>
<div v-if="hasDiffFiles" class="diff-stats-group">
diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue
deleted file mode 100644
index 49982a81372..00000000000
--- a/app/assets/javascripts/diffs/components/diff_table_cell.vue
+++ /dev/null
@@ -1,206 +0,0 @@
-<script>
-import { mapGetters, mapActions } from 'vuex';
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
-import DiffGutterAvatars from './diff_gutter_avatars.vue';
-import { __ } from '~/locale';
-import {
- CONTEXT_LINE_TYPE,
- LINE_POSITION_RIGHT,
- EMPTY_CELL_TYPE,
- OLD_NO_NEW_LINE_TYPE,
- OLD_LINE_TYPE,
- NEW_NO_NEW_LINE_TYPE,
- LINE_HOVER_CLASS_NAME,
-} from '../constants';
-
-export default {
- components: {
- DiffGutterAvatars,
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- line: {
- type: Object,
- required: true,
- },
- fileHash: {
- type: String,
- required: true,
- },
- isHighlighted: {
- type: Boolean,
- required: true,
- },
- showCommentButton: {
- type: Boolean,
- required: false,
- default: false,
- },
- linePosition: {
- type: String,
- required: false,
- default: '',
- },
- lineType: {
- type: String,
- required: false,
- default: '',
- },
- isBottom: {
- type: Boolean,
- required: false,
- default: false,
- },
- isHover: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- isCommentButtonRendered: false,
- };
- },
- computed: {
- ...mapGetters(['isLoggedIn']),
- lineCode() {
- return (
- this.line.line_code ||
- (this.line.left && this.line.left.line_code) ||
- (this.line.right && this.line.right.line_code)
- );
- },
- lineHref() {
- return `#${this.line.line_code || ''}`;
- },
- shouldShowCommentButton() {
- return this.isHover && !this.isContextLine && !this.isMetaLine && !this.hasDiscussions;
- },
- hasDiscussions() {
- return this.line.discussions && this.line.discussions.length > 0;
- },
- shouldShowAvatarsOnGutter() {
- if (!this.line.type && this.linePosition === LINE_POSITION_RIGHT) {
- return false;
- }
- return this.showCommentButton && this.hasDiscussions;
- },
- shouldRenderCommentButton() {
- if (!this.isCommentButtonRendered) {
- return false;
- }
-
- if (this.isLoggedIn && this.showCommentButton) {
- const isDiffHead = parseBoolean(getParameterByName('diff_head'));
- return !isDiffHead || gon.features?.mergeRefHeadComments;
- }
-
- return false;
- },
- isContextLine() {
- return this.line.type === CONTEXT_LINE_TYPE;
- },
- isMetaLine() {
- const { type } = this.line;
-
- return (
- type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE
- );
- },
- classNameMap() {
- const { type } = this.line;
-
- return [
- type,
- {
- hll: this.isHighlighted,
- [LINE_HOVER_CLASS_NAME]:
- this.isLoggedIn && this.isHover && !this.isContextLine && !this.isMetaLine,
- },
- ];
- },
- lineNumber() {
- return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line;
- },
- addCommentTooltip() {
- const brokenSymlinks = this.line.commentsDisabled;
- let tooltip = __('Add a comment to this line');
-
- if (brokenSymlinks) {
- if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) {
- tooltip = __(
- 'Commenting on symbolic links that replace or are replaced by files is currently not supported.',
- );
- } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) {
- tooltip = __(
- 'Commenting on files that replace or are replaced by symbolic links is currently not supported.',
- );
- }
- }
-
- return tooltip;
- },
- },
- mounted() {
- this.unwatchShouldShowCommentButton = this.$watch('shouldShowCommentButton', newVal => {
- if (newVal) {
- this.isCommentButtonRendered = true;
- this.unwatchShouldShowCommentButton();
- }
- });
- },
- beforeDestroy() {
- this.unwatchShouldShowCommentButton();
- },
- methods: {
- ...mapActions('diffs', ['showCommentForm', 'setHighlightedRow', 'toggleLineDiscussions']),
- handleCommentButton() {
- this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash });
- },
- },
-};
-</script>
-
-<template>
- <td ref="td" :class="classNameMap">
- <span
- ref="addNoteTooltip"
- v-gl-tooltip
- class="add-diff-note tooltip-wrapper"
- :title="addCommentTooltip"
- >
- <button
- v-if="shouldRenderCommentButton"
- v-show="shouldShowCommentButton"
- ref="addDiffNoteButton"
- type="button"
- class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
- :disabled="line.commentsDisabled"
- @click="handleCommentButton"
- >
- <gl-icon :size="12" name="comment" />
- </button>
- </span>
- <a
- v-if="lineNumber"
- ref="lineNumberRef"
- :data-linenumber="lineNumber"
- :href="lineHref"
- @click="setHighlightedRow(lineCode)"
- >
- </a>
- <diff-gutter-avatars
- v-if="shouldShowAvatarsOnGutter"
- :discussions="line.discussions"
- :discussions-expanded="line.discussionsExpanded"
- @toggleLineDiscussions="
- toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded })
- "
- />
- </td>
-</template>
diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue
deleted file mode 100644
index ff1af5569dc..00000000000
--- a/app/assets/javascripts/diffs/components/edit_button.vue
+++ /dev/null
@@ -1,64 +0,0 @@
-<script>
-import { GlTooltipDirective, GlDeprecatedButton, GlIcon } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- components: {
- GlDeprecatedButton,
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- editPath: {
- type: String,
- required: false,
- default: '',
- },
- canCurrentUserFork: {
- type: Boolean,
- required: true,
- },
- canModifyBlob: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- tooltipTitle() {
- if (this.isDisabled) {
- return __("Can't edit as source branch was deleted");
- }
-
- return __('Edit file');
- },
- isDisabled() {
- return !this.editPath;
- },
- },
- methods: {
- handleEditClick(evt) {
- if (this.canCurrentUserFork && !this.canModifyBlob) {
- evt.preventDefault();
- this.$emit('showForkMessage');
- }
- },
- },
-};
-</script>
-
-<template>
- <span v-gl-tooltip.top :title="tooltipTitle">
- <gl-deprecated-button
- :href="editPath"
- :disabled="isDisabled"
- :class="{ 'cursor-not-allowed': isDisabled }"
- class="rounded-0 js-edit-blob"
- @click.native="handleEditClick"
- >
- <gl-icon name="pencil" />
- </gl-deprecated-button>
- </span>
-</template>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
index 7fab750089e..99cf79a70d4 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -1,22 +1,9 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
-import {
- MATCH_LINE_TYPE,
- NEW_LINE_TYPE,
- OLD_LINE_TYPE,
- CONTEXT_LINE_TYPE,
- CONTEXT_LINE_CLASS_NAME,
- LINE_POSITION_LEFT,
- LINE_POSITION_RIGHT,
- LINE_HOVER_CLASS_NAME,
- OLD_NO_NEW_LINE_TYPE,
- NEW_NO_NEW_LINE_TYPE,
- EMPTY_CELL_TYPE,
-} from '../constants';
-import { __ } from '~/locale';
-import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
+import { CONTEXT_LINE_CLASS_NAME } from '../constants';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
+import * as utils from './diff_row_utils';
export default {
components: {
@@ -61,14 +48,11 @@ export default {
...mapGetters('diffs', ['fileLineCoverage']),
...mapState({
isHighlighted(state) {
- if (this.isCommented) return true;
-
- const lineCode = this.line.line_code;
- return lineCode ? lineCode === state.diffs.highlightedRow : false;
+ return utils.isHighlighted(state, this.line, this.isCommented);
},
}),
isContextLine() {
- return this.line.type === CONTEXT_LINE_TYPE;
+ return utils.isContextLine(this.line.type);
},
classNameMap() {
return [
@@ -82,82 +66,44 @@ export default {
return this.line.line_code || `${this.fileHash}_${this.line.old_line}_${this.line.new_line}`;
},
isMatchLine() {
- return this.line.type === MATCH_LINE_TYPE;
+ return utils.isMatchLine(this.line.type);
},
coverageState() {
return this.fileLineCoverage(this.filePath, this.line.new_line);
},
isMetaLine() {
- const { type } = this.line;
-
- return (
- type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE
- );
+ return utils.isMetaLine(this.line.type);
},
classNameMapCell() {
- const { type } = this.line;
-
- return [
- type,
- {
- hll: this.isHighlighted,
- [LINE_HOVER_CLASS_NAME]:
- this.isLoggedIn && this.isHover && !this.isContextLine && !this.isMetaLine,
- },
- ];
+ return utils.classNameMapCell(this.line, this.isHighlighted, this.isLoggedIn, this.isHover);
},
addCommentTooltip() {
- const brokenSymlinks = this.line.commentsDisabled;
- let tooltip = __('Add a comment to this line');
-
- if (brokenSymlinks) {
- if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) {
- tooltip = __(
- 'Commenting on symbolic links that replace or are replaced by files is currently not supported.',
- );
- } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) {
- tooltip = __(
- 'Commenting on files that replace or are replaced by symbolic links is currently not supported.',
- );
- }
- }
-
- return tooltip;
+ return utils.addCommentTooltip(this.line);
},
shouldRenderCommentButton() {
- if (this.isLoggedIn) {
- const isDiffHead = parseBoolean(getParameterByName('diff_head'));
- return !isDiffHead || gon.features?.mergeRefHeadComments;
- }
-
- return false;
+ return utils.shouldRenderCommentButton(this.isLoggedIn, true);
},
shouldShowCommentButton() {
- return this.isHover && !this.isContextLine && !this.isMetaLine && !this.hasDiscussions;
+ return utils.shouldShowCommentButton(
+ this.isHover,
+ this.isContextLine,
+ this.isMetaLine,
+ this.hasDiscussions,
+ );
},
hasDiscussions() {
- return this.line.discussions && this.line.discussions.length > 0;
+ return utils.hasDiscussions(this.line);
},
lineHref() {
- return `#${this.line.line_code || ''}`;
+ return utils.lineHref(this.line);
},
lineCode() {
- return (
- this.line.line_code ||
- (this.line.left && this.line.left.line_code) ||
- (this.line.right && this.line.right.line_code)
- );
+ return utils.lineCode(this.line);
},
shouldShowAvatarsOnGutter() {
return this.hasDiscussions;
},
},
- created() {
- this.newLineType = NEW_LINE_TYPE;
- this.oldLineType = OLD_LINE_TYPE;
- this.linePositionLeft = LINE_POSITION_LEFT;
- this.linePositionRight = LINE_POSITION_RIGHT;
- },
mounted() {
this.scrollToLineIfNeededInline(this.line);
},
@@ -242,6 +188,7 @@ export default {
class="line-coverage"
></td>
<td
+ :key="line.line_code"
v-safe-html="line.rich_text"
:class="[
line.type,
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
index b525490f7cc..127e3f214cf 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
@@ -113,8 +113,8 @@ export default {
},
methods: {
...mapActions('diffs', ['showCommentForm']),
- showNewDiscussionForm() {
- this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.diffFileHash });
+ showNewDiscussionForm(lineCode) {
+ this.showCommentForm({ lineCode, fileHash: this.diffFileHash });
},
},
};
@@ -134,7 +134,7 @@ export default {
v-if="!hasDraftLeft"
:has-form="showLeftSideCommentForm"
:render-reply-placeholder="shouldRenderReplyPlaceholderOnLeft"
- @showNewDiscussionForm="showNewDiscussionForm"
+ @showNewDiscussionForm="showNewDiscussionForm(line.left.line_code)"
>
<template #form>
<diff-line-note-form
@@ -159,7 +159,7 @@ export default {
v-if="!hasDraftRight"
:has-form="showRightSideCommentForm"
:render-reply-placeholder="shouldRenderReplyPlaceholderOnRight"
- @showNewDiscussionForm="showNewDiscussionForm"
+ @showNewDiscussionForm="showNewDiscussionForm(line.right.line_code)"
>
<template #form>
<diff-line-note-form
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
index 0bf47dc77a6..cdc6db791f0 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
@@ -2,21 +2,9 @@
import { mapActions, mapGetters, mapState } from 'vuex';
import $ from 'jquery';
import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
-import {
- MATCH_LINE_TYPE,
- NEW_LINE_TYPE,
- OLD_LINE_TYPE,
- CONTEXT_LINE_TYPE,
- CONTEXT_LINE_CLASS_NAME,
- OLD_NO_NEW_LINE_TYPE,
- PARALLEL_DIFF_VIEW_TYPE,
- NEW_NO_NEW_LINE_TYPE,
- EMPTY_CELL_TYPE,
- LINE_HOVER_CLASS_NAME,
-} from '../constants';
-import { __ } from '~/locale';
-import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
+import { CONTEXT_LINE_CLASS_NAME, PARALLEL_DIFF_VIEW_TYPE } from '../constants';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
+import * as utils from './diff_row_utils';
export default {
components: {
@@ -63,20 +51,15 @@ export default {
...mapGetters(['isLoggedIn']),
...mapState({
isHighlighted(state) {
- if (this.isCommented) return true;
-
- const lineCode =
- (this.line.left && this.line.left.line_code) ||
- (this.line.right && this.line.right.line_code);
-
- return lineCode ? lineCode === state.diffs.highlightedRow : false;
+ const line = this.line.left?.line_code ? this.line.left : this.line.right;
+ return utils.isHighlighted(state, line, this.isCommented);
},
}),
isContextLineLeft() {
- return this.line.left && this.line.left.type === CONTEXT_LINE_TYPE;
+ return utils.isContextLine(this.line.left?.type);
},
isContextLineRight() {
- return this.line.right && this.line.right.type === CONTEXT_LINE_TYPE;
+ return utils.isContextLine(this.line.right?.type);
},
classNameMap() {
return {
@@ -85,157 +68,80 @@ export default {
};
},
parallelViewLeftLineType() {
- if (this.line.right && this.line.right.type === NEW_NO_NEW_LINE_TYPE) {
- return OLD_NO_NEW_LINE_TYPE;
- }
-
- const lineTypeClass = this.line.left ? this.line.left.type : EMPTY_CELL_TYPE;
-
- return [
- lineTypeClass,
- {
- hll: this.isHighlighted,
- },
- ];
+ return utils.parallelViewLeftLineType(this.line, this.isHighlighted);
},
isMatchLineLeft() {
- return this.line.left && this.line.left.type === MATCH_LINE_TYPE;
+ return utils.isMatchLine(this.line.left?.type);
},
isMatchLineRight() {
- return this.line.right && this.line.right.type === MATCH_LINE_TYPE;
+ return utils.isMatchLine(this.line.right?.type);
},
coverageState() {
return this.fileLineCoverage(this.filePath, this.line.right.new_line);
},
classNameMapCellLeft() {
- const { type } = this.line.left;
-
- return [
- type,
- {
- hll: this.isHighlighted,
- [LINE_HOVER_CLASS_NAME]:
- this.isLoggedIn && this.isLeftHover && !this.isContextLineLeft && !this.isMetaLineLeft,
- },
- ];
+ return utils.classNameMapCell(
+ this.line.left,
+ this.isHighlighted,
+ this.isLoggedIn,
+ this.isLeftHover,
+ );
},
classNameMapCellRight() {
- const { type } = this.line.right;
-
- return [
- type,
- {
- hll: this.isHighlighted,
- [LINE_HOVER_CLASS_NAME]:
- this.isLoggedIn &&
- this.isRightHover &&
- !this.isContextLineRight &&
- !this.isMetaLineRight,
- },
- ];
+ return utils.classNameMapCell(
+ this.line.right,
+ this.isHighlighted,
+ this.isLoggedIn,
+ this.isRightHover,
+ );
},
addCommentTooltipLeft() {
- const brokenSymlinks = this.line.left.commentsDisabled;
- let tooltip = __('Add a comment to this line');
-
- if (brokenSymlinks) {
- if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) {
- tooltip = __(
- 'Commenting on symbolic links that replace or are replaced by files is currently not supported.',
- );
- } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) {
- tooltip = __(
- 'Commenting on files that replace or are replaced by symbolic links is currently not supported.',
- );
- }
- }
-
- return tooltip;
+ return utils.addCommentTooltip(this.line.left);
},
addCommentTooltipRight() {
- const brokenSymlinks = this.line.right.commentsDisabled;
- let tooltip = __('Add a comment to this line');
-
- if (brokenSymlinks) {
- if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) {
- tooltip = __(
- 'Commenting on symbolic links that replace or are replaced by files is currently not supported.',
- );
- } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) {
- tooltip = __(
- 'Commenting on files that replace or are replaced by symbolic links is currently not supported.',
- );
- }
- }
-
- return tooltip;
+ return utils.addCommentTooltip(this.line.right);
},
shouldRenderCommentButton() {
- if (!this.isCommentButtonRendered) {
- return false;
- }
-
- if (this.isLoggedIn) {
- const isDiffHead = parseBoolean(getParameterByName('diff_head'));
- return !isDiffHead || gon.features?.mergeRefHeadComments;
- }
-
- return false;
+ return utils.shouldRenderCommentButton(this.isLoggedIn, this.isCommentButtonRendered);
},
shouldShowCommentButtonLeft() {
- return (
- this.isLeftHover &&
- !this.isContextLineLeft &&
- !this.isMetaLineLeft &&
- !this.hasDiscussionsLeft
+ return utils.shouldShowCommentButton(
+ this.isLeftHover,
+ this.isContextLineLeft,
+ this.isMetaLineLeft,
+ this.hasDiscussionsLeft,
);
},
shouldShowCommentButtonRight() {
- return (
- this.isRightHover &&
- !this.isContextLineRight &&
- !this.isMetaLineRight &&
- !this.hasDiscussionsRight
+ return utils.shouldShowCommentButton(
+ this.isRightHover,
+ this.isContextLineRight,
+ this.isMetaLineRight,
+ this.hasDiscussionsRight,
);
},
hasDiscussionsLeft() {
- return this.line.left?.discussions?.length > 0;
+ return utils.hasDiscussions(this.line.left);
},
hasDiscussionsRight() {
- return this.line.right?.discussions?.length > 0;
+ return utils.hasDiscussions(this.line.right);
},
lineHrefOld() {
- return `#${this.line.left.line_code || ''}`;
+ return utils.lineHref(this.line.left);
},
lineHrefNew() {
- return `#${this.line.right.line_code || ''}`;
+ return utils.lineHref(this.line.right);
},
lineCode() {
- return (
- (this.line.left && this.line.left.line_code) ||
- (this.line.right && this.line.right.line_code)
- );
+ return utils.lineCode(this.line);
},
isMetaLineLeft() {
- const type = this.line.left?.type;
-
- return (
- type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE
- );
+ return utils.isMetaLine(this.line.left?.type);
},
isMetaLineRight() {
- const type = this.line.right?.type;
-
- return (
- type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || type === EMPTY_CELL_TYPE
- );
+ return utils.isMetaLine(this.line.right?.type);
},
},
- created() {
- this.newLineType = NEW_LINE_TYPE;
- this.oldLineType = OLD_LINE_TYPE;
- this.parallelDiffViewType = PARALLEL_DIFF_VIEW_TYPE;
- },
mounted() {
this.scrollToLineIfNeededParallel(this.line);
this.unwatchShouldShowCommentButton = this.$watch(
@@ -341,6 +247,7 @@ export default {
<td :class="parallelViewLeftLineType" class="line-coverage left-side"></td>
<td
:id="line.left.line_code"
+ :key="line.left.line_code"
v-safe-html="line.left.rich_text"
:class="parallelViewLeftLineType"
class="line_content with-coverage parallel left-side"
@@ -401,6 +308,7 @@ export default {
></td>
<td
:id="line.right.line_code"
+ :key="line.right.rich_text"
v-safe-html="line.right.rich_text"
:class="[
line.right.type,
diff --git a/app/assets/javascripts/diffs/diff_file.js b/app/assets/javascripts/diffs/diff_file.js
index 610b71235d9..933197a2c7f 100644
--- a/app/assets/javascripts/diffs/diff_file.js
+++ b/app/assets/javascripts/diffs/diff_file.js
@@ -18,9 +18,21 @@ function fileSymlinkInformation(file, fileList) {
);
}
+function collapsed(file) {
+ const viewer = file.viewer || {};
+
+ return {
+ automaticallyCollapsed: viewer.automaticallyCollapsed || viewer.collapsed || false,
+ };
+}
+
export function prepareRawDiffFile({ file, allFiles }) {
Object.assign(file, {
brokenSymlink: fileSymlinkInformation(file, allFiles),
+ viewer: {
+ ...file.viewer,
+ ...collapsed(file),
+ },
});
return file;
diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js
new file mode 100644
index 00000000000..8699cd88a18
--- /dev/null
+++ b/app/assets/javascripts/diffs/i18n.js
@@ -0,0 +1,5 @@
+import { __ } from '~/locale';
+
+export const DIFF_FILE_HEADER = {
+ optionsDropdownTitle: __('Options'),
+};
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 0f275f1cb3e..966b706fc31 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -103,7 +103,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
commit(types.VIEW_DIFF_FILE, state.diffFiles[0].file_hash);
}
- if (gon.features?.codeNavigation) {
+ if (state.diffFiles?.length) {
// eslint-disable-next-line promise/catch-or-return,promise/no-nesting
import('~/code_navigation').then(m =>
m.default({
@@ -236,7 +236,7 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi
commit(types.RENDER_FILE, file);
}
- if (file.viewer.collapsed) {
+ if (file.viewer.automaticallyCollapsed) {
eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`);
scrollToElement(document.getElementById(file.file_hash));
} else {
@@ -252,7 +252,8 @@ export const startRenderDiffsQueue = ({ state, commit }) => {
const nextFile = state.diffFiles.find(
file =>
!file.renderIt &&
- (file.viewer && (!file.viewer.collapsed || file.viewer.name !== diffViewerModes.text)),
+ (file.viewer &&
+ (!file.viewer.automaticallyCollapsed || file.viewer.name !== diffViewerModes.text)),
);
if (nextFile) {
@@ -631,7 +632,7 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d
filePath: diffFile.file_path,
viewer: {
...diffFile.alternate_viewer,
- collapsed: false,
+ automaticallyCollapsed: false,
},
});
commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: diffFile.file_path, lines });
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index 42df5873a41..91425c7825b 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -9,7 +9,7 @@ export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW
export const isInlineView = state => state.diffViewType === INLINE_DIFF_VIEW_TYPE;
export const hasCollapsedFile = state =>
- state.diffFiles.some(file => file.viewer && file.viewer.collapsed);
+ state.diffFiles.some(file => file.viewer && file.viewer.automaticallyCollapsed);
export const commitId = state => (state.commit && state.commit.id ? state.commit.id : null);
@@ -46,15 +46,24 @@ export const diffHasAllCollapsedDiscussions = (state, getters) => diff => {
* @param {Object} diff
* @returns {Boolean}
*/
-export const diffHasExpandedDiscussions = (state, getters) => diff => {
- const discussions = getters.getDiffFileDiscussions(diff);
-
- return (
- (discussions &&
- discussions.length &&
- discussions.find(discussion => discussion.expanded) !== undefined) ||
- false
- );
+export const diffHasExpandedDiscussions = state => diff => {
+ const lines = {
+ [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [],
+ [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => {
+ if (line.left) {
+ acc.push(line.left);
+ }
+
+ if (line.right) {
+ acc.push(line.right);
+ }
+
+ return acc;
+ }, []),
+ };
+ return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType]
+ .filter(l => l.discussions.length >= 1)
+ .some(l => l.discussionsExpanded);
};
/**
@@ -62,8 +71,25 @@ export const diffHasExpandedDiscussions = (state, getters) => diff => {
* @param {Boolean} diff
* @returns {Boolean}
*/
-export const diffHasDiscussions = (state, getters) => diff =>
- getters.getDiffFileDiscussions(diff).length > 0;
+export const diffHasDiscussions = state => diff => {
+ const lines = {
+ [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [],
+ [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => {
+ if (line.left) {
+ acc.push(line.left);
+ }
+
+ if (line.right) {
+ acc.push(line.right);
+ }
+
+ return acc;
+ }, []),
+ };
+ return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType].some(
+ l => l.discussions.length >= 1,
+ );
+};
/**
* Returns an array with the discussions of the given diff
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 7925c620c4e..13ecf6a997d 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -172,7 +172,7 @@ export default {
state.diffFiles.forEach(file => {
Object.assign(file, {
viewer: Object.assign(file.viewer, {
- collapsed: false,
+ automaticallyCollapsed: false,
}),
});
});
@@ -355,7 +355,7 @@ export default {
const file = state.diffFiles.find(f => f.file_path === filePath);
if (file && file.viewer) {
- file.viewer.collapsed = collapsed;
+ file.viewer.automaticallyCollapsed = collapsed;
}
},
[types.SET_HIDDEN_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) {
diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js
index 9ee692e953a..b02eb37206a 100644
--- a/app/assets/javascripts/editor/constants.js
+++ b/app/assets/javascripts/editor/constants.js
@@ -5,3 +5,4 @@ export const EDITOR_LITE_INSTANCE_ERROR_NO_EL = __(
);
export const URI_PREFIX = 'gitlab';
+export const CONTENT_UPDATE_DEBOUNCE = 250;
diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js
index bbd5461ae4d..e52e64d4c2d 100644
--- a/app/assets/javascripts/editor/editor_lite.js
+++ b/app/assets/javascripts/editor/editor_lite.js
@@ -39,6 +39,26 @@ export default class Editor {
monacoEditor.setModelLanguage(model, id);
}
+ static pushToImportsArray(arr, toImport) {
+ arr.push(import(toImport));
+ }
+
+ static loadExtensions(extensions) {
+ if (!extensions) {
+ return Promise.resolve();
+ }
+ const promises = [];
+ const extensionsArray = typeof extensions === 'string' ? extensions.split(',') : extensions;
+
+ extensionsArray.forEach(ext => {
+ const prefix = ext.includes('/') ? '' : 'editor/';
+ const trimmedExt = ext.replace(/^\//, '').trim();
+ Editor.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`);
+ });
+
+ return Promise.all(promises);
+ }
+
/**
* Creates a monaco instance with the given options.
*
@@ -53,6 +73,7 @@ export default class Editor {
blobPath = '',
blobContent = '',
blobGlobalId = '',
+ extensions = [],
...instanceOptions
} = {}) {
if (!el) {
@@ -80,6 +101,22 @@ export default class Editor {
model.dispose();
});
instance.updateModelLanguage = path => Editor.updateModelLanguage(path, instance);
+ instance.use = args => this.use(args, instance);
+
+ Editor.loadExtensions(extensions, instance)
+ .then(modules => {
+ if (modules) {
+ modules.forEach(module => {
+ instance.use(module.default);
+ });
+ }
+ })
+ .then(() => {
+ el.dispatchEvent(new Event('editor-ready'));
+ })
+ .catch(e => {
+ throw e;
+ });
this.instances.push(instance);
return instance;
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
index 4567c807c40..4a56843c0b5 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -1,53 +1,57 @@
-import { uniq } from 'lodash';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
import emojiAliases from 'emojis/aliases.json';
import axios from '../lib/utils/axios_utils';
-
import AccessorUtilities from '../lib/utils/accessor';
let emojiMap = null;
-let emojiPromise = null;
let validEmojiNames = null;
export const EMOJI_VERSION = '1';
const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
-export function initEmojiMap() {
- emojiPromise =
- emojiPromise ||
- new Promise((resolve, reject) => {
- if (emojiMap) {
- resolve(emojiMap);
- } else if (
- isLocalStorageAvailable &&
- window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION &&
- window.localStorage.getItem('gl-emoji-map')
- ) {
- emojiMap = JSON.parse(window.localStorage.getItem('gl-emoji-map'));
- validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
- resolve(emojiMap);
- } else {
- // We load the JSON file direct from the server
- // because it can't be loaded from a CDN due to
- // cross domain problems with JSON
- axios
- .get(`${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`)
- .then(({ data }) => {
- emojiMap = data;
- validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
- resolve(emojiMap);
- if (isLocalStorageAvailable) {
- window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION);
- window.localStorage.setItem('gl-emoji-map', JSON.stringify(emojiMap));
- }
- })
- .catch(err => {
- reject(err);
- });
- }
- });
+async function loadEmoji() {
+ if (
+ isLocalStorageAvailable &&
+ window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION &&
+ window.localStorage.getItem('gl-emoji-map')
+ ) {
+ return JSON.parse(window.localStorage.getItem('gl-emoji-map'));
+ }
+
+ // We load the JSON file direct from the server
+ // because it can't be loaded from a CDN due to
+ // cross domain problems with JSON
+ const { data } = await axios.get(
+ `${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`,
+ );
+ window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION);
+ window.localStorage.setItem('gl-emoji-map', JSON.stringify(data));
+ return data;
+}
+
+async function prepareEmojiMap() {
+ emojiMap = await loadEmoji();
+
+ validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
+
+ Object.keys(emojiMap).forEach(name => {
+ emojiMap[name].aliases = [];
+ emojiMap[name].name = name;
+ });
+ Object.entries(emojiAliases).forEach(([alias, name]) => {
+ // This check, `if (name in emojiMap)` is necessary during testing. In
+ // production, it shouldn't be necessary, because at no point should there
+ // be an entry in aliases.json with no corresponding entry in emojis.json.
+ // However, during testing, the endpoint for emojis.json is mocked with a
+ // small dataset, whereas aliases.json is always `import`ed directly.
+ if (name in emojiMap) emojiMap[name].aliases.push(alias);
+ });
+}
- return emojiPromise;
+export function initEmojiMap() {
+ initEmojiMap.promise = initEmojiMap.promise || prepareEmojiMap();
+ return initEmojiMap.promise;
}
export function normalizeEmojiName(name) {
@@ -62,13 +66,148 @@ export function isEmojiNameValid(name) {
return validEmojiNames.indexOf(name) >= 0;
}
-export function filterEmojiNames(filter) {
- const match = filter.toLowerCase();
- return validEmojiNames.filter(name => name.indexOf(match) >= 0);
+export function getAllEmoji() {
+ return emojiMap;
+}
+
+/**
+ * Retrieves an emoji by name or alias.
+ *
+ * Note: `initEmojiMap` must have been called and completed before this method
+ * can safely be called.
+ *
+ * @param {String} query The emoji name
+ * @param {Boolean} fallback If true, a fallback emoji will be returned if the
+ * named emoji does not exist. Defaults to false.
+ * @returns {Object} The matching emoji.
+ */
+export function getEmoji(query, fallback = false) {
+ // TODO https://gitlab.com/gitlab-org/gitlab/-/issues/268208
+ const fallbackEmoji = emojiMap.grey_question;
+ if (!query) {
+ return fallback ? fallbackEmoji : null;
+ }
+
+ if (!emojiMap) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('The emoji map is uninitialized or initialization has not completed');
+ }
+
+ const lowercaseQuery = query.toLowerCase();
+ const name = normalizeEmojiName(lowercaseQuery);
+
+ if (name in emojiMap) {
+ return emojiMap[name];
+ }
+
+ return fallback ? fallbackEmoji : null;
}
-export function filterEmojiNamesByAlias(filter) {
- return uniq(filterEmojiNames(filter).map(name => normalizeEmojiName(name)));
+const searchMatchers = {
+ // Fuzzy matching compares using a fuzzy matching library
+ fuzzy: (value, query) => {
+ const score = fuzzaldrinPlus.score(value, query) > 0;
+ return { score, success: score > 0 };
+ },
+ // Contains matching compares by indexOf
+ contains: (value, query) => {
+ const index = value.indexOf(query.toLowerCase());
+ return { index, success: index >= 0 };
+ },
+ // Exact matching compares by equality
+ exact: (value, query) => {
+ return { success: value === query.toLowerCase() };
+ },
+};
+
+const searchPredicates = {
+ // Search by name
+ name: (matcher, query) => emoji => {
+ const m = matcher(emoji.name, query);
+ return [{ ...m, emoji, field: emoji.name }];
+ },
+ // Search by alias
+ alias: (matcher, query) => emoji =>
+ emoji.aliases.map(alias => {
+ const m = matcher(alias, query);
+ return { ...m, emoji, field: alias };
+ }),
+ // Search by description
+ description: (matcher, query) => emoji => {
+ const m = matcher(emoji.d, query);
+ return [{ ...m, emoji, field: emoji.d }];
+ },
+ // Search by unicode value (always exact)
+ unicode: (matcher, query) => emoji => {
+ return [{ emoji, field: emoji.e, success: emoji.e === query }];
+ },
+};
+
+/**
+ * Searches emoji by name, aliases, description, and unicode value and returns
+ * an array of matches.
+ *
+ * Behavior is undefined if `opts.fields` is empty or if `opts.match` is fuzzy
+ * and the query is empty.
+ *
+ * Note: `initEmojiMap` must have been called and completed before this method
+ * can safely be called.
+ *
+ * @param {String} query Search query.
+ * @param {Object} opts Search options (optional).
+ * @param {String[]} opts.fields Fields to search. Choices are 'name', 'alias',
+ * 'description', and 'unicode' (value). Default is all (four) fields.
+ * @param {String} opts.match Search method to use. Choices are 'exact',
+ * 'contains', or 'fuzzy'. All methods are case-insensitive. Exact matching (the
+ * default) compares by equality. Contains matching compares by indexOf. Fuzzy
+ * matching compares using a fuzzy matching library.
+ * @param {Boolean} opts.fallback If true, a fallback emoji will be returned if
+ * the result set is empty. Defaults to false.
+ * @param {Boolean} opts.raw Returns the raw match data instead of just the
+ * matching emoji.
+ * @returns {Object[]} A list of emoji that match the query.
+ */
+export function searchEmoji(query, opts) {
+ if (!emojiMap) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('The emoji map is uninitialized or initialization has not completed');
+ }
+
+ const {
+ fields = ['name', 'alias', 'description', 'unicode'],
+ match = 'exact',
+ fallback = false,
+ raw = false,
+ } = opts || {};
+
+ const fallbackEmoji = emojiMap.grey_question;
+ if (!query) {
+ if (fallback) {
+ return raw ? [{ emoji: fallbackEmoji }] : [fallbackEmoji];
+ }
+
+ return [];
+ }
+
+ // optimization for an exact match in name and alias
+ if (match === 'exact' && new Set([...fields, 'name', 'alias']).size === 2) {
+ const emoji = getEmoji(query, fallback);
+ return emoji ? [emoji] : [];
+ }
+
+ const matcher = searchMatchers[match] || searchMatchers.exact;
+ const predicates = fields.map(f => searchPredicates[f](matcher, query));
+
+ const results = Object.values(emojiMap)
+ .flatMap(emoji => predicates.flatMap(predicate => predicate(emoji)))
+ .filter(r => r.success);
+
+ // Fallback to question mark for unknown emojis
+ if (fallback && results.length === 0) {
+ return raw ? [{ emoji: fallbackEmoji }] : [fallbackEmoji];
+ }
+
+ return raw ? results : results.map(r => r.emoji);
}
let emojiCategoryMap;
@@ -95,16 +234,10 @@ export function getEmojiCategoryMap() {
}
export function getEmojiInfo(query) {
- let name = normalizeEmojiName(query);
- let emojiInfo = emojiMap[name];
-
- // Fallback to question mark for unknown emojis
- if (!emojiInfo) {
- name = 'grey_question';
- emojiInfo = emojiMap[name];
- }
-
- return { ...emojiInfo, name };
+ return searchEmoji(query, {
+ fields: ['name', 'alias'],
+ fallback: true,
+ })[0];
}
export function emojiFallbackImageSrc(inputName) {
diff --git a/app/assets/javascripts/emoji/support/index.js b/app/assets/javascripts/emoji/support/index.js
index 1f7852dd487..14b80be9b43 100644
--- a/app/assets/javascripts/emoji/support/index.js
+++ b/app/assets/javascripts/emoji/support/index.js
@@ -5,6 +5,14 @@ import getUnicodeSupportMap from './unicode_support_map';
let browserUnicodeSupportMap;
export default function isEmojiUnicodeSupportedByBrowser(emojiUnicode, unicodeVersion) {
+ // Skipping the map creation for Bots + RSPec
+ if (
+ navigator.userAgent.indexOf('HeadlessChrome') > -1 ||
+ navigator.userAgent.indexOf('Lighthouse') > -1 ||
+ navigator.userAgent.indexOf('Speedindex') > -1
+ ) {
+ return true;
+ }
browserUnicodeSupportMap = browserUnicodeSupportMap || getUnicodeSupportMap();
return isEmojiUnicodeSupported(browserUnicodeSupportMap, emojiUnicode, unicodeVersion);
}
diff --git a/app/assets/javascripts/environments/components/enable_review_app_button.vue b/app/assets/javascripts/environments/components/enable_review_app_button.vue
deleted file mode 100644
index 8fbbc5189bf..00000000000
--- a/app/assets/javascripts/environments/components/enable_review_app_button.vue
+++ /dev/null
@@ -1,109 +0,0 @@
-<script>
-import { GlButton, GlModal, GlModalDirective, GlLink, GlSprintf } from '@gitlab/ui';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
-import { s__ } from '~/locale';
-
-export default {
- components: {
- GlButton,
- GlLink,
- GlModal,
- GlSprintf,
- ModalCopyButton,
- },
- directives: {
- 'gl-modal': GlModalDirective,
- },
- instructionText: {
- step1: s__(
- 'EnableReviewApp|%{stepStart}Step 1%{stepEnd}. Ensure you have Kubernetes set up and have a base domain for your %{linkStart}cluster%{linkEnd}.',
- ),
- step2: s__('EnableReviewApp|%{stepStart}Step 2%{stepEnd}. Copy the following snippet:'),
- step3: s__(
- `EnableReviewApp|%{stepStart}Step 3%{stepEnd}. Add it to the project %{linkStart}gitlab-ci.yml%{linkEnd} file.`,
- ),
- },
- modalInfo: {
- closeText: s__('EnableReviewApp|Close'),
- copyToClipboardText: s__('EnableReviewApp|Copy snippet text'),
- copyString: `deploy_review:
- stage: deploy
- script:
- - echo "Deploy a review app"
- environment:
- name: review/$CI_COMMIT_REF_NAME
- url: https://$CI_ENVIRONMENT_SLUG.example.com
- only:
- - branches
- except:
- - master`,
- id: 'enable-review-app-info',
- title: s__('ReviewApp|Enable Review App'),
- },
-};
-</script>
-<template>
- <div>
- <gl-button
- v-gl-modal="$options.modalInfo.id"
- variant="info"
- category="secondary"
- type="button"
- class="js-enable-review-app-button"
- >
- {{ s__('Environments|Enable review app') }}
- </gl-button>
- <gl-modal
- :modal-id="$options.modalInfo.id"
- :title="$options.modalInfo.title"
- size="lg"
- class="text-2 ws-normal"
- ok-only
- ok-variant="light"
- :ok-title="$options.modalInfo.closeText"
- >
- <p>
- <gl-sprintf :message="$options.instructionText.step1">
- <template #step="{ content }">
- <strong>{{ content }}</strong>
- </template>
- <template #link="{ content }">
- <gl-link
- href="https://docs.gitlab.com/ee/user/project/clusters/add_remove_clusters.html"
- target="_blank"
- >{{ content }}</gl-link
- >
- </template>
- </gl-sprintf>
- </p>
- <div>
- <p>
- <gl-sprintf :message="$options.instructionText.step2">
- <template #step="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </p>
- <div class="flex align-items-start">
- <pre class="w-100"> {{ $options.modalInfo.copyString }} </pre>
- <modal-copy-button
- :title="$options.modalInfo.copyToClipboardText"
- :text="$options.modalInfo.copyString"
- :modal-id="$options.modalInfo.id"
- css-classes="border-0"
- />
- </div>
- </div>
- <p>
- <gl-sprintf :message="$options.instructionText.step3">
- <template #step="{ content }">
- <strong>{{ content }}</strong>
- </template>
- <template #link="{ content }">
- <gl-link href="blob/master/.gitlab-ci.yml" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
- </gl-modal>
- </div>
-</template>
diff --git a/app/assets/javascripts/environments/components/enable_review_app_modal.vue b/app/assets/javascripts/environments/components/enable_review_app_modal.vue
new file mode 100644
index 00000000000..3dd1d5a1bcc
--- /dev/null
+++ b/app/assets/javascripts/environments/components/enable_review_app_modal.vue
@@ -0,0 +1,98 @@
+<script>
+import { GlLink, GlModal, GlSprintf } from '@gitlab/ui';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlLink,
+ GlModal,
+ GlSprintf,
+ ModalCopyButton,
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ instructionText: {
+ step1: s__(
+ 'EnableReviewApp|%{stepStart}Step 1%{stepEnd}. Ensure you have Kubernetes set up and have a base domain for your %{linkStart}cluster%{linkEnd}.',
+ ),
+ step2: s__('EnableReviewApp|%{stepStart}Step 2%{stepEnd}. Copy the following snippet:'),
+ step3: s__(
+ `EnableReviewApp|%{stepStart}Step 3%{stepEnd}. Add it to the project %{linkStart}gitlab-ci.yml%{linkEnd} file.`,
+ ),
+ },
+ modalInfo: {
+ closeText: s__('EnableReviewApp|Close'),
+ copyToClipboardText: s__('EnableReviewApp|Copy snippet text'),
+ copyString: `deploy_review:
+ stage: deploy
+ script:
+ - echo "Deploy a review app"
+ environment:
+ name: review/$CI_COMMIT_REF_NAME
+ url: https://$CI_ENVIRONMENT_SLUG.example.com
+ only:
+ - branches
+ except:
+ - master`,
+ title: s__('ReviewApp|Enable Review App'),
+ },
+};
+</script>
+<template>
+ <gl-modal
+ :modal-id="modalId"
+ :title="$options.modalInfo.title"
+ size="lg"
+ ok-only
+ ok-variant="light"
+ :ok-title="$options.modalInfo.closeText"
+ >
+ <p>
+ <gl-sprintf :message="$options.instructionText.step1">
+ <template #step="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #link="{ content }">
+ <gl-link
+ href="https://docs.gitlab.com/ee/user/project/clusters/add_remove_clusters.html"
+ target="_blank"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </p>
+ <div>
+ <p>
+ <gl-sprintf :message="$options.instructionText.step2">
+ <template #step="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <div class="gl-display-flex align-items-start">
+ <pre class="gl-w-full"> {{ $options.modalInfo.copyString }} </pre>
+ <modal-copy-button
+ :title="$options.modalInfo.copyToClipboardText"
+ :text="$options.modalInfo.copyString"
+ :modal-id="modalId"
+ css-classes="border-0"
+ />
+ </div>
+ </div>
+ <p>
+ <gl-sprintf :message="$options.instructionText.step3">
+ <template #step="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #link="{ content }">
+ <gl-link href="blob/master/.gitlab-ci.yml" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index 035b276bc3b..bc35a07fe4a 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,13 +1,12 @@
<script>
-import { GlButton, GlIcon, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import { formatTime } from '~/lib/utils/datetime_utility';
import eventHub from '../event_hub';
-import tooltip from '../../vue_shared/directives/tooltip';
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
GlButton,
@@ -70,13 +69,14 @@ export default {
<template>
<div class="btn-group" role="group">
<gl-button
- v-tooltip
+ v-gl-tooltip
:title="title"
:aria-label="title"
:disabled="isLoading"
class="dropdown dropdown-new js-environment-actions-dropdown"
data-container="body"
data-toggle="dropdown"
+ data-testid="environment-actions-button"
>
<span>
<gl-icon name="play" />
diff --git a/app/assets/javascripts/environments/components/environment_delete.vue b/app/assets/javascripts/environments/components/environment_delete.vue
index 55aaa6d57bd..039b40a3596 100644
--- a/app/assets/javascripts/environments/components/environment_delete.vue
+++ b/app/assets/javascripts/environments/components/environment_delete.vue
@@ -4,7 +4,6 @@
* Used in the environments table and the environment detail view.
*/
-import $ from 'jquery';
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
@@ -42,7 +41,7 @@ export default {
},
methods: {
onClick() {
- $(this.$el).tooltip('dispose');
+ this.$root.$emit('bv::hide::tooltip', this.$options.deleteEnvironmentTooltipId);
eventHub.$emit('requestDeleteEnvironment', this.environment);
},
onDeleteEnvironment(environment) {
@@ -51,15 +50,16 @@ export default {
}
},
},
+ deleteEnvironmentTooltipId: 'delete-environment-button-tooltip',
};
</script>
<template>
<loading-button
- v-gl-tooltip
+ v-gl-tooltip="{ id: $options.deleteEnvironmentTooltipId }"
:loading="isLoading"
:title="title"
:aria-label="title"
- container-class="btn btn-danger d-none d-sm-none d-md-block"
+ container-class="btn btn-danger d-none d-md-block"
data-toggle="modal"
data-target="#delete-environment-modal"
@click="onClick"
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 8850ed19a4b..48e81b168ec 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -570,7 +570,7 @@ export default {
</div>
<div
- class="table-section deployment-column d-none d-sm-none d-md-block"
+ class="table-section deployment-column d-none d-md-block"
:class="tableData.deploy.spacing"
role="gridcell"
>
@@ -594,11 +594,7 @@ export default {
</div>
</div>
- <div
- class="table-section d-none d-sm-none d-md-block"
- :class="tableData.build.spacing"
- role="gridcell"
- >
+ <div class="table-section d-none d-md-block" :class="tableData.build.spacing" role="gridcell">
<a v-if="shouldRenderBuildName" :href="buildPath" class="build-link cgray">
<tooltip-on-truncate
:title="buildName"
diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue
index c63d54d586d..ff74f81c98e 100644
--- a/app/assets/javascripts/environments/components/environment_stop.vue
+++ b/app/assets/javascripts/environments/components/environment_stop.vue
@@ -4,7 +4,6 @@
* Used in environments table.
*/
-import $ from 'jquery';
import { GlTooltipDirective, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
@@ -40,7 +39,7 @@ export default {
},
methods: {
onClick() {
- $(this.$el).tooltip('dispose');
+ this.$root.$emit('bv::hide::tooltip', this.$options.stopEnvironmentTooltipId);
eventHub.$emit('requestStopEnvironment', this.environment);
},
onStopEnvironment(environment) {
@@ -49,11 +48,12 @@ export default {
}
},
},
+ stopEnvironmentTooltipId: 'stop-environment-button-tooltip',
};
</script>
<template>
<gl-button
- v-gl-tooltip
+ v-gl-tooltip="{ id: $options.stopEnvironmentTooltipId }"
:loading="isLoading"
:title="title"
:aria-label="title"
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index b5a7be90204..4750b8ef01b 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -39,7 +39,7 @@ export default {
:aria-label="title"
:href="terminalPath"
:class="{ disabled: disabled }"
- class="btn terminal-button d-none d-sm-none d-md-block text-secondary"
+ class="btn terminal-button d-none d-md-block text-secondary"
>
<gl-icon name="terminal" />
</a>
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index f0e74d96f09..9bafc7ed153 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -1,28 +1,39 @@
<script>
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlBadge, GlButton, GlModalDirective, GlTab, GlTabs } from '@gitlab/ui';
import { deprecatedCreateFlash as Flash } from '~/flash';
import { s__ } from '~/locale';
import emptyState from './empty_state.vue';
import eventHub from '../event_hub';
import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin';
-import EnableReviewAppButton from './enable_review_app_button.vue';
+import EnableReviewAppModal from './enable_review_app_modal.vue';
import StopEnvironmentModal from './stop_environment_modal.vue';
import DeleteEnvironmentModal from './delete_environment_modal.vue';
import ConfirmRollbackModal from './confirm_rollback_modal.vue';
export default {
+ i18n: {
+ newEnvironmentButtonLabel: s__('Environments|New environment'),
+ reviewAppButtonLabel: s__('Environments|Enable review app'),
+ },
+ modal: {
+ id: 'enable-review-app-info',
+ },
components: {
ConfirmRollbackModal,
emptyState,
- EnableReviewAppButton,
- GlDeprecatedButton,
+ EnableReviewAppModal,
+ GlBadge,
+ GlButton,
+ GlTab,
+ GlTabs,
StopEnvironmentModal,
DeleteEnvironmentModal,
},
-
+ directives: {
+ 'gl-modal': GlModalDirective,
+ },
mixins: [CIPaginationMixin, environmentsMixin],
-
props: {
endpoint: {
type: String,
@@ -124,43 +135,108 @@ export default {
};
</script>
<template>
- <div>
+ <div class="environments-section">
<stop-environment-modal :environment="environmentInStopModal" />
<delete-environment-modal :environment="environmentInDeleteModal" />
<confirm-rollback-modal :environment="environmentInRollbackModal" />
- <div class="top-area">
- <tabs :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" />
-
- <div class="nav-controls">
- <enable-review-app-button v-if="state.reviewAppDetails.can_setup_review_app" class="mr-2" />
- <gl-deprecated-button
- v-if="canCreateEnvironment && !isLoading"
+ <div class="gl-w-full">
+ <div
+ class="
+ gl-display-flex
+ gl-flex-direction-column
+ gl-mt-3
+ gl-display-md-none!"
+ >
+ <gl-button
+ v-if="state.reviewAppDetails.can_setup_review_app"
+ v-gl-modal="$options.modal.id"
+ data-testid="enable-review-app"
+ variant="info"
+ category="secondary"
+ type="button"
+ class="gl-mb-3 gl-flex-fill-1"
+ >
+ {{ $options.i18n.reviewAppButtonLabel }}
+ </gl-button>
+ <gl-button
+ v-if="canCreateEnvironment"
:href="newEnvironmentPath"
+ data-testid="new-environment"
category="primary"
variant="success"
>
- {{ s__('Environments|New environment') }}
- </gl-deprecated-button>
+ {{ $options.i18n.newEnvironmentButtonLabel }}
+ </gl-button>
</div>
+ <gl-tabs content-class="gl-display-none">
+ <gl-tab
+ v-for="(tab, idx) in tabs"
+ :key="idx"
+ :title-item-class="`js-environments-tab-${tab.scope}`"
+ @click="onChangeTab(tab.scope)"
+ >
+ <template #title>
+ <span>{{ tab.name }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge>
+ </template>
+ </gl-tab>
+ <template #tabs-end>
+ <div
+ class="
+ gl-display-none
+ gl-display-md-flex
+ gl-lg-align-items-center
+ gl-lg-flex-direction-row
+ gl-lg-flex-fill-1
+ gl-lg-justify-content-end
+ gl-lg-mt-0"
+ >
+ <gl-button
+ v-if="state.reviewAppDetails.can_setup_review_app"
+ v-gl-modal="$options.modal.id"
+ data-testid="enable-review-app"
+ variant="info"
+ category="secondary"
+ type="button"
+ class="gl-mb-3 gl-lg-mr-3 gl-lg-mb-0"
+ >
+ {{ $options.i18n.reviewAppButtonLabel }}
+ </gl-button>
+ <gl-button
+ v-if="canCreateEnvironment"
+ :href="newEnvironmentPath"
+ data-testid="new-environment"
+ category="primary"
+ variant="success"
+ >
+ {{ $options.i18n.newEnvironmentButtonLabel }}
+ </gl-button>
+ </div>
+ </template>
+ </gl-tabs>
+ <container
+ :is-loading="isLoading"
+ :environments="state.environments"
+ :pagination="state.paginationInformation"
+ :can-read-environment="canReadEnvironment"
+ :canary-deployment-feature-id="canaryDeploymentFeatureId"
+ :show-canary-deployment-callout="showCanaryDeploymentCallout"
+ :user-callouts-path="userCalloutsPath"
+ :lock-promotion-svg-path="lockPromotionSvgPath"
+ :help-canary-deployments-path="helpCanaryDeploymentsPath"
+ :deploy-boards-help-path="deployBoardsHelpPath"
+ @onChangePage="onChangePage"
+ >
+ <template v-if="!isLoading && state.environments.length === 0" #emptyState>
+ <empty-state :help-path="helpPagePath" />
+ </template>
+ </container>
+ <enable-review-app-modal
+ v-if="state.reviewAppDetails.can_setup_review_app"
+ :modal-id="$options.modal.id"
+ data-testid="enable-review-app-modal"
+ />
</div>
-
- <container
- :is-loading="isLoading"
- :environments="state.environments"
- :pagination="state.paginationInformation"
- :can-read-environment="canReadEnvironment"
- :canary-deployment-feature-id="canaryDeploymentFeatureId"
- :show-canary-deployment-callout="showCanaryDeploymentCallout"
- :user-callouts-path="userCalloutsPath"
- :lock-promotion-svg-path="lockPromotionSvgPath"
- :help-canary-deployments-path="helpCanaryDeploymentsPath"
- :deploy-boards-help-path="deployBoardsHelpPath"
- @onChangePage="onChangePage"
- >
- <template v-if="!isLoading && state.environments.length === 0" #emptyState>
- <empty-state :help-path="helpPagePath" />
- </template>
- </container>
</div>
</template>
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index c06ab265915..c1b3eabec16 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -184,7 +184,6 @@ export default {
:deploy-boards-help-path="deployBoardsHelpPath"
:is-loading="model.isLoadingDeployBoard"
:is-empty="model.isEmptyDeployBoard"
- :has-legacy-app-label="model.hasLegacyAppLabel"
:logs-path="model.logs_path"
/>
</div>
diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue
index 88612376b6e..f0dafe0620e 100644
--- a/app/assets/javascripts/environments/components/stop_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue
@@ -1,8 +1,7 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings, vue/no-v-html */
-import { GlTooltipDirective } from '@gitlab/ui';
+/* eslint-disable @gitlab/vue-require-i18n-strings */
+import { GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
-import { s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
export default {
@@ -11,6 +10,7 @@ export default {
components: {
GlModal: DeprecatedModal2,
+ GlSprintf,
},
directives: {
@@ -24,27 +24,6 @@ export default {
},
},
- computed: {
- noStopActionMessage() {
- return sprintf(
- s__(
- `Environments|Note that this action will stop the environment,
- but it will %{emphasisStart}not%{emphasisEnd} have an effect on any existing deployment
- due to no “stop environment action” being defined
- in the %{ciConfigLinkStart}.gitlab-ci.yml%{ciConfigLinkEnd} file.`,
- ),
- {
- emphasisStart: '<strong>',
- emphasisEnd: '</strong>',
- ciConfigLinkStart:
- '<a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer">',
- ciConfigLinkEnd: '</a>',
- },
- false,
- );
- },
- },
-
methods: {
onSubmit() {
eventHub.$emit('stopEnvironment', this.environment);
@@ -72,9 +51,27 @@ export default {
<p>{{ s__('Environments|Are you sure you want to stop this environment?') }}</p>
<div v-if="!environment.has_stop_action" class="warning_message">
- <p v-html="noStopActionMessage"></p>
+ <p>
+ <gl-sprintf
+ :message="
+ s__(`Environments|Note that this action will stop the environment,
+ but it will %{emphasisStart}not%{emphasisEnd} have an effect on any existing deployment
+ due to no “stop environment action” being defined
+ in the %{ciConfigLinkStart}.gitlab-ci.yml%{ciConfigLinkEnd} file.`)
+ "
+ >
+ <template #emphasis="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #ciConfigLink="{ content }">
+ <a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer">
+ {{ content }}</a
+ >
+ </template>
+ </gl-sprintf>
+ </p>
<a
- href="https://docs.gitlab.com/ee/ci/environments.html#stopping-an-environment"
+ href="https://docs.gitlab.com/ee/ci/environments/#stopping-an-environment"
target="_blank"
rel="noopener noreferrer"
>{{ s__('Environments|Learn more about stopping environments') }}</a
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index 16d25615779..25f5483c58b 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -1,4 +1,5 @@
<script>
+import { GlBadge, GlTab, GlTabs } from '@gitlab/ui';
import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import StopEnvironmentModal from '../components/stop_environment_modal.vue';
@@ -6,8 +7,11 @@ import DeleteEnvironmentModal from '../components/delete_environment_modal.vue';
export default {
components: {
- StopEnvironmentModal,
DeleteEnvironmentModal,
+ GlBadge,
+ GlTab,
+ GlTabs,
+ StopEnvironmentModal,
},
mixins: [environmentsMixin, CIPaginationMixin],
@@ -68,14 +72,26 @@ export default {
<stop-environment-modal :environment="environmentInStopModal" />
<delete-environment-modal :environment="environmentInDeleteModal" />
- <h4 class="js-folder-name environments-folder-name">
+ <h4 class="gl-font-weight-normal" data-testid="folder-name">
{{ s__('Environments|Environments') }} /
<b>{{ folderName }}</b>
</h4>
- <div class="top-area">
- <tabs v-if="!isLoading" :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" />
- </div>
+ <gl-tabs v-if="!isLoading" scope="environments" content-class="gl-display-none">
+ <gl-tab
+ v-for="(tab, i) in tabs"
+ :key="`${tab.name}-${i}`"
+ :active="tab.isActive"
+ :title-item-class="tab.isActive ? 'gl-outline-none' : ''"
+ :title-link-attributes="{ 'data-testid': `environments-tab-${tab.scope}` }"
+ @click="onChangeTab(tab.scope)"
+ >
+ <template #title>
+ <span>{{ tab.name }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
<container
:is-loading="isLoading"
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
index a4938fe13ed..cd4bb476b6e 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
@@ -94,7 +94,9 @@ export default {
<clipboard-button
:title="__('Copy file path')"
:text="filePath"
- css-class="btn-default btn-transparent btn-clipboard position-static"
+ category="tertiary"
+ size="small"
+ css-class="gl-mr-1"
/>
<gl-sprintf v-if="errorFn" :message="__('%{spanStart}in%{spanEnd} %{errorFn}')">
diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue
index db90ac1c740..786abc8ce49 100644
--- a/app/assets/javascripts/error_tracking_settings/components/app.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/app.vue
@@ -92,15 +92,13 @@ export default {
@select-project="updateSelectedProject"
/>
</div>
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button
- :disabled="settingsLoading"
- class="js-error-tracking-button"
- variant="success"
- @click="handleSubmit"
- >
- {{ __('Save changes') }}
- </gl-button>
- </div>
+ <gl-button
+ :disabled="settingsLoading"
+ class="js-error-tracking-button"
+ variant="success"
+ @click="handleSubmit"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
index f1fb1a44758..b1b699d2e2a 100644
--- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
@@ -1,10 +1,9 @@
<script>
import { mapActions, mapState } from 'vuex';
-import { GlFormInput, GlIcon } from '@gitlab/ui';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import { GlFormInput, GlIcon, GlButton } from '@gitlab/ui';
export default {
- components: { GlFormInput, GlIcon, LoadingButton },
+ components: { GlFormInput, GlIcon, GlButton },
computed: {
...mapState(['apiHost', 'connectError', 'connectSuccessful', 'isLoadingProjects', 'token']),
tokenInputState() {
@@ -57,12 +56,16 @@ export default {
/>
</div>
<div class="col-4 col-md-3 gl-pl-0">
- <loading-button
+ <gl-button
class="js-error-tracking-connect gl-ml-2 d-inline-flex"
- :label="isLoadingProjects ? __('Connecting') : __('Connect')"
+ category="secondary"
+ variant="default"
:loading="isLoadingProjects"
@click="fetchProjects"
- />
+ >
+ {{ isLoadingProjects ? __('Connecting') : __('Connect') }}
+ </gl-button>
+
<gl-icon
v-show="connectSuccessful"
class="js-error-tracking-connect-success gl-ml-2 text-success align-middle"
diff --git a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
new file mode 100644
index 00000000000..686399843dd
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
@@ -0,0 +1,258 @@
+<script>
+import {
+ GlFormGroup,
+ GlFormInput,
+ GlModal,
+ GlTooltipDirective,
+ GlLoadingIcon,
+ GlSprintf,
+ GlLink,
+ GlIcon,
+} from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import Callout from '~/vue_shared/components/callout.vue';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ GlModal,
+ ModalCopyButton,
+ GlIcon,
+ Callout,
+ GlLoadingIcon,
+ GlSprintf,
+ GlLink,
+ },
+
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+
+ props: {
+ instanceId: {
+ type: String,
+ required: true,
+ },
+ modalId: {
+ type: String,
+ required: false,
+ default: 'configure-feature-flags',
+ },
+ isRotating: {
+ type: Boolean,
+ required: true,
+ },
+ hasRotateError: {
+ type: Boolean,
+ required: true,
+ },
+ canUserRotateToken: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ inject: [
+ 'projectName',
+ 'featureFlagsHelpPagePath',
+ 'unleashApiUrl',
+ 'featureFlagsClientExampleHelpPagePath',
+ 'featureFlagsClientLibrariesHelpPagePath',
+ ],
+ translations: {
+ cancelActionLabel: __('Close'),
+ modalTitle: s__('FeatureFlags|Configure feature flags'),
+ apiUrlLabelText: s__('FeatureFlags|API URL'),
+ apiUrlCopyText: __('Copy URL'),
+ instanceIdLabelText: s__('FeatureFlags|Instance ID'),
+ instanceIdCopyText: __('Copy ID'),
+ instanceIdRegenerateError: __('Unable to generate new instance ID'),
+ instanceIdRegenerateText: __(
+ 'Regenerating the instance ID can break integration depending on the client you are using.',
+ ),
+ instanceIdRegenerateActionLabel: __('Regenerate instance ID'),
+ },
+ data() {
+ return {
+ enteredProjectName: '',
+ };
+ },
+ computed: {
+ cancelActionProps() {
+ return {
+ text: this.$options.translations.cancelActionLabel,
+ };
+ },
+ canRegenerateInstanceId() {
+ return this.canUserRotateToken && this.enteredProjectName === this.projectName;
+ },
+ regenerateInstanceIdActionProps() {
+ return this.canUserRotateToken
+ ? {
+ text: this.$options.translations.instanceIdRegenerateActionLabel,
+ attributes: [
+ {
+ category: 'secondary',
+ disabled: !this.canRegenerateInstanceId,
+ loading: this.isRotating,
+ variant: 'danger',
+ },
+ ],
+ }
+ : null;
+ },
+ },
+
+ methods: {
+ clearState() {
+ this.enteredProjectName = '';
+ },
+ rotateToken() {
+ this.$emit('token');
+ this.clearState();
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ :modal-id="modalId"
+ :action-cancel="cancelActionProps"
+ :action-primary="regenerateInstanceIdActionProps"
+ @canceled="clearState"
+ @hide="clearState"
+ @primary.prevent="rotateToken"
+ >
+ <template #modal-title>
+ {{ $options.translations.modalTitle }}
+ </template>
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ 'FeatureFlags|Install a %{docsLinkAnchoredStart}compatible client library%{docsLinkAnchoredEnd} and specify the API URL, application name, and instance ID during the configuration setup. %{docsLinkStart}More Information%{docsLinkEnd}',
+ )
+ "
+ >
+ <template #docsLinkAnchored="{ content }">
+ <gl-link
+ :href="featureFlagsClientLibrariesHelpPagePath"
+ target="_blank"
+ data-testid="help-client-link"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ <template #docsLink="{ content }">
+ <gl-link :href="featureFlagsHelpPagePath" target="_blank" data-testid="help-link">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <callout category="warning">
+ <gl-sprintf
+ :message="
+ s__(
+ 'FeatureFlags|Set the Unleash client application name to the name of the environment your application runs in. This value is used to match environment scopes. See the %{linkStart}example client configuration%{linkEnd}.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="featureFlagsClientExampleHelpPagePath" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </callout>
+ <div class="form-group">
+ <label for="api_url" class="label-bold">{{ $options.translations.apiUrlLabelText }}</label>
+ <div class="input-group">
+ <input
+ id="api_url"
+ :value="unleashApiUrl"
+ readonly
+ class="form-control"
+ type="text"
+ name="api_url"
+ />
+ <span class="input-group-append">
+ <modal-copy-button
+ :text="unleashApiUrl"
+ :title="$options.translations.apiUrlCopyText"
+ :modal-id="modalId"
+ class="input-group-text"
+ />
+ </span>
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="instance_id" class="label-bold">{{
+ $options.translations.instanceIdLabelText
+ }}</label>
+ <div class="input-group">
+ <input
+ id="instance_id"
+ :value="instanceId"
+ class="form-control"
+ type="text"
+ name="instance_id"
+ readonly
+ :disabled="isRotating"
+ />
+
+ <gl-loading-icon
+ v-if="isRotating"
+ class="position-absolute align-self-center instance-id-loading-icon"
+ />
+
+ <div class="input-group-append">
+ <modal-copy-button
+ :text="instanceId"
+ :title="$options.translations.instanceIdCopyText"
+ :modal-id="modalId"
+ :disabled="isRotating"
+ class="input-group-text"
+ />
+ </div>
+ </div>
+ </div>
+ <div
+ v-if="hasRotateError"
+ class="text-danger d-flex align-items-center font-weight-normal mb-2"
+ >
+ <gl-icon name="warning" class="mr-1" />
+ <span>{{ $options.translations.instanceIdRegenerateError }}</span>
+ </div>
+ <callout
+ v-if="canUserRotateToken"
+ category="danger"
+ :message="$options.translations.instanceIdRegenerateText"
+ />
+ <p v-if="canUserRotateToken" data-testid="prevent-accident-text">
+ <gl-sprintf
+ :message="
+ s__(
+ 'FeatureFlags|To prevent accidental actions we ask you to confirm your intention. Please type %{projectName} to proceed or close this modal to cancel.',
+ )
+ "
+ >
+ <template #projectName>
+ <span class="gl-font-weight-bold gl-text-red-500">{{ projectName }}</span>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-form-group>
+ <gl-form-input
+ v-if="canUserRotateToken"
+ id="project_name_verification"
+ v-model="enteredProjectName"
+ name="project_name"
+ type="text"
+ :disabled="isRotating"
+ />
+ </gl-form-group>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
new file mode 100644
index 00000000000..26b18f9bf5a
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
@@ -0,0 +1,144 @@
+<script>
+import { GlAlert, GlLoadingIcon, GlToggle } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import axios from '~/lib/utils/axios_utils';
+import { sprintf, s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { LEGACY_FLAG, NEW_FLAG_ALERT } from '../constants';
+import FeatureFlagForm from './form.vue';
+
+export default {
+ components: {
+ GlAlert,
+ GlLoadingIcon,
+ GlToggle,
+ FeatureFlagForm,
+ },
+ mixins: [glFeatureFlagMixin()],
+ inject: {
+ showUserCallout: {},
+ userCalloutId: {
+ default: '',
+ },
+ userCalloutsPath: {
+ default: '',
+ },
+ },
+ data() {
+ return {
+ userShouldSeeNewFlagAlert: this.showUserCallout,
+ };
+ },
+ translations: {
+ legacyFlagAlert: s__(
+ 'FeatureFlags|GitLab is moving to a new way of managing feature flags, and in 13.4, this feature flag will become read-only. Please create a new feature flag.',
+ ),
+ legacyReadOnlyFlagAlert: s__(
+ 'FeatureFlags|GitLab is moving to a new way of managing feature flags. This feature flag is read-only, and it will be removed in 14.0. Please create a new feature flag.',
+ ),
+ newFlagAlert: NEW_FLAG_ALERT,
+ },
+ computed: {
+ ...mapState([
+ 'path',
+ 'error',
+ 'name',
+ 'description',
+ 'scopes',
+ 'strategies',
+ 'isLoading',
+ 'hasError',
+ 'iid',
+ 'active',
+ 'version',
+ ]),
+ title() {
+ return this.iid
+ ? `^${this.iid} ${this.name}`
+ : sprintf(s__('Edit %{name}'), { name: this.name });
+ },
+ deprecated() {
+ return this.hasNewVersionFlags && this.version === LEGACY_FLAG;
+ },
+ deprecatedAndEditable() {
+ return this.deprecated && !this.hasLegacyReadOnlyFlags;
+ },
+ deprecatedAndReadOnly() {
+ return this.deprecated && this.hasLegacyReadOnlyFlags;
+ },
+ hasNewVersionFlags() {
+ return this.glFeatures.featureFlagsNewVersion;
+ },
+ hasLegacyReadOnlyFlags() {
+ return (
+ this.glFeatures.featureFlagsLegacyReadOnly &&
+ !this.glFeatures.featureFlagsLegacyReadOnlyOverride
+ );
+ },
+ shouldShowNewFlagAlert() {
+ return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert;
+ },
+ },
+ created() {
+ return this.fetchFeatureFlag();
+ },
+ methods: {
+ ...mapActions(['updateFeatureFlag', 'fetchFeatureFlag', 'toggleActive']),
+ dismissNewVersionFlagAlert() {
+ this.userShouldSeeNewFlagAlert = false;
+ axios.post(this.userCalloutsPath, {
+ feature_name: this.userCalloutId,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert
+ v-if="shouldShowNewFlagAlert"
+ variant="warning"
+ class="gl-my-5"
+ @dismiss="dismissNewVersionFlagAlert"
+ >
+ {{ $options.translations.newFlagAlert }}
+ </gl-alert>
+ <gl-loading-icon v-if="isLoading" />
+
+ <template v-else-if="!isLoading && !hasError">
+ <gl-alert v-if="deprecatedAndEditable" variant="warning" :dismissible="false" class="gl-my-5">
+ {{ $options.translations.legacyFlagAlert }}
+ </gl-alert>
+ <gl-alert v-if="deprecatedAndReadOnly" variant="warning" :dismissible="false" class="gl-my-5">
+ {{ $options.translations.legacyReadOnlyFlagAlert }}
+ </gl-alert>
+ <div class="gl-display-flex gl-align-items-center gl-mb-4 gl-mt-4">
+ <gl-toggle
+ :value="active"
+ data-testid="feature-flag-status-toggle"
+ data-track-event="click_button"
+ data-track-label="feature_flag_toggle"
+ class="gl-mr-4"
+ @change="toggleActive"
+ />
+ <h3 class="page-title gl-m-0">{{ title }}</h3>
+ </div>
+
+ <div v-if="error.length" class="alert alert-danger">
+ <p v-for="(message, index) in error" :key="index" class="gl-mb-0">{{ message }}</p>
+ </div>
+
+ <feature-flag-form
+ :name="name"
+ :description="description"
+ :scopes="scopes"
+ :strategies="strategies"
+ :cancel-path="path"
+ :submit-text="__('Save changes')"
+ :active="active"
+ :version="version"
+ @handleSubmit="data => updateFeatureFlag(data)"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue
new file mode 100644
index 00000000000..3caf536b6a2
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue
@@ -0,0 +1,181 @@
+<script>
+import { debounce } from 'lodash';
+import { GlDeprecatedButton, GlSearchBoxByType } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+
+/**
+ * Creates a searchable input for environments.
+ *
+ * When given a value, it will render it as selected value
+ * Otherwise it will render a placeholder for the search input.
+ * It will fetch the available environments on focus.
+ *
+ * When the user types, it will trigger an event to allow
+ * for API queries outside of the component.
+ *
+ * When results are returned, it renders a selectable
+ * list with the suggestions
+ *
+ * When no results are returned, it will render a
+ * button with a `Create` label. When clicked, it will
+ * emit an event to allow for the creation of a new
+ * record.
+ *
+ */
+
+export default {
+ name: 'EnvironmentsSearchableInput',
+ components: {
+ GlDeprecatedButton,
+ GlSearchBoxByType,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ placeholder: {
+ type: String,
+ required: false,
+ default: __('Search an environment spec'),
+ },
+ createButtonLabel: {
+ type: String,
+ required: false,
+ default: __('Create'),
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ inject: ['environmentsEndpoint'],
+ data() {
+ return {
+ environmentSearch: this.value,
+ results: [],
+ showSuggestions: false,
+ isLoading: false,
+ };
+ },
+ computed: {
+ /**
+ * Creates a label with the value of the filter
+ * @returns {String}
+ */
+ composedCreateButtonLabel() {
+ return `${this.createButtonLabel} ${this.environmentSearch}`;
+ },
+ shouldRenderCreateButton() {
+ return !this.isLoading && !this.results.length;
+ },
+ },
+ methods: {
+ fetchEnvironments: debounce(function debouncedFetchEnvironments() {
+ this.isLoading = true;
+ this.openSuggestions();
+ axios
+ .get(this.environmentsEndpoint, { params: { query: this.environmentSearch } })
+ .then(({ data }) => {
+ this.results = data || [];
+ this.isLoading = false;
+ })
+ .catch(() => {
+ this.isLoading = false;
+ this.closeSuggestions();
+ createFlash(__('Something went wrong on our end. Please try again.'));
+ });
+ }, 250),
+ /**
+ * Opens the list of suggestions
+ */
+ openSuggestions() {
+ this.showSuggestions = true;
+ },
+ /**
+ * Closes the list of suggestions and cleans the results
+ */
+ closeSuggestions() {
+ this.showSuggestions = false;
+ this.environmentSearch = '';
+ },
+ /**
+ * On click, it will:
+ * 1. clear the input value
+ * 2. close the list of suggestions
+ * 3. emit an event
+ */
+ clearInput() {
+ this.closeSuggestions();
+ this.$emit('clearInput');
+ },
+ /**
+ * When the user selects a value from the list of suggestions
+ *
+ * It emits an event with the selected value
+ * Clears the filter
+ * and closes the list of suggestions
+ *
+ * @param {String} selected
+ */
+ selectEnvironment(selected) {
+ this.$emit('selectEnvironment', selected);
+ this.results = [];
+ this.closeSuggestions();
+ },
+
+ /**
+ * When the user clicks the create button
+ * it emits an event with the filter value
+ */
+ createClicked() {
+ this.$emit('createClicked', this.environmentSearch);
+ this.closeSuggestions();
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="dropdown position-relative">
+ <gl-search-box-by-type
+ v-model.trim="environmentSearch"
+ class="js-env-search"
+ :aria-label="placeholder"
+ :placeholder="placeholder"
+ :disabled="disabled"
+ :is-loading="isLoading"
+ @focus="fetchEnvironments"
+ @keyup="fetchEnvironments"
+ />
+ <div
+ v-if="showSuggestions"
+ class="dropdown-menu d-block dropdown-menu-selectable dropdown-menu-full-width"
+ >
+ <div class="dropdown-content">
+ <ul v-if="results.length">
+ <li v-for="(result, i) in results" :key="i">
+ <gl-deprecated-button class="btn-transparent" @click="selectEnvironment(result)">{{
+ result
+ }}</gl-deprecated-button>
+ </li>
+ </ul>
+ <div v-else-if="!results.length" class="text-secondary gl-p-3">
+ {{ __('No matching results') }}
+ </div>
+ <div v-if="shouldRenderCreateButton" class="dropdown-footer">
+ <gl-deprecated-button
+ class="js-create-button btn-blank dropdown-item"
+ @click="createClicked"
+ >{{ composedCreateButtonLabel }}</gl-deprecated-button
+ >
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
new file mode 100644
index 00000000000..eb7046a3d9b
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -0,0 +1,326 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { isEmpty } from 'lodash';
+import { GlAlert, GlButton, GlModalDirective, GlSprintf, GlTabs } from '@gitlab/ui';
+
+import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../constants';
+import FeatureFlagsTab from './feature_flags_tab.vue';
+import FeatureFlagsTable from './feature_flags_table.vue';
+import UserListsTable from './user_lists_table.vue';
+import { s__ } from '~/locale';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import {
+ buildUrlWithCurrentLocation,
+ getParameterByName,
+ historyPushState,
+} from '~/lib/utils/common_utils';
+
+import ConfigureFeatureFlagsModal from './configure_feature_flags_modal.vue';
+
+const SCOPES = { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE };
+
+export default {
+ components: {
+ ConfigureFeatureFlagsModal,
+ FeatureFlagsTab,
+ FeatureFlagsTable,
+ GlAlert,
+ GlButton,
+ GlSprintf,
+ GlTabs,
+ TablePagination,
+ UserListsTable,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ inject: {
+ newUserListPath: { default: '' },
+ newFeatureFlagPath: { default: '' },
+ canUserConfigure: { required: true },
+ featureFlagsLimitExceeded: { required: true },
+ },
+ data() {
+ const scope = getParameterByName('scope') || SCOPES.FEATURE_FLAG_SCOPE;
+ return {
+ scope,
+ page: getParameterByName('page') || '1',
+ isUserListAlertDismissed: false,
+ shouldShowFeatureFlagsLimitWarning: this.featureFlagsLimitExceeded,
+ selectedTab: Object.values(SCOPES).indexOf(scope),
+ };
+ },
+ computed: {
+ ...mapState([
+ FEATURE_FLAG_SCOPE,
+ USER_LIST_SCOPE,
+ 'alerts',
+ 'count',
+ 'pageInfo',
+ 'isLoading',
+ 'hasError',
+ 'options',
+ 'instanceId',
+ 'isRotating',
+ 'hasRotateError',
+ ]),
+ topAreaBaseClasses() {
+ return ['gl-display-flex', 'gl-flex-direction-column'];
+ },
+ canUserRotateToken() {
+ return this.rotateInstanceIdPath !== '';
+ },
+ currentlyDisplayedData() {
+ return this.dataForScope(this.scope);
+ },
+ shouldRenderPagination() {
+ return (
+ !this.isLoading &&
+ !this.hasError &&
+ this.currentlyDisplayedData.length > 0 &&
+ this.pageInfo[this.scope].total > this.pageInfo[this.scope].perPage
+ );
+ },
+ shouldShowEmptyState() {
+ return !this.isLoading && !this.hasError && this.currentlyDisplayedData.length === 0;
+ },
+ shouldRenderErrorState() {
+ return this.hasError && !this.isLoading;
+ },
+ shouldRenderFeatureFlags() {
+ return this.shouldRenderTable(SCOPES.FEATURE_FLAG_SCOPE);
+ },
+ shouldRenderUserLists() {
+ return this.shouldRenderTable(SCOPES.USER_LIST_SCOPE);
+ },
+ hasNewPath() {
+ return !isEmpty(this.newFeatureFlagPath);
+ },
+ emptyStateTitle() {
+ return s__('FeatureFlags|Get started with feature flags');
+ },
+ },
+ created() {
+ this.setFeatureFlagsOptions({ scope: this.scope, page: this.page });
+ this.fetchFeatureFlags();
+ this.fetchUserLists();
+ },
+ methods: {
+ ...mapActions([
+ 'setFeatureFlagsOptions',
+ 'fetchFeatureFlags',
+ 'fetchUserLists',
+ 'rotateInstanceId',
+ 'toggleFeatureFlag',
+ 'deleteUserList',
+ 'clearAlert',
+ ]),
+ onChangeTab(scope) {
+ this.scope = scope;
+ this.updateFeatureFlagOptions({
+ scope,
+ page: '1',
+ });
+ },
+ onFeatureFlagsTab() {
+ this.onChangeTab(SCOPES.FEATURE_FLAG_SCOPE);
+ },
+ onUserListsTab() {
+ this.onChangeTab(SCOPES.USER_LIST_SCOPE);
+ },
+ onChangePage(page) {
+ this.updateFeatureFlagOptions({
+ scope: this.scope,
+ /* URLS parameters are strings, we need to parse to match types */
+ page: Number(page).toString(),
+ });
+ },
+ updateFeatureFlagOptions(parameters) {
+ const queryString = Object.keys(parameters)
+ .map(parameter => {
+ const value = parameters[parameter];
+ return `${parameter}=${encodeURIComponent(value)}`;
+ })
+ .join('&');
+
+ historyPushState(buildUrlWithCurrentLocation(`?${queryString}`));
+ this.setFeatureFlagsOptions(parameters);
+ if (this.scope === SCOPES.FEATURE_FLAG_SCOPE) {
+ this.fetchFeatureFlags();
+ } else {
+ this.fetchUserLists();
+ }
+ },
+ shouldRenderTable(scope) {
+ return (
+ !this.isLoading &&
+ this.dataForScope(scope).length > 0 &&
+ !this.hasError &&
+ this.scope === scope
+ );
+ },
+ dataForScope(scope) {
+ return this[scope];
+ },
+ onDismissFeatureFlagsLimitWarning() {
+ this.shouldShowFeatureFlagsLimitWarning = false;
+ },
+ onNewFeatureFlagCLick() {
+ if (this.featureFlagsLimitExceeded) {
+ this.shouldShowFeatureFlagsLimitWarning = true;
+ }
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert
+ v-if="shouldShowFeatureFlagsLimitWarning"
+ variant="warning"
+ @dismiss="onDismissFeatureFlagsLimitWarning"
+ >
+ <gl-sprintf
+ :message="
+ s__(
+ 'FeatureFlags|Feature flags limit reached (%{featureFlagsLimit}). Delete one or more feature flags before adding new ones.',
+ )
+ "
+ >
+ <template #featureFlagsLimit>
+ <span>{{ featureFlagsLimit }}</span>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ <configure-feature-flags-modal
+ v-if="canUserConfigure"
+ :instance-id="instanceId"
+ :is-rotating="isRotating"
+ :has-rotate-error="hasRotateError"
+ :can-user-rotate-token="canUserRotateToken"
+ modal-id="configure-feature-flags"
+ @token="rotateInstanceId()"
+ />
+ <div :class="topAreaBaseClasses">
+ <div class="gl-display-flex gl-flex-direction-column gl-display-md-none!">
+ <gl-button
+ v-if="canUserConfigure"
+ v-gl-modal="'configure-feature-flags'"
+ variant="info"
+ category="secondary"
+ data-qa-selector="configure_feature_flags_button"
+ data-testid="ff-configure-button"
+ class="gl-mb-3"
+ >
+ {{ s__('FeatureFlags|Configure') }}
+ </gl-button>
+
+ <gl-button
+ v-if="newUserListPath"
+ :href="newUserListPath"
+ variant="success"
+ category="secondary"
+ class="gl-mb-3"
+ data-testid="ff-new-list-button"
+ >
+ {{ s__('FeatureFlags|New user list') }}
+ </gl-button>
+
+ <gl-button
+ v-if="hasNewPath"
+ :href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath"
+ variant="success"
+ data-testid="ff-new-button"
+ @click="onNewFeatureFlagCLick"
+ >
+ {{ s__('FeatureFlags|New feature flag') }}
+ </gl-button>
+ </div>
+ <gl-tabs v-model="selectedTab" class="gl-align-items-center gl-w-full">
+ <feature-flags-tab
+ :title="s__('FeatureFlags|Feature Flags')"
+ :count="count.featureFlags"
+ :alerts="alerts"
+ :is-loading="isLoading"
+ :loading-label="s__('FeatureFlags|Loading feature flags')"
+ :error-state="shouldRenderErrorState"
+ :error-title="s__(`FeatureFlags|There was an error fetching the feature flags.`)"
+ :empty-state="shouldShowEmptyState"
+ :empty-title="emptyStateTitle"
+ data-testid="feature-flags-tab"
+ @dismissAlert="clearAlert"
+ @changeTab="onFeatureFlagsTab"
+ >
+ <feature-flags-table
+ v-if="shouldRenderFeatureFlags"
+ :feature-flags="featureFlags"
+ @toggle-flag="toggleFeatureFlag"
+ />
+ </feature-flags-tab>
+ <feature-flags-tab
+ :title="s__('FeatureFlags|User Lists')"
+ :count="count.userLists"
+ :alerts="alerts"
+ :is-loading="isLoading"
+ :loading-label="s__('FeatureFlags|Loading user lists')"
+ :error-state="shouldRenderErrorState"
+ :error-title="s__(`FeatureFlags|There was an error fetching the user lists.`)"
+ :empty-state="shouldShowEmptyState"
+ :empty-title="emptyStateTitle"
+ data-testid="user-lists-tab"
+ @dismissAlert="clearAlert"
+ @changeTab="onUserListsTab"
+ >
+ <user-lists-table
+ v-if="shouldRenderUserLists"
+ :user-lists="userLists"
+ @delete="deleteUserList"
+ />
+ </feature-flags-tab>
+ <template #tabs-end>
+ <div
+ class="gl-display-none gl-display-md-flex gl-align-items-center gl-flex-fill-1 gl-justify-content-end"
+ >
+ <gl-button
+ v-if="canUserConfigure"
+ v-gl-modal="'configure-feature-flags'"
+ variant="info"
+ category="secondary"
+ data-qa-selector="configure_feature_flags_button"
+ data-testid="ff-configure-button"
+ class="gl-mb-0 gl-mr-4"
+ >
+ {{ s__('FeatureFlags|Configure') }}
+ </gl-button>
+
+ <gl-button
+ v-if="newUserListPath"
+ :href="newUserListPath"
+ variant="success"
+ category="secondary"
+ class="gl-mb-0 gl-mr-4"
+ data-testid="ff-new-list-button"
+ >
+ {{ s__('FeatureFlags|New user list') }}
+ </gl-button>
+
+ <gl-button
+ v-if="hasNewPath"
+ :href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath"
+ variant="success"
+ data-testid="ff-new-button"
+ @click="onNewFeatureFlagCLick"
+ >
+ {{ s__('FeatureFlags|New feature flag') }}
+ </gl-button>
+ </div>
+ </template>
+ </gl-tabs>
+ </div>
+ <table-pagination
+ v-if="shouldRenderPagination"
+ :change="onChangePage"
+ :page-info="pageInfo[scope]"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue
new file mode 100644
index 00000000000..5c35aa33e14
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue
@@ -0,0 +1,108 @@
+<script>
+import { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTab } from '@gitlab/ui';
+
+export default {
+ components: { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTab },
+ props: {
+ title: {
+ required: true,
+ type: String,
+ },
+ count: {
+ required: false,
+ type: Number,
+ default: null,
+ },
+ alerts: {
+ required: true,
+ type: Array,
+ },
+ isLoading: {
+ required: true,
+ type: Boolean,
+ },
+ loadingLabel: {
+ required: true,
+ type: String,
+ },
+ errorState: {
+ required: true,
+ type: Boolean,
+ },
+ errorTitle: {
+ required: true,
+ type: String,
+ },
+ emptyState: {
+ required: true,
+ type: Boolean,
+ },
+ emptyTitle: {
+ required: true,
+ type: String,
+ },
+ },
+ inject: ['errorStateSvgPath', 'featureFlagsHelpPagePath'],
+ computed: {
+ itemCount() {
+ return this.count ?? 0;
+ },
+ },
+ methods: {
+ clearAlert(index) {
+ this.$emit('dismissAlert', index);
+ },
+ onClick(event) {
+ return this.$emit('changeTab', event);
+ },
+ },
+};
+</script>
+<template>
+ <gl-tab @click="onClick">
+ <template #title>
+ <span data-testid="feature-flags-tab-title">{{ title }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ itemCount }}</gl-badge>
+ </template>
+ <template>
+ <gl-alert
+ v-for="(message, index) in alerts"
+ :key="index"
+ data-testid="serverErrors"
+ variant="danger"
+ @dismiss="clearAlert(index)"
+ >
+ {{ message }}
+ </gl-alert>
+
+ <gl-loading-icon v-if="isLoading" :label="loadingLabel" size="md" class="gl-mt-4" />
+
+ <gl-empty-state
+ v-else-if="errorState"
+ :title="errorTitle"
+ :description="s__(`FeatureFlags|Try again in a few moments or contact your support team.`)"
+ :svg-path="errorStateSvgPath"
+ data-testid="error-state"
+ />
+
+ <gl-empty-state
+ v-else-if="emptyState"
+ :title="emptyTitle"
+ :svg-path="errorStateSvgPath"
+ data-testid="empty-state"
+ >
+ <template #description>
+ {{
+ s__(
+ 'FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.',
+ )
+ }}
+ <gl-link :href="featureFlagsHelpPagePath" target="_blank">
+ {{ s__('FeatureFlags|More information') }}
+ </gl-link>
+ </template>
+ </gl-empty-state>
+ <slot> </slot>
+ </template>
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
new file mode 100644
index 00000000000..54d038606f4
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -0,0 +1,271 @@
+<script>
+import { GlBadge, GlButton, GlTooltipDirective, GlModal, GlToggle, GlIcon } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { ROLLOUT_STRATEGY_PERCENT_ROLLOUT, NEW_VERSION_FLAG, LEGACY_FLAG } from '../constants';
+import { labelForStrategy } from '../utils';
+
+export default {
+ components: {
+ GlBadge,
+ GlButton,
+ GlIcon,
+ GlModal,
+ GlToggle,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [glFeatureFlagMixin()],
+ props: {
+ featureFlags: {
+ type: Array,
+ required: true,
+ },
+ },
+ inject: ['csrfToken'],
+ data() {
+ return {
+ deleteFeatureFlagUrl: null,
+ deleteFeatureFlagName: null,
+ };
+ },
+ translations: {
+ legacyFlagAlert: s__('FeatureFlags|Flag becomes read only soon'),
+ legacyFlagReadOnlyAlert: s__('FeatureFlags|Flag is read-only'),
+ },
+ computed: {
+ permissions() {
+ return this.glFeatures.featureFlagPermissions;
+ },
+ isNewVersionFlagsEnabled() {
+ return this.glFeatures.featureFlagsNewVersion;
+ },
+ isLegacyReadOnlyFlagsEnabled() {
+ return (
+ this.glFeatures.featureFlagsLegacyReadOnly &&
+ !this.glFeatures.featureFlagsLegacyReadOnlyOverride
+ );
+ },
+ modalTitle() {
+ return sprintf(s__('FeatureFlags|Delete %{name}?'), {
+ name: this.deleteFeatureFlagName,
+ });
+ },
+ deleteModalMessage() {
+ return sprintf(s__('FeatureFlags|Feature flag %{name} will be removed. Are you sure?'), {
+ name: this.deleteFeatureFlagName,
+ });
+ },
+ modalId() {
+ return 'delete-feature-flag';
+ },
+ legacyFlagToolTipText() {
+ const { legacyFlagReadOnlyAlert, legacyFlagAlert } = this.$options.translations;
+
+ return this.isLegacyReadOnlyFlagsEnabled ? legacyFlagReadOnlyAlert : legacyFlagAlert;
+ },
+ },
+ methods: {
+ isLegacyFlag(flag) {
+ return !this.isNewVersionFlagsEnabled || flag.version !== NEW_VERSION_FLAG;
+ },
+ statusToggleDisabled(flag) {
+ return this.isLegacyReadOnlyFlagsEnabled && flag.version === LEGACY_FLAG;
+ },
+ scopeTooltipText(scope) {
+ return !scope.active
+ ? sprintf(s__('FeatureFlags|Inactive flag for %{scope}'), {
+ scope: scope.environmentScope,
+ })
+ : '';
+ },
+ badgeText(scope) {
+ const displayName =
+ scope.environmentScope === '*'
+ ? s__('FeatureFlags|* (All environments)')
+ : scope.environmentScope;
+
+ const displayPercentage =
+ scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT
+ ? `: ${scope.rolloutPercentage}%`
+ : '';
+
+ return `${displayName}${displayPercentage}`;
+ },
+ badgeVariant(scope) {
+ return scope.active ? 'info' : 'muted';
+ },
+ strategyBadgeText(strategy) {
+ return labelForStrategy(strategy);
+ },
+ featureFlagIidText(featureFlag) {
+ return featureFlag.iid ? `^${featureFlag.iid}` : '';
+ },
+ canDeleteFlag(flag) {
+ return !this.permissions || (flag.scopes || []).every(scope => scope.can_update);
+ },
+ setDeleteModalData(featureFlag) {
+ this.deleteFeatureFlagUrl = featureFlag.destroy_path;
+ this.deleteFeatureFlagName = featureFlag.name;
+
+ this.$refs[this.modalId].show();
+ },
+ onSubmit() {
+ this.$refs.form.submit();
+ },
+ toggleFeatureFlag(flag) {
+ this.$emit('toggle-flag', {
+ ...flag,
+ active: !flag.active,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div class="table-holder js-feature-flag-table">
+ <div class="gl-responsive-table-row table-row-header" role="row">
+ <div class="table-section section-10">
+ {{ s__('FeatureFlags|ID') }}
+ </div>
+ <div class="table-section section-10" role="columnheader">
+ {{ s__('FeatureFlags|Status') }}
+ </div>
+ <div class="table-section section-20" role="columnheader">
+ {{ s__('FeatureFlags|Feature Flag') }}
+ </div>
+ <div class="table-section section-40" role="columnheader">
+ {{ s__('FeatureFlags|Environment Specs') }}
+ </div>
+ </div>
+
+ <template v-for="featureFlag in featureFlags">
+ <div :key="featureFlag.id" class="gl-responsive-table-row" role="row">
+ <div class="table-section section-10" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|ID') }}</div>
+ <div class="table-mobile-content js-feature-flag-id">
+ {{ featureFlagIidText(featureFlag) }}
+ </div>
+ </div>
+ <div class="table-section section-10" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|Status') }}</div>
+ <div class="table-mobile-content">
+ <gl-toggle
+ v-if="featureFlag.update_path"
+ :value="featureFlag.active"
+ :disabled="statusToggleDisabled(featureFlag)"
+ data-testid="feature-flag-status-toggle"
+ data-track-event="click_button"
+ data-track-label="feature_flag_toggle"
+ @change="toggleFeatureFlag(featureFlag)"
+ />
+ <gl-badge
+ v-else-if="featureFlag.active"
+ variant="success"
+ data-testid="feature-flag-status-badge"
+ >
+ {{ s__('FeatureFlags|Active') }}
+ </gl-badge>
+ <gl-badge v-else variant="danger">{{ s__('FeatureFlags|Inactive') }}</gl-badge>
+ </div>
+ </div>
+
+ <div class="table-section section-20" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ 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">
+ <div class="feature-flag-name text-monospace text-truncate">
+ {{ featureFlag.name }}
+ </div>
+ <gl-icon
+ v-if="isLegacyFlag(featureFlag)"
+ v-gl-tooltip.hover="legacyFlagToolTipText"
+ class="gl-ml-3"
+ name="information-o"
+ />
+ </div>
+ <div class="feature-flag-description text-secondary text-truncate">
+ {{ featureFlag.description }}
+ </div>
+ </div>
+ </div>
+
+ <div class="table-section section-40" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Environment Specs') }}
+ </div>
+ <div
+ class="table-mobile-content d-flex flex-wrap justify-content-end justify-content-md-start js-feature-flag-environments"
+ >
+ <template v-if="isLegacyFlag(featureFlag)">
+ <gl-badge
+ v-for="scope in featureFlag.scopes"
+ :key="scope.id"
+ v-gl-tooltip.hover="scopeTooltipText(scope)"
+ :variant="badgeVariant(scope)"
+ :data-qa-selector="`feature-flag-scope-${badgeVariant(scope)}-badge`"
+ class="gl-mr-3 gl-mt-2"
+ >
+ {{ badgeText(scope) }}
+ </gl-badge>
+ </template>
+ <template v-else>
+ <gl-badge
+ v-for="strategy in featureFlag.strategies"
+ :key="strategy.id"
+ data-testid="strategy-badge"
+ variant="info"
+ class="gl-mr-3 gl-mt-2"
+ >
+ {{ strategyBadgeText(strategy) }}
+ </gl-badge>
+ </template>
+ </div>
+ </div>
+
+ <div class="table-section section-20 table-button-footer" role="gridcell">
+ <div class="table-action-buttons btn-group">
+ <template v-if="featureFlag.edit_path">
+ <gl-button
+ v-gl-tooltip.hover.bottom="__('Edit')"
+ class="js-feature-flag-edit-button"
+ icon="pencil"
+ :href="featureFlag.edit_path"
+ />
+ </template>
+ <template v-if="featureFlag.destroy_path">
+ <gl-button
+ v-gl-tooltip.hover.bottom="__('Delete')"
+ class="js-feature-flag-delete-button"
+ variant="danger"
+ icon="remove"
+ :disabled="!canDeleteFlag(featureFlag)"
+ @click="setDeleteModalData(featureFlag)"
+ />
+ </template>
+ </div>
+ </div>
+ </div>
+ </template>
+
+ <gl-modal
+ :ref="modalId"
+ :title="modalTitle"
+ :ok-title="s__('FeatureFlags|Delete feature flag')"
+ :modal-id="modalId"
+ title-tag="h4"
+ ok-variant="danger"
+ category="primary"
+ @ok="onSubmit"
+ >
+ {{ deleteModalMessage }}
+ <form ref="form" :action="deleteFeatureFlagUrl" method="post" class="js-requires-input">
+ <input ref="method" type="hidden" name="_method" value="delete" />
+ <input :value="csrfToken" type="hidden" name="authenticity_token" />
+ </form>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue
new file mode 100644
index 00000000000..3c1944d91bd
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/form.vue
@@ -0,0 +1,606 @@
+<script>
+import Vue from 'vue';
+import { memoize, isString, cloneDeep, isNumber, uniqueId } from 'lodash';
+import {
+ GlButton,
+ GlDeprecatedBadge as GlBadge,
+ GlTooltip,
+ GlTooltipDirective,
+ GlFormTextarea,
+ GlFormCheckbox,
+ GlSprintf,
+ GlIcon,
+} from '@gitlab/ui';
+import Api from '~/api';
+import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue';
+import { s__ } from '~/locale';
+import { deprecatedCreateFlash as flash, FLASH_TYPES } from '~/flash';
+import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import ToggleButton from '~/vue_shared/components/toggle_button.vue';
+import EnvironmentsDropdown from './environments_dropdown.vue';
+import Strategy from './strategy.vue';
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ALL_ENVIRONMENTS_NAME,
+ INTERNAL_ID_PREFIX,
+ NEW_VERSION_FLAG,
+ LEGACY_FLAG,
+} from '../constants';
+import { createNewEnvironmentScope } from '../store/helpers';
+
+export default {
+ components: {
+ GlButton,
+ GlBadge,
+ GlFormTextarea,
+ GlFormCheckbox,
+ GlTooltip,
+ GlSprintf,
+ GlIcon,
+ ToggleButton,
+ EnvironmentsDropdown,
+ Strategy,
+ RelatedIssuesRoot,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [featureFlagsMixin()],
+ props: {
+ active: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ description: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ scopes: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ cancelPath: {
+ type: String,
+ required: true,
+ },
+ submitText: {
+ type: String,
+ required: true,
+ },
+ strategies: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ version: {
+ type: String,
+ required: false,
+ default: LEGACY_FLAG,
+ },
+ },
+ inject: {
+ projectId: {},
+ featureFlagIssuesEndpoint: {
+ default: '',
+ },
+ },
+ translations: {
+ allEnvironmentsText: s__('FeatureFlags|* (All Environments)'),
+
+ helpText: s__(
+ 'FeatureFlags|Feature Flag behavior is built up by creating a set of rules to define the status of target environments. A default wildcard rule %{codeStart}*%{codeEnd} for %{boldStart}All Environments%{boldEnd} is set, and you are able to add as many rules as you need by choosing environment specs below. You can toggle the behavior for each of your rules to set them %{boldStart}Active%{boldEnd} or %{boldStart}Inactive%{boldEnd}.',
+ ),
+
+ newHelpText: s__(
+ 'FeatureFlags|Enable features for specific users and environments by configuring feature flag strategies.',
+ ),
+ noStrategiesText: s__('FeatureFlags|Feature Flag has no strategies'),
+ },
+
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+
+ // Matches numbers 0 through 100
+ rolloutPercentageRegex: /^[0-9]$|^[1-9][0-9]$|^100$/,
+
+ data() {
+ return {
+ formName: this.name,
+ formDescription: this.description,
+
+ // operate on a clone to avoid mutating props
+ formScopes: this.scopes.map(s => ({ ...s })),
+ formStrategies: cloneDeep(this.strategies),
+
+ newScope: '',
+ userLists: [],
+ };
+ },
+ computed: {
+ filteredScopes() {
+ return this.formScopes.filter(scope => !scope.shouldBeDestroyed);
+ },
+ filteredStrategies() {
+ return this.formStrategies.filter(s => !s.shouldBeDestroyed);
+ },
+ canUpdateFlag() {
+ return !this.permissionsFlag || (this.formScopes || []).every(scope => scope.canUpdate);
+ },
+ permissionsFlag() {
+ return this.glFeatures.featureFlagPermissions;
+ },
+ supportsStrategies() {
+ return this.glFeatures.featureFlagsNewVersion && this.version === NEW_VERSION_FLAG;
+ },
+ showRelatedIssues() {
+ return this.featureFlagIssuesEndpoint.length > 0;
+ },
+ readOnly() {
+ return (
+ this.glFeatures.featureFlagsNewVersion &&
+ this.glFeatures.featureFlagsLegacyReadOnly &&
+ !this.glFeatures.featureFlagsLegacyReadOnlyOverride &&
+ this.version === LEGACY_FLAG
+ );
+ },
+ },
+ mounted() {
+ if (this.supportsStrategies) {
+ Api.fetchFeatureFlagUserLists(this.projectId)
+ .then(({ data }) => {
+ this.userLists = data;
+ })
+ .catch(() => {
+ flash(s__('FeatureFlags|There was an error retrieving user lists'), FLASH_TYPES.WARNING);
+ });
+ }
+ },
+ methods: {
+ keyFor(strategy) {
+ if (strategy.id) {
+ return strategy.id;
+ }
+
+ return uniqueId('strategy_');
+ },
+
+ addStrategy() {
+ this.formStrategies.push({ name: ROLLOUT_STRATEGY_ALL_USERS, parameters: {}, scopes: [] });
+ },
+
+ deleteStrategy(s) {
+ if (isNumber(s.id)) {
+ Vue.set(s, 'shouldBeDestroyed', true);
+ } else {
+ this.formStrategies = this.formStrategies.filter(strategy => strategy !== s);
+ }
+ },
+
+ isAllEnvironment(name) {
+ return name === ALL_ENVIRONMENTS_NAME;
+ },
+
+ /**
+ * When the user clicks the remove button we delete the scope
+ *
+ * If the scope has an ID, we need to add the `shouldBeDestroyed` flag.
+ * If the scope does *not* have an ID, we can just remove it.
+ *
+ * This flag will be used when submitting the data to the backend
+ * to determine which records to delete (via a "_destroy" property).
+ *
+ * @param {Object} scope
+ */
+ removeScope(scope) {
+ if (isString(scope.id) && scope.id.startsWith(INTERNAL_ID_PREFIX)) {
+ this.formScopes = this.formScopes.filter(s => s !== scope);
+ } else {
+ Vue.set(scope, 'shouldBeDestroyed', true);
+ }
+ },
+
+ /**
+ * Creates a new scope and adds it to the list of scopes
+ *
+ * @param overrides An object whose properties will
+ * be used override the default scope options
+ */
+ createNewScope(overrides) {
+ this.formScopes.push(createNewEnvironmentScope(overrides, this.permissionsFlag));
+ this.newScope = '';
+ },
+
+ /**
+ * When the user clicks the submit button
+ * it triggers an event with the form data
+ */
+ handleSubmit() {
+ const flag = {
+ name: this.formName,
+ description: this.formDescription,
+ active: this.active,
+ version: this.version,
+ };
+
+ if (this.version === LEGACY_FLAG) {
+ flag.scopes = this.formScopes;
+ } else {
+ flag.strategies = this.formStrategies;
+ }
+
+ this.$emit('handleSubmit', flag);
+ },
+
+ canUpdateScope(scope) {
+ return !this.permissionsFlag || scope.canUpdate;
+ },
+
+ isRolloutPercentageInvalid: memoize(function isRolloutPercentageInvalid(percentage) {
+ return !this.$options.rolloutPercentageRegex.test(percentage);
+ }),
+
+ /**
+ * Generates a unique ID for the strategy based on the v-for index
+ *
+ * @param index The index of the strategy
+ */
+ rolloutStrategyId(index) {
+ return `rollout-strategy-${index}`;
+ },
+
+ /**
+ * Generates a unique ID for the percentage based on the v-for index
+ *
+ * @param index The index of the percentage
+ */
+ rolloutPercentageId(index) {
+ return `rollout-percentage-${index}`;
+ },
+ rolloutUserId(index) {
+ return `rollout-user-id-${index}`;
+ },
+
+ shouldDisplayIncludeUserIds(scope) {
+ return ![ROLLOUT_STRATEGY_ALL_USERS, ROLLOUT_STRATEGY_USER_ID].includes(
+ scope.rolloutStrategy,
+ );
+ },
+ shouldDisplayUserIds(scope) {
+ return scope.rolloutStrategy === ROLLOUT_STRATEGY_USER_ID || scope.shouldIncludeUserIds;
+ },
+ onStrategyChange(index) {
+ const scope = this.filteredScopes[index];
+ scope.shouldIncludeUserIds =
+ scope.rolloutUserIds.length > 0 &&
+ scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT;
+ },
+ onFormStrategyChange(strategy, index) {
+ Object.assign(this.filteredStrategies[index], strategy);
+ },
+ },
+};
+</script>
+<template>
+ <form class="feature-flags-form">
+ <fieldset>
+ <div class="row">
+ <div class="form-group col-md-4">
+ <label for="feature-flag-name" class="label-bold">{{ s__('FeatureFlags|Name') }} *</label>
+ <input
+ id="feature-flag-name"
+ v-model="formName"
+ :disabled="!canUpdateFlag"
+ class="form-control"
+ />
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="form-group col-md-4">
+ <label for="feature-flag-description" class="label-bold">
+ {{ s__('FeatureFlags|Description') }}
+ </label>
+ <textarea
+ id="feature-flag-description"
+ v-model="formDescription"
+ :disabled="!canUpdateFlag"
+ class="form-control"
+ rows="4"
+ ></textarea>
+ </div>
+ </div>
+
+ <related-issues-root
+ v-if="showRelatedIssues"
+ :endpoint="featureFlagIssuesEndpoint"
+ :can-admin="true"
+ :show-categorized-issues="false"
+ />
+
+ <template v-if="supportsStrategies">
+ <div class="row">
+ <div class="col-md-12">
+ <h4>{{ s__('FeatureFlags|Strategies') }}</h4>
+ <div class="flex align-items-baseline justify-content-between">
+ <p class="mr-3">{{ $options.translations.newHelpText }}</p>
+ <gl-button variant="success" category="secondary" @click="addStrategy">
+ {{ s__('FeatureFlags|Add strategy') }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+ <div v-if="filteredStrategies.length > 0" data-testid="feature-flag-strategies">
+ <strategy
+ v-for="(strategy, index) in filteredStrategies"
+ :key="keyFor(strategy)"
+ :strategy="strategy"
+ :index="index"
+ :user-lists="userLists"
+ @change="onFormStrategyChange($event, index)"
+ @delete="deleteStrategy(strategy)"
+ />
+ </div>
+ <div v-else class="flex justify-content-center border-top py-4 w-100">
+ <span>{{ $options.translations.noStrategiesText }}</span>
+ </div>
+ </template>
+
+ <div v-else class="row">
+ <div class="form-group col-md-12">
+ <h4>{{ s__('FeatureFlags|Target environments') }}</h4>
+ <gl-sprintf :message="$options.translations.helpText">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ <template #bold="{ content }">
+ <b>{{ content }}</b>
+ </template>
+ </gl-sprintf>
+
+ <div class="js-scopes-table gl-mt-3">
+ <div class="gl-responsive-table-row table-row-header" role="row">
+ <div class="table-section section-30" role="columnheader">
+ {{ s__('FeatureFlags|Environment Spec') }}
+ </div>
+ <div class="table-section section-20 text-center" role="columnheader">
+ {{ s__('FeatureFlags|Status') }}
+ </div>
+ <div class="table-section section-40" role="columnheader">
+ {{ s__('FeatureFlags|Rollout Strategy') }}
+ </div>
+ </div>
+
+ <div
+ v-for="(scope, index) in filteredScopes"
+ :key="scope.id"
+ ref="scopeRow"
+ class="gl-responsive-table-row"
+ role="row"
+ >
+ <div class="table-section section-30" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Environment Spec') }}
+ </div>
+ <div
+ class="table-mobile-content js-feature-flag-status d-flex align-items-center justify-content-start"
+ >
+ <p v-if="isAllEnvironment(scope.environmentScope)" class="js-scope-all pl-3">
+ {{ $options.translations.allEnvironmentsText }}
+ </p>
+
+ <environments-dropdown
+ v-else
+ class="col-12"
+ :value="scope.environmentScope"
+ :disabled="!canUpdateScope(scope) || scope.environmentScope !== ''"
+ @selectEnvironment="env => (scope.environmentScope = env)"
+ @createClicked="env => (scope.environmentScope = env)"
+ @clearInput="env => (scope.environmentScope = '')"
+ />
+
+ <gl-badge v-if="permissionsFlag && scope.protected" variant="success">
+ {{ s__('FeatureFlags|Protected') }}
+ </gl-badge>
+ </div>
+ </div>
+
+ <div class="table-section section-20 text-center" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Status') }}
+ </div>
+ <div class="table-mobile-content js-feature-flag-status">
+ <toggle-button
+ :value="scope.active"
+ :disabled-input="!active || !canUpdateScope(scope)"
+ @change="status => (scope.active = status)"
+ />
+ </div>
+ </div>
+
+ <div class="table-section section-40" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Rollout Strategy') }}
+ </div>
+ <div class="table-mobile-content js-rollout-strategy form-inline">
+ <label class="sr-only" :for="rolloutStrategyId(index)">
+ {{ s__('FeatureFlags|Rollout Strategy') }}
+ </label>
+ <div class="select-wrapper col-12 col-md-8 p-0">
+ <select
+ :id="rolloutStrategyId(index)"
+ v-model="scope.rolloutStrategy"
+ :disabled="!scope.active"
+ class="form-control select-control w-100 js-rollout-strategy"
+ @change="onStrategyChange(index)"
+ >
+ <option :value="$options.ROLLOUT_STRATEGY_ALL_USERS">
+ {{ s__('FeatureFlags|All users') }}
+ </option>
+ <option :value="$options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT">
+ {{ s__('FeatureFlags|Percent rollout (logged in users)') }}
+ </option>
+ <option :value="$options.ROLLOUT_STRATEGY_USER_ID">
+ {{ s__('FeatureFlags|User IDs') }}
+ </option>
+ </select>
+ <gl-icon
+ name="chevron-down"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
+ :size="16"
+ />
+ </div>
+
+ <div
+ v-if="scope.rolloutStrategy === $options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT"
+ class="d-flex-center mt-2 mt-md-0 ml-md-2"
+ >
+ <label class="sr-only" :for="rolloutPercentageId(index)">
+ {{ s__('FeatureFlags|Rollout Percentage') }}
+ </label>
+ <div class="gl-w-9">
+ <input
+ :id="rolloutPercentageId(index)"
+ v-model="scope.rolloutPercentage"
+ :disabled="!scope.active"
+ :class="{
+ 'is-invalid': isRolloutPercentageInvalid(scope.rolloutPercentage),
+ }"
+ type="number"
+ min="0"
+ max="100"
+ :pattern="$options.rolloutPercentageRegex.source"
+ class="rollout-percentage js-rollout-percentage form-control text-right w-100"
+ />
+ </div>
+ <gl-tooltip
+ v-if="isRolloutPercentageInvalid(scope.rolloutPercentage)"
+ :target="rolloutPercentageId(index)"
+ >
+ {{
+ s__('FeatureFlags|Percent rollout must be a whole number between 0 and 100')
+ }}
+ </gl-tooltip>
+ <span class="ml-1">%</span>
+ </div>
+ <div class="d-flex flex-column align-items-start mt-2 w-100">
+ <gl-form-checkbox
+ v-if="shouldDisplayIncludeUserIds(scope)"
+ v-model="scope.shouldIncludeUserIds"
+ >{{ s__('FeatureFlags|Include additional user IDs') }}</gl-form-checkbox
+ >
+ <template v-if="shouldDisplayUserIds(scope)">
+ <label :for="rolloutUserId(index)" class="mb-2">
+ {{ s__('FeatureFlags|User IDs') }}
+ </label>
+ <gl-form-textarea
+ :id="rolloutUserId(index)"
+ v-model="scope.rolloutUserIds"
+ class="w-100"
+ />
+ </template>
+ </div>
+ </div>
+ </div>
+
+ <div class="table-section section-10 text-right" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Remove') }}
+ </div>
+ <div class="table-mobile-content js-feature-flag-delete">
+ <gl-button
+ v-if="!isAllEnvironment(scope.environmentScope) && canUpdateScope(scope)"
+ v-gl-tooltip
+ :title="s__('FeatureFlags|Remove')"
+ class="js-delete-scope btn-transparent pr-3 pl-3"
+ icon="clear"
+ @click="removeScope(scope)"
+ />
+ </div>
+ </div>
+ </div>
+
+ <div class="js-add-new-scope gl-responsive-table-row" role="row">
+ <div class="table-section section-30" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Environment Spec') }}
+ </div>
+ <div class="table-mobile-content js-feature-flag-status">
+ <environments-dropdown
+ class="js-new-scope-name col-12"
+ :value="newScope"
+ @selectEnvironment="env => createNewScope({ environmentScope: env })"
+ @createClicked="env => createNewScope({ environmentScope: env })"
+ />
+ </div>
+ </div>
+
+ <div class="table-section section-20 text-center" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Status') }}
+ </div>
+ <div class="table-mobile-content js-feature-flag-status">
+ <toggle-button
+ :disabled-input="!active"
+ :value="false"
+ @change="createNewScope({ active: true })"
+ />
+ </div>
+ </div>
+
+ <div class="table-section section-40" role="gridcell">
+ <div class="table-mobile-header" role="rowheader">
+ {{ s__('FeatureFlags|Rollout Strategy') }}
+ </div>
+ <div class="table-mobile-content js-rollout-strategy form-inline">
+ <label class="sr-only" for="new-rollout-strategy-placeholder">{{
+ s__('FeatureFlags|Rollout Strategy')
+ }}</label>
+ <div class="select-wrapper col-12 col-md-8 p-0">
+ <select
+ id="new-rollout-strategy-placeholder"
+ disabled
+ class="form-control select-control w-100"
+ >
+ <option>{{ s__('FeatureFlags|All users') }}</option>
+ </select>
+ <gl-icon
+ name="chevron-down"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
+ :size="16"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </fieldset>
+
+ <div class="form-actions">
+ <gl-button
+ ref="submitButton"
+ :disabled="readOnly"
+ type="button"
+ variant="success"
+ class="js-ff-submit col-xs-12"
+ @click="handleSubmit"
+ >{{ submitText }}</gl-button
+ >
+ <gl-button :href="cancelPath" class="js-ff-cancel col-xs-12 float-right">
+ {{ __('Cancel') }}
+ </gl-button>
+ </div>
+ </form>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
new file mode 100644
index 00000000000..f2017c22abf
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
@@ -0,0 +1,100 @@
+<script>
+import { debounce } from 'lodash';
+import {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlIcon,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __, sprintf } from '~/locale';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlIcon,
+ GlLoadingIcon,
+ },
+ inject: ['environmentsEndpoint'],
+ data() {
+ return {
+ environmentSearch: '',
+ results: [],
+ isLoading: false,
+ };
+ },
+ translations: {
+ addEnvironmentsLabel: __('Add environment'),
+ noResultsLabel: __('No matching results'),
+ },
+ computed: {
+ createEnvironmentLabel() {
+ return sprintf(__('Create %{environment}'), { environment: this.environmentSearch });
+ },
+ },
+ methods: {
+ addEnvironment(newEnvironment) {
+ this.$emit('add', newEnvironment);
+ this.environmentSearch = '';
+ this.results = [];
+ },
+ fetchEnvironments: debounce(function debouncedFetchEnvironments() {
+ this.isLoading = true;
+ axios
+ .get(this.environmentsEndpoint, { params: { query: this.environmentSearch } })
+ .then(({ data }) => {
+ this.results = data || [];
+ })
+ .catch(() => {
+ createFlash(__('Something went wrong on our end. Please try again.'));
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ }, 250),
+ setFocus() {
+ this.$refs.searchBox.focusInput();
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown class="js-new-environments-dropdown" @shown="setFocus">
+ <template #button-content>
+ <span class="d-md-none mr-1">
+ {{ $options.translations.addEnvironmentsLabel }}
+ </span>
+ <gl-icon class="d-none d-md-inline-flex" name="plus" />
+ </template>
+ <gl-search-box-by-type
+ ref="searchBox"
+ v-model.trim="environmentSearch"
+ @focus="fetchEnvironments"
+ @keyup="fetchEnvironments"
+ />
+ <gl-loading-icon v-if="isLoading" />
+ <gl-dropdown-item
+ v-for="environment in results"
+ v-else-if="results.length"
+ :key="environment"
+ @click="addEnvironment(environment)"
+ >
+ {{ environment }}
+ </gl-dropdown-item>
+ <template v-else-if="environmentSearch.length">
+ <span ref="noResults" class="text-secondary gl-p-3">
+ {{ $options.translations.noMatchingResults }}
+ </span>
+ <gl-dropdown-divider />
+ <gl-dropdown-item @click="addEnvironment(environmentSearch)">
+ {{ createEnvironmentLabel }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
new file mode 100644
index 00000000000..9472eddf336
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
@@ -0,0 +1,101 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { GlAlert } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import FeatureFlagForm from './form.vue';
+import {
+ LEGACY_FLAG,
+ NEW_VERSION_FLAG,
+ NEW_FLAG_ALERT,
+ ROLLOUT_STRATEGY_ALL_USERS,
+} from '../constants';
+import { createNewEnvironmentScope } from '../store/helpers';
+
+import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+export default {
+ components: {
+ GlAlert,
+ FeatureFlagForm,
+ },
+ mixins: [featureFlagsMixin()],
+ inject: {
+ showUserCallout: {},
+ userCalloutId: {
+ default: '',
+ },
+ userCalloutsPath: {
+ default: '',
+ },
+ },
+ data() {
+ return {
+ userShouldSeeNewFlagAlert: this.showUserCallout,
+ };
+ },
+ translations: {
+ newFlagAlert: NEW_FLAG_ALERT,
+ },
+ computed: {
+ ...mapState(['error', 'path']),
+ scopes() {
+ return [
+ createNewEnvironmentScope(
+ {
+ environmentScope: '*',
+ active: true,
+ },
+ this.glFeatures.featureFlagsPermissions,
+ ),
+ ];
+ },
+ version() {
+ return this.hasNewVersionFlags ? NEW_VERSION_FLAG : LEGACY_FLAG;
+ },
+ hasNewVersionFlags() {
+ return this.glFeatures.featureFlagsNewVersion;
+ },
+ shouldShowNewFlagAlert() {
+ return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert;
+ },
+ strategies() {
+ return [{ name: ROLLOUT_STRATEGY_ALL_USERS, parameters: {}, scopes: [] }];
+ },
+ },
+ methods: {
+ ...mapActions(['createFeatureFlag']),
+ dismissNewVersionFlagAlert() {
+ this.userShouldSeeNewFlagAlert = false;
+ axios.post(this.userCalloutsPath, {
+ feature_name: this.userCalloutId,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert
+ v-if="shouldShowNewFlagAlert"
+ variant="warning"
+ class="gl-my-5"
+ @dismiss="dismissNewVersionFlagAlert"
+ >
+ {{ $options.translations.newFlagAlert }}
+ </gl-alert>
+ <h3 class="page-title">{{ s__('FeatureFlags|New feature flag') }}</h3>
+
+ <div v-if="error.length" class="alert alert-danger">
+ <p v-for="(message, index) in error" :key="index" class="mb-0">{{ message }}</p>
+ </div>
+
+ <feature-flag-form
+ :cancel-path="path"
+ :submit-text="s__('FeatureFlags|Create feature flag')"
+ :scopes="scopes"
+ :strategies="strategies"
+ :version="version"
+ @handleSubmit="data => createFeatureFlag(data)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/strategies/default.vue b/app/assets/javascripts/feature_flags/components/strategies/default.vue
new file mode 100644
index 00000000000..cb8ffbddfbd
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/strategies/default.vue
@@ -0,0 +1,10 @@
+<script>
+export default {
+ mounted() {
+ this.$emit('change', { parameters: {} });
+ },
+ render() {
+ return this.$slots.default;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue
new file mode 100644
index 00000000000..020a0d43096
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue
@@ -0,0 +1,121 @@
+<script>
+import { GlFormInput, GlFormSelect } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { PERCENT_ROLLOUT_GROUP_ID } from '../../constants';
+import ParameterFormGroup from './parameter_form_group.vue';
+
+export default {
+ components: {
+ GlFormInput,
+ GlFormSelect,
+ ParameterFormGroup,
+ },
+ props: {
+ strategy: {
+ required: true,
+ type: Object,
+ },
+ },
+ i18n: {
+ percentageDescription: __('Enter a whole number between 0 and 100'),
+ percentageInvalid: __('Percent rollout must be a whole number between 0 and 100'),
+ percentageLabel: __('Percentage'),
+ stickinessDescription: __('Consistency guarantee method'),
+ stickinessLabel: __('Based on'),
+ },
+ stickinessOptions: [
+ {
+ value: 'DEFAULT',
+ text: __('Available ID'),
+ },
+ {
+ value: 'USERID',
+ text: __('User ID'),
+ },
+ {
+ value: 'SESSIONID',
+ text: __('Session ID'),
+ },
+ {
+ value: 'RANDOM',
+ text: __('Random'),
+ },
+ ],
+ computed: {
+ isValid() {
+ const percentageNum = Number(this.percentage);
+ return Number.isInteger(percentageNum) && percentageNum >= 0 && percentageNum <= 100;
+ },
+ percentage() {
+ return this.strategy?.parameters?.rollout ?? '100';
+ },
+ stickiness() {
+ return this.strategy?.parameters?.stickiness ?? this.$options.stickinessOptions[0].value;
+ },
+ },
+ methods: {
+ onPercentageChange(value) {
+ this.$emit('change', {
+ parameters: {
+ groupId: PERCENT_ROLLOUT_GROUP_ID,
+ rollout: value,
+ stickiness: this.stickiness,
+ },
+ });
+ },
+ onStickinessChange(value) {
+ this.$emit('change', {
+ parameters: {
+ groupId: PERCENT_ROLLOUT_GROUP_ID,
+ rollout: this.percentage,
+ stickiness: value,
+ },
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex">
+ <div class="gl-mr-7" data-testid="strategy-flexible-rollout-percentage">
+ <parameter-form-group
+ :label="$options.i18n.percentageLabel"
+ :description="isValid ? $options.i18n.percentageDescription : ''"
+ :invalid-feedback="$options.i18n.percentageInvalid"
+ :state="isValid"
+ >
+ <template #default="{ inputId }">
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-form-input
+ :id="inputId"
+ :value="percentage"
+ :state="isValid"
+ class="rollout-percentage gl-text-right gl-w-9"
+ type="number"
+ min="0"
+ max="100"
+ @input="onPercentageChange"
+ />
+ <span class="ml-1">%</span>
+ </div>
+ </template>
+ </parameter-form-group>
+ </div>
+
+ <div class="gl-mr-7" data-testid="strategy-flexible-rollout-stickiness">
+ <parameter-form-group
+ :label="$options.i18n.stickinessLabel"
+ :description="$options.i18n.stickinessDescription"
+ >
+ <template #default="{ inputId }">
+ <gl-form-select
+ :id="inputId"
+ :value="stickiness"
+ :options="$options.stickinessOptions"
+ @change="onStickinessChange"
+ />
+ </template>
+ </parameter-form-group>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue
new file mode 100644
index 00000000000..ec97e8b1350
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue
@@ -0,0 +1,63 @@
+<script>
+import { GlFormSelect } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ParameterFormGroup from './parameter_form_group.vue';
+
+export default {
+ components: {
+ GlFormSelect,
+ ParameterFormGroup,
+ },
+ props: {
+ strategy: {
+ required: true,
+ type: Object,
+ },
+ userLists: {
+ required: false,
+ type: Array,
+ default: () => [],
+ },
+ },
+ translations: {
+ rolloutUserListLabel: s__('FeatureFlag|List'),
+ rolloutUserListDescription: s__('FeatureFlag|Select a user list'),
+ rolloutUserListNoListError: s__('FeatureFlag|There are no configured user lists'),
+ },
+ computed: {
+ userListOptions() {
+ return this.userLists.map(({ name, id }) => ({ value: id, text: name }));
+ },
+ hasUserLists() {
+ return this.userListOptions.length > 0;
+ },
+ userListId() {
+ return this.strategy?.userListId ?? '';
+ },
+ },
+ methods: {
+ onUserListChange(list) {
+ this.$emit('change', {
+ userListId: list,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <parameter-form-group
+ :state="hasUserLists"
+ :invalid-feedback="$options.translations.rolloutUserListNoListError"
+ :label="$options.translations.rolloutUserListLabel"
+ :description="hasUserLists ? $options.translations.rolloutUserListDescription : ''"
+ >
+ <template #default="{ inputId }">
+ <gl-form-select
+ :id="inputId"
+ :value="userListId"
+ :options="userListOptions"
+ @change="onUserListChange"
+ />
+ </template>
+ </parameter-form-group>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/strategies/parameter_form_group.vue b/app/assets/javascripts/feature_flags/components/strategies/parameter_form_group.vue
new file mode 100644
index 00000000000..7f2c6d55db8
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/strategies/parameter_form_group.vue
@@ -0,0 +1,22 @@
+<script>
+import { uniqueId } from 'lodash';
+import { GlFormGroup } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFormGroup,
+ },
+ props: {
+ inputId: {
+ required: false,
+ type: String,
+ default: () => uniqueId('feature_flag_strategies_'),
+ },
+ },
+};
+</script>
+<template>
+ <gl-form-group :label-for="inputId" v-bind="$attrs">
+ <slot v-bind="{ inputId }"></slot>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue
new file mode 100644
index 00000000000..d262769c891
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue
@@ -0,0 +1,69 @@
+<script>
+import { GlFormInput } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import { PERCENT_ROLLOUT_GROUP_ID } from '../../constants';
+import ParameterFormGroup from './parameter_form_group.vue';
+
+export default {
+ components: {
+ GlFormInput,
+ ParameterFormGroup,
+ },
+ props: {
+ strategy: {
+ required: true,
+ type: Object,
+ },
+ },
+ i18n: {
+ rolloutPercentageDescription: __('Enter a whole number between 0 and 100'),
+ rolloutPercentageInvalid: s__(
+ 'FeatureFlags|Percent rollout must be a whole number between 0 and 100',
+ ),
+ rolloutPercentageLabel: s__('FeatureFlag|Percentage'),
+ },
+ computed: {
+ isValid() {
+ const percentageNum = Number(this.percentage);
+ return Number.isInteger(percentageNum) && percentageNum >= 0 && percentageNum <= 100;
+ },
+ percentage() {
+ return this.strategy?.parameters?.percentage ?? '100';
+ },
+ },
+ methods: {
+ onPercentageChange(value) {
+ this.$emit('change', {
+ parameters: {
+ percentage: value,
+ groupId: PERCENT_ROLLOUT_GROUP_ID,
+ },
+ });
+ },
+ },
+};
+</script>
+<template>
+ <parameter-form-group
+ :label="$options.i18n.rolloutPercentageLabel"
+ :description="isValid ? $options.i18n.rolloutPercentageDescription : ''"
+ :invalid-feedback="$options.i18n.rolloutPercentageInvalid"
+ :state="isValid"
+ >
+ <template #default="{ inputId }">
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-form-input
+ :id="inputId"
+ :value="percentage"
+ :state="isValid"
+ class="rollout-percentage gl-text-right gl-w-9"
+ type="number"
+ min="0"
+ max="100"
+ @input="onPercentageChange"
+ />
+ <span class="gl-ml-2">%</span>
+ </div>
+ </template>
+ </parameter-form-group>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/strategies/users_with_id.vue b/app/assets/javascripts/feature_flags/components/strategies/users_with_id.vue
new file mode 100644
index 00000000000..094127fb710
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/strategies/users_with_id.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlFormTextarea } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+
+import ParameterFormGroup from './parameter_form_group.vue';
+
+export default {
+ components: {
+ ParameterFormGroup,
+ GlFormTextarea,
+ },
+ props: {
+ strategy: {
+ required: true,
+ type: Object,
+ },
+ },
+ translations: {
+ rolloutUserIdsDescription: __('Enter one or more user ID separated by commas'),
+ rolloutUserIdsLabel: s__('FeatureFlag|User IDs'),
+ },
+ computed: {
+ userIds() {
+ return this.strategy?.parameters?.userIds ?? '';
+ },
+ },
+ methods: {
+ onUserIdsChange(value) {
+ this.$emit('change', {
+ parameters: {
+ userIds: value,
+ },
+ });
+ },
+ },
+};
+</script>
+<template>
+ <parameter-form-group
+ :label="$options.translations.rolloutUserIdsLabel"
+ :description="$options.translations.rolloutUserIdsDescription"
+ >
+ <template #default="{ inputId }">
+ <gl-form-textarea :id="inputId" :value="userIds" @input="onUserIdsChange" />
+ </template>
+ </parameter-form-group>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue
new file mode 100644
index 00000000000..9c41dde62e4
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/strategy.vue
@@ -0,0 +1,206 @@
+<script>
+import Vue from 'vue';
+import { isNumber } from 'lodash';
+import { GlAlert, GlButton, GlFormSelect, GlFormGroup, GlIcon, GlLink, GlToken } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import {
+ EMPTY_PARAMETERS,
+ STRATEGY_SELECTIONS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+} from '../constants';
+
+import NewEnvironmentsDropdown from './new_environments_dropdown.vue';
+import StrategyParameters from './strategy_parameters.vue';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlFormGroup,
+ GlFormSelect,
+ GlIcon,
+ GlLink,
+ GlToken,
+ NewEnvironmentsDropdown,
+ StrategyParameters,
+ },
+ inject: {
+ strategyTypeDocsPagePath: {
+ default: '',
+ },
+ environmentsScopeDocsPath: {
+ default: '',
+ },
+ },
+ props: {
+ strategy: {
+ type: Object,
+ required: true,
+ },
+ index: {
+ type: Number,
+ required: true,
+ },
+ userLists: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+
+ i18n: {
+ allEnvironments: __('All environments'),
+ environmentsLabel: __('Environments'),
+ strategyTypeDescription: __('Select strategy activation method'),
+ strategyTypeLabel: s__('FeatureFlag|Type'),
+ environmentsSelectDescription: s__(
+ 'FeatureFlag|Select the environment scope for this feature flag',
+ ),
+ considerFlexibleRollout: s__(
+ 'FeatureFlags|Consider using the more flexible "Percent rollout" strategy instead.',
+ ),
+ },
+
+ strategies: STRATEGY_SELECTIONS,
+
+ data() {
+ return {
+ environments: this.strategy.scopes || [],
+ formStrategy: { ...this.strategy },
+ };
+ },
+ computed: {
+ strategyTypeId() {
+ return `strategy-type-${this.index}`;
+ },
+ environmentsDropdownId() {
+ return `environments-dropdown-${this.index}`;
+ },
+ appliesToAllEnvironments() {
+ return (
+ this.filteredEnvironments.length === 1 &&
+ this.filteredEnvironments[0].environmentScope === '*'
+ );
+ },
+ filteredEnvironments() {
+ return this.environments.filter(e => !e.shouldBeDestroyed);
+ },
+ isPercentUserRollout() {
+ return this.formStrategy.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT;
+ },
+ },
+ methods: {
+ addEnvironment(environment) {
+ const allEnvironmentsScope = this.environments.find(scope => scope.environmentScope === '*');
+ if (allEnvironmentsScope) {
+ allEnvironmentsScope.shouldBeDestroyed = true;
+ }
+ this.environments.push({ environmentScope: environment });
+ this.onStrategyChange({ ...this.formStrategy, scopes: this.environments });
+ },
+ onStrategyTypeChange(name) {
+ this.onStrategyChange({
+ ...this.formStrategy,
+ ...EMPTY_PARAMETERS,
+ name,
+ });
+ },
+ onStrategyChange(s) {
+ this.$emit('change', s);
+ this.formStrategy = s;
+ },
+ removeScope(environment) {
+ if (isNumber(environment.id)) {
+ Vue.set(environment, 'shouldBeDestroyed', true);
+ } else {
+ this.environments = this.environments.filter(e => e !== environment);
+ }
+ if (this.filteredEnvironments.length === 0) {
+ this.environments.push({ environmentScope: '*' });
+ }
+ this.onStrategyChange({ ...this.formStrategy, scopes: this.environments });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert v-if="isPercentUserRollout" variant="tip" :dismissible="false">
+ {{ $options.i18n.considerFlexibleRollout }}
+ </gl-alert>
+
+ <div class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-py-6">
+ <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row flex-md-wrap">
+ <div class="mr-5">
+ <gl-form-group :label="$options.i18n.strategyTypeLabel" :label-for="strategyTypeId">
+ <template #description>
+ {{ $options.i18n.strategyTypeDescription }}
+ <gl-link :href="strategyTypeDocsPagePath" target="_blank">
+ <gl-icon name="question" />
+ </gl-link>
+ </template>
+ <gl-form-select
+ :id="strategyTypeId"
+ :value="formStrategy.name"
+ :options="$options.strategies"
+ @change="onStrategyTypeChange"
+ />
+ </gl-form-group>
+ </div>
+
+ <div data-testid="strategy">
+ <strategy-parameters
+ :strategy="strategy"
+ :user-lists="userLists"
+ @change="onStrategyChange"
+ />
+ </div>
+
+ <div
+ class="align-self-end align-self-md-stretch order-first offset-md-0 order-md-0 gl-ml-auto"
+ >
+ <gl-button
+ data-testid="delete-strategy-button"
+ variant="danger"
+ icon="remove"
+ @click="$emit('delete')"
+ />
+ </div>
+ </div>
+
+ <label class="gl-display-block" :for="environmentsDropdownId">{{
+ $options.i18n.environmentsLabel
+ }}</label>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row align-items-start gl-md-align-items-center"
+ >
+ <new-environments-dropdown
+ :id="environmentsDropdownId"
+ class="gl-mr-3"
+ @add="addEnvironment"
+ />
+ <span v-if="appliesToAllEnvironments" class="text-secondary gl-mt-3 mt-md-0 ml-md-3">
+ {{ $options.i18n.allEnvironments }}
+ </span>
+ <div v-else class="gl-display-flex gl-align-items-center">
+ <gl-token
+ v-for="environment in filteredEnvironments"
+ :key="environment.id"
+ class="gl-mt-3 gl-mr-3 mt-md-0 mr-md-0 ml-md-2 rounded-pill"
+ @close="removeScope(environment)"
+ >
+ {{ environment.environmentScope }}
+ </gl-token>
+ </div>
+ </div>
+ </div>
+ <span class="gl-display-inline-block gl-py-3">
+ {{ $options.i18n.environmentsSelectDescription }}
+ </span>
+ <gl-link :href="environmentsScopeDocsPath" target="_blank">
+ <gl-icon name="question" />
+ </gl-link>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/strategy_parameters.vue b/app/assets/javascripts/feature_flags/components/strategy_parameters.vue
new file mode 100644
index 00000000000..b6e06880315
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/strategy_parameters.vue
@@ -0,0 +1,54 @@
+<script>
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+} from '../constants';
+
+import Default from './strategies/default.vue';
+import FlexibleRollout from './strategies/flexible_rollout.vue';
+import PercentRollout from './strategies/percent_rollout.vue';
+import UsersWithId from './strategies/users_with_id.vue';
+import GitlabUserList from './strategies/gitlab_user_list.vue';
+
+const STRATEGIES = Object.freeze({
+ [ROLLOUT_STRATEGY_ALL_USERS]: Default,
+ [ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT]: FlexibleRollout,
+ [ROLLOUT_STRATEGY_PERCENT_ROLLOUT]: PercentRollout,
+ [ROLLOUT_STRATEGY_USER_ID]: UsersWithId,
+ [ROLLOUT_STRATEGY_GITLAB_USER_LIST]: GitlabUserList,
+});
+
+export default {
+ props: {
+ strategy: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ strategyComponent() {
+ return STRATEGIES[(this.strategy?.name)];
+ },
+ },
+ methods: {
+ onChange(value) {
+ this.$emit('change', {
+ ...this.strategy,
+ ...value,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <component
+ :is="strategyComponent"
+ v-if="strategyComponent"
+ :strategy="strategy"
+ v-bind="$attrs"
+ @change="onChange"
+ />
+</template>
diff --git a/app/assets/javascripts/feature_flags/components/user_lists_table.vue b/app/assets/javascripts/feature_flags/components/user_lists_table.vue
new file mode 100644
index 00000000000..0bfd18f992c
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/components/user_lists_table.vue
@@ -0,0 +1,122 @@
+<script>
+import {
+ GlButton,
+ GlButtonGroup,
+ GlModal,
+ GlSprintf,
+ GlTooltipDirective,
+ GlModalDirective,
+} from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+
+export default {
+ components: { GlButton, GlButtonGroup, GlModal, GlSprintf },
+ directives: { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective },
+ mixins: [timeagoMixin],
+ props: {
+ userLists: {
+ type: Array,
+ required: true,
+ },
+ },
+ translations: {
+ createdTimeagoLabel: s__('UserList|created %{timeago}'),
+ deleteListTitle: s__('UserList|Delete %{name}?'),
+ deleteListMessage: s__('User list %{name} will be removed. Are you sure?'),
+ },
+ modal: {
+ id: 'deleteListModal',
+ actionPrimary: {
+ text: s__('Delete user list'),
+ attributes: { variant: 'danger', 'data-testid': 'modal-confirm' },
+ },
+ },
+ data() {
+ return {
+ deleteUserList: null,
+ };
+ },
+ computed: {
+ deleteListName() {
+ return this.deleteUserList?.name;
+ },
+ modalTitle() {
+ return sprintf(this.$options.translations.deleteListTitle, {
+ name: this.deleteListName,
+ });
+ },
+ },
+ methods: {
+ createdTimeago(list) {
+ return sprintf(this.$options.translations.createdTimeagoLabel, {
+ timeago: this.timeFormatted(list.created_at),
+ });
+ },
+ displayList(list) {
+ return list.user_xids.replace(/,/g, ', ');
+ },
+ onDelete() {
+ this.$emit('delete', this.deleteUserList);
+ },
+ confirmDeleteList(list) {
+ this.deleteUserList = list;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div
+ v-for="list in userLists"
+ :key="list.id"
+ data-testid="ffUserList"
+ class="gl-border-b-solid gl-border-gray-100 gl-border-b-1 gl-w-full gl-py-4 gl-display-flex gl-justify-content-space-between"
+ >
+ <div class="gl-display-flex gl-flex-direction-column gl-overflow-hidden gl-flex-grow-1">
+ <span data-testid="ffUserListName" class="gl-font-weight-bold gl-mb-2">
+ {{ list.name }}
+ </span>
+ <span
+ v-gl-tooltip
+ :title="tooltipTitle(list.created_at)"
+ data-testid="ffUserListTimestamp"
+ class="gl-text-gray-300 gl-mb-2"
+ >
+ {{ createdTimeago(list) }}
+ </span>
+ <span data-testid="ffUserListIds" class="gl-str-truncated">{{ displayList(list) }}</span>
+ </div>
+
+ <gl-button-group class="gl-align-self-start gl-mt-2">
+ <gl-button
+ :href="list.path"
+ category="secondary"
+ icon="pencil"
+ data-testid="edit-user-list"
+ />
+ <gl-button
+ v-gl-modal="$options.modal.id"
+ category="secondary"
+ variant="danger"
+ icon="remove"
+ data-testid="delete-user-list"
+ @click="confirmDeleteList(list)"
+ />
+ </gl-button-group>
+ </div>
+ <gl-modal
+ :title="modalTitle"
+ :modal-id="$options.modal.id"
+ :action-primary="$options.modal.actionPrimary"
+ static
+ @primary="onDelete"
+ >
+ <gl-sprintf :message="$options.translations.deleteListMessage">
+ <template #name>
+ <b>{{ deleteListName }}</b>
+ </template>
+ </gl-sprintf>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/feature_flags/constants.js b/app/assets/javascripts/feature_flags/constants.js
new file mode 100644
index 00000000000..4843eca149a
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/constants.js
@@ -0,0 +1,54 @@
+import { property } from 'lodash';
+import { s__ } from '~/locale';
+
+export const ROLLOUT_STRATEGY_ALL_USERS = 'default';
+export const ROLLOUT_STRATEGY_PERCENT_ROLLOUT = 'gradualRolloutUserId';
+export const ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT = 'flexibleRollout';
+export const ROLLOUT_STRATEGY_USER_ID = 'userWithId';
+export const ROLLOUT_STRATEGY_GITLAB_USER_LIST = 'gitlabUserList';
+
+export const PERCENT_ROLLOUT_GROUP_ID = 'default';
+
+export const DEFAULT_PERCENT_ROLLOUT = '100';
+
+export const ALL_ENVIRONMENTS_NAME = '*';
+
+export const INTERNAL_ID_PREFIX = 'internal_';
+
+export const fetchPercentageParams = property(['parameters', 'percentage']);
+export const fetchUserIdParams = property(['parameters', 'userIds']);
+
+export const NEW_VERSION_FLAG = 'new_version_flag';
+export const LEGACY_FLAG = 'legacy_flag';
+
+export const NEW_FLAG_ALERT = s__(
+ 'FeatureFlags|Feature Flags will look different in the next milestone. No action is needed, but you may notice the functionality was changed to improve the workflow.',
+);
+
+export const FEATURE_FLAG_SCOPE = 'featureFlags';
+export const USER_LIST_SCOPE = 'userLists';
+
+export const EMPTY_PARAMETERS = { parameters: {}, userListId: undefined };
+
+export const STRATEGY_SELECTIONS = [
+ {
+ value: ROLLOUT_STRATEGY_ALL_USERS,
+ text: s__('FeatureFlags|All users'),
+ },
+ {
+ value: ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT,
+ text: s__('FeatureFlags|Percent rollout'),
+ },
+ {
+ value: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ text: s__('FeatureFlags|Percent of users'),
+ },
+ {
+ value: ROLLOUT_STRATEGY_USER_ID,
+ text: s__('FeatureFlags|User IDs'),
+ },
+ {
+ value: ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+ text: s__('FeatureFlags|User List'),
+ },
+];
diff --git a/app/assets/javascripts/feature_flags/edit.js b/app/assets/javascripts/feature_flags/edit.js
new file mode 100644
index 00000000000..b4d2111acf3
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/edit.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import createStore from './store/edit';
+import EditFeatureFlag from './components/edit_feature_flag.vue';
+
+Vue.use(Vuex);
+
+export default () => {
+ const el = document.querySelector('#js-edit-feature-flag');
+ const {
+ environmentsScopeDocsPath,
+ strategyTypeDocsPagePath,
+ endpoint,
+ featureFlagsPath,
+ environmentsEndpoint,
+ projectId,
+ featureFlagIssuesEndpoint,
+ userCalloutsPath,
+ userCalloutId,
+ showUserCallout,
+ } = el.dataset;
+
+ return new Vue({
+ store: createStore({ endpoint, path: featureFlagsPath }),
+ el,
+ provide: {
+ environmentsScopeDocsPath,
+ strategyTypeDocsPagePath,
+ environmentsEndpoint,
+ projectId,
+ featureFlagIssuesEndpoint,
+ userCalloutsPath,
+ userCalloutId,
+ showUserCallout: parseBoolean(showUserCallout),
+ },
+ render(createElement) {
+ return createElement(EditFeatureFlag);
+ },
+ });
+};
diff --git a/app/assets/javascripts/feature_flags/index.js b/app/assets/javascripts/feature_flags/index.js
new file mode 100644
index 00000000000..a92805d5d85
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/index.js
@@ -0,0 +1,49 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import csrf from '~/lib/utils/csrf';
+import FeatureFlagsComponent from './components/feature_flags.vue';
+import createStore from './store/index';
+
+Vue.use(Vuex);
+
+export default () => {
+ const el = document.querySelector('#feature-flags-vue');
+
+ const {
+ projectName,
+ featureFlagsHelpPagePath,
+ errorStateSvgPath,
+ endpoint,
+ projectId,
+ unleashApiInstanceId,
+ rotateInstanceIdPath,
+ featureFlagsClientLibrariesHelpPagePath,
+ featureFlagsClientExampleHelpPagePath,
+ unleashApiUrl,
+ canUserAdminFeatureFlag,
+ newFeatureFlagPath,
+ newUserListPath,
+ featureFlagsLimitExceeded,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ store: createStore({ endpoint, projectId, unleashApiInstanceId, rotateInstanceIdPath }),
+ provide: {
+ projectName,
+ featureFlagsHelpPagePath,
+ errorStateSvgPath,
+ featureFlagsClientLibrariesHelpPagePath,
+ featureFlagsClientExampleHelpPagePath,
+ unleashApiUrl,
+ csrfToken: csrf.token,
+ canUserConfigure: canUserAdminFeatureFlag !== undefined,
+ newFeatureFlagPath,
+ newUserListPath,
+ featureFlagsLimitExceeded,
+ },
+ render(createElement) {
+ return createElement(FeatureFlagsComponent);
+ },
+ });
+};
diff --git a/app/assets/javascripts/feature_flags/new.js b/app/assets/javascripts/feature_flags/new.js
new file mode 100644
index 00000000000..a1efbd87ec4
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/new.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import createStore from './store/new';
+import NewFeatureFlag from './components/new_feature_flag.vue';
+
+Vue.use(Vuex);
+
+export default () => {
+ const el = document.querySelector('#js-new-feature-flag');
+ const {
+ environmentsScopeDocsPath,
+ strategyTypeDocsPagePath,
+ endpoint,
+ featureFlagsPath,
+ environmentsEndpoint,
+ projectId,
+ userCalloutsPath,
+ userCalloutId,
+ showUserCallout,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ store: createStore({ endpoint, path: featureFlagsPath }),
+ provide: {
+ environmentsScopeDocsPath,
+ strategyTypeDocsPagePath,
+ environmentsEndpoint,
+ projectId,
+ userCalloutsPath,
+ userCalloutId,
+ showUserCallout: parseBoolean(showUserCallout),
+ },
+ render(createElement) {
+ return createElement(NewFeatureFlag);
+ },
+ });
+};
diff --git a/app/assets/javascripts/feature_flags/store/edit/actions.js b/app/assets/javascripts/feature_flags/store/edit/actions.js
new file mode 100644
index 00000000000..3678c2f7788
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/edit/actions.js
@@ -0,0 +1,61 @@
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import { __ } from '~/locale';
+import { NEW_VERSION_FLAG } from '../../constants';
+import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers';
+
+/**
+ * Handles the edition of a feature flag.
+ *
+ * Will dispatch `requestUpdateFeatureFlag`
+ * Serializes the params and makes a put request
+ * Dispatches an action acording to the request status.
+ *
+ * @param {Object} params
+ */
+export const updateFeatureFlag = ({ state, dispatch }, params) => {
+ dispatch('requestUpdateFeatureFlag');
+
+ axios
+ .put(
+ state.endpoint,
+ params.version === NEW_VERSION_FLAG
+ ? mapStrategiesToRails(params)
+ : mapFromScopesViewModel(params),
+ )
+ .then(() => {
+ dispatch('receiveUpdateFeatureFlagSuccess');
+ visitUrl(state.path);
+ })
+ .catch(error => dispatch('receiveUpdateFeatureFlagError', error.response.data));
+};
+
+export const requestUpdateFeatureFlag = ({ commit }) => commit(types.REQUEST_UPDATE_FEATURE_FLAG);
+export const receiveUpdateFeatureFlagSuccess = ({ commit }) =>
+ commit(types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS);
+export const receiveUpdateFeatureFlagError = ({ commit }, error) =>
+ commit(types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, error);
+
+/**
+ * Fetches the feature flag data for the edit form
+ */
+export const fetchFeatureFlag = ({ state, dispatch }) => {
+ dispatch('requestFeatureFlag');
+
+ axios
+ .get(state.endpoint)
+ .then(({ data }) => dispatch('receiveFeatureFlagSuccess', data))
+ .catch(() => dispatch('receiveFeatureFlagError'));
+};
+
+export const requestFeatureFlag = ({ commit }) => commit(types.REQUEST_FEATURE_FLAG);
+export const receiveFeatureFlagSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_FEATURE_FLAG_SUCCESS, response);
+export const receiveFeatureFlagError = ({ commit }) => {
+ commit(types.RECEIVE_FEATURE_FLAG_ERROR);
+ createFlash(__('Something went wrong on our end. Please try again!'));
+};
+
+export const toggleActive = ({ commit }, active) => commit(types.TOGGLE_ACTIVE, active);
diff --git a/app/assets/javascripts/feature_flags/store/edit/index.js b/app/assets/javascripts/feature_flags/store/edit/index.js
new file mode 100644
index 00000000000..f737e0517fc
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/edit/index.js
@@ -0,0 +1,11 @@
+import Vuex from 'vuex';
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default data =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: state(data),
+ });
diff --git a/app/assets/javascripts/feature_flags/store/edit/mutation_types.js b/app/assets/javascripts/feature_flags/store/edit/mutation_types.js
new file mode 100644
index 00000000000..c215dad3513
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/edit/mutation_types.js
@@ -0,0 +1,9 @@
+export const REQUEST_UPDATE_FEATURE_FLAG = 'REQUEST_UPDATE_FEATURE_FLAG';
+export const RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS';
+export const RECEIVE_UPDATE_FEATURE_FLAG_ERROR = 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR';
+
+export const REQUEST_FEATURE_FLAG = 'REQUEST_FEATURE_FLAG';
+export const RECEIVE_FEATURE_FLAG_SUCCESS = 'RECEIVE_FEATURE_FLAG_SUCCESS';
+export const RECEIVE_FEATURE_FLAG_ERROR = 'RECEIVE_FEATURE_FLAG_ERROR';
+
+export const TOGGLE_ACTIVE = 'TOGGLE_ACTIVE';
diff --git a/app/assets/javascripts/feature_flags/store/edit/mutations.js b/app/assets/javascripts/feature_flags/store/edit/mutations.js
new file mode 100644
index 00000000000..e60dbaf4a34
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/edit/mutations.js
@@ -0,0 +1,39 @@
+import * as types from './mutation_types';
+import { mapToScopesViewModel, mapStrategiesToViewModel } from '../helpers';
+import { LEGACY_FLAG } from '../../constants';
+
+export default {
+ [types.REQUEST_FEATURE_FLAG](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_FEATURE_FLAG_SUCCESS](state, response) {
+ state.isLoading = false;
+ state.hasError = false;
+
+ state.name = response.name;
+ state.description = response.description;
+ state.iid = response.iid;
+ state.active = response.active;
+ state.scopes = mapToScopesViewModel(response.scopes);
+ state.strategies = mapStrategiesToViewModel(response.strategies);
+ state.version = response.version || LEGACY_FLAG;
+ },
+ [types.RECEIVE_FEATURE_FLAG_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+ [types.REQUEST_UPDATE_FEATURE_FLAG](state) {
+ state.isSendingRequest = true;
+ state.error = [];
+ },
+ [types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](state) {
+ state.isSendingRequest = false;
+ },
+ [types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, error) {
+ state.isSendingRequest = false;
+ state.error = error.message || [];
+ },
+ [types.TOGGLE_ACTIVE](state, active) {
+ state.active = active;
+ },
+};
diff --git a/app/assets/javascripts/feature_flags/store/edit/state.js b/app/assets/javascripts/feature_flags/store/edit/state.js
new file mode 100644
index 00000000000..ec507532d6a
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/edit/state.js
@@ -0,0 +1,18 @@
+import { LEGACY_FLAG } from '../../constants';
+
+export default ({ path, endpoint }) => ({
+ endpoint,
+ path,
+ isSendingRequest: false,
+ error: [],
+
+ name: null,
+ description: null,
+ scopes: [],
+ isLoading: false,
+ hasError: false,
+ iid: null,
+ active: true,
+ strategies: [],
+ version: LEGACY_FLAG,
+});
diff --git a/app/assets/javascripts/feature_flags/store/helpers.js b/app/assets/javascripts/feature_flags/store/helpers.js
new file mode 100644
index 00000000000..db6da815abf
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/helpers.js
@@ -0,0 +1,213 @@
+import { isEmpty, uniqueId, isString } from 'lodash';
+import {
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+ INTERNAL_ID_PREFIX,
+ DEFAULT_PERCENT_ROLLOUT,
+ PERCENT_ROLLOUT_GROUP_ID,
+ fetchPercentageParams,
+ fetchUserIdParams,
+ LEGACY_FLAG,
+} from '../constants';
+
+/**
+ * Converts raw scope objects fetched from the API into an array of scope
+ * objects that is easier/nicer to bind to in Vue.
+ * @param {Array} scopesFromRails An array of scope objects fetched from the API
+ */
+export const mapToScopesViewModel = scopesFromRails =>
+ (scopesFromRails || []).map(s => {
+ const percentStrategy = (s.strategies || []).find(
+ strat => strat.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ );
+
+ const rolloutPercentage = fetchPercentageParams(percentStrategy) || DEFAULT_PERCENT_ROLLOUT;
+
+ const userStrategy = (s.strategies || []).find(
+ strat => strat.name === ROLLOUT_STRATEGY_USER_ID,
+ );
+
+ const rolloutStrategy =
+ (percentStrategy && percentStrategy.name) ||
+ (userStrategy && userStrategy.name) ||
+ ROLLOUT_STRATEGY_ALL_USERS;
+
+ const rolloutUserIds = (fetchUserIdParams(userStrategy) || '')
+ .split(',')
+ .filter(id => id)
+ .join(', ');
+
+ return {
+ id: s.id,
+ environmentScope: s.environment_scope,
+ active: Boolean(s.active),
+ canUpdate: Boolean(s.can_update),
+ protected: Boolean(s.protected),
+ rolloutStrategy,
+ rolloutPercentage,
+ rolloutUserIds,
+
+ // eslint-disable-next-line no-underscore-dangle
+ shouldBeDestroyed: Boolean(s._destroy),
+ shouldIncludeUserIds: rolloutUserIds.length > 0 && percentStrategy !== null,
+ };
+ });
+/**
+ * Converts the parameters emitted by the Vue component into
+ * the shape that the Rails API expects.
+ * @param {Array} scopesFromVue An array of scope objects from the Vue component
+ */
+export const mapFromScopesViewModel = params => {
+ const scopes = (params.scopes || []).map(s => {
+ const parameters = {};
+ if (s.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT) {
+ parameters.groupId = PERCENT_ROLLOUT_GROUP_ID;
+ parameters.percentage = s.rolloutPercentage;
+ } else if (s.rolloutStrategy === ROLLOUT_STRATEGY_USER_ID) {
+ parameters.userIds = (s.rolloutUserIds || '').replace(/, /g, ',');
+ }
+
+ const userIdParameters = {};
+
+ if (s.shouldIncludeUserIds && s.rolloutStrategy !== ROLLOUT_STRATEGY_USER_ID) {
+ userIdParameters.userIds = (s.rolloutUserIds || '').replace(/, /g, ',');
+ }
+
+ // Strip out any internal IDs
+ const id = isString(s.id) && s.id.startsWith(INTERNAL_ID_PREFIX) ? undefined : s.id;
+
+ const strategies = [
+ {
+ name: s.rolloutStrategy,
+ parameters,
+ },
+ ];
+
+ if (!isEmpty(userIdParameters)) {
+ strategies.push({ name: ROLLOUT_STRATEGY_USER_ID, parameters: userIdParameters });
+ }
+
+ return {
+ id,
+ environment_scope: s.environmentScope,
+ active: s.active,
+ can_update: s.canUpdate,
+ protected: s.protected,
+ _destroy: s.shouldBeDestroyed,
+ strategies,
+ };
+ });
+
+ const model = {
+ operations_feature_flag: {
+ name: params.name,
+ description: params.description,
+ active: params.active,
+ scopes_attributes: scopes,
+ version: LEGACY_FLAG,
+ },
+ };
+
+ return model;
+};
+
+/**
+ * Creates a new feature flag environment scope object for use
+ * in a Vue component. An optional parameter can be passed to
+ * override the property values that are created by default.
+ *
+ * @param {Object} overrides An optional object whose
+ * property values will be used to override the default values.
+ *
+ */
+export const createNewEnvironmentScope = (overrides = {}, featureFlagPermissions = false) => {
+ const defaultScope = {
+ environmentScope: '',
+ active: false,
+ id: uniqueId(INTERNAL_ID_PREFIX),
+ rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
+ rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
+ rolloutUserIds: '',
+ };
+
+ const newScope = {
+ ...defaultScope,
+ ...overrides,
+ };
+
+ if (featureFlagPermissions) {
+ newScope.canUpdate = true;
+ newScope.protected = false;
+ }
+
+ return newScope;
+};
+
+const mapStrategyScopesToRails = scopes =>
+ scopes.length === 0
+ ? [{ environment_scope: '*' }]
+ : scopes.map(s => ({
+ id: s.id,
+ _destroy: s.shouldBeDestroyed,
+ environment_scope: s.environmentScope,
+ }));
+
+const mapStrategyScopesToView = scopes =>
+ scopes.map(s => ({
+ id: s.id,
+ // eslint-disable-next-line no-underscore-dangle
+ shouldBeDestroyed: Boolean(s._destroy),
+ environmentScope: s.environment_scope,
+ }));
+
+const mapStrategiesParametersToViewModel = params => {
+ if (params.userIds) {
+ return { ...params, userIds: params.userIds.split(',').join(', ') };
+ }
+ return params;
+};
+
+export const mapStrategiesToViewModel = strategiesFromRails =>
+ (strategiesFromRails || []).map(s => ({
+ id: s.id,
+ name: s.name,
+ parameters: mapStrategiesParametersToViewModel(s.parameters),
+ userListId: s.user_list?.id,
+ // eslint-disable-next-line no-underscore-dangle
+ shouldBeDestroyed: Boolean(s._destroy),
+ scopes: mapStrategyScopesToView(s.scopes),
+ }));
+
+const mapStrategiesParametersToRails = params => {
+ if (params.userIds) {
+ return { ...params, userIds: params.userIds.replace(/\s*,\s*/g, ',') };
+ }
+ return params;
+};
+
+const mapStrategyToRails = strategy => {
+ const mappedStrategy = {
+ id: strategy.id,
+ name: strategy.name,
+ _destroy: strategy.shouldBeDestroyed,
+ scopes_attributes: mapStrategyScopesToRails(strategy.scopes || []),
+ parameters: mapStrategiesParametersToRails(strategy.parameters),
+ };
+
+ if (strategy.name === ROLLOUT_STRATEGY_GITLAB_USER_LIST) {
+ mappedStrategy.user_list_id = strategy.userListId;
+ }
+ return mappedStrategy;
+};
+
+export const mapStrategiesToRails = params => ({
+ operations_feature_flag: {
+ name: params.name,
+ description: params.description,
+ version: params.version,
+ active: params.active,
+ strategies_attributes: (params.strategies || []).map(mapStrategyToRails),
+ },
+});
diff --git a/app/assets/javascripts/feature_flags/store/index/actions.js b/app/assets/javascripts/feature_flags/store/index/actions.js
new file mode 100644
index 00000000000..a8c1a72c016
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/index/actions.js
@@ -0,0 +1,97 @@
+import Api from '~/api';
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+
+export const setFeatureFlagsOptions = ({ commit }, options) =>
+ commit(types.SET_FEATURE_FLAGS_OPTIONS, options);
+
+export const fetchFeatureFlags = ({ state, dispatch }) => {
+ dispatch('requestFeatureFlags');
+
+ axios
+ .get(state.endpoint, {
+ params: state.options,
+ })
+ .then(response =>
+ dispatch('receiveFeatureFlagsSuccess', {
+ data: response.data || {},
+ headers: response.headers,
+ }),
+ )
+ .catch(() => dispatch('receiveFeatureFlagsError'));
+};
+
+export const requestFeatureFlags = ({ commit }) => commit(types.REQUEST_FEATURE_FLAGS);
+export const receiveFeatureFlagsSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_FEATURE_FLAGS_SUCCESS, response);
+export const receiveFeatureFlagsError = ({ commit }) => commit(types.RECEIVE_FEATURE_FLAGS_ERROR);
+
+export const fetchUserLists = ({ state, dispatch }) => {
+ dispatch('requestUserLists');
+
+ return Api.fetchFeatureFlagUserLists(state.projectId, state.options.page)
+ .then(({ data, headers }) => dispatch('receiveUserListsSuccess', { data, headers }))
+ .catch(() => dispatch('receiveUserListsError'));
+};
+
+export const requestUserLists = ({ commit }) => commit(types.REQUEST_USER_LISTS);
+export const receiveUserListsSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_USER_LISTS_SUCCESS, response);
+export const receiveUserListsError = ({ commit }) => commit(types.RECEIVE_USER_LISTS_ERROR);
+
+export const toggleFeatureFlag = ({ dispatch }, flag) => {
+ dispatch('updateFeatureFlag', flag);
+
+ axios
+ .put(flag.update_path, {
+ operations_feature_flag: flag,
+ })
+ .then(response => dispatch('receiveUpdateFeatureFlagSuccess', response.data))
+ .catch(() => dispatch('receiveUpdateFeatureFlagError', flag.id));
+};
+
+export const updateFeatureFlag = ({ commit }, flag) => commit(types.UPDATE_FEATURE_FLAG, flag);
+
+export const receiveUpdateFeatureFlagSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS, data);
+export const receiveUpdateFeatureFlagError = ({ commit }, id) =>
+ commit(types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, id);
+
+export const deleteUserList = ({ state, dispatch }, list) => {
+ dispatch('requestDeleteUserList', list);
+
+ return Api.deleteFeatureFlagUserList(state.projectId, list.iid)
+ .then(() => dispatch('fetchUserLists'))
+ .catch(error =>
+ dispatch('receiveDeleteUserListError', {
+ list,
+ error: error?.response?.data ?? error,
+ }),
+ );
+};
+
+export const requestDeleteUserList = ({ commit }, list) =>
+ commit(types.REQUEST_DELETE_USER_LIST, list);
+
+export const receiveDeleteUserListError = ({ commit }, { error, list }) => {
+ commit(types.RECEIVE_DELETE_USER_LIST_ERROR, { error, list });
+};
+
+export const rotateInstanceId = ({ state, dispatch }) => {
+ dispatch('requestRotateInstanceId');
+
+ axios
+ .post(state.rotateEndpoint)
+ .then(({ data = {}, headers }) => dispatch('receiveRotateInstanceIdSuccess', { data, headers }))
+ .catch(() => dispatch('receiveRotateInstanceIdError'));
+};
+
+export const requestRotateInstanceId = ({ commit }) => commit(types.REQUEST_ROTATE_INSTANCE_ID);
+export const receiveRotateInstanceIdSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS, response);
+export const receiveRotateInstanceIdError = ({ commit }) =>
+ commit(types.RECEIVE_ROTATE_INSTANCE_ID_ERROR);
+
+export const clearAlert = ({ commit }, index) => {
+ commit(types.RECEIVE_CLEAR_ALERT, index);
+};
diff --git a/app/assets/javascripts/feature_flags/store/index/index.js b/app/assets/javascripts/feature_flags/store/index/index.js
new file mode 100644
index 00000000000..f737e0517fc
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/index/index.js
@@ -0,0 +1,11 @@
+import Vuex from 'vuex';
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default data =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: state(data),
+ });
diff --git a/app/assets/javascripts/feature_flags/store/index/mutation_types.js b/app/assets/javascripts/feature_flags/store/index/mutation_types.js
new file mode 100644
index 00000000000..189c763782e
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/index/mutation_types.js
@@ -0,0 +1,22 @@
+export const SET_FEATURE_FLAGS_OPTIONS = 'SET_FEATURE_FLAGS_OPTIONS';
+
+export const REQUEST_FEATURE_FLAGS = 'REQUEST_FEATURE_FLAGS';
+export const RECEIVE_FEATURE_FLAGS_SUCCESS = 'RECEIVE_FEATURE_FLAGS_SUCCESS';
+export const RECEIVE_FEATURE_FLAGS_ERROR = 'RECEIVE_FEATURE_FLAGS_ERROR';
+
+export const REQUEST_USER_LISTS = 'REQUEST_USER_LISTS';
+export const RECEIVE_USER_LISTS_SUCCESS = 'RECEIVE_USER_LISTS_SUCCESS';
+export const RECEIVE_USER_LISTS_ERROR = 'RECEIVE_USER_LISTS_ERROR';
+
+export const REQUEST_DELETE_USER_LIST = 'REQUEST_DELETE_USER_LIST';
+export const RECEIVE_DELETE_USER_LIST_ERROR = 'RECEIVE_DELETE_USER_LIST_ERROR';
+
+export const UPDATE_FEATURE_FLAG = 'UPDATE_FEATURE_FLAG';
+export const RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS';
+export const RECEIVE_UPDATE_FEATURE_FLAG_ERROR = 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR';
+
+export const REQUEST_ROTATE_INSTANCE_ID = 'REQUEST_ROTATE_INSTANCE_ID';
+export const RECEIVE_ROTATE_INSTANCE_ID_SUCCESS = 'RECEIVE_ROTATE_INSTANCE_ID_SUCCESS';
+export const RECEIVE_ROTATE_INSTANCE_ID_ERROR = 'RECEIVE_ROTATE_INSTANCE_ID_ERROR';
+
+export const RECEIVE_CLEAR_ALERT = 'RECEIVE_CLEAR_ALERT';
diff --git a/app/assets/javascripts/feature_flags/store/index/mutations.js b/app/assets/javascripts/feature_flags/store/index/mutations.js
new file mode 100644
index 00000000000..bdc23e66214
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/index/mutations.js
@@ -0,0 +1,113 @@
+import Vue from 'vue';
+import * as types from './mutation_types';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../constants';
+import { mapToScopesViewModel } from '../helpers';
+
+const mapFlag = flag => ({ ...flag, scopes: mapToScopesViewModel(flag.scopes || []) });
+
+const updateFlag = (state, flag) => {
+ const index = state[FEATURE_FLAG_SCOPE].findIndex(({ id }) => id === flag.id);
+ Vue.set(state[FEATURE_FLAG_SCOPE], index, flag);
+};
+
+const createPaginationInfo = (state, headers) => {
+ let paginationInfo;
+ if (Object.keys(headers).length) {
+ const normalizedHeaders = normalizeHeaders(headers);
+ paginationInfo = parseIntPagination(normalizedHeaders);
+ } else {
+ paginationInfo = headers;
+ }
+ return paginationInfo;
+};
+
+export default {
+ [types.SET_FEATURE_FLAGS_OPTIONS](state, options = {}) {
+ state.options = options;
+ },
+ [types.REQUEST_FEATURE_FLAGS](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_FEATURE_FLAGS_SUCCESS](state, response) {
+ state.isLoading = false;
+ state.hasError = false;
+ state[FEATURE_FLAG_SCOPE] = (response.data.feature_flags || []).map(mapFlag);
+
+ const paginationInfo = createPaginationInfo(state, response.headers);
+ state.count = {
+ ...state.count,
+ [FEATURE_FLAG_SCOPE]: paginationInfo?.total ?? state[FEATURE_FLAG_SCOPE].length,
+ };
+ state.pageInfo = {
+ ...state.pageInfo,
+ [FEATURE_FLAG_SCOPE]: paginationInfo,
+ };
+ },
+ [types.RECEIVE_FEATURE_FLAGS_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+ [types.REQUEST_USER_LISTS](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_USER_LISTS_SUCCESS](state, response) {
+ state.isLoading = false;
+ state.hasError = false;
+ state[USER_LIST_SCOPE] = response.data || [];
+
+ const paginationInfo = createPaginationInfo(state, response.headers);
+ state.count = {
+ ...state.count,
+ [USER_LIST_SCOPE]: paginationInfo?.total ?? state[USER_LIST_SCOPE].length,
+ };
+ state.pageInfo = {
+ ...state.pageInfo,
+ [USER_LIST_SCOPE]: paginationInfo,
+ };
+ },
+ [types.RECEIVE_USER_LISTS_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+ [types.REQUEST_ROTATE_INSTANCE_ID](state) {
+ state.isRotating = true;
+ state.hasRotateError = false;
+ },
+ [types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS](
+ state,
+ {
+ data: { token },
+ },
+ ) {
+ state.isRotating = false;
+ state.instanceId = token;
+ state.hasRotateError = false;
+ },
+ [types.RECEIVE_ROTATE_INSTANCE_ID_ERROR](state) {
+ state.isRotating = false;
+ state.hasRotateError = true;
+ },
+ [types.UPDATE_FEATURE_FLAG](state, flag) {
+ updateFlag(state, flag);
+ },
+ [types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](state, data) {
+ updateFlag(state, mapFlag(data));
+ },
+ [types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, i) {
+ const flag = state[FEATURE_FLAG_SCOPE].find(({ id }) => i === id);
+ updateFlag(state, { ...flag, active: !flag.active });
+ },
+ [types.REQUEST_DELETE_USER_LIST](state, list) {
+ state.userLists = state.userLists.filter(l => l !== list);
+ },
+ [types.RECEIVE_DELETE_USER_LIST_ERROR](state, { error, list }) {
+ state.isLoading = false;
+ state.hasError = false;
+ state.alerts = [].concat(error.message);
+ state.userLists = state.userLists.concat(list).sort((l1, l2) => l1.iid - l2.iid);
+ },
+ [types.RECEIVE_CLEAR_ALERT](state, index) {
+ state.alerts.splice(index, 1);
+ },
+};
diff --git a/app/assets/javascripts/feature_flags/store/index/state.js b/app/assets/javascripts/feature_flags/store/index/state.js
new file mode 100644
index 00000000000..f8439b02639
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/index/state.js
@@ -0,0 +1,18 @@
+import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../constants';
+
+export default ({ endpoint, projectId, unleashApiInstanceId, rotateInstanceIdPath }) => ({
+ [FEATURE_FLAG_SCOPE]: [],
+ [USER_LIST_SCOPE]: [],
+ alerts: [],
+ count: {},
+ pageInfo: { [FEATURE_FLAG_SCOPE]: {}, [USER_LIST_SCOPE]: {} },
+ isLoading: true,
+ hasError: false,
+ endpoint,
+ rotateEndpoint: rotateInstanceIdPath,
+ instanceId: unleashApiInstanceId,
+ isRotating: false,
+ hasRotateError: false,
+ options: {},
+ projectId,
+});
diff --git a/app/assets/javascripts/feature_flags/store/new/actions.js b/app/assets/javascripts/feature_flags/store/new/actions.js
new file mode 100644
index 00000000000..e21c128cd39
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/new/actions.js
@@ -0,0 +1,37 @@
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { NEW_VERSION_FLAG } from '../../constants';
+import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers';
+
+/**
+ * Handles the creation of a new feature flag.
+ *
+ * Will dispatch `requestCreateFeatureFlag`
+ * Serializes the params and makes a post request
+ * Dispatches an action acording to the request status.
+ *
+ * @param {Object} params
+ */
+export const createFeatureFlag = ({ state, dispatch }, params) => {
+ dispatch('requestCreateFeatureFlag');
+
+ return axios
+ .post(
+ state.endpoint,
+ params.version === NEW_VERSION_FLAG
+ ? mapStrategiesToRails(params)
+ : mapFromScopesViewModel(params),
+ )
+ .then(() => {
+ dispatch('receiveCreateFeatureFlagSuccess');
+ visitUrl(state.path);
+ })
+ .catch(error => dispatch('receiveCreateFeatureFlagError', error.response.data));
+};
+
+export const requestCreateFeatureFlag = ({ commit }) => commit(types.REQUEST_CREATE_FEATURE_FLAG);
+export const receiveCreateFeatureFlagSuccess = ({ commit }) =>
+ commit(types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS);
+export const receiveCreateFeatureFlagError = ({ commit }, error) =>
+ commit(types.RECEIVE_CREATE_FEATURE_FLAG_ERROR, error);
diff --git a/app/assets/javascripts/feature_flags/store/new/index.js b/app/assets/javascripts/feature_flags/store/new/index.js
new file mode 100644
index 00000000000..f737e0517fc
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/new/index.js
@@ -0,0 +1,11 @@
+import Vuex from 'vuex';
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default data =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: state(data),
+ });
diff --git a/app/assets/javascripts/feature_flags/store/new/mutation_types.js b/app/assets/javascripts/feature_flags/store/new/mutation_types.js
new file mode 100644
index 00000000000..71cc57c8d2e
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/new/mutation_types.js
@@ -0,0 +1,3 @@
+export const REQUEST_CREATE_FEATURE_FLAG = 'REQUEST_CREATE_FEATURE_FLAG';
+export const RECEIVE_CREATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_CREATE_FEATURE_FLAG_SUCCESS';
+export const RECEIVE_CREATE_FEATURE_FLAG_ERROR = 'RECEIVE_CREATE_FEATURE_FLAG_ERROR';
diff --git a/app/assets/javascripts/feature_flags/store/new/mutations.js b/app/assets/javascripts/feature_flags/store/new/mutations.js
new file mode 100644
index 00000000000..eeefc8413e8
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/new/mutations.js
@@ -0,0 +1,15 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_CREATE_FEATURE_FLAG](state) {
+ state.isSendingRequest = true;
+ state.error = [];
+ },
+ [types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS](state) {
+ state.isSendingRequest = false;
+ },
+ [types.RECEIVE_CREATE_FEATURE_FLAG_ERROR](state, error) {
+ state.isSendingRequest = false;
+ state.error = error.message || [];
+ },
+};
diff --git a/app/assets/javascripts/feature_flags/store/new/state.js b/app/assets/javascripts/feature_flags/store/new/state.js
new file mode 100644
index 00000000000..56940925aa0
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/new/state.js
@@ -0,0 +1,6 @@
+export default ({ endpoint, path }) => ({
+ endpoint,
+ path,
+ isSendingRequest: false,
+ error: [],
+});
diff --git a/app/assets/javascripts/feature_flags/utils.js b/app/assets/javascripts/feature_flags/utils.js
new file mode 100644
index 00000000000..24c570657e6
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/utils.js
@@ -0,0 +1,66 @@
+import { s__, n__, sprintf } from '~/locale';
+import {
+ ALL_ENVIRONMENTS_NAME,
+ ROLLOUT_STRATEGY_ALL_USERS,
+ ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT,
+ ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
+ ROLLOUT_STRATEGY_USER_ID,
+ ROLLOUT_STRATEGY_GITLAB_USER_LIST,
+} from './constants';
+
+const badgeTextByType = {
+ [ROLLOUT_STRATEGY_ALL_USERS]: {
+ name: s__('FeatureFlags|All Users'),
+ parameters: null,
+ },
+ [ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT]: {
+ name: s__('FeatureFlags|Percent rollout'),
+ parameters: ({ parameters: { rollout, stickiness } }) => {
+ switch (stickiness) {
+ case 'USERID':
+ return sprintf(s__('FeatureFlags|%{percent} by user ID'), { percent: `${rollout}%` });
+ case 'SESSIONID':
+ return sprintf(s__('FeatureFlags|%{percent} by session ID'), { percent: `${rollout}%` });
+ case 'RANDOM':
+ return sprintf(s__('FeatureFlags|%{percent} randomly'), { percent: `${rollout}%` });
+ default:
+ return sprintf(s__('FeatureFlags|%{percent} by available ID'), {
+ percent: `${rollout}%`,
+ });
+ }
+ },
+ },
+ [ROLLOUT_STRATEGY_PERCENT_ROLLOUT]: {
+ name: s__('FeatureFlags|Percent of users'),
+ parameters: ({ parameters: { percentage } }) => `${percentage}%`,
+ },
+ [ROLLOUT_STRATEGY_USER_ID]: {
+ name: s__('FeatureFlags|User IDs'),
+ parameters: ({ parameters: { userIds } }) =>
+ sprintf(n__('FeatureFlags|%d user', 'FeatureFlags|%d users', userIds.split(',').length)),
+ },
+ [ROLLOUT_STRATEGY_GITLAB_USER_LIST]: {
+ name: s__('FeatureFlags|User List'),
+ parameters: ({ user_list: { name } }) => name,
+ },
+};
+
+const scopeName = ({ environment_scope: scope }) =>
+ scope === ALL_ENVIRONMENTS_NAME ? s__('FeatureFlags|All Environments') : scope;
+
+export const labelForStrategy = strategy => {
+ const { name, parameters } = badgeTextByType[strategy.name];
+
+ if (parameters) {
+ return sprintf('%{name} - %{parameters}: %{scopes}', {
+ name,
+ parameters: parameters(strategy),
+ scopes: strategy.scopes.map(scopeName).join(', '),
+ });
+ }
+
+ return sprintf('%{name}: %{scopes}', {
+ name,
+ scopes: strategy.scopes.map(scopeName).join(', '),
+ });
+};
diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
index 80f78c154ee..51077296e20 100644
--- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
+++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
@@ -1,6 +1,6 @@
import { __ } from '~/locale';
-export default IssuableTokenKeys => {
+export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
const draftToken = {
token: {
formattedKey: __('Draft'),
@@ -51,16 +51,101 @@ export default IssuableTokenKeys => {
IssuableTokenKeys.tokenKeysWithAlternative.push(draftToken.token);
IssuableTokenKeys.conditions.push(...draftToken.conditions);
- const targetBranchToken = {
- formattedKey: __('Target-Branch'),
- key: 'target-branch',
- type: 'string',
- param: '',
- symbol: '',
- icon: 'arrow-right',
- tag: 'branch',
+ if (!disableTargetBranchFilter) {
+ const targetBranchToken = {
+ formattedKey: __('Target-Branch'),
+ key: 'target-branch',
+ type: 'string',
+ param: '',
+ symbol: '',
+ icon: 'arrow-right',
+ tag: 'branch',
+ };
+
+ IssuableTokenKeys.tokenKeys.push(targetBranchToken);
+ IssuableTokenKeys.tokenKeysWithAlternative.push(targetBranchToken);
+ }
+
+ const approvedBy = {
+ token: {
+ formattedKey: __('Approved-By'),
+ key: 'approved-by',
+ type: 'array',
+ param: 'usernames[]',
+ symbol: '@',
+ icon: 'approval',
+ tag: '@approved-by',
+ },
+ condition: [
+ {
+ url: 'approved_by_usernames[]=None',
+ tokenKey: 'approved-by',
+ value: __('None'),
+ operator: '=',
+ },
+ {
+ url: 'not[approved_by_usernames][]=None',
+ tokenKey: 'approved-by',
+ value: __('None'),
+ operator: '!=',
+ },
+ {
+ url: 'approved_by_usernames[]=Any',
+ tokenKey: 'approved-by',
+ value: __('Any'),
+ operator: '=',
+ },
+ {
+ url: 'not[approved_by_usernames][]=Any',
+ tokenKey: 'approved-by',
+ value: __('Any'),
+ operator: '!=',
+ },
+ ],
};
- IssuableTokenKeys.tokenKeys.push(targetBranchToken);
- IssuableTokenKeys.tokenKeysWithAlternative.push(targetBranchToken);
+ const tokenPosition = 2;
+ IssuableTokenKeys.tokenKeys.splice(tokenPosition, 0, ...[approvedBy.token]);
+ IssuableTokenKeys.tokenKeysWithAlternative.splice(tokenPosition, 0, ...[approvedBy.token]);
+ IssuableTokenKeys.conditions.push(...approvedBy.condition);
+
+ if (gon?.features?.deploymentFilters) {
+ const environmentToken = {
+ formattedKey: __('Environment'),
+ key: 'environment',
+ type: 'string',
+ param: '',
+ symbol: '',
+ icon: 'cloud-gear',
+ tag: 'environment',
+ };
+
+ const deployedBeforeToken = {
+ formattedKey: __('Deployed-before'),
+ key: 'deployed-before',
+ type: 'string',
+ param: '',
+ symbol: '',
+ icon: 'clock',
+ tag: 'deployed_before',
+ };
+
+ const deployedAfterToken = {
+ formattedKey: __('Deployed-after'),
+ key: 'deployed-after',
+ type: 'string',
+ param: '',
+ symbol: '',
+ icon: 'clock',
+ tag: 'deployed_after',
+ };
+
+ IssuableTokenKeys.tokenKeys.push(environmentToken, deployedBeforeToken, deployedAfterToken);
+
+ IssuableTokenKeys.tokenKeysWithAlternative.push(
+ environmentToken,
+ deployedBeforeToken,
+ deployedAfterToken,
+ );
+ }
};
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
index 49bd3cda127..d7645f96406 100644
--- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -15,6 +15,7 @@ export default class AvailableDropdownMappings {
labelsEndpoint,
milestonesEndpoint,
releasesEndpoint,
+ environmentsEndpoint,
groupsOnly,
includeAncestorGroups,
includeDescendantGroups,
@@ -24,6 +25,7 @@ export default class AvailableDropdownMappings {
this.labelsEndpoint = labelsEndpoint;
this.milestonesEndpoint = milestonesEndpoint;
this.releasesEndpoint = releasesEndpoint;
+ this.environmentsEndpoint = environmentsEndpoint;
this.groupsOnly = groupsOnly;
this.includeAncestorGroups = includeAncestorGroups;
this.includeDescendantGroups = includeDescendantGroups;
@@ -69,6 +71,11 @@ export default class AvailableDropdownMappings {
gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-assignee'),
},
+ 'approved-by': {
+ reference: null,
+ gl: DropdownUser,
+ element: this.container.querySelector('#js-dropdown-approved-by'),
+ },
milestone: {
reference: null,
gl: DropdownNonUser,
@@ -144,6 +151,16 @@ export default class AvailableDropdownMappings {
},
element: this.container.querySelector('#js-dropdown-target-branch'),
},
+ environment: {
+ reference: null,
+ gl: DropdownNonUser,
+ extraArguments: {
+ endpoint: this.getEnvironmentsEndpoint(),
+ symbol: '',
+ preprocessing: data => data.map(env => ({ title: env })),
+ },
+ element: this.container.querySelector('#js-dropdown-environment'),
+ },
};
}
@@ -189,6 +206,10 @@ export default class AvailableDropdownMappings {
return mergeUrlParams(params, endpoint);
}
+ getEnvironmentsEndpoint() {
+ return `${this.environmentsEndpoint}.json`;
+ }
+
getGroupId() {
return this.filteredSearchInput.getAttribute('data-group-id') || '';
}
diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js
index 0b9fe969da1..6cd6f9c9906 100644
--- a/app/assets/javascripts/filtered_search/constants.js
+++ b/app/assets/javascripts/filtered_search/constants.js
@@ -1,4 +1,4 @@
-export const USER_TOKEN_TYPES = ['author', 'assignee'];
+export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by'];
export const DROPDOWN_TYPE = {
hint: 'hint',
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
index 161a65c511d..762383f5a1d 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -13,6 +13,7 @@ export default class FilteredSearchDropdownManager {
labelsEndpoint = '',
milestonesEndpoint = '',
releasesEndpoint = '',
+ environmentsEndpoint = '',
epicsEndpoint = '',
tokenizer,
page,
@@ -29,6 +30,7 @@ export default class FilteredSearchDropdownManager {
this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint);
this.releasesEndpoint = removeTrailingSlash(releasesEndpoint);
this.epicsEndpoint = removeTrailingSlash(epicsEndpoint);
+ this.environmentsEndpoint = removeTrailingSlash(environmentsEndpoint);
this.tokenizer = tokenizer;
this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 3e4a9880134..261532f8867 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -110,6 +110,7 @@ export default class FilteredSearchManager {
labelsEndpoint = '',
milestonesEndpoint = '',
releasesEndpoint = '',
+ environmentsEndpoint = '',
epicsEndpoint = '',
} = this.filteredSearchInput.dataset;
@@ -118,6 +119,7 @@ export default class FilteredSearchManager {
labelsEndpoint,
milestonesEndpoint,
releasesEndpoint,
+ environmentsEndpoint,
epicsEndpoint,
tokenizer: this.tokenizer,
page: this.page,
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index f2dd8d5ace5..1d5f09a265b 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -1,5 +1,5 @@
-import * as Sentry from '@sentry/browser';
import { escape } from 'lodash';
+import * as Sentry from '~/sentry/wrapper';
import { spriteIcon } from './lib/utils/common_utils';
const FLASH_TYPES = {
diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js
index 1998bf4358a..5b03e1d19db 100644
--- a/app/assets/javascripts/frequent_items/index.js
+++ b/app/assets/javascripts/frequent_items/index.js
@@ -16,53 +16,63 @@ const frequentItemDropdowns = [
},
];
+const initFrequentItemList = (namespace, key) => {
+ const el = document.getElementById(`js-${namespace}-dropdown`);
+
+ // Don't do anything if element doesn't exist (No groups dropdown)
+ // This is for when the user accesses GitLab without logging in
+ if (!el) {
+ return;
+ }
+
+ import('./components/app.vue')
+ .then(({ default: FrequentItems }) => {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ data() {
+ const { dataset } = this.$options.el;
+ const item = {
+ id: Number(dataset[`${key}Id`]),
+ name: dataset[`${key}Name`],
+ namespace: dataset[`${key}Namespace`],
+ webUrl: dataset[`${key}WebUrl`],
+ avatarUrl: dataset[`${key}AvatarUrl`] || null,
+ lastAccessedOn: Date.now(),
+ };
+
+ return {
+ currentUserName: dataset.userName,
+ currentItem: item,
+ };
+ },
+ render(createElement) {
+ return createElement(FrequentItems, {
+ props: {
+ namespace,
+ currentUserName: this.currentUserName,
+ currentItem: this.currentItem,
+ },
+ });
+ },
+ });
+ })
+ .catch(() => {});
+};
+
export default function initFrequentItemDropdowns() {
frequentItemDropdowns.forEach(dropdown => {
const { namespace, key } = dropdown;
- const el = document.getElementById(`js-${namespace}-dropdown`);
const navEl = document.getElementById(`nav-${namespace}-dropdown`);
// Don't do anything if element doesn't exist (No groups dropdown)
// This is for when the user accesses GitLab without logging in
- if (!el || !navEl) {
+ if (!navEl) {
return;
}
- import('./components/app.vue')
- .then(({ default: FrequentItems }) => {
- // eslint-disable-next-line no-new
- new Vue({
- el,
- data() {
- const { dataset } = this.$options.el;
- const item = {
- id: Number(dataset[`${key}Id`]),
- name: dataset[`${key}Name`],
- namespace: dataset[`${key}Namespace`],
- webUrl: dataset[`${key}WebUrl`],
- avatarUrl: dataset[`${key}AvatarUrl`] || null,
- lastAccessedOn: Date.now(),
- };
-
- return {
- currentUserName: dataset.userName,
- currentItem: item,
- };
- },
- render(createElement) {
- return createElement(FrequentItems, {
- props: {
- namespace,
- currentUserName: this.currentUserName,
- currentItem: this.currentItem,
- },
- });
- },
- });
- })
- .catch(() => {});
-
$(navEl).on('shown.bs.dropdown', () => {
+ initFrequentItemList(namespace, key);
eventHub.$emit(`${namespace}-dropdownOpen`);
});
});
diff --git a/app/assets/javascripts/frequent_items/utils.js b/app/assets/javascripts/frequent_items/utils.js
index 112e8eaaf17..954d426c86c 100644
--- a/app/assets/javascripts/frequent_items/utils.js
+++ b/app/assets/javascripts/frequent_items/utils.js
@@ -1,6 +1,6 @@
import { take } from 'lodash';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import { sanitize } from 'dompurify';
+import { sanitize } from '~/lib/dompurify';
import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants';
export const isMobile = () => ['md', 'sm', 'xs'].includes(bp.getBreakpointSize());
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 409733c73b9..62948f74aaa 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -52,7 +52,6 @@ export const defaultAutocompleteConfig = {
milestones: true,
labels: true,
snippets: true,
- vulnerabilities: true,
};
class GfmAutoComplete {
@@ -179,6 +178,9 @@ class GfmAutoComplete {
}
setupEmoji($input) {
+ const self = this;
+ const { filter, ...defaults } = this.getDefaultCallbacks();
+
// Emoji
$input.atwho({
at: ':',
@@ -189,18 +191,47 @@ class GfmAutoComplete {
}
return tmpl;
},
- // eslint-disable-next-line no-template-curly-in-string
- insertTpl: ':${name}:',
+ insertTpl: GfmAutoComplete.Emoji.insertTemplateFunction,
skipSpecialCharacterTest: true,
data: GfmAutoComplete.defaultLoadingData,
callbacks: {
- ...this.getDefaultCallbacks(),
+ ...defaults,
matcher(flag, subtext) {
const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi');
const match = regexp.exec(subtext);
return match && match.length ? match[1] : null;
},
+ filter(query, items, searchKey) {
+ const filtered = filter.call(this, query, items, searchKey);
+ if (query.length === 0 || GfmAutoComplete.isLoading(items)) {
+ return filtered;
+ }
+
+ // map from value to "<value> is <field> of <emoji>", arranged by emoji
+ const emojis = {};
+ filtered.forEach(({ name: value }) => {
+ self.emojiLookup[value].forEach(({ emoji: { name }, kind }) => {
+ let entry = emojis[name];
+ if (!entry) {
+ entry = {};
+ emojis[name] = entry;
+ }
+ if (!(kind in entry) || value.localeCompare(entry[kind]) < 0) {
+ entry[kind] = value;
+ }
+ });
+ });
+
+ // collate results to list, prefering name > unicode > alias > description
+ const results = [];
+ Object.values(emojis).forEach(({ name, unicode, alias, description }) => {
+ results.push(name || unicode || alias || description);
+ });
+
+ // return to the form atwho wants
+ return results.map(name => ({ name }));
+ },
},
});
}
@@ -593,12 +624,7 @@ class GfmAutoComplete {
if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]);
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
- Emoji.initEmojiMap()
- .then(() => {
- this.loadData($input, at, Emoji.getValidEmojiNames());
- GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag;
- })
- .catch(() => {});
+ this.loadEmojiData($input, at).catch(() => {});
} else if (dataSource) {
AjaxCache.retrieve(dataSource, true)
.then(data => {
@@ -621,6 +647,39 @@ class GfmAutoComplete {
return $input.trigger('keyup');
}
+ async loadEmojiData($input, at) {
+ await Emoji.initEmojiMap();
+
+ // All the emoji
+ const emojis = Emoji.getAllEmoji();
+
+ // Add all of the fields to atwho's database
+ this.loadData($input, at, [
+ ...Object.keys(emojis), // Names
+ ...Object.values(emojis).flatMap(({ aliases }) => aliases), // Aliases
+ ...Object.values(emojis).map(({ e }) => e), // Unicode values
+ ...Object.values(emojis).map(({ d }) => d), // Descriptions
+ ]);
+
+ // Construct a lookup that can correlate a value to "<value> is the <field> of <emoji>"
+ const lookup = {};
+ const add = (key, kind, emoji) => {
+ if (!(key in lookup)) {
+ lookup[key] = [];
+ }
+ lookup[key].push({ kind, emoji });
+ };
+ Object.values(emojis).forEach(emoji => {
+ add(emoji.name, 'name', emoji);
+ add(emoji.d, 'description', emoji);
+ add(emoji.e, 'unicode', emoji);
+ emoji.aliases.forEach(a => add(a, 'alias', emoji));
+ });
+ this.emojiLookup = lookup;
+
+ GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag;
+ }
+
clearCache() {
this.cachedData = {};
}
@@ -648,8 +707,7 @@ class GfmAutoComplete {
// https://github.com/ichord/At.js
const atSymbolsWithBar = Object.keys(controllers)
.join('|')
- .replace(/[$]/, '\\$&')
- .replace(/[+]/, '\\+');
+ .replace(/[$]/, '\\$&');
const atSymbolsWithoutBar = Object.keys(controllers).join('');
const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop();
const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
@@ -680,19 +738,39 @@ GfmAutoComplete.atTypeMap = {
'~': 'labels',
'%': 'milestones',
'/': 'commands',
- '+': 'vulnerabilities',
$: 'snippets',
};
+function findEmoji(name) {
+ return Emoji.searchEmoji(name, { match: 'contains', raw: true }).sort((a, b) => {
+ if (a.index !== b.index) {
+ return a.index - b.index;
+ }
+ return a.field.localeCompare(b.field);
+ });
+}
+
// Emoji
GfmAutoComplete.glEmojiTag = null;
GfmAutoComplete.Emoji = {
+ insertTemplateFunction(value) {
+ const results = findEmoji(value.name);
+ if (results.length) {
+ return `:${results[0].emoji.name}:`;
+ }
+ return `:${value.name}:`;
+ },
templateFunction(name) {
// glEmojiTag helper is loaded on-demand in fetchData()
- if (GfmAutoComplete.glEmojiTag) {
+ if (!GfmAutoComplete.glEmojiTag) return `<li>${name}</li>`;
+
+ const results = findEmoji(name);
+ if (!results.length) {
return `<li>${name} ${GfmAutoComplete.glEmojiTag(name)}</li>`;
}
- return `<li>${name}</li>`;
+
+ const { field, emoji } = results[0];
+ return `<li>${field} ${GfmAutoComplete.glEmojiTag(emoji.name)}</li>`;
},
};
// Team Members
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index 0a1e5490237..6958cf4c173 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -6,7 +6,14 @@ import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_
import { disableButtonIfEmptyField } from '~/lib/utils/common_utils';
export default class GLForm {
- constructor(form, enableGFM = {}) {
+ /**
+ * Create a GLForm
+ *
+ * @param {jQuery} form Root element of the GLForm
+ * @param {Object} enableGFM Which autocomplete features should be enabled?
+ * @param {Boolean} forceNew If true, treat the element as a **new** form even if `gfm-form` class already exists.
+ */
+ constructor(form, enableGFM = {}, forceNew = false) {
this.form = form;
this.textarea = this.form.find('textarea.js-gfm-input');
this.enableGFM = { ...defaultAutocompleteConfig, ...enableGFM };
@@ -22,7 +29,7 @@ export default class GLForm {
// Before we start, we should clean up any previous data for this form
this.destroy();
// Set up the form
- this.setupForm();
+ this.setupForm(forceNew);
this.form.data('glForm', this);
}
@@ -39,8 +46,8 @@ export default class GLForm {
this.form.data('glForm', null);
}
- setupForm() {
- const isNewForm = this.form.is(':not(.gfm-form)');
+ setupForm(forceNew = false) {
+ const isNewForm = this.form.is(':not(.gfm-form)') || forceNew;
this.form.removeClass('js-new-note-form');
if (isNewForm) {
this.form.find('.div-dropzone').remove();
diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
index 79494cb173b..7a991ac2455 100644
--- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
+++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
@@ -92,11 +92,9 @@ export default {
</a>
</p>
</gl-form-group>
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button variant="success" category="primary" @click="updateGrafanaIntegration">
- {{ __('Save Changes') }}
- </gl-button>
- </div>
+ <gl-button variant="success" category="primary" @click="updateGrafanaIntegration">
+ {{ __('Save Changes') }}
+ </gl-button>
</form>
</div>
</section>
diff --git a/app/assets/javascripts/group_label_subscription.js b/app/assets/javascripts/group_label_subscription.js
index 8c6c0714ee8..bfaa54080bd 100644
--- a/app/assets/javascripts/group_label_subscription.js
+++ b/app/assets/javascripts/group_label_subscription.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import { __ } from '~/locale';
import axios from './lib/utils/axios_utils';
import { deprecatedCreateFlash as flash } from './flash';
+import { fixTitle, hide } from '~/tooltips';
const tooltipTitles = {
group: __('Unsubscribe at group level'),
@@ -59,9 +60,9 @@ export default class GroupLabelSubscription {
const type = $button.hasClass('js-group-level') ? 'group' : 'project';
const newTitle = tooltipTitles[type];
- $('.js-unsubscribe-button', $button.closest('.label-actions-list'))
- .tooltip('hide')
- .attr('title', newTitle)
- .tooltip('_fixTitle');
+ const $el = $('.js-unsubscribe-button', $button.closest('.label-actions-list'));
+ hide($el);
+ $el.attr('title', `${newTitle}`);
+ fixTitle($el);
}
}
diff --git a/app/assets/javascripts/group_settings/components/shared_runners_form.vue b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
new file mode 100644
index 00000000000..e396521ce7c
--- /dev/null
+++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
@@ -0,0 +1,139 @@
+<script>
+import { GlToggle, GlLoadingIcon, GlTooltip, GlAlert } from '@gitlab/ui';
+import { debounce } from 'lodash';
+import { __ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import {
+ DEBOUNCE_TOGGLE_DELAY,
+ ERROR_MESSAGE,
+ ENABLED,
+ DISABLED,
+ ALLOW_OVERRIDE,
+} from '../constants';
+
+export default {
+ components: {
+ GlToggle,
+ GlLoadingIcon,
+ GlTooltip,
+ GlAlert,
+ },
+ props: {
+ updatePath: {
+ type: String,
+ required: true,
+ },
+ sharedRunnersAvailability: {
+ type: String,
+ required: true,
+ },
+ parentSharedRunnersAvailability: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ isLoading: false,
+ enabled: true,
+ allowOverride: false,
+ error: null,
+ };
+ },
+ computed: {
+ toggleDisabled() {
+ return this.parentSharedRunnersAvailability === DISABLED || this.isLoading;
+ },
+ enabledOrDisabledSetting() {
+ return this.enabled ? ENABLED : DISABLED;
+ },
+ disabledWithOverrideSetting() {
+ return this.allowOverride ? ALLOW_OVERRIDE : DISABLED;
+ },
+ },
+ created() {
+ if (this.sharedRunnersAvailability !== ENABLED) {
+ this.enabled = false;
+ }
+
+ if (this.sharedRunnersAvailability === ALLOW_OVERRIDE) {
+ this.allowOverride = true;
+ }
+ },
+ methods: {
+ generatePayload(data) {
+ return { shared_runners_setting: data };
+ },
+ enableOrDisable() {
+ this.updateRunnerSettings(this.generatePayload(this.enabledOrDisabledSetting));
+
+ // reset override toggle to false if shared runners are enabled
+ this.allowOverride = false;
+ },
+ override() {
+ this.updateRunnerSettings(this.generatePayload(this.disabledWithOverrideSetting));
+ },
+ updateRunnerSettings: debounce(function debouncedUpdateRunnerSettings(setting) {
+ this.isLoading = true;
+
+ axios
+ .put(this.updatePath, setting)
+ .then(() => {
+ this.isLoading = false;
+ })
+ .catch(error => {
+ const message = [
+ error.response?.data?.error || __('An error occurred while updating configuration.'),
+ ERROR_MESSAGE,
+ ].join(' ');
+
+ this.error = message;
+ });
+ }, DEBOUNCE_TOGGLE_DELAY),
+ },
+};
+</script>
+
+<template>
+ <div ref="sharedRunnersForm">
+ <gl-alert v-if="error" variant="danger" :dismissible="false">{{ error }}</gl-alert>
+
+ <h4 class="gl-display-flex gl-align-items-center">
+ {{ __('Set up shared runner availability') }}
+ <gl-loading-icon v-if="isLoading" class="gl-ml-3" inline />
+ </h4>
+
+ <section class="gl-mt-5">
+ <gl-toggle
+ v-model="enabled"
+ :disabled="toggleDisabled"
+ :label="__('Enable shared runners for this group')"
+ data-testid="enable-runners-toggle"
+ @change="enableOrDisable"
+ />
+
+ <span class="gl-text-gray-600">
+ {{ __('Enable shared runners for all projects and subgroups in this group.') }}
+ </span>
+ </section>
+
+ <section v-if="!enabled" class="gl-mt-5">
+ <gl-toggle
+ v-model="allowOverride"
+ :disabled="toggleDisabled"
+ :label="__('Allow projects and subgroups to override the group setting')"
+ data-testid="override-runners-toggle"
+ @change="override"
+ />
+
+ <span class="gl-text-gray-600">
+ {{ __('Allows projects or subgroups in this group to override the global setting.') }}
+ </span>
+ </section>
+
+ <gl-tooltip v-if="toggleDisabled" :target="() => $refs.sharedRunnersForm">
+ {{ __('Shared runners are disabled for the parent group') }}
+ </gl-tooltip>
+ </div>
+</template>
diff --git a/app/assets/javascripts/group_settings/constants.js b/app/assets/javascripts/group_settings/constants.js
new file mode 100644
index 00000000000..c7bb851c06b
--- /dev/null
+++ b/app/assets/javascripts/group_settings/constants.js
@@ -0,0 +1,11 @@
+import { __ } from '~/locale';
+
+// Debounce delay in milliseconds
+export const DEBOUNCE_TOGGLE_DELAY = 1000;
+
+export const ERROR_MESSAGE = __('Refresh the page and try again.');
+
+// runner setting options
+export const ENABLED = 'enabled';
+export const DISABLED = 'disabled_and_unoverridable';
+export const ALLOW_OVERRIDE = 'disabled_with_override';
diff --git a/app/assets/javascripts/group_settings/mount_shared_runners.js b/app/assets/javascripts/group_settings/mount_shared_runners.js
new file mode 100644
index 00000000000..44284204c41
--- /dev/null
+++ b/app/assets/javascripts/group_settings/mount_shared_runners.js
@@ -0,0 +1,15 @@
+import Vue from 'vue';
+import UpdateSharedRunnersForm from './components/shared_runners_form.vue';
+
+export default (containerId = 'update-shared-runners-form') => {
+ const containerEl = document.getElementById(containerId);
+
+ return new Vue({
+ el: containerEl,
+ render(createElement) {
+ return createElement(UpdateSharedRunnersForm, {
+ props: containerEl.dataset,
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue
index 8c7192b49a0..d2a613bed4f 100644
--- a/app/assets/javascripts/groups/components/group_folder.vue
+++ b/app/assets/javascripts/groups/components/group_folder.vue
@@ -1,8 +1,12 @@
<script>
+import { GlIcon } from '@gitlab/ui';
import { n__ } from '../../locale';
import { MAX_CHILDREN_COUNT } from '../constants';
export default {
+ components: {
+ GlIcon,
+ },
props: {
parentGroup: {
type: Object,
@@ -45,7 +49,7 @@ export default {
/>
<li v-if="hasMoreChildren" class="group-row">
<a :href="parentGroup.relativePath" class="group-row-contents has-more-items py-2">
- <i class="fa fa-external-link" aria-hidden="true"> </i> {{ moreChildrenStats }}
+ <gl-icon name="external-link" aria-hidden="true" /> {{ moreChildrenStats }}
</a>
</li>
</ul>
diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue
index 5487e25066e..2e92a608f76 100644
--- a/app/assets/javascripts/groups/components/item_actions.vue
+++ b/app/assets/javascripts/groups/components/item_actions.vue
@@ -53,6 +53,7 @@ export default {
:aria-label="leaveBtnTitle"
data-container="body"
data-placement="bottom"
+ data-testid="leave-group-btn"
class="leave-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5"
@click.prevent="onLeaveGroup"
>
@@ -66,6 +67,7 @@ export default {
:aria-label="editBtnTitle"
data-container="body"
data-placement="bottom"
+ data-testid="edit-group-btn"
class="edit-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5"
>
<gl-icon name="settings" class="position-top-0 align-middle" />
diff --git a/app/assets/javascripts/groups/components/item_stats_value.vue b/app/assets/javascripts/groups/components/item_stats_value.vue
index 18efd8c6823..52dc7cfa19d 100644
--- a/app/assets/javascripts/groups/components/item_stats_value.vue
+++ b/app/assets/javascripts/groups/components/item_stats_value.vue
@@ -1,13 +1,12 @@
<script>
-import { GlIcon } from '@gitlab/ui';
-import tooltip from '~/vue_shared/directives/tooltip';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
components: {
GlIcon,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
title: {
@@ -51,12 +50,13 @@ export default {
<template>
<span
- v-tooltip
+ v-gl-tooltip
:data-placement="tooltipPlacement"
:class="cssClass"
:title="title"
data-container="body"
>
- <gl-icon :name="iconName" /> <span v-if="isValuePresent" class="stat-value"> {{ value }} </span>
+ <gl-icon :name="iconName" />
+ <span v-if="isValuePresent" class="stat-value" data-testid="itemStatValue"> {{ value }} </span>
</span>
</template>
diff --git a/app/assets/javascripts/groups/members/components/app.vue b/app/assets/javascripts/groups/members/components/app.vue
index e94b28f5773..2e6dd4a0bad 100644
--- a/app/assets/javascripts/groups/members/components/app.vue
+++ b/app/assets/javascripts/groups/members/components/app.vue
@@ -1,11 +1,38 @@
<script>
+import { mapState, mapMutations } from 'vuex';
+import { GlAlert } from '@gitlab/ui';
+import MembersTable from '~/vue_shared/components/members/table/members_table.vue';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import { HIDE_ERROR } from '~/vuex_shared/modules/members/mutation_types';
+
export default {
name: 'GroupMembersApp',
+ components: { MembersTable, GlAlert },
+ computed: {
+ ...mapState(['showError', 'errorMessage']),
+ },
+ watch: {
+ showError(value) {
+ if (value) {
+ this.$nextTick(() => {
+ scrollToElement(this.$refs.errorAlert.$el);
+ });
+ }
+ },
+ },
+ methods: {
+ ...mapMutations({
+ hideError: HIDE_ERROR,
+ }),
+ },
};
</script>
<template>
- <span>
- <!-- Temporary empty template -->
- </span>
+ <div>
+ <gl-alert v-if="showError" ref="errorAlert" variant="danger" @dismiss="hideError">{{
+ errorMessage
+ }}</gl-alert>
+ <members-table />
+ </div>
</template>
diff --git a/app/assets/javascripts/groups/members/constants.js b/app/assets/javascripts/groups/members/constants.js
new file mode 100644
index 00000000000..6d71b666d7a
--- /dev/null
+++ b/app/assets/javascripts/groups/members/constants.js
@@ -0,0 +1,5 @@
+export const GROUP_MEMBER_BASE_PROPERTY_NAME = 'group_member';
+export const GROUP_MEMBER_ACCESS_LEVEL_PROPERTY_NAME = 'access_level';
+
+export const GROUP_LINK_BASE_PROPERTY_NAME = 'group_link';
+export const GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME = 'group_access';
diff --git a/app/assets/javascripts/groups/members/index.js b/app/assets/javascripts/groups/members/index.js
index 4ca1756f10c..3bbef14d199 100644
--- a/app/assets/javascripts/groups/members/index.js
+++ b/app/assets/javascripts/groups/members/index.js
@@ -1,23 +1,24 @@
import Vue from 'vue';
import Vuex from 'vuex';
+import { GlToast } from '@gitlab/ui';
+import { parseDataAttributes } from 'ee_else_ce/groups/members/utils';
import App from './components/app.vue';
import membersModule from '~/vuex_shared/modules/members';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-export default el => {
+export const initGroupMembersApp = (el, tableFields, requestFormatter) => {
if (!el) {
return () => {};
}
Vue.use(Vuex);
-
- const { members, groupId } = el.dataset;
+ Vue.use(GlToast);
const store = new Vuex.Store({
...membersModule({
- members: convertObjectPropsToCamelCase(JSON.parse(members), { deep: true }),
- sourceId: parseInt(groupId, 10),
+ ...parseDataAttributes(el),
currentUserId: gon.current_user_id || null,
+ tableFields,
+ requestFormatter,
}),
});
diff --git a/app/assets/javascripts/groups/members/utils.js b/app/assets/javascripts/groups/members/utils.js
new file mode 100644
index 00000000000..662eecc4e38
--- /dev/null
+++ b/app/assets/javascripts/groups/members/utils.js
@@ -0,0 +1,44 @@
+import { isUndefined } from 'lodash';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import {
+ GROUP_MEMBER_BASE_PROPERTY_NAME,
+ GROUP_MEMBER_ACCESS_LEVEL_PROPERTY_NAME,
+ GROUP_LINK_BASE_PROPERTY_NAME,
+ GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME,
+} from './constants';
+
+export const parseDataAttributes = el => {
+ const { members, groupId, memberPath } = el.dataset;
+
+ return {
+ members: convertObjectPropsToCamelCase(JSON.parse(members), { deep: true }),
+ sourceId: parseInt(groupId, 10),
+ memberPath,
+ };
+};
+
+const baseRequestFormatter = (basePropertyName, accessLevelPropertyName) => ({
+ accessLevel,
+ ...otherProperties
+}) => {
+ const accessLevelProperty = !isUndefined(accessLevel)
+ ? { [accessLevelPropertyName]: accessLevel }
+ : {};
+
+ return {
+ [basePropertyName]: {
+ ...accessLevelProperty,
+ ...otherProperties,
+ },
+ };
+};
+
+export const memberRequestFormatter = baseRequestFormatter(
+ GROUP_MEMBER_BASE_PROPERTY_NAME,
+ GROUP_MEMBER_ACCESS_LEVEL_PROPERTY_NAME,
+);
+
+export const groupLinkRequestFormatter = baseRequestFormatter(
+ GROUP_LINK_BASE_PROPERTY_NAME,
+ GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME,
+);
diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue
index 183816921c1..644808cb83a 100644
--- a/app/assets/javascripts/ide/components/activity_bar.vue
+++ b/app/assets/javascripts/ide/components/activity_bar.vue
@@ -1,8 +1,6 @@
<script>
-import $ from 'jquery';
import { mapActions, mapState } from 'vuex';
-import { GlIcon } from '@gitlab/ui';
-import tooltip from '~/vue_shared/directives/tooltip';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { leftSidebarViews } from '../constants';
export default {
@@ -10,7 +8,7 @@ export default {
GlIcon,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
computed: {
...mapState(['currentActivityView']),
@@ -22,9 +20,7 @@ export default {
this.updateActivityBarView(view);
- // TODO: We must use JQuery here to interact with the Bootstrap tooltip API
- // https://gitlab.com/gitlab-org/gitlab/-/issues/217577
- $(e.currentTarget).tooltip('hide');
+ this.$root.$emit('bv::hide::tooltip');
},
},
leftSidebarViews,
@@ -32,11 +28,11 @@ export default {
</script>
<template>
- <nav class="ide-activity-bar">
+ <nav class="ide-activity-bar" data-testid="left-sidebar">
<ul class="list-unstyled">
<li>
<button
- v-tooltip
+ v-gl-tooltip.right.viewport
:class="{
active: currentActivityView === $options.leftSidebarViews.edit.name,
}"
@@ -54,7 +50,7 @@ export default {
</li>
<li>
<button
- v-tooltip
+ v-gl-tooltip.right.viewport
:class="{
active: currentActivityView === $options.leftSidebarViews.review.name,
}"
@@ -71,7 +67,7 @@ export default {
</li>
<li>
<button
- v-tooltip
+ v-gl-tooltip.right.viewport
:class="{
active: currentActivityView === $options.leftSidebarViews.commit.name,
}"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
index de4b0a34002..b89329c92ec 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -1,8 +1,8 @@
<script>
-/* eslint-disable vue/no-v-html */
import { escape } from 'lodash';
import { mapState, mapGetters, createNamespacedHelpers } from 'vuex';
-import { sprintf, s__ } from '~/locale';
+import { GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
import consts from '../../stores/modules/commit/constants';
import RadioGroup from './radio_group.vue';
import NewMergeRequestOption from './new_merge_request_option.vue';
@@ -13,6 +13,7 @@ const { mapState: mapCommitState, mapActions: mapCommitActions } = createNamespa
export default {
components: {
+ GlSprintf,
RadioGroup,
NewMergeRequestOption,
},
@@ -20,12 +21,8 @@ export default {
...mapState(['currentBranchId', 'changedFiles', 'stagedFiles']),
...mapCommitState(['commitAction']),
...mapGetters(['currentBranch', 'emptyRepo', 'canPushToBranch']),
- commitToCurrentBranchText() {
- return sprintf(
- s__('IDE|Commit to %{branchName} branch'),
- { branchName: `<strong class="monospace">${escape(this.currentBranchId)}</strong>` },
- false,
- );
+ currentBranchText() {
+ return escape(this.currentBranchId);
},
containsStagedChanges() {
return this.changedFiles.length > 0 && this.stagedFiles.length > 0;
@@ -77,11 +74,13 @@ export default {
:disabled="!canPushToBranch"
:title="$options.currentBranchPermissionsTooltip"
>
- <span
- class="ide-option-label"
- data-qa-selector="commit_to_current_branch_radio"
- v-html="commitToCurrentBranchText"
- ></span>
+ <span class="ide-option-label" data-qa-selector="commit_to_current_branch_radio">
+ <gl-sprintf :message="s__('IDE|Commit to %{branchName} branch')">
+ <template #branchName>
+ <strong class="monospace">{{ currentBranchText }}</strong>
+ </template>
+ </gl-sprintf>
+ </span>
</radio-group>
<template v-if="!emptyRepo">
<radio-group
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
index bbcb866c758..53fac09ab66 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions } from 'vuex';
-import { GlModal } from '@gitlab/ui';
+import { GlModal, GlButton } from '@gitlab/ui';
import { sprintf, __ } from '~/locale';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
@@ -8,6 +8,7 @@ import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
export default {
components: {
GlModal,
+ GlButton,
FileIcon,
ChangedFileIcon,
},
@@ -52,15 +53,16 @@ export default {
</strong>
<changed-file-icon :file="activeFile" :is-centered="false" />
<div class="ml-auto">
- <button
+ <gl-button
v-if="canDiscard"
ref="discardButton"
- type="button"
- class="btn btn-remove btn-inverted gl-mr-3"
+ category="secondary"
+ variant="danger"
+ class="gl-mr-3"
@click="showDiscardModal"
>
{{ __('Discard changes') }}
- </button>
+ </gl-button>
</div>
<gl-modal
ref="discardModal"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index 73c56514fce..f36fe87ccfa 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -7,7 +7,6 @@ import CommitMessageField from './message_field.vue';
import Actions from './actions.vue';
import SuccessMessage from './success_message.vue';
import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants';
-import consts from '../../stores/modules/commit/constants';
import { createUnexpectedCommitError } from '../../lib/errors';
export default {
@@ -45,12 +44,11 @@ export default {
return this.currentActivityView === leftSidebarViews.commit.name;
},
commitErrorPrimaryAction() {
- if (!this.lastCommitError?.canCreateBranch) {
- return undefined;
- }
+ const { primaryAction } = this.lastCommitError || {};
return {
- text: __('Create new branch'),
+ button: primaryAction ? { text: primaryAction.text } : undefined,
+ callback: primaryAction?.callback?.bind(this, this.$store) || (() => {}),
};
},
},
@@ -78,9 +76,6 @@ export default {
commit() {
return this.commitChanges();
},
- forceCreateNewBranch() {
- return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commit());
- },
handleCompactState() {
if (this.lastCommitMsg) {
this.isCompact = false;
@@ -188,9 +183,9 @@ export default {
ref="commitErrorModal"
modal-id="ide-commit-error-modal"
:title="lastCommitError.title"
- :action-primary="commitErrorPrimaryAction"
+ :action-primary="commitErrorPrimaryAction.button"
:action-cancel="{ text: __('Cancel') }"
- @ok="forceCreateNewBranch"
+ @ok="commitErrorPrimaryAction.callback"
>
<div v-safe-html="lastCommitError.messageHTML"></div>
</gl-modal>
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 2787b10a48b..7d08815b033 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlPopover } from '@gitlab/ui';
import { __, sprintf } from '../../../locale';
import popover from '../../../vue_shared/directives/popover';
import { MAX_TITLE_LENGTH, MAX_BODY_LENGTH } from '../../constants';
@@ -10,6 +10,7 @@ export default {
},
components: {
GlIcon,
+ GlPopover,
},
props: {
text: {
@@ -58,7 +59,7 @@ export default {
},
},
popoverOptions: {
- trigger: 'hover',
+ triggers: 'hover',
placement: 'top',
content: sprintf(
__(`
@@ -83,9 +84,16 @@ export default {
<ul class="nav-links">
<li>
{{ __('Commit Message') }}
- <span v-popover="$options.popoverOptions" class="form-text text-muted gl-ml-3">
- <gl-icon name="question" />
- </span>
+ <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" />
+ </span>
+ <gl-popover
+ target="ide-commit-message-question"
+ container="ide-commit-message-popover-container"
+ v-bind="$options.popoverOptions"
+ />
+ </div>
</li>
</ul>
</div>
@@ -108,6 +116,7 @@ export default {
:placeholder="placeholder"
:value="text"
class="note-textarea ide-commit-message-textarea"
+ data-qa-selector="ide_commit_message_field"
dir="auto"
name="commit-message"
@scroll="handleScroll"
diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
index 732fa0786b0..dec8aa61838 100644
--- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
+++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
@@ -1,8 +1,12 @@
<script>
+import { GlButton } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { viewerTypes } from '../constants';
export default {
+ components: {
+ GlButton,
+ },
props: {
viewer: {
type: String,
@@ -31,7 +35,7 @@ export default {
<template>
<div class="dropdown">
- <button type="button" class="btn btn-link" data-toggle="dropdown">{{ __('Edit') }}</button>
+ <gl-button variant="link" data-toggle="dropdown">{{ __('Edit') }}</gl-button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-open-left">
<ul>
<li>
diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue
index b6a57d1b6e6..88dca2f0556 100644
--- a/app/assets/javascripts/ide/components/file_templates/bar.vue
+++ b/app/assets/javascripts/ide/components/file_templates/bar.vue
@@ -1,10 +1,12 @@
<script>
+import { GlButton } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import Dropdown from './dropdown.vue';
export default {
components: {
Dropdown,
+ GlButton,
},
computed: {
...mapGetters(['activeFile']),
@@ -65,9 +67,9 @@ export default {
@click="selectTemplate"
/>
<transition name="fade">
- <button v-show="updateSuccess" type="button" class="btn btn-default" @click="undo">
+ <gl-button v-show="updateSuccess" category="secondary" variant="default" @click="undo">
{{ __('Undo') }}
- </button>
+ </gl-button>
</transition>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
index d80662f6ae1..cfd2555b769 100644
--- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue
+++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
@@ -1,12 +1,13 @@
<script>
import $ from 'jquery';
import { mapActions, mapState } from 'vuex';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
export default {
components: {
DropdownButton,
+ GlIcon,
GlLoadingIcon,
},
props: {
@@ -85,7 +86,7 @@ export default {
type="search"
class="dropdown-input-field qa-dropdown-filter-input"
/>
- <i aria-hidden="true" class="fa fa-search dropdown-input-search"></i>
+ <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" />
</div>
<div class="dropdown-content">
<gl-loading-icon v-if="showLoading" size="lg" />
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 1b03d9eee8b..8f23856fd6c 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -2,7 +2,18 @@
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
+import {
+ WEBIDE_MARK_APP_START,
+ WEBIDE_MARK_FILE_FINISH,
+ WEBIDE_MARK_FILE_CLICKED,
+ WEBIDE_MARK_TREE_FINISH,
+ WEBIDE_MEASURE_TREE_FROM_REQUEST,
+ WEBIDE_MEASURE_FILE_FROM_REQUEST,
+ WEBIDE_MEASURE_FILE_AFTER_INTERACTION,
+} from '~/performance_constants';
+import { performanceMarkAndMeasure } from '~/performance_utils';
import { modalTypes } from '../constants';
+import eventHub from '../eventhub';
import FindFile from '~/vue_shared/components/file_finder/index.vue';
import NewModal from './new_dropdown/modal.vue';
import IdeSidebar from './ide_side_bar.vue';
@@ -14,6 +25,22 @@ import ErrorMessage from './error_message.vue';
import CommitEditorHeader from './commit_sidebar/editor_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { measurePerformance } from '../utils';
+
+eventHub.$on(WEBIDE_MEASURE_TREE_FROM_REQUEST, () =>
+ measurePerformance(WEBIDE_MARK_TREE_FINISH, WEBIDE_MEASURE_TREE_FROM_REQUEST),
+);
+eventHub.$on(WEBIDE_MEASURE_FILE_FROM_REQUEST, () =>
+ measurePerformance(WEBIDE_MARK_FILE_FINISH, WEBIDE_MEASURE_FILE_FROM_REQUEST),
+);
+eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, () =>
+ measurePerformance(
+ WEBIDE_MARK_FILE_FINISH,
+ WEBIDE_MEASURE_FILE_AFTER_INTERACTION,
+ WEBIDE_MARK_FILE_CLICKED,
+ ),
+);
+
export default {
components: {
NewModal,
@@ -59,6 +86,9 @@ export default {
if (this.themeName)
document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`);
},
+ beforeCreate() {
+ performanceMarkAndMeasure({ mark: WEBIDE_MARK_APP_START });
+ },
methods: {
...mapActions(['toggleFileFinder']),
onBeforeUnload(e = {}) {
diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue
index e36d0a5a5b1..7d2f0acb08c 100644
--- a/app/assets/javascripts/ide/components/ide_review.vue
+++ b/app/assets/javascripts/ide/components/ide_review.vue
@@ -23,26 +23,32 @@ export default {
},
},
mounted() {
- if (this.activeFile && this.activeFile.pending && !this.activeFile.deleted) {
- this.$router.push(this.getUrlForPath(this.activeFile.path), () => {
- this.updateViewer('editor');
- });
- } else if (this.activeFile && this.activeFile.deleted) {
- this.resetOpenFiles();
- }
-
- this.$nextTick(() => {
- this.updateViewer(this.currentMergeRequestId ? viewerTypes.mr : viewerTypes.diff);
- });
+ this.initialize();
+ },
+ activated() {
+ this.initialize();
},
methods: {
...mapActions(['updateViewer', 'resetOpenFiles']),
+ initialize() {
+ if (this.activeFile && this.activeFile.pending && !this.activeFile.deleted) {
+ this.$router.push(this.getUrlForPath(this.activeFile.path), () => {
+ this.updateViewer(viewerTypes.edit);
+ });
+ } else if (this.activeFile && this.activeFile.deleted) {
+ this.resetOpenFiles();
+ }
+
+ this.$nextTick(() => {
+ this.updateViewer(this.currentMergeRequestId ? viewerTypes.mr : viewerTypes.diff);
+ });
+ },
},
};
</script>
<template>
- <ide-tree-list :viewer-type="viewer" header-class="ide-review-header">
+ <ide-tree-list header-class="ide-review-header">
<template #header>
<div class="ide-review-button-holder">
{{ __('Review') }}
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
index ed68ca5cae9..53dfc133fc8 100644
--- a/app/assets/javascripts/ide/components/ide_side_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -7,9 +7,8 @@ import ActivityBar from './activity_bar.vue';
import RepoCommitSection from './repo_commit_section.vue';
import CommitForm from './commit_sidebar/form.vue';
import IdeReview from './ide_review.vue';
-import SuccessMessage from './commit_sidebar/success_message.vue';
import IdeProjectHeader from './ide_project_header.vue';
-import { leftSidebarViews, SIDEBAR_INIT_WIDTH } from '../constants';
+import { SIDEBAR_INIT_WIDTH } from '../constants';
export default {
components: {
@@ -20,18 +19,11 @@ export default {
IdeTree,
CommitForm,
IdeReview,
- SuccessMessage,
IdeProjectHeader,
},
computed: {
...mapState(['loading', 'currentActivityView', 'changedFiles', 'stagedFiles', 'lastCommitMsg']),
...mapGetters(['currentProject', 'someUncommittedChanges']),
- showSuccessMessage() {
- return (
- this.currentActivityView === leftSidebarViews.edit.name &&
- (this.lastCommitMsg && !this.someUncommittedChanges)
- );
- },
},
SIDEBAR_INIT_WIDTH,
};
@@ -44,7 +36,7 @@ export default {
class="multi-file-commit-panel flex-column"
>
<template v-if="loading">
- <div class="multi-file-commit-panel-inner">
+ <div class="multi-file-commit-panel-inner" data-testid="ide-side-bar-inner">
<div v-for="n in 3" :key="n" class="multi-file-loading-container">
<gl-skeleton-loading />
</div>
@@ -54,9 +46,11 @@ export default {
<ide-project-header :project="currentProject" />
<div class="ide-context-body d-flex flex-fill">
<activity-bar />
- <div class="multi-file-commit-panel-inner">
+ <div class="multi-file-commit-panel-inner" data-testid="ide-side-bar-inner">
<div class="multi-file-commit-panel-inner-content">
- <component :is="currentActivityView" />
+ <keep-alive>
+ <component :is="currentActivityView" />
+ </keep-alive>
</div>
<commit-form />
</div>
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index 146e818d654..ee292190e06 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -1,10 +1,9 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
import { mapActions, mapState, mapGetters } from 'vuex';
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import IdeStatusList from './ide_status_list.vue';
import IdeStatusMr from './ide_status_mr.vue';
-import tooltip from '~/vue_shared/directives/tooltip';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
import CiIcon from '../../vue_shared/components/ci_icon.vue';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
@@ -19,7 +18,7 @@ export default {
IdeStatusMr,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
mixins: [timeAgoMixin],
data() {
@@ -85,7 +84,7 @@ export default {
@click="openRightPane($options.rightSidebarViews.pipelines)"
>
<ci-icon
- v-tooltip
+ v-gl-tooltip
:status="latestPipeline.details.status"
:title="latestPipeline.details.status.text"
/>
@@ -99,7 +98,7 @@ export default {
<gl-icon name="commit" />
<a
- v-tooltip
+ v-gl-tooltip
:title="lastCommit.message"
:href="getCommitPath(lastCommit.short_id)"
class="commit-sha"
@@ -116,7 +115,7 @@ export default {
/>
{{ lastCommit.author_name }}
<time
- v-tooltip
+ v-gl-tooltip
:datetime="lastCommit.committed_date"
:title="tooltipTitle(lastCommit.committed_date)"
data-placement="top"
diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue
index 747d5044790..51d783df0ad 100644
--- a/app/assets/javascripts/ide/components/ide_tree.vue
+++ b/app/assets/javascripts/ide/components/ide_tree.vue
@@ -1,6 +1,6 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
-import { modalTypes } from '../constants';
+import { modalTypes, viewerTypes } from '../constants';
import IdeTreeList from './ide_tree_list.vue';
import Upload from './new_dropdown/upload.vue';
import NewEntryButton from './new_dropdown/button.vue';
@@ -18,15 +18,10 @@ export default {
...mapGetters(['currentProject', 'currentTree', 'activeFile', 'getUrlForPath']),
},
mounted() {
- if (!this.activeFile) return;
-
- if (this.activeFile.pending && !this.activeFile.deleted) {
- this.$router.push(this.getUrlForPath(this.activeFile.path), () => {
- this.updateViewer('editor');
- });
- } else if (this.activeFile.deleted) {
- this.resetOpenFiles();
- }
+ this.initialize();
+ },
+ activated() {
+ this.initialize();
},
methods: {
...mapActions(['updateViewer', 'createTempEntry', 'resetOpenFiles']),
@@ -36,12 +31,27 @@ export default {
createNewFolder() {
this.$refs.newModal.open(modalTypes.tree);
},
+ initialize() {
+ this.$nextTick(() => {
+ this.updateViewer(viewerTypes.edit);
+ });
+
+ if (!this.activeFile) return;
+
+ if (this.activeFile.pending && !this.activeFile.deleted) {
+ this.$router.push(this.getUrlForPath(this.activeFile.path), () => {
+ this.updateViewer(viewerTypes.edit);
+ });
+ } else if (this.activeFile.deleted) {
+ this.resetOpenFiles();
+ }
+ },
},
};
</script>
<template>
- <ide-tree-list viewer-type="editor">
+ <ide-tree-list>
<template #header>
{{ __('Edit') }}
<div class="ide-tree-actions ml-auto d-flex">
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index 776d8459515..dd226f07fb0 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -2,6 +2,13 @@
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import FileTree from '~/vue_shared/components/file_tree.vue';
+import {
+ WEBIDE_MARK_TREE_START,
+ WEBIDE_MEASURE_TREE_FROM_REQUEST,
+ WEBIDE_MARK_FILE_CLICKED,
+} from '~/performance_constants';
+import { performanceMarkAndMeasure } from '~/performance_utils';
+import eventHub from '../eventhub';
import IdeFileRow from './ide_file_row.vue';
import NavDropdown from './nav_dropdown.vue';
@@ -12,10 +19,6 @@ export default {
FileTree,
},
props: {
- viewerType: {
- type: String,
- required: true,
- },
headerClass: {
type: String,
required: false,
@@ -29,11 +32,19 @@ export default {
return !this.currentTree || this.currentTree.loading;
},
},
- mounted() {
- this.updateViewer(this.viewerType);
+ beforeCreate() {
+ performanceMarkAndMeasure({ mark: WEBIDE_MARK_TREE_START });
+ },
+ updated() {
+ if (this.currentTree?.tree?.length) {
+ eventHub.$emit(WEBIDE_MEASURE_TREE_FROM_REQUEST);
+ }
},
methods: {
- ...mapActions(['updateViewer', 'toggleTreeOpen']),
+ ...mapActions(['toggleTreeOpen']),
+ clickedFile() {
+ performanceMarkAndMeasure({ mark: WEBIDE_MARK_FILE_CLICKED });
+ },
},
IdeFileRow,
};
@@ -51,7 +62,7 @@ export default {
<nav-dropdown />
<slot name="header"></slot>
</header>
- <div class="ide-tree-body h-100">
+ <div class="ide-tree-body h-100" data-testid="ide-tree-body">
<template v-if="currentTree.tree.length">
<file-tree
v-for="file in currentTree.tree"
@@ -60,6 +71,7 @@ export default {
:level="0"
:file-row-component="$options.IdeFileRow"
@toggleTreeOpen="toggleTreeOpen"
+ @clickFile="clickedFile"
/>
</template>
<div v-else class="file-row">{{ __('No files') }}</div>
diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue
index 11033a5cc88..a5ae8bbfe9a 100644
--- a/app/assets/javascripts/ide/components/jobs/detail.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail.vue
@@ -2,9 +2,8 @@
/* eslint-disable vue/no-v-html */
import { mapActions, mapState } from 'vuex';
import { throttle } from 'lodash';
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '../../../locale';
-import tooltip from '../../../vue_shared/directives/tooltip';
import ScrollButton from './detail/scroll_button.vue';
import JobDescription from './detail/description.vue';
@@ -15,7 +14,7 @@ const scrollPositions = {
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
@@ -84,7 +83,7 @@ export default {
<job-description :job="detailJob" />
<div class="controllers ml-auto">
<a
- v-tooltip
+ v-gl-tooltip
:title="__('Show complete raw log')"
:href="detailJob.rawPath"
data-placement="top"
@@ -92,7 +91,7 @@ export default {
class="controllers-buttons"
target="_blank"
>
- <i aria-hidden="true" class="fa fa-file-text-o"></i>
+ <gl-icon name="doc-text" aria-hidden="true" />
</a>
<scroll-button :disabled="isScrolledToTop" direction="up" @click="scrollUp" />
<scroll-button :disabled="isScrolledToBottom" direction="down" @click="scrollDown" />
diff --git a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue
index 2c679a3edc7..f4859b9f312 100644
--- a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue
@@ -1,7 +1,6 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '../../../../locale';
-import tooltip from '../../../../vue_shared/directives/tooltip';
const directions = {
up: 'up',
@@ -10,7 +9,7 @@ const directions = {
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
@@ -46,7 +45,7 @@ export default {
<template>
<div
- v-tooltip
+ v-gl-tooltip
:title="tooltipTitle"
class="controllers-buttons"
data-container="body"
diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue
index 0b643947139..6c7f084c164 100644
--- a/app/assets/javascripts/ide/components/jobs/stage.vue
+++ b/app/assets/javascripts/ide/components/jobs/stage.vue
@@ -1,12 +1,11 @@
<script>
-import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
-import tooltip from '../../../vue_shared/directives/tooltip';
+import { GlLoadingIcon, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import CiIcon from '../../../vue_shared/components/ci_icon.vue';
import Item from './item.vue';
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
@@ -67,7 +66,7 @@ export default {
<ci-icon :status="stage.status" :size="24" />
<strong
ref="stageTitle"
- v-tooltip="showTooltip"
+ v-gl-tooltip="showTooltip"
:title="showTooltip ? stage.name : null"
data-container="body"
class="gl-ml-3 text-truncate"
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index 528475849de..5ad836f346a 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -152,6 +152,7 @@ export default {
v-model.trim="entryName"
type="text"
class="form-control"
+ data-testid="file-name-field"
data-qa-selector="file_name_field"
:placeholder="placeholder"
/>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
index 84ff05c9750..4a9a2a57acd 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
@@ -35,7 +35,7 @@ export default {
name: `${this.path ? `${this.path}/` : ''}${name}`,
type: 'blob',
content,
- rawPath: !isText ? target.result : '',
+ rawPath: !isText ? URL.createObjectURL(file) : '',
});
if (isText) {
@@ -44,7 +44,7 @@ export default {
reader.addEventListener('load', e => emitCreateEvent(e.target.result), { once: true });
reader.readAsText(file);
} else {
- emitCreateEvent(encodedContent);
+ emitCreateEvent(rawContent);
}
},
readFile(file) {
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
index 5eed57bb6c5..92b99b5c731 100644
--- a/app/assets/javascripts/ide/components/repo_commit_section.vue
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -26,28 +26,34 @@ export default {
},
},
mounted() {
- const file =
- this.lastOpenedFile && this.lastOpenedFile.type !== 'tree'
- ? this.lastOpenedFile
- : this.activeFile;
-
- if (!file) return;
-
- this.openPendingTab({
- file,
- keyPrefix: file.staged ? stageKeys.staged : stageKeys.unstaged,
- })
- .then(changeViewer => {
- if (changeViewer) {
- this.updateViewer('diff');
- }
- })
- .catch(e => {
- throw e;
- });
+ this.initialize();
+ },
+ activated() {
+ this.initialize();
},
methods: {
...mapActions(['openPendingTab', 'updateViewer', 'updateActivityBarView']),
+ initialize() {
+ const file =
+ this.lastOpenedFile && this.lastOpenedFile.type !== 'tree'
+ ? this.lastOpenedFile
+ : this.activeFile;
+
+ if (!file) return;
+
+ this.openPendingTab({
+ file,
+ keyPrefix: file.staged ? stageKeys.staged : stageKeys.unstaged,
+ })
+ .then(changeViewer => {
+ if (changeViewer) {
+ this.updateViewer('diff');
+ }
+ })
+ .catch(e => {
+ throw e;
+ });
+ },
},
stageKeys,
};
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index f342ce1739c..56bbb6349cd 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -5,6 +5,14 @@ import { deprecatedCreateFlash as flash } from '~/flash';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import {
+ WEBIDE_MARK_FILE_CLICKED,
+ WEBIDE_MARK_FILE_START,
+ WEBIDE_MEASURE_FILE_AFTER_INTERACTION,
+ WEBIDE_MEASURE_FILE_FROM_REQUEST,
+} from '~/performance_constants';
+import { performanceMarkAndMeasure } from '~/performance_utils';
+import eventHub from '../eventhub';
+import {
leftSidebarViews,
viewerTypes,
FILE_VIEW_MODE_EDITOR,
@@ -60,7 +68,7 @@ export default {
]),
...mapGetters('fileTemplates', ['showFileTemplatesBar']),
shouldHideEditor() {
- return this.file && !isTextFile(this.file);
+ return this.file && !this.file.loading && !isTextFile(this.file);
},
showContentViewer() {
return (
@@ -164,6 +172,9 @@ export default {
}
},
},
+ beforeCreate() {
+ performanceMarkAndMeasure({ mark: WEBIDE_MARK_FILE_START });
+ },
beforeDestroy() {
this.editor.dispose();
},
@@ -224,6 +235,7 @@ export default {
return this.getFileData({
path: this.file.path,
makeFileActive: false,
+ toggleLoading: false,
}).then(() =>
this.getRawFileData({
path: this.file.path,
@@ -289,6 +301,11 @@ export default {
});
this.$emit('editorSetup');
+ if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) {
+ eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION);
+ } else {
+ eventHub.$emit(WEBIDE_MEASURE_FILE_FROM_REQUEST);
+ }
},
refreshEditorDimensions() {
if (this.showEditor) {
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 59b1969face..bdb11e6b004 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -47,9 +47,9 @@ export const diffViewerErrors = Object.freeze({
});
export const leftSidebarViews = {
- edit: { name: 'ide-tree', keepAlive: false },
- review: { name: 'ide-review', keepAlive: false },
- commit: { name: 'repo-commit-section', keepAlive: false },
+ edit: { name: 'ide-tree' },
+ review: { name: 'ide-review' },
+ commit: { name: 'repo-commit-section' },
};
export const rightSidebarViews = {
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 7c767009de5..56d48e87c18 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -73,11 +73,9 @@ export function initIde(el, options = {}) {
* @param {Objects} options - Extra options for the IDE (Used by EE).
*/
export function startIde(options) {
- document.addEventListener('DOMContentLoaded', () => {
- const ideElement = document.getElementById('ide');
- if (ideElement) {
- resetServiceWorkersPublicPath();
- initIde(ideElement, options);
- }
- });
+ const ideElement = document.getElementById('ide');
+ if (ideElement) {
+ resetServiceWorkersPublicPath();
+ initIde(ideElement, options);
+ }
}
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
index 2b12230c7cd..493dedcd89a 100644
--- a/app/assets/javascripts/ide/lib/editor.js
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -157,8 +157,10 @@ export default class Editor {
}
updateDimensions() {
- this.instance.layout();
- this.updateDiffView();
+ if (this.instance) {
+ this.instance.layout();
+ this.updateDiffView();
+ }
}
setPosition({ lineNumber, column }) {
diff --git a/app/assets/javascripts/ide/lib/errors.js b/app/assets/javascripts/ide/lib/errors.js
index 6ae18bc8180..e62d9d1e77f 100644
--- a/app/assets/javascripts/ide/lib/errors.js
+++ b/app/assets/javascripts/ide/lib/errors.js
@@ -1,25 +1,49 @@
import { escape } from 'lodash';
import { __ } from '~/locale';
+import consts from '../stores/modules/commit/constants';
const CODEOWNERS_REGEX = /Push.*protected branches.*CODEOWNERS/;
const BRANCH_CHANGED_REGEX = /changed.*since.*start.*edit/;
+const BRANCH_ALREADY_EXISTS = /branch.*already.*exists/;
-export const createUnexpectedCommitError = () => ({
+const createNewBranchAndCommit = store =>
+ store
+ .dispatch('commit/updateCommitAction', consts.COMMIT_TO_NEW_BRANCH)
+ .then(() => store.dispatch('commit/commitChanges'));
+
+export const createUnexpectedCommitError = message => ({
title: __('Unexpected error'),
- messageHTML: __('Could not commit. An unexpected error occurred.'),
- canCreateBranch: false,
+ messageHTML: escape(message) || __('Could not commit. An unexpected error occurred.'),
});
export const createCodeownersCommitError = message => ({
title: __('CODEOWNERS rule violation'),
messageHTML: escape(message),
- canCreateBranch: true,
+ primaryAction: {
+ text: __('Create new branch'),
+ callback: createNewBranchAndCommit,
+ },
});
export const createBranchChangedCommitError = message => ({
title: __('Branch changed'),
messageHTML: `${escape(message)}<br/><br/>${__('Would you like to create a new branch?')}`,
- canCreateBranch: true,
+ primaryAction: {
+ text: __('Create new branch'),
+ callback: createNewBranchAndCommit,
+ },
+});
+
+export const branchAlreadyExistsCommitError = message => ({
+ title: __('Branch already exists'),
+ messageHTML: `${escape(message)}<br/><br/>${__(
+ 'Would you like to try auto-generating a branch name?',
+ )}`,
+ primaryAction: {
+ text: __('Create new branch'),
+ callback: store =>
+ store.dispatch('commit/addSuffixToBranchName').then(() => createNewBranchAndCommit(store)),
+ },
});
export const parseCommitError = e => {
@@ -33,7 +57,9 @@ export const parseCommitError = e => {
return createCodeownersCommitError(message);
} else if (BRANCH_CHANGED_REGEX.test(message)) {
return createBranchChangedCommitError(message);
+ } else if (BRANCH_ALREADY_EXISTS.test(message)) {
+ return branchAlreadyExistsCommitError(message);
}
- return createUnexpectedCommitError();
+ return createUnexpectedCommitError(message);
};
diff --git a/app/assets/javascripts/ide/lib/languages/README.md b/app/assets/javascripts/ide/lib/languages/README.md
index e4d1a4c7818..c4f3de00783 100644
--- a/app/assets/javascripts/ide/lib/languages/README.md
+++ b/app/assets/javascripts/ide/lib/languages/README.md
@@ -1,7 +1,7 @@
# Web IDE Languages
The Web IDE uses the [Monaco editor](https://microsoft.github.io/monaco-editor/) which uses the [Monarch library](https://microsoft.github.io/monaco-editor/monarch.html) for syntax highlighting.
-The Web IDE currently supports all langauges defined in the [monaco-languages](https://github.com/microsoft/monaco-languages/tree/master/src) repository.
+The Web IDE currently supports all languages defined in the [monaco-languages](https://github.com/microsoft/monaco-languages/tree/master/src) repository.
## Adding New Languages
@@ -14,7 +14,7 @@ Should you be willing to help us and add support to GitLab for any missing langu
2. Create a new file in this folder called `{languageName}.js`, where `{languageName}` is the name of the language you want to add support for.
3. Follow the [Monarch documentation](https://microsoft.github.io/monaco-editor/monarch.html) to add a configuration for the new language.
- Example: The [`vue.js`](./vue.js) file in the current directory adds support for Vue.js Syntax Highlighting.
-4. Add tests for the new langauge implementation in `spec/frontend/ide/lib/languages/{langaugeName}.js`.
+4. Add tests for the new language implementation in `spec/frontend/ide/lib/languages/{langaugeName}.js`.
- Example: See [`vue_spec.js`](spec/frontend/ide/lib/languages/vue_spec.js).
5. Create a [Merge Request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) with your newly added language.
diff --git a/app/assets/javascripts/ide/lib/languages/hcl.js b/app/assets/javascripts/ide/lib/languages/hcl.js
new file mode 100644
index 00000000000..4539719b1f2
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/languages/hcl.js
@@ -0,0 +1,192 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See https://github.com/microsoft/monaco-languages/blob/master/LICENSE.md
+ *--------------------------------------------------------------------------------------------*/
+
+/* eslint-disable no-useless-escape */
+/* eslint-disable @gitlab/require-i18n-strings */
+
+const conf = {
+ comments: {
+ lineComment: '//',
+ blockComment: ['/*', '*/'],
+ },
+ brackets: [['{', '}'], ['[', ']'], ['(', ')']],
+ autoClosingPairs: [
+ { open: '{', close: '}' },
+ { open: '[', close: ']' },
+ { open: '(', close: ')' },
+ { open: '"', close: '"', notIn: ['string'] },
+ ],
+ surroundingPairs: [
+ { open: '{', close: '}' },
+ { open: '[', close: ']' },
+ { open: '(', close: ')' },
+ { open: '"', close: '"' },
+ ],
+};
+
+const language = {
+ defaultToken: '',
+ tokenPostfix: '.hcl',
+
+ keywords: [
+ 'var',
+ 'local',
+ 'path',
+ 'for_each',
+ 'any',
+ 'string',
+ 'number',
+ 'bool',
+ 'true',
+ 'false',
+ 'null',
+ 'if ',
+ 'else ',
+ 'endif ',
+ 'for ',
+ 'in',
+ 'endfor',
+ ],
+
+ operators: [
+ '=',
+ '>=',
+ '<=',
+ '==',
+ '!=',
+ '+',
+ '-',
+ '*',
+ '/',
+ '%',
+ '&&',
+ '||',
+ '!',
+ '<',
+ '>',
+ '?',
+ '...',
+ ':',
+ ],
+
+ symbols: /[=><!~?:&|+\-*\/\^%]+/,
+ escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
+ terraformFunctions: /(abs|ceil|floor|log|max|min|pow|signum|chomp|format|formatlist|indent|join|lower|regex|regexall|replace|split|strrev|substr|title|trimspace|upper|chunklist|coalesce|coalescelist|compact|concat|contains|distinct|element|flatten|index|keys|length|list|lookup|map|matchkeys|merge|range|reverse|setintersection|setproduct|setunion|slice|sort|transpose|values|zipmap|base64decode|base64encode|base64gzip|csvdecode|jsondecode|jsonencode|urlencode|yamldecode|yamlencode|abspath|dirname|pathexpand|basename|file|fileexists|fileset|filebase64|templatefile|formatdate|timeadd|timestamp|base64sha256|base64sha512|bcrypt|filebase64sha256|filebase64sha512|filemd5|filemd1|filesha256|filesha512|md5|rsadecrypt|sha1|sha256|sha512|uuid|uuidv5|cidrhost|cidrnetmask|cidrsubnet|tobool|tolist|tomap|tonumber|toset|tostring)/,
+ terraformMainBlocks: /(module|data|terraform|resource|provider|variable|output|locals)/,
+ tokenizer: {
+ root: [
+ // highlight main blocks
+ [
+ /^@terraformMainBlocks([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)(\{)/,
+ ['type', '', 'string', '', 'string', '', '@brackets'],
+ ],
+ // highlight all the remaining blocks
+ [
+ /(\w+[ \t]+)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)(\{)/,
+ ['identifier', '', 'string', '', 'string', '', '@brackets'],
+ ],
+ // highlight block
+ [
+ /(\w+[ \t]+)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)([\w-]+|"[\w-]+"|)(=)(\{)/,
+ ['identifier', '', 'string', '', 'operator', '', '@brackets'],
+ ],
+ // terraform general highlight - shared with expressions
+ { include: '@terraform' },
+ ],
+ terraform: [
+ // highlight terraform functions
+ [/@terraformFunctions(\()/, ['type', '@brackets']],
+ // all other words are variables or keywords
+ [
+ /[a-zA-Z_]\w*-*/, // must work with variables such as foo-bar and also with negative numbers
+ {
+ cases: {
+ '@keywords': { token: 'keyword.$0' },
+ '@default': 'variable',
+ },
+ },
+ ],
+ { include: '@whitespace' },
+ { include: '@heredoc' },
+ // delimiters and operators
+ [/[{}()\[\]]/, '@brackets'],
+ [/[<>](?!@symbols)/, '@brackets'],
+ [
+ /@symbols/,
+ {
+ cases: {
+ '@operators': 'operator',
+ '@default': '',
+ },
+ },
+ ],
+ // numbers
+ [/\d*\d+[eE]([\-+]?\d+)?/, 'number.float'],
+ [/\d*\.\d+([eE][\-+]?\d+)?/, 'number.float'],
+ [/\d[\d']*/, 'number'],
+ [/\d/, 'number'],
+ [/[;,.]/, 'delimiter'], // delimiter: after number because of .\d floats
+ // strings
+ [/"/, 'string', '@string'], // this will include expressions
+ [/'/, 'invalid'],
+ ],
+ heredoc: [
+ [
+ /<<[-]*\s*["]?([\w\-]+)["]?/,
+ { token: 'string.heredoc.delimiter', next: '@heredocBody.$1' },
+ ],
+ ],
+ heredocBody: [
+ [
+ /^([\w\-]+)$/,
+ {
+ cases: {
+ '$1==$S2': [
+ {
+ token: 'string.heredoc.delimiter',
+ next: '@popall',
+ },
+ ],
+ '@default': 'string.heredoc',
+ },
+ },
+ ],
+ [/./, 'string.heredoc'],
+ ],
+ whitespace: [
+ [/[ \t\r\n]+/, ''],
+ [/\/\*/, 'comment', '@comment'],
+ [/\/\/.*$/, 'comment'],
+ [/#.*$/, 'comment'],
+ ],
+ comment: [[/[^\/*]+/, 'comment'], [/\*\//, 'comment', '@pop'], [/[\/*]/, 'comment']],
+ string: [
+ [/\$\{/, { token: 'delimiter', next: '@stringExpression' }],
+ [/[^\\"\$]+/, 'string'],
+ [/@escapes/, 'string.escape'],
+ [/\\./, 'string.escape.invalid'],
+ [/"/, 'string', '@popall'],
+ ],
+ stringInsideExpression: [
+ [/[^\\"]+/, 'string'],
+ [/@escapes/, 'string.escape'],
+ [/\\./, 'string.escape.invalid'],
+ [/"/, 'string', '@pop'],
+ ],
+ stringExpression: [
+ [/\}/, { token: 'delimiter', next: '@pop' }],
+ [/"/, 'string', '@stringInsideExpression'],
+ { include: '@terraform' },
+ ],
+ },
+};
+
+export default {
+ id: 'hcl',
+ extensions: ['.tf', '.tfvars', '.hcl'],
+ aliases: ['Terraform', 'tf', 'HCL', 'hcl'],
+ conf,
+ language,
+};
diff --git a/app/assets/javascripts/ide/lib/languages/index.js b/app/assets/javascripts/ide/lib/languages/index.js
index 0c85a1104fc..580ad820bf9 100644
--- a/app/assets/javascripts/ide/lib/languages/index.js
+++ b/app/assets/javascripts/ide/lib/languages/index.js
@@ -1,5 +1,6 @@
import vue from './vue';
+import hcl from './hcl';
-const languages = [vue];
+const languages = [vue, hcl];
export default languages;
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index 3515d1fc933..a0df85540f9 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -59,7 +59,7 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => {
export const getFileData = (
{ state, commit, dispatch, getters },
- { path, makeFileActive = true, openFile = makeFileActive },
+ { path, makeFileActive = true, openFile = makeFileActive, toggleLoading = true },
) => {
const file = state.entries[path];
const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path);
@@ -99,7 +99,7 @@ export const getFileData = (
});
})
.finally(() => {
- commit(types.TOGGLE_LOADING, { entry: file, forceValue: false });
+ if (toggleLoading) commit(types.TOGGLE_LOADING, { entry: file, forceValue: false });
});
};
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index b8304a9b68d..500ce9f32d5 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -6,6 +6,7 @@ import {
PERMISSION_CREATE_MR,
PERMISSION_PUSH_CODE,
} from '../constants';
+import { addNumericSuffix } from '~/ide/utils';
import Api from '~/api';
export const activeFile = state => state.openFiles.find(file => file.active) || null;
@@ -167,10 +168,7 @@ export const getAvailableFileName = (state, getters) => path => {
let newPath = path;
while (getters.entryExists(newPath)) {
- newPath = newPath.replace(
- /([ _-]?)(\d*)(\..+?$|$)/,
- (_, before, number, after) => `${before || '_'}${Number(number) + 1}${after}`,
- );
+ newPath = addNumericSuffix(newPath);
}
return newPath;
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index 90a6c644d17..e0d2028d2e1 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -8,6 +8,7 @@ import consts from './constants';
import { leftSidebarViews } from '../../../constants';
import eventHub from '../../../eventhub';
import { parseCommitError } from '../../../lib/errors';
+import { addNumericSuffix } from '~/ide/utils';
export const updateCommitMessage = ({ commit }, message) => {
commit(types.UPDATE_COMMIT_MESSAGE, message);
@@ -17,11 +18,8 @@ export const discardDraft = ({ commit }) => {
commit(types.UPDATE_COMMIT_MESSAGE, '');
};
-export const updateCommitAction = ({ commit, getters }, commitAction) => {
- commit(types.UPDATE_COMMIT_ACTION, {
- commitAction,
- });
- commit(types.TOGGLE_SHOULD_CREATE_MR, !getters.shouldHideNewMrOption);
+export const updateCommitAction = ({ commit }, commitAction) => {
+ commit(types.UPDATE_COMMIT_ACTION, { commitAction });
};
export const toggleShouldCreateMR = ({ commit }) => {
@@ -32,6 +30,12 @@ export const updateBranchName = ({ commit }, branchName) => {
commit(types.UPDATE_NEW_BRANCH_NAME, branchName);
};
+export const addSuffixToBranchName = ({ commit, state }) => {
+ const newBranchName = addNumericSuffix(state.newBranchName, true);
+
+ commit(types.UPDATE_NEW_BRANCH_NAME, newBranchName);
+};
+
export const setLastCommitMessage = ({ commit, rootGetters }, data) => {
const { currentProject } = rootGetters;
const commitStats = data.stats
@@ -107,7 +111,7 @@ export const updateFilesAfterCommit = ({ commit, dispatch, rootState, rootGetter
export const commitChanges = ({ commit, state, getters, dispatch, rootState, rootGetters }) => {
// Pull commit options out because they could change
// During some of the pre and post commit processing
- const { shouldCreateMR, isCreatingNewBranch, branchName } = getters;
+ const { shouldCreateMR, shouldHideNewMrOption, isCreatingNewBranch, branchName } = getters;
const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH;
const stageFilesPromise = rootState.stagedFiles.length
? Promise.resolve()
@@ -167,7 +171,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true });
}, 5000);
- if (shouldCreateMR) {
+ if (shouldCreateMR && !shouldHideNewMrOption) {
const { currentProject } = rootGetters;
const targetBranch = isCreatingNewBranch
? rootState.currentBranchId
diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutations.js b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
index 2cf6e8e6f36..c4bfad6405e 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/mutations.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
@@ -10,9 +10,7 @@ export default {
Object.assign(state, { commitAction });
},
[types.UPDATE_NEW_BRANCH_NAME](state, newBranchName) {
- Object.assign(state, {
- newBranchName,
- });
+ Object.assign(state, { newBranchName });
},
[types.UPDATE_LOADING](state, submitCommitLoading) {
Object.assign(state, {
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
index c90bc2a3320..a981f86fa40 100644
--- a/app/assets/javascripts/ide/stores/mutations/file.js
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -19,19 +19,20 @@ export default {
}
},
[types.TOGGLE_FILE_OPEN](state, path) {
- Object.assign(state.entries[path], {
- opened: !state.entries[path].opened,
- });
+ const entry = state.entries[path];
- if (state.entries[path].opened) {
+ entry.opened = !entry.opened;
+ if (entry.opened && !entry.tempFile) {
+ entry.loading = true;
+ }
+
+ if (entry.opened) {
Object.assign(state, {
openFiles: state.openFiles.filter(f => f.path !== path).concat(state.entries[path]),
});
} else {
- const file = state.entries[path];
-
Object.assign(state, {
- openFiles: state.openFiles.filter(f => f.key !== file.key),
+ openFiles: state.openFiles.filter(f => f.key !== entry.key),
});
}
},
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index d9cdc7727ad..b7ced3a271a 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -3,7 +3,7 @@ import {
relativePathToAbsolute,
isAbsolute,
isRootRelative,
- isBase64DataUrl,
+ isBlobUrl,
} from '~/lib/utils/url_utility';
export const dataStructure = () => ({
@@ -110,14 +110,19 @@ export const createCommitPayload = ({
}) => ({
branch,
commit_message: state.commitMessage || getters.preBuiltCommitMessage,
- actions: getCommitFiles(rootState.stagedFiles).map(f => ({
- action: commitActionForFile(f),
- file_path: f.path,
- previous_path: f.prevPath || undefined,
- content: f.prevPath && !f.changed ? null : f.content || undefined,
- encoding: isBase64DataUrl(f.rawPath) ? 'base64' : 'text',
- last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha,
- })),
+ actions: getCommitFiles(rootState.stagedFiles).map(f => {
+ const isBlob = isBlobUrl(f.rawPath);
+ const content = isBlob ? btoa(f.content) : f.content;
+
+ return {
+ action: commitActionForFile(f),
+ file_path: f.path,
+ previous_path: f.prevPath || undefined,
+ content: f.prevPath && !f.changed ? null : content || undefined,
+ encoding: isBlob ? 'base64' : 'text',
+ last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha,
+ };
+ }),
start_sha: newBranch ? rootGetters.lastCommit.id : undefined,
});
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index cde53e1ef00..4cf4f5e1d81 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -1,6 +1,7 @@
import { languages } from 'monaco-editor';
import { flatten, isString } from 'lodash';
import { SIDE_LEFT, SIDE_RIGHT } from './constants';
+import { performanceMarkAndMeasure } from '~/performance_utils';
const toLowerCase = x => x.toLowerCase();
@@ -42,16 +43,17 @@ const KNOWN_TYPES = [
},
];
-export function isTextFile({ name, content, mimeType = '' }) {
+export function isTextFile({ name, raw, content, mimeType = '' }) {
const knownType = KNOWN_TYPES.find(type => type.isMatch(mimeType, name));
-
if (knownType) return knownType.isText;
// does the string contain ascii characters only (ranges from space to tilde, tabs and new lines)
const asciiRegex = /^[ -~\t\n\r]+$/;
+ const fileContents = raw || content;
+
// for unknown types, determine the type by evaluating the file contents
- return isString(content) && (content === '' || asciiRegex.test(content));
+ return isString(fileContents) && (fileContents === '' || asciiRegex.test(fileContents));
}
export const createPathWithExt = p => {
@@ -137,3 +139,49 @@ export function readFileAsDataURL(file) {
export function getFileEOL(content = '') {
return content.includes('\r\n') ? 'CRLF' : 'LF';
}
+
+/**
+ * Adds or increments the numeric suffix to a filename/branch name.
+ * Retains underscore or dash before the numeric suffix if it already exists.
+ *
+ * Examples:
+ * hello -> hello-1
+ * hello-2425 -> hello-2425
+ * hello.md -> hello-1.md
+ * hello_2.md -> hello_3.md
+ * hello_ -> hello_1
+ * master-patch-22432 -> master-patch-22433
+ * patch_332 -> patch_333
+ *
+ * @param {string} filename File name or branch name
+ * @param {number} [randomize] Should randomize the numeric suffix instead of auto-incrementing?
+ */
+export function addNumericSuffix(filename, randomize = false) {
+ return filename.replace(/([ _-]?)(\d*)(\..+?$|$)/, (_, before, number, after) => {
+ const n = randomize
+ ? Math.random()
+ .toString()
+ .substring(2, 7)
+ .slice(-5)
+ : Number(number) + 1;
+ return `${before || '-'}${n}${after}`;
+ });
+}
+
+export const measurePerformance = (
+ mark,
+ measureName,
+ measureStart = undefined,
+ measureEnd = mark,
+) => {
+ performanceMarkAndMeasure({
+ mark,
+ measures: [
+ {
+ name: measureName,
+ start: measureStart,
+ end: measureEnd,
+ },
+ ],
+ });
+};
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index 670c42cbdac..e1f9d858f2b 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -2,95 +2,107 @@
import {
GlLoadingIcon,
GlTable,
- GlAlert,
GlAvatarsInline,
GlAvatarLink,
GlAvatar,
GlTooltipDirective,
GlButton,
- GlSearchBoxByType,
GlIcon,
- GlPagination,
- GlTabs,
- GlTab,
- GlBadge,
GlEmptyState,
} from '@gitlab/ui';
-import { debounce } from 'lodash';
+import Tracking from '~/tracking';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue';
+import {
+ tdClass,
+ thClass,
+ bodyTrClass,
+ initialPaginationState,
+} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { s__ } from '~/locale';
-import { mergeUrlParams, joinPaths, visitUrl } from '~/lib/utils/url_utility';
+import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility';
import getIncidents from '../graphql/queries/get_incidents.query.graphql';
import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
import SeverityToken from '~/sidebar/components/severity/severity.vue';
import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants';
-import { I18N, DEFAULT_PAGE_SIZE, INCIDENT_SEARCH_DELAY, INCIDENT_STATUS_TABS } from '../constants';
-
-const TH_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' };
-const tdClass =
- 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap';
-const thClass = 'gl-hover-bg-blue-50';
-const bodyTrClass =
- 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-blue-50 gl-hover-border-b-solid gl-hover-border-blue-200';
-
-const initialPaginationState = {
- currentPage: 1,
- prevPageCursor: '',
- nextPageCursor: '',
- firstPageSize: DEFAULT_PAGE_SIZE,
- lastPageSize: null,
-};
+import {
+ I18N,
+ INCIDENT_STATUS_TABS,
+ TH_CREATED_AT_TEST_ID,
+ TH_INCIDENT_SLA_TEST_ID,
+ TH_SEVERITY_TEST_ID,
+ TH_PUBLISHED_TEST_ID,
+ INCIDENT_DETAILS_PATH,
+ trackIncidentCreateNewOptions,
+ trackIncidentListViewsOptions,
+} from '../constants';
export default {
+ trackIncidentCreateNewOptions,
+ trackIncidentListViewsOptions,
i18n: I18N,
statusTabs: INCIDENT_STATUS_TABS,
fields: [
{
key: 'severity',
label: s__('IncidentManagement|Severity'),
- thClass: `gl-pointer-events-none`,
- tdClass,
+ thClass: `${thClass} w-15p`,
+ tdClass: `${tdClass} sortable-cell`,
+ sortable: true,
+ thAttr: TH_SEVERITY_TEST_ID,
},
{
key: 'title',
label: s__('IncidentManagement|Incident'),
- thClass: `gl-pointer-events-none gl-w-half`,
+ thClass: `gl-pointer-events-none`,
tdClass,
},
{
key: 'createdAt',
label: s__('IncidentManagement|Date created'),
- thClass,
+ thClass: `${thClass} gl-w-eighth`,
tdClass: `${tdClass} sortable-cell`,
sortable: true,
- thAttr: TH_TEST_ID,
+ thAttr: TH_CREATED_AT_TEST_ID,
+ },
+ {
+ key: 'incidentSla',
+ label: s__('IncidentManagement|Time to SLA'),
+ thClass: `gl-pointer-events-none gl-text-right gl-w-eighth`,
+ tdClass: `${tdClass} gl-text-right`,
+ thAttr: TH_INCIDENT_SLA_TEST_ID,
},
{
key: 'assignees',
label: s__('IncidentManagement|Assignees'),
- thClass: 'gl-pointer-events-none',
+ thClass: 'gl-pointer-events-none w-15p',
tdClass,
},
+ {
+ key: 'published',
+ label: s__('IncidentManagement|Published'),
+ thClass: `${thClass} w-15p`,
+ tdClass: `${tdClass} sortable-cell`,
+ sortable: true,
+ thAttr: TH_PUBLISHED_TEST_ID,
+ },
],
components: {
GlLoadingIcon,
GlTable,
- GlAlert,
GlAvatarsInline,
GlAvatarLink,
GlAvatar,
GlButton,
TimeAgoTooltip,
- GlSearchBoxByType,
GlIcon,
- GlPagination,
- GlTabs,
- GlTab,
PublishedCell: () => import('ee_component/incidents/components/published_cell.vue'),
- GlBadge,
+ ServiceLevelAgreementCell: () =>
+ import('ee_component/incidents/components/service_level_agreement_cell.vue'),
GlEmptyState,
SeverityToken,
+ PaginatedTableWithSearchAndTabs,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -103,6 +115,10 @@ export default {
'issuePath',
'publishedAvailable',
'emptyListSvgPath',
+ 'textQuery',
+ 'authorUsernameQuery',
+ 'assigneeUsernameQuery',
+ 'slaFeatureAvailable',
],
apollo: {
incidents: {
@@ -110,8 +126,10 @@ export default {
variables() {
return {
searchTerm: this.searchTerm,
- status: this.statusFilter,
+ authorUsername: this.authorUsername,
+ assigneeUsername: this.assigneeUsername,
projectPath: this.projectPath,
+ status: this.statusFilter,
issueTypes: ['INCIDENT'],
sort: this.sort,
firstPageSize: this.pagination.firstPageSize,
@@ -135,6 +153,8 @@ export default {
variables() {
return {
searchTerm: this.searchTerm,
+ authorUsername: this.authorUsername,
+ assigneeUsername: this.assigneeUsername,
projectPath: this.projectPath,
issueTypes: ['INCIDENT'],
};
@@ -149,14 +169,17 @@ export default {
errored: false,
isErrorAlertDismissed: false,
redirecting: false,
- searchTerm: '',
- pagination: initialPaginationState,
incidents: {},
+ incidentsCount: {},
sort: 'created_desc',
sortBy: 'createdAt',
sortDesc: true,
statusFilter: '',
filteredByStatus: '',
+ searchTerm: this.textQuery,
+ authorUsername: this.authorUsernameQuery,
+ assigneeUsername: this.assigneeUsernameQuery,
+ pagination: initialPaginationState,
};
},
computed: {
@@ -166,29 +189,15 @@ export default {
loading() {
return this.$apollo.queries.incidents.loading;
},
- hasIncidents() {
- return this.incidents?.list?.length;
- },
- incidentsForCurrentTab() {
- return this.incidentsCount?.[this.filteredByStatus.toLowerCase()] ?? 0;
- },
- showPaginationControls() {
- return Boolean(
- this.incidents?.pageInfo?.hasNextPage || this.incidents?.pageInfo?.hasPreviousPage,
- );
- },
- prevPage() {
- return Math.max(this.pagination.currentPage - 1, 0);
+ isEmpty() {
+ return !this.incidents?.list?.length;
},
- nextPage() {
- const nextPage = this.pagination.currentPage + 1;
- return nextPage > Math.ceil(this.incidentsForCurrentTab / DEFAULT_PAGE_SIZE)
- ? null
- : nextPage;
+ showList() {
+ return !this.isEmpty || this.errored || this.loading;
},
tbodyTrClass() {
return {
- [bodyTrClass]: !this.loading && this.hasIncidents,
+ [bodyTrClass]: !this.loading && !this.isEmpty,
};
},
newIncidentPath() {
@@ -201,24 +210,12 @@ export default {
);
},
availableFields() {
- return this.publishedAvailable
- ? [
- ...this.$options.fields,
- ...[
- {
- key: 'published',
- label: s__('IncidentManagement|Published'),
- thClass: 'gl-pointer-events-none',
- },
- ],
- ]
- : this.$options.fields;
- },
- isEmpty() {
- return !this.incidents.list?.length;
- },
- showList() {
- return !this.isEmpty || this.errored || this.loading;
+ const isHidden = {
+ published: !this.publishedAvailable,
+ incidentSla: !this.slaFeatureAvailable,
+ };
+
+ return this.$options.fields.filter(({ key }) => !isHidden[key]);
},
activeClosedTabHasNoIncidents() {
const { all, closed } = this.incidentsCount || {};
@@ -244,205 +241,181 @@ export default {
},
},
methods: {
- onInputChange: debounce(function debounceSearch(input) {
- const trimmedInput = input.trim();
- if (trimmedInput !== this.searchTerm) {
- this.searchTerm = trimmedInput;
- }
- }, INCIDENT_SEARCH_DELAY),
- filterIncidentsByStatus(tabIndex) {
- const { filters, status } = this.$options.statusTabs[tabIndex];
- this.statusFilter = filters;
- this.filteredByStatus = status;
- },
hasAssignees(assignees) {
return Boolean(assignees.nodes?.length);
},
navigateToIncidentDetails({ iid }) {
- return visitUrl(joinPaths(this.issuePath, iid));
+ return visitUrl(joinPaths(this.issuePath, INCIDENT_DETAILS_PATH, iid));
},
- handlePageChange(page) {
- const { startCursor, endCursor } = this.incidents.pageInfo;
-
- if (page > this.pagination.currentPage) {
- this.pagination = {
- ...initialPaginationState,
- nextPageCursor: endCursor,
- currentPage: page,
- };
- } else {
- this.pagination = {
- lastPageSize: DEFAULT_PAGE_SIZE,
- firstPageSize: null,
- prevPageCursor: startCursor,
- nextPageCursor: '',
- currentPage: page,
- };
- }
- },
- resetPagination() {
- this.pagination = initialPaginationState;
+ navigateToCreateNewIncident() {
+ const { category, action } = this.$options.trackIncidentCreateNewOptions;
+ Tracking.event(category, action);
+ this.redirecting = true;
},
fetchSortedData({ sortBy, sortDesc }) {
- const sortingDirection = sortDesc ? 'desc' : 'asc';
- const sortingColumn = convertToSnakeCase(sortBy).replace(/_.*/, '');
+ const sortingDirection = sortDesc ? 'DESC' : 'ASC';
+ const sortingColumn = convertToSnakeCase(sortBy)
+ .replace(/_.*/, '')
+ .toUpperCase();
+ this.pagination = initialPaginationState;
this.sort = `${sortingColumn}_${sortingDirection}`;
},
getSeverity(severity) {
return INCIDENT_SEVERITY[severity];
},
+ pageChanged(pagination) {
+ this.pagination = pagination;
+ },
+ statusChanged({ filters, status }) {
+ this.statusFilter = filters;
+ this.filteredByStatus = status;
+ },
+ filtersChanged({ searchTerm, authorUsername, assigneeUsername }) {
+ this.searchTerm = searchTerm;
+ this.authorUsername = authorUsername;
+ this.assigneeUsername = assigneeUsername;
+ },
+ errorAlertDismissed() {
+ this.isErrorAlertDismissed = true;
+ },
},
};
</script>
<template>
- <div class="incident-management-list">
- <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true">
- {{ $options.i18n.errorMsg }}
- </gl-alert>
-
- <div
- class="incident-management-list-header gl-display-flex gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-gray-100"
- >
- <gl-tabs content-class="gl-p-0" @input="filterIncidentsByStatus">
- <gl-tab v-for="tab in $options.statusTabs" :key="tab.status" :data-testid="tab.status">
- <template #title>
- <span>{{ tab.title }}</span>
- <gl-badge v-if="incidentsCount" pill size="sm" class="gl-tab-counter-badge">
- {{ incidentsCount[tab.status.toLowerCase()] }}
- </gl-badge>
- </template>
- </gl-tab>
- </gl-tabs>
-
- <gl-button
- v-if="!isEmpty || activeClosedTabHasNoIncidents"
- class="gl-my-3 gl-mr-5 create-incident-button"
- data-testid="createIncidentBtn"
- data-qa-selector="create_incident_button"
- :loading="redirecting"
- :disabled="redirecting"
- category="primary"
- variant="success"
- :href="newIncidentPath"
- @click="redirecting = true"
- >
- {{ $options.i18n.createIncidentBtnLabel }}
- </gl-button>
- </div>
-
- <div class="gl-bg-gray-10 gl-p-5 gl-border-b-solid gl-border-b-1 gl-border-gray-100">
- <gl-search-box-by-type
- :value="searchTerm"
- class="gl-bg-white"
- :placeholder="$options.i18n.searchPlaceholder"
- @input="onInputChange"
- />
- </div>
-
- <h4 class="gl-display-block d-md-none my-3">
- {{ s__('IncidentManagement|Incidents') }}
- </h4>
- <gl-table
- v-if="showList"
+ <div>
+ <paginated-table-with-search-and-tabs
+ :show-items="showList"
+ :show-error-msg="showErrorMsg"
+ :i18n="$options.i18n"
:items="incidents.list || []"
- :fields="availableFields"
- :show-empty="true"
- :busy="loading"
- stacked="md"
- :tbody-tr-class="tbodyTrClass"
- :no-local-sorting="true"
- :sort-direction="'desc'"
- :sort-desc.sync="sortDesc"
- :sort-by.sync="sortBy"
- sort-icon-left
- fixed
- @row-clicked="navigateToIncidentDetails"
- @sort-changed="fetchSortedData"
+ :page-info="incidents.pageInfo"
+ :items-count="incidentsCount"
+ :status-tabs="$options.statusTabs"
+ :track-views-options="$options.trackIncidentListViewsOptions"
+ filter-search-key="incidents"
+ @page-changed="pageChanged"
+ @tabs-changed="statusChanged"
+ @filters-changed="filtersChanged"
+ @error-alert-dismissed="errorAlertDismissed"
>
- <template #cell(severity)="{ item }">
- <severity-token :severity="getSeverity(item.severity)" />
+ <template #header-actions>
+ <gl-button
+ v-if="!isEmpty || activeClosedTabHasNoIncidents"
+ class="gl-my-3 gl-mr-5 create-incident-button"
+ data-testid="createIncidentBtn"
+ data-qa-selector="create_incident_button"
+ :loading="redirecting"
+ :disabled="redirecting"
+ category="primary"
+ variant="success"
+ :href="newIncidentPath"
+ @click="navigateToCreateNewIncident"
+ >
+ {{ $options.i18n.createIncidentBtnLabel }}
+ </gl-button>
</template>
- <template #cell(title)="{ item }">
- <div :class="{ 'gl-display-flex gl-align-items-center': item.state === 'closed' }">
- <div class="gl-max-w-full text-truncate" :title="item.title">{{ item.title }}</div>
- <gl-icon
- v-if="item.state === 'closed'"
- name="issue-close"
- class="gl-mx-1 gl-fill-blue-500 gl-flex-shrink-0"
- :size="16"
- data-testid="incident-closed"
- />
- </div>
+ <template #title>
+ {{ s__('IncidentManagement|Incidents') }}
</template>
- <template #cell(createdAt)="{ item }">
- <time-ago-tooltip :time="item.createdAt" />
- </template>
+ <template #table>
+ <gl-table
+ :items="incidents.list || []"
+ :fields="availableFields"
+ :show-empty="true"
+ :busy="loading"
+ stacked="md"
+ :tbody-tr-class="tbodyTrClass"
+ :no-local-sorting="true"
+ :sort-direction="'desc'"
+ :sort-desc.sync="sortDesc"
+ :sort-by.sync="sortBy"
+ sort-icon-left
+ fixed
+ @row-clicked="navigateToIncidentDetails"
+ @sort-changed="fetchSortedData"
+ >
+ <template #cell(severity)="{ item }">
+ <severity-token :severity="getSeverity(item.severity)" />
+ </template>
+
+ <template #cell(title)="{ item }">
+ <div :class="{ 'gl-display-flex gl-align-items-center': item.state === 'closed' }">
+ <div class="gl-max-w-full text-truncate" :title="item.title">{{ item.title }}</div>
+ <gl-icon
+ v-if="item.state === 'closed'"
+ name="issue-close"
+ class="gl-mx-1 gl-fill-blue-500 gl-flex-shrink-0"
+ :size="16"
+ data-testid="incident-closed"
+ />
+ </div>
+ </template>
+
+ <template #cell(createdAt)="{ item }">
+ <time-ago-tooltip :time="item.createdAt" />
+ </template>
+
+ <template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }">
+ <service-level-agreement-cell :sla-due-at="item.slaDueAt" data-testid="incident-sla" />
+ </template>
- <template #cell(assignees)="{ item }">
- <div data-testid="incident-assignees">
- <template v-if="hasAssignees(item.assignees)">
- <gl-avatars-inline
- :avatars="item.assignees.nodes"
- :collapsed="true"
- :max-visible="4"
- :avatar-size="24"
- badge-tooltip-prop="name"
- :badge-tooltip-max-chars="100"
- >
- <template #avatar="{ avatar }">
- <gl-avatar-link
- :key="avatar.username"
- v-gl-tooltip
- target="_blank"
- :href="avatar.webUrl"
- :title="avatar.name"
+ <template #cell(assignees)="{ item }">
+ <div data-testid="incident-assignees">
+ <template v-if="hasAssignees(item.assignees)">
+ <gl-avatars-inline
+ :avatars="item.assignees.nodes"
+ :collapsed="true"
+ :max-visible="4"
+ :avatar-size="24"
+ badge-tooltip-prop="name"
+ :badge-tooltip-max-chars="100"
>
- <gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="24" />
- </gl-avatar-link>
+ <template #avatar="{ avatar }">
+ <gl-avatar-link
+ :key="avatar.username"
+ v-gl-tooltip
+ target="_blank"
+ :href="avatar.webUrl"
+ :title="avatar.name"
+ >
+ <gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="24" />
+ </gl-avatar-link>
+ </template>
+ </gl-avatars-inline>
</template>
- </gl-avatars-inline>
+ <template v-else>
+ {{ $options.i18n.unassigned }}
+ </template>
+ </div>
</template>
- <template v-else>
- {{ $options.i18n.unassigned }}
+
+ <template v-if="publishedAvailable" #cell(published)="{ item }">
+ <published-cell
+ :status-page-published-incident="item.statusPagePublishedIncident"
+ :un-published="$options.i18n.unPublished"
+ />
+ </template>
+ <template #table-busy>
+ <gl-loading-icon size="lg" color="dark" class="mt-3" />
</template>
- </div>
- </template>
- <template v-if="publishedAvailable" #cell(published)="{ item }">
- <published-cell
- :status-page-published-incident="item.statusPagePublishedIncident"
- :un-published="$options.i18n.unPublished"
- />
- </template>
- <template #table-busy>
- <gl-loading-icon size="lg" color="dark" class="mt-3" />
+ <template v-if="errored" #empty>
+ {{ $options.i18n.noIncidents }}
+ </template>
+ </gl-table>
</template>
-
- <template v-if="errored" #empty>
- {{ $options.i18n.noIncidents }}
+ <template #emtpy-state>
+ <gl-empty-state
+ :title="emptyStateData.title"
+ :svg-path="emptyListSvgPath"
+ :description="emptyStateData.description"
+ :primary-button-link="emptyStateData.btnLink"
+ :primary-button-text="emptyStateData.btnText"
+ />
</template>
- </gl-table>
-
- <gl-empty-state
- v-else
- :title="emptyStateData.title"
- :svg-path="emptyListSvgPath"
- :description="emptyStateData.description"
- :primary-button-link="emptyStateData.btnLink"
- :primary-button-text="emptyStateData.btnText"
- />
-
- <gl-pagination
- v-if="showPaginationControls"
- :value="pagination.currentPage"
- :prev-page="prevPage"
- :next-page="nextPage"
- align="center"
- class="gl-pagination gl-mt-3"
- @input="handlePageChange"
- />
+ </paginated-table-with-search-and-tabs>
</div>
</template>
diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js
index 289b36d9848..b82980b5628 100644
--- a/app/assets/javascripts/incidents/constants.js
+++ b/app/assets/javascripts/incidents/constants.js
@@ -1,4 +1,5 @@
-import { s__, __ } from '~/locale';
+/* eslint-disable @gitlab/require-i18n-strings */
+import { s__ } from '~/locale';
export const I18N = {
errorMsg: s__('IncidentManagement|There was an error displaying the incidents.'),
@@ -6,7 +7,6 @@ export const I18N = {
unassigned: s__('IncidentManagement|Unassigned'),
createIncidentBtnLabel: s__('IncidentManagement|Create incident'),
unPublished: s__('IncidentManagement|Unpublished'),
- searchPlaceholder: __('Search results…'),
emptyState: {
title: s__('IncidentManagement|Display your incidents in a dedicated view'),
emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'),
@@ -34,5 +34,33 @@ export const INCIDENT_STATUS_TABS = [
},
];
-export const INCIDENT_SEARCH_DELAY = 300;
export const DEFAULT_PAGE_SIZE = 20;
+export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' };
+export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' };
+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';
+
+/**
+ * Tracks snowplow event when user clicks create new incident
+ */
+export const trackIncidentCreateNewOptions = {
+ category: 'Incident Management',
+ action: 'create_incident_button_clicks',
+};
+
+/**
+ * Tracks snowplow event when user views incidents list
+ */
+export const trackIncidentListViewsOptions = {
+ category: 'Incident Management',
+ action: 'view_incidents_list',
+};
+
+/**
+ * Tracks snowplow event when user views incident details
+ */
+export const trackIncidentDetailsViewsOptions = {
+ category: 'Incident Management',
+ action: 'view_incident_details',
+};
diff --git a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql
index 0b784b104a8..4e44a506c4f 100644
--- a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql
+++ b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql
@@ -1,6 +1,17 @@
-query getIncidentsCountByStatus($searchTerm: String, $projectPath: ID!, $issueTypes: [IssueType!]) {
+query getIncidentsCountByStatus(
+ $searchTerm: String
+ $projectPath: ID!
+ $issueTypes: [IssueType!]
+ $authorUsername: String = ""
+ $assigneeUsername: String = ""
+) {
project(fullPath: $projectPath) {
- issueStatusCounts(search: $searchTerm, types: $issueTypes) {
+ issueStatusCounts(
+ search: $searchTerm
+ types: $issueTypes
+ authorUsername: $authorUsername
+ assigneeUsername: $assigneeUsername
+ ) {
all
opened
closed
diff --git a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql
index dab130835e2..f97664a3b77 100644
--- a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql
+++ b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql
@@ -9,7 +9,9 @@ query getIncidents(
$lastPageSize: Int
$prevPageCursor: String = ""
$nextPageCursor: String = ""
- $searchTerm: String
+ $searchTerm: String = ""
+ $authorUsername: String = ""
+ $assigneeUsername: String = ""
) {
project(fullPath: $projectPath) {
issues(
@@ -17,6 +19,8 @@ query getIncidents(
types: $issueTypes
sort: $sort
state: $status
+ authorUsername: $authorUsername
+ assigneeUsername: $assigneeUsername
first: $firstPageSize
last: $lastPageSize
after: $nextPageCursor
diff --git a/app/assets/javascripts/incidents/list.js b/app/assets/javascripts/incidents/list.js
index 7505d07449c..6f87fbbe775 100644
--- a/app/assets/javascripts/incidents/list.js
+++ b/app/assets/javascripts/incidents/list.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
import IncidentsList from './components/incidents_list.vue';
Vue.use(VueApollo);
@@ -16,6 +17,10 @@ export default () => {
issuePath,
publishedAvailable,
emptyListSvgPath,
+ textQuery,
+ authorUsernameQuery,
+ assigneeUsernameQuery,
+ slaFeatureAvailable,
} = domEl.dataset;
const apolloProvider = new VueApollo({
@@ -30,8 +35,12 @@ export default () => {
incidentType,
newIssuePath,
issuePath,
- publishedAvailable,
+ publishedAvailable: parseBoolean(publishedAvailable),
emptyListSvgPath,
+ textQuery,
+ authorUsernameQuery,
+ assigneeUsernameQuery,
+ slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
},
apolloProvider,
components: {
diff --git a/app/assets/javascripts/incidents_settings/components/alerts_form.vue b/app/assets/javascripts/incidents_settings/components/alerts_form.vue
index 17a77f650e0..5fe0badc56e 100644
--- a/app/assets/javascripts/incidents_settings/components/alerts_form.vue
+++ b/app/assets/javascripts/incidents_settings/components/alerts_form.vue
@@ -130,18 +130,16 @@ export default {
<span>{{ $options.i18n.autoCloseIncidents.label }}</span>
</gl-form-checkbox>
</gl-form-group>
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button
- ref="submitBtn"
- data-qa-selector="save_changes_button"
- :disabled="loading"
- variant="success"
- type="submit"
- class="js-no-auto-disable"
- >
- {{ $options.i18n.saveBtnLabel }}
- </gl-button>
- </div>
+ <gl-button
+ ref="submitBtn"
+ data-qa-selector="save_changes_button"
+ :disabled="loading"
+ variant="success"
+ type="submit"
+ class="js-no-auto-disable"
+ >
+ {{ $options.i18n.saveBtnLabel }}
+ </gl-button>
</form>
</div>
</template>
diff --git a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue
index d6e963c6f4f..c90ff8079b8 100644
--- a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue
+++ b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue
@@ -11,6 +11,8 @@ export default {
GlTab,
AlertsSettingsForm,
PagerDutySettingsForm,
+ ServiceLevelAgreementForm: () =>
+ import('ee_component/incidents_settings/components/service_level_agreement_form.vue'),
},
tabs: INTEGRATION_TABS_CONFIG,
i18n: I18N_INTEGRATION_TABS,
@@ -45,6 +47,7 @@ export default {
>
<component :is="tab.component" class="gl-pt-3" :data-testid="`${tab.component}-tab`" />
</gl-tab>
+ <service-level-agreement-form />
</gl-tabs>
</div>
</section>
diff --git a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue
index 8b608d9f391..9a8c4bc5af9 100644
--- a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue
+++ b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue
@@ -124,7 +124,6 @@ export default {
class="col-8 col-md-9 gl-p-0"
:label="$options.i18n.webhookUrl.label"
label-for="url"
- label-class="label-bold"
>
<gl-form-input-group id="url" data-testid="webhook-url" readonly :value="webhookUrl">
<template #append>
@@ -149,17 +148,15 @@ export default {
</template>
</gl-sprintf>
</div>
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button
- v-gl-modal.resetWebhookModal
- class="gl-mt-3"
- :disabled="loading"
- :loading="resettingWebhook"
- data-testid="webhook-reset-btn"
- >
- {{ $options.i18n.webhookUrl.resetWebhookUrl }}
- </gl-button>
- </div>
+ <gl-button
+ v-gl-modal.resetWebhookModal
+ class="gl-mt-3"
+ :disabled="loading"
+ :loading="resettingWebhook"
+ data-testid="webhook-reset-btn"
+ >
+ {{ $options.i18n.webhookUrl.resetWebhookUrl }}
+ </gl-button>
<gl-modal
modal-id="resetWebhookModal"
:title="$options.i18n.webhookUrl.resetWebhookUrl"
@@ -170,17 +167,15 @@ export default {
{{ $options.i18n.webhookUrl.restKeyInfo }}
</gl-modal>
</gl-form-group>
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button
- ref="submitBtn"
- :disabled="isSaveDisabled"
- variant="success"
- type="submit"
- class="js-no-auto-disable"
- >
- {{ $options.i18n.saveBtnLabel }}
- </gl-button>
- </div>
+ <gl-button
+ ref="submitBtn"
+ :disabled="isSaveDisabled"
+ variant="success"
+ type="submit"
+ class="js-no-auto-disable"
+ >
+ {{ $options.i18n.saveBtnLabel }}
+ </gl-button>
</form>
</div>
</template>
diff --git a/app/assets/javascripts/incidents_settings/index.js b/app/assets/javascripts/incidents_settings/index.js
index ad875d49768..e9ba4294519 100644
--- a/app/assets/javascripts/incidents_settings/index.js
+++ b/app/assets/javascripts/incidents_settings/index.js
@@ -21,6 +21,9 @@ export default () => {
pagerdutyWebhookUrl,
pagerdutyResetKeyPath,
autoCloseIncident,
+ slaActive,
+ slaMinutes,
+ slaFeatureAvailable,
},
} = el;
@@ -40,6 +43,11 @@ export default () => {
active: parseBoolean(pagerdutyActive),
webhookUrl: pagerdutyWebhookUrl,
},
+ serviceLevelAgreementSettings: {
+ active: parseBoolean(slaActive),
+ minutes: slaMinutes,
+ available: parseBoolean(slaFeatureAvailable),
+ },
},
render(createElement) {
return createElement(SettingsTabs);
diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js
index 528d5d8072f..1e82ecb05b5 100644
--- a/app/assets/javascripts/init_issuable_sidebar.js
+++ b/app/assets/javascripts/init_issuable_sidebar.js
@@ -5,10 +5,14 @@ import LabelsSelect from './labels_select';
import IssuableContext from './issuable_context';
import Sidebar from './right_sidebar';
import DueDateSelectors from './due_date_select';
-import { mountSidebarLabels } from '~/sidebar/mount_sidebar';
+import { mountSidebarLabels, getSidebarOptions } from '~/sidebar/mount_sidebar';
export default () => {
- const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
+ const sidebarOptEl = document.querySelector('.js-sidebar-options');
+
+ if (!sidebarOptEl) return;
+
+ const sidebarOptions = getSidebarOptions(sidebarOptEl);
new MilestoneSelect({
full_path: sidebarOptions.fullPath,
diff --git a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
new file mode 100644
index 00000000000..890381a8f29
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
@@ -0,0 +1,60 @@
+<script>
+import { mapGetters } from 'vuex';
+import { GlModal } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlModal,
+ },
+ computed: {
+ ...mapGetters(['isSavingOrTesting']),
+ primaryProps() {
+ return {
+ text: __('Save'),
+ attributes: [
+ { variant: 'success' },
+ { category: 'primary' },
+ { disabled: this.isSavingOrTesting },
+ ],
+ };
+ },
+ cancelProps() {
+ return {
+ text: __('Cancel'),
+ };
+ },
+ },
+ methods: {
+ onSubmit() {
+ this.$emit('submit');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ modal-id="confirmSaveIntegration"
+ size="sm"
+ :title="s__('Integrations|Save settings?')"
+ :action-primary="primaryProps"
+ :action-cancel="cancelProps"
+ @primary="onSubmit"
+ >
+ <p>
+ {{
+ s__(
+ 'Integrations|Saving will update the default settings for all projects that are not using custom settings.',
+ )
+ }}
+ </p>
+ <p class="gl-mb-0">
+ {{
+ s__(
+ 'Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults.',
+ )
+ }}
+ </p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 0460ed6791e..0fd39c5635d 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -1,8 +1,9 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlModalDirective } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
+import { integrationLevels } from '../constants';
import OverrideDropdown from './override_dropdown.vue';
import ActiveCheckbox from './active_checkbox.vue';
@@ -10,6 +11,7 @@ import JiraTriggerFields from './jira_trigger_fields.vue';
import JiraIssuesFields from './jira_issues_fields.vue';
import TriggerFields from './trigger_fields.vue';
import DynamicField from './dynamic_field.vue';
+import ConfirmationModal from './confirmation_modal.vue';
export default {
name: 'IntegrationForm',
@@ -20,8 +22,12 @@ export default {
JiraIssuesFields,
TriggerFields,
DynamicField,
+ ConfirmationModal,
GlButton,
},
+ directives: {
+ 'gl-modal': GlModalDirective,
+ },
mixins: [glFeatureFlagsMixin()],
computed: {
...mapGetters(['currentKey', 'propsSource', 'isSavingOrTesting']),
@@ -32,6 +38,9 @@ export default {
isJira() {
return this.propsSource.type === 'jira';
},
+ isInstanceLevel() {
+ return this.propsSource.integrationLevel === integrationLevels.INSTANCE;
+ },
showJiraIssuesFields() {
return this.isJira && this.glFeatures.jiraIssuesIntegration;
},
@@ -82,7 +91,21 @@ export default {
v-bind="propsSource.jiraIssuesProps"
/>
<div v-if="isEditable" class="footer-block row-content-block">
+ <template v-if="isInstanceLevel">
+ <gl-button
+ v-gl-modal.confirmSaveIntegration
+ category="primary"
+ variant="success"
+ :loading="isSaving"
+ :disabled="isSavingOrTesting"
+ data-qa-selector="save_changes_button"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ <confirmation-modal @submit="onSaveClick" />
+ </template>
<gl-button
+ v-else
category="primary"
variant="success"
type="submit"
@@ -93,6 +116,7 @@ export default {
>
{{ __('Save changes') }}
</gl-button>
+
<gl-button
v-if="propsSource.canTest"
:loading="isTesting"
diff --git a/app/assets/javascripts/invite_member/components/invite_member_modal.vue b/app/assets/javascripts/invite_member/components/invite_member_modal.vue
new file mode 100644
index 00000000000..3df99bccdb0
--- /dev/null
+++ b/app/assets/javascripts/invite_member/components/invite_member_modal.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlModal, GlLink } from '@gitlab/ui';
+import eventHub from '../event_hub';
+import { s__, __ } from '~/locale';
+import { OPEN_MODAL, MODAL_ID } from '../constants';
+
+export default {
+ cancelProps: {
+ text: __('Got it'),
+ attributes: [
+ {
+ variant: 'info',
+ },
+ ],
+ },
+ modalId: MODAL_ID,
+ components: {
+ GlLink,
+ GlModal,
+ },
+ inject: {
+ membersPath: {
+ default: '',
+ },
+ },
+ i18n: {
+ modalTitle: s__("InviteMember|Oops, this feature isn't ready yet"),
+ bodyTopMessage: s__(
+ "InviteMember|We're working to allow everyone to invite new members, making it easier for teams to get started with GitLab",
+ ),
+ bodyMiddleMessage: s__(
+ 'InviteMember|Until then, ask an owner to invite new project members for you',
+ ),
+ linkText: s__('InviteMember|See who can invite members for you'),
+ },
+ mounted() {
+ eventHub.$on(OPEN_MODAL, this.openModal);
+ },
+ methods: {
+ openModal() {
+ this.$root.$emit('bv::show::modal', MODAL_ID);
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal :modal-id="$options.modalId" size="sm" :action-cancel="$options.cancelProps">
+ <template #modal-title>
+ {{ $options.i18n.modalTitle }}
+ <gl-emoji
+ class="gl-vertical-align-baseline font-size-inherit gl-mr-1"
+ data-name="sweat_smile"
+ />
+ </template>
+ <p>{{ $options.i18n.bodyTopMessage }}</p>
+ <p>{{ $options.i18n.bodyMiddleMessage }}</p>
+ <gl-link
+ :href="membersPath"
+ data-track-event="click_who_can_invite_link"
+ data-track-label="invite_members_message"
+ >{{ $options.i18n.linkText }}</gl-link
+ >
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/invite_member/components/invite_member_trigger.vue b/app/assets/javascripts/invite_member/components/invite_member_trigger.vue
new file mode 100644
index 00000000000..6e886e0e002
--- /dev/null
+++ b/app/assets/javascripts/invite_member/components/invite_member_trigger.vue
@@ -0,0 +1,37 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import eventHub from '../event_hub';
+import { OPEN_MODAL } from '../constants';
+
+export default {
+ components: {
+ GlLink,
+ },
+ inject: {
+ displayText: {
+ default: '',
+ },
+ event: {
+ default: '',
+ },
+ label: {
+ default: '',
+ },
+ },
+ methods: {
+ openModal() {
+ eventHub.$emit(OPEN_MODAL);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-link
+ data-is-link="true"
+ :data-track-event="event"
+ :data-track-label="label"
+ @click="openModal"
+ >{{ displayText }}
+ </gl-link>
+</template>
diff --git a/app/assets/javascripts/invite_member/constants.js b/app/assets/javascripts/invite_member/constants.js
new file mode 100644
index 00000000000..fee6e7a260a
--- /dev/null
+++ b/app/assets/javascripts/invite_member/constants.js
@@ -0,0 +1,2 @@
+export const OPEN_MODAL = 'openModal';
+export const MODAL_ID = 'invite-member-modal';
diff --git a/app/assets/javascripts/invite_member/event_hub.js b/app/assets/javascripts/invite_member/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/invite_member/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/invite_member/init_invite_member_modal.js b/app/assets/javascripts/invite_member/init_invite_member_modal.js
new file mode 100644
index 00000000000..7d60d78d3d9
--- /dev/null
+++ b/app/assets/javascripts/invite_member/init_invite_member_modal.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import { GlToast } from '@gitlab/ui';
+import InviteMemberModal from './components/invite_member_modal.vue';
+
+Vue.use(GlToast);
+
+export default function initInviteMembersModal() {
+ const el = document.querySelector('.js-invite-member-modal');
+
+ if (!el) {
+ return false;
+ }
+
+ const { membersPath } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: { membersPath },
+ render: createElement => createElement(InviteMemberModal),
+ });
+}
diff --git a/app/assets/javascripts/invite_member/init_invite_member_trigger.js b/app/assets/javascripts/invite_member/init_invite_member_trigger.js
new file mode 100644
index 00000000000..a5f904b87a6
--- /dev/null
+++ b/app/assets/javascripts/invite_member/init_invite_member_trigger.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import InviteMemberTrigger from './components/invite_member_trigger.vue';
+
+export default function initInviteMembersTrigger() {
+ const el = document.querySelector('.js-invite-member-trigger');
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ provide: { ...el.dataset },
+ render: createElement => createElement(InviteMemberTrigger),
+ });
+}
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
new file mode 100644
index 00000000000..d2ea14a658b
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -0,0 +1,224 @@
+<script>
+import {
+ GlModal,
+ GlDropdown,
+ GlDropdownItem,
+ GlDatepicker,
+ GlLink,
+ GlSprintf,
+ GlSearchBoxByType,
+ GlButton,
+ GlFormInput,
+} from '@gitlab/ui';
+import eventHub from '../event_hub';
+import { s__, sprintf } from '~/locale';
+import Api from '~/api';
+
+export default {
+ name: 'InviteMembersModal',
+ components: {
+ GlDatepicker,
+ GlLink,
+ GlModal,
+ GlDropdown,
+ GlDropdownItem,
+ GlSprintf,
+ GlSearchBoxByType,
+ GlButton,
+ GlFormInput,
+ },
+ props: {
+ groupId: {
+ type: String,
+ required: true,
+ },
+ groupName: {
+ type: String,
+ required: true,
+ },
+ accessLevels: {
+ type: Object,
+ required: true,
+ },
+ defaultAccessLevel: {
+ type: String,
+ required: true,
+ },
+ helpLink: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ visible: true,
+ modalId: 'invite-members-modal',
+ selectedAccessLevel: this.defaultAccessLevel,
+ newUsersToInvite: '',
+ selectedDate: undefined,
+ };
+ },
+ computed: {
+ introText() {
+ return sprintf(s__("InviteMembersModal|You're inviting members to the %{group_name} group"), {
+ group_name: this.groupName,
+ });
+ },
+ toastOptions() {
+ return {
+ onComplete: () => {
+ this.selectedAccessLevel = this.defaultAccessLevel;
+ this.newUsersToInvite = '';
+ },
+ };
+ },
+ postData() {
+ return {
+ user_id: this.newUsersToInvite,
+ access_level: this.selectedAccessLevel,
+ expires_at: this.selectedDate,
+ format: 'json',
+ };
+ },
+ selectedRoleName() {
+ return Object.keys(this.accessLevels).find(
+ key => this.accessLevels[key] === Number(this.selectedAccessLevel),
+ );
+ },
+ },
+ mounted() {
+ eventHub.$on('openModal', this.openModal);
+ },
+ methods: {
+ openModal() {
+ this.$root.$emit('bv::show::modal', this.modalId);
+ },
+ closeModal() {
+ this.$root.$emit('bv::hide::modal', this.modalId);
+ },
+ sendInvite() {
+ this.submitForm(this.postData);
+ this.closeModal();
+ },
+ cancelInvite() {
+ this.selectedAccessLevel = this.defaultAccessLevel;
+ this.selectedDate = undefined;
+ this.newUsersToInvite = '';
+ this.closeModal();
+ },
+ changeSelectedItem(item) {
+ this.selectedAccessLevel = item;
+ },
+ submitForm(formData) {
+ return Api.inviteGroupMember(this.groupId, formData)
+ .then(() => {
+ this.showToastMessageSuccess();
+ })
+ .catch(error => {
+ this.showToastMessageError(error);
+ });
+ },
+ showToastMessageSuccess() {
+ this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
+ },
+ showToastMessageError(error) {
+ const message = error.response.data.message || this.$options.labels.toastMessageUnsuccessful;
+
+ this.$toast.show(message, this.toastOptions);
+ },
+ },
+ labels: {
+ modalTitle: s__('InviteMembersModal|Invite team members'),
+ userToInvite: s__('InviteMembersModal|GitLab member or Email address'),
+ userPlaceholder: s__('InviteMembersModal|Search for members to invite'),
+ accessLevel: s__('InviteMembersModal|Choose a role permission'),
+ accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
+ toastMessageSuccessful: s__('InviteMembersModal|Users were succesfully added'),
+ toastMessageUnsuccessful: s__('InviteMembersModal|User not invited. Feature coming soon!'),
+ readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`),
+ inviteButtonText: s__('InviteMembersModal|Invite'),
+ cancelButtonText: s__('InviteMembersModal|Cancel'),
+ },
+};
+</script>
+<template>
+ <gl-modal :modal-id="modalId" size="sm" :title="$options.labels.modalTitle">
+ <div class="gl-ml-5 gl-mr-5">
+ <div>{{ introText }}</div>
+
+ <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.userToInvite }}</label>
+ <div class="gl-mt-2">
+ <gl-search-box-by-type
+ v-model="newUsersToInvite"
+ :placeholder="$options.labels.userPlaceholder"
+ type="text"
+ autocomplete="off"
+ autocorrect="off"
+ autocapitalize="off"
+ spellcheck="false"
+ />
+ </div>
+
+ <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full">
+ <gl-dropdown
+ menu-class="dropdown-menu-selectable"
+ class="gl-shadow-none gl-w-full"
+ v-bind="$attrs"
+ :text="selectedRoleName"
+ >
+ <template v-for="(key, item) in accessLevels">
+ <gl-dropdown-item
+ :key="key"
+ active-class="is-active"
+ :is-checked="key === selectedAccessLevel"
+ @click="changeSelectedItem(key)"
+ >
+ <div>{{ item }}</div>
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+ </div>
+
+ <div class="gl-mt-2">
+ <gl-sprintf :message="$options.labels.readMoreText">
+ <template #link="{content}">
+ <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+
+ <label class="gl-font-weight-bold gl-mt-5" for="expires_at">{{
+ $options.labels.accessExpireDate
+ }}</label>
+ <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
+ <gl-datepicker
+ v-model="selectedDate"
+ class="gl-display-inline!"
+ :min-date="new Date()"
+ :target="null"
+ >
+ <template #default="{ formattedDate }">
+ <gl-form-input
+ class="gl-w-full"
+ :value="formattedDate"
+ :placeholder="__(`YYYY-MM-DD`)"
+ />
+ </template>
+ </gl-datepicker>
+ </div>
+ </div>
+
+ <template #modal-footer>
+ <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-p-3">
+ <gl-button ref="cancelButton" @click="cancelInvite">
+ {{ $options.labels.cancelButtonText }}
+ </gl-button>
+ <div class="gl-mr-3"></div>
+ <gl-button ref="inviteButton" variant="success" @click="sendInvite">{{
+ $options.labels.inviteButtonText
+ }}</gl-button>
+ </div>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
new file mode 100644
index 00000000000..d133e3655e3
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlLink, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ GlLink,
+ GlIcon,
+ },
+ props: {
+ displayText: {
+ type: String,
+ required: false,
+ default: s__('InviteMembers|Invite team members'),
+ },
+ icon: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ methods: {
+ openModal() {
+ eventHub.$emit('openModal');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-link @click="openModal">
+ <div v-if="icon" class="nav-icon-container">
+ <gl-icon :size="16" :name="icon" />
+ </div>
+ <span class="nav-item-name"> {{ displayText }} </span>
+ </gl-link>
+</template>
diff --git a/app/assets/javascripts/invite_members/event_hub.js b/app/assets/javascripts/invite_members/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/invite_members/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js
new file mode 100644
index 00000000000..92aa3187fc3
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import { GlToast } from '@gitlab/ui';
+import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
+
+Vue.use(GlToast);
+
+export default function initInviteMembersModal() {
+ const el = document.querySelector('.js-invite-members-modal');
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ render: createElement =>
+ createElement(InviteMembersModal, {
+ props: {
+ ...el.dataset,
+ accessLevels: JSON.parse(el.dataset.accessLevels),
+ groupName: el.dataset.groupName.toUpperCase(),
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/invite_members/init_invite_members_trigger.js b/app/assets/javascripts/invite_members/init_invite_members_trigger.js
new file mode 100644
index 00000000000..bee4f1c0f72
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_invite_members_trigger.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+
+export default function initInviteMembersTrigger() {
+ const el = document.querySelector('.js-invite-members-trigger');
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ render: createElement =>
+ createElement(InviteMembersTrigger, {
+ props: {
+ ...el.dataset,
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js
index 566efa0d7d6..6f2bd2da078 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable_context.js
@@ -6,6 +6,7 @@ import UsersSelect from './users_select';
export default class IssuableContext {
constructor(currentUser) {
this.userSelect = new UsersSelect(currentUser);
+ this.reviewersSelect = new UsersSelect(currentUser, '.js-reviewer-search');
import(/* webpackChunkName: 'select2' */ 'select2/select2')
.then(() => {
diff --git a/app/assets/javascripts/issuable_create/components/issuable_form.vue b/app/assets/javascripts/issuable_create/components/issuable_form.vue
index 17e51b3dbac..d7b88cc7fc8 100644
--- a/app/assets/javascripts/issuable_create/components/issuable_form.vue
+++ b/app/assets/javascripts/issuable_create/components/issuable_form.vue
@@ -71,6 +71,7 @@ export default {
:markdown-docs-path="descriptionHelpPath"
:add-spacing-classes="false"
:show-suggest-popover="true"
+ :textarea-value="issuableDescription"
>
<textarea
id="issuable-description"
diff --git a/app/assets/javascripts/issuable_show/components/issuable_body.vue b/app/assets/javascripts/issuable_show/components/issuable_body.vue
new file mode 100644
index 00000000000..e6a05c1ab8b
--- /dev/null
+++ b/app/assets/javascripts/issuable_show/components/issuable_body.vue
@@ -0,0 +1,103 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+import IssuableTitle from './issuable_title.vue';
+import IssuableDescription from './issuable_description.vue';
+import IssuableEditForm from './issuable_edit_form.vue';
+
+export default {
+ components: {
+ GlLink,
+ TimeAgoTooltip,
+ IssuableTitle,
+ IssuableDescription,
+ IssuableEditForm,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ statusBadgeClass: {
+ type: String,
+ required: true,
+ },
+ statusIcon: {
+ type: String,
+ required: true,
+ },
+ enableEdit: {
+ type: Boolean,
+ required: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: true,
+ },
+ editFormVisible: {
+ type: Boolean,
+ required: true,
+ },
+ descriptionPreviewPath: {
+ type: String,
+ required: true,
+ },
+ descriptionHelpPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ isUpdated() {
+ return Boolean(this.issuable.updatedAt);
+ },
+ updatedBy() {
+ return this.issuable.updatedBy;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="issue-details issuable-details">
+ <div class="detail-page-description content-block">
+ <issuable-edit-form
+ v-if="editFormVisible"
+ :issuable="issuable"
+ :enable-autocomplete="enableAutocomplete"
+ :description-preview-path="descriptionPreviewPath"
+ :description-help-path="descriptionHelpPath"
+ >
+ <template #edit-form-actions="issuableMeta">
+ <slot name="edit-form-actions" v-bind="issuableMeta"></slot>
+ </template>
+ </issuable-edit-form>
+ <template v-else>
+ <issuable-title
+ :issuable="issuable"
+ :status-badge-class="statusBadgeClass"
+ :status-icon="statusIcon"
+ :enable-edit="enableEdit"
+ @edit-issuable="$emit('edit-issuable', $event)"
+ >
+ <template #status-badge>
+ <slot name="status-badge"></slot>
+ </template>
+ </issuable-title>
+ <issuable-description v-if="issuable.descriptionHtml" :issuable="issuable" />
+ <small v-if="isUpdated" class="edited-text gl-font-sm!">
+ {{ __('Edited') }}
+ <time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" />
+ <span v-if="updatedBy">
+ {{ __('by') }}
+ <gl-link :href="updatedBy.webUrl" class="author-link gl-font-sm!">
+ <span>{{ updatedBy.name }}</span>
+ </gl-link>
+ </span>
+ </small>
+ </template>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issuable_show/components/issuable_description.vue b/app/assets/javascripts/issuable_show/components/issuable_description.vue
new file mode 100644
index 00000000000..091a4be5bd8
--- /dev/null
+++ b/app/assets/javascripts/issuable_show/components/issuable_description.vue
@@ -0,0 +1,31 @@
+<script>
+import $ from 'jquery';
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import '~/behaviors/markdown/render_gfm';
+
+export default {
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ },
+ mounted() {
+ this.renderGFM();
+ },
+ methods: {
+ renderGFM() {
+ $(this.$refs.gfmContainer).renderGFM();
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="description">
+ <div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue b/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue
new file mode 100644
index 00000000000..7b9a83a740f
--- /dev/null
+++ b/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue
@@ -0,0 +1,135 @@
+<script>
+import $ from 'jquery';
+import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
+
+import Autosave from '~/autosave';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ MarkdownField,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: true,
+ },
+ descriptionPreviewPath: {
+ type: String,
+ required: true,
+ },
+ descriptionHelpPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ const { title, description } = this.issuable;
+
+ return {
+ title,
+ description,
+ };
+ },
+ created() {
+ eventHub.$on('update.issuable', this.resetAutosave);
+ eventHub.$on('close.form', this.resetAutosave);
+ },
+ mounted() {
+ this.initAutosave();
+ },
+ beforeDestroy() {
+ eventHub.$off('update.issuable', this.resetAutosave);
+ eventHub.$off('close.form', this.resetAutosave);
+ },
+ methods: {
+ initAutosave() {
+ const { titleInput, descriptionInput } = this.$refs;
+
+ if (!titleInput || !descriptionInput) return;
+
+ this.autosaveTitle = new Autosave($(titleInput.$el), [
+ document.location.pathname,
+ document.location.search,
+ 'title',
+ ]);
+
+ this.autosaveDescription = new Autosave($(descriptionInput.$el), [
+ document.location.pathname,
+ document.location.search,
+ 'description',
+ ]);
+ },
+ resetAutosave() {
+ this.autosaveTitle.reset();
+ this.autosaveDescription.reset();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form>
+ <gl-form-group
+ data-testid="title"
+ :label="__('Title')"
+ :label-sr-only="true"
+ label-for="issuable-title"
+ class="col-12"
+ >
+ <gl-form-input
+ id="issuable-title"
+ ref="titleInput"
+ v-model.trim="title"
+ :placeholder="__('Title')"
+ :aria-label="__('Title')"
+ :autofocus="true"
+ class="qa-title-input"
+ />
+ </gl-form-group>
+ <gl-form-group
+ data-testid="description"
+ :label="__('Description')"
+ :label-sr-only="true"
+ label-for="issuable-description"
+ class="col-12 common-note-form"
+ >
+ <markdown-field
+ :markdown-preview-path="descriptionPreviewPath"
+ :markdown-docs-path="descriptionHelpPath"
+ :enable-autocomplete="enableAutocomplete"
+ :textarea-value="description"
+ >
+ <template #textarea>
+ <textarea
+ id="issuable-description"
+ ref="descriptionInput"
+ v-model="description"
+ :data-supports-quick-actions="enableAutocomplete"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files here…')"
+ class="note-textarea js-gfm-input js-autosize markdown-area
+ qa-description-textarea"
+ dir="auto"
+ ></textarea>
+ </template>
+ </markdown-field>
+ </gl-form-group>
+ <div data-testid="actions" class="col-12 gl-mt-3 gl-mb-3 clearfix">
+ <slot
+ name="edit-form-actions"
+ :issuable-title="title"
+ :issuable-description="description"
+ ></slot>
+ </div>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/issuable_show/components/issuable_header.vue b/app/assets/javascripts/issuable_show/components/issuable_header.vue
new file mode 100644
index 00000000000..3815c50cac6
--- /dev/null
+++ b/app/assets/javascripts/issuable_show/components/issuable_header.vue
@@ -0,0 +1,120 @@
+<script>
+import { GlIcon, GlButton, GlTooltipDirective, GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui';
+
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: {
+ GlIcon,
+ GlButton,
+ GlAvatarLink,
+ GlAvatarLabeled,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ createdAt: {
+ type: String,
+ required: true,
+ },
+ author: {
+ type: Object,
+ required: true,
+ },
+ statusBadgeClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ statusIcon: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ blocked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ confidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ authorId() {
+ return getIdFromGraphQLId(`${this.author.id}`);
+ },
+ },
+ mounted() {
+ this.toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button');
+ },
+ methods: {
+ handleRightSidebarToggleClick() {
+ if (this.toggleSidebarButtonEl) {
+ this.toggleSidebarButtonEl.dispatchEvent(new Event('click'));
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="detail-page-header">
+ <div class="detail-page-header-body">
+ <div data-testid="status" class="issuable-status-box status-box" :class="statusBadgeClass">
+ <gl-icon v-if="statusIcon" :name="statusIcon" class="d-block d-sm-none" />
+ <span class="d-none d-sm-block"><slot name="status-badge"></slot></span>
+ </div>
+ <div class="issuable-meta gl-display-flex gl-align-items-center">
+ <div class="gl-display-inline-block">
+ <div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline">
+ <gl-icon name="lock" :aria-label="__('Blocked')" />
+ </div>
+ <div v-if="confidential" data-testid="confidential" class="issuable-warning-icon inline">
+ <gl-icon name="eye-slash" :aria-label="__('Confidential')" />
+ </div>
+ </div>
+ <span>
+ {{ __('Opened') }}
+ <time-ago-tooltip data-testid="startTimeItem" :time="createdAt" />
+ {{ __('by') }}
+ </span>
+ <gl-avatar-link
+ data-testid="avatar"
+ :data-user-id="authorId"
+ :data-username="author.username"
+ :data-name="author.name"
+ :href="author.webUrl"
+ target="_blank"
+ class="js-user-link gl-ml-2"
+ >
+ <gl-avatar-labeled
+ :size="24"
+ :src="author.avatarUrl"
+ :label="author.name"
+ class="d-none d-sm-inline-flex gl-ml-1"
+ />
+ <strong class="author d-sm-none d-inline">@{{ author.username }}</strong>
+ </gl-avatar-link>
+ </div>
+ <gl-button
+ data-testid="sidebar-toggle"
+ icon="chevron-double-lg-left"
+ class="d-block d-sm-none gutter-toggle issuable-gutter-toggle"
+ :aria-label="__('Expand sidebar')"
+ @click="handleRightSidebarToggleClick"
+ />
+ </div>
+ <div
+ data-testid="header-actions"
+ class="detail-page-header-actions js-issuable-actions js-issuable-buttons gl-display-flex gl-display-md-block"
+ >
+ <slot name="header-actions"></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
new file mode 100644
index 00000000000..b41f5e270a8
--- /dev/null
+++ b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
@@ -0,0 +1,98 @@
+<script>
+import IssuableSidebar from '~/issuable_sidebar/components/issuable_sidebar_root.vue';
+
+import IssuableHeader from './issuable_header.vue';
+import IssuableBody from './issuable_body.vue';
+
+export default {
+ components: {
+ IssuableSidebar,
+ IssuableHeader,
+ IssuableBody,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ statusBadgeClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ statusIcon: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ enableEdit: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ editFormVisible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ descriptionPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ descriptionHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="issuable-show-container">
+ <issuable-header
+ :status-badge-class="statusBadgeClass"
+ :status-icon="statusIcon"
+ :blocked="issuable.blocked"
+ :confidential="issuable.confidential"
+ :created-at="issuable.createdAt"
+ :author="issuable.author"
+ >
+ <template #status-badge>
+ <slot name="status-badge"></slot>
+ </template>
+ <template #header-actions>
+ <slot name="header-actions"></slot>
+ </template>
+ </issuable-header>
+ <issuable-body
+ :issuable="issuable"
+ :status-badge-class="statusBadgeClass"
+ :status-icon="statusIcon"
+ :enable-edit="enableEdit"
+ :enable-autocomplete="enableAutocomplete"
+ :edit-form-visible="editFormVisible"
+ :description-preview-path="descriptionPreviewPath"
+ :description-help-path="descriptionHelpPath"
+ @edit-issuable="$emit('edit-issuable', $event)"
+ >
+ <template #status-badge>
+ <slot name="status-badge"></slot>
+ </template>
+ <template #edit-form-actions="actionsProps">
+ <slot name="edit-form-actions" v-bind="actionsProps"></slot>
+ </template>
+ </issuable-body>
+ <issuable-sidebar @sidebar-toggle="$emit('sidebar-toggle', $event)">
+ <template #right-sidebar-items="sidebarProps">
+ <slot name="right-sidebar-items" v-bind="sidebarProps"></slot>
+ </template>
+ </issuable-sidebar>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issuable_show/components/issuable_title.vue b/app/assets/javascripts/issuable_show/components/issuable_title.vue
new file mode 100644
index 00000000000..d3b42fd2ffb
--- /dev/null
+++ b/app/assets/javascripts/issuable_show/components/issuable_title.vue
@@ -0,0 +1,96 @@
+<script>
+import {
+ GlIcon,
+ GlButton,
+ GlIntersectionObserver,
+ GlTooltipDirective,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ GlButton,
+ GlIntersectionObserver,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ SafeHtml,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ statusBadgeClass: {
+ type: String,
+ required: true,
+ },
+ statusIcon: {
+ type: String,
+ required: true,
+ },
+ enableEdit: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ stickyTitleVisible: false,
+ };
+ },
+ methods: {
+ handleTitleAppear() {
+ this.stickyTitleVisible = false;
+ },
+ handleTitleDisappear() {
+ this.stickyTitleVisible = true;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="title-container">
+ <h2 v-safe-html="issuable.titleHtml" class="title qa-title" dir="auto"></h2>
+ <gl-button
+ v-if="enableEdit"
+ v-gl-tooltip.bottom
+ :title="__('Edit title and description')"
+ icon="pencil"
+ class="btn-edit js-issuable-edit qa-edit-button"
+ @click="$emit('edit-issuable', $event)"
+ />
+ </div>
+ <gl-intersection-observer @appear="handleTitleAppear" @disappear="handleTitleDisappear">
+ <transition name="issuable-header-slide">
+ <div
+ v-if="stickyTitleVisible"
+ class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3"
+ data-testid="header"
+ >
+ <div
+ class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5"
+ >
+ <p
+ data-testid="status"
+ class="issuable-status-box status-box gl-my-0"
+ :class="statusBadgeClass"
+ >
+ <gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" />
+ <span class="gl-display-none d-sm-block"><slot name="status-badge"></slot></span>
+ </p>
+ <p
+ class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0"
+ :title="issuable.title"
+ >
+ {{ issuable.title }}
+ </p>
+ </div>
+ </div>
+ </transition>
+ </gl-intersection-observer>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issuable_show/constants.js b/app/assets/javascripts/issuable_show/constants.js
new file mode 100644
index 00000000000..346f45c7d90
--- /dev/null
+++ b/app/assets/javascripts/issuable_show/constants.js
@@ -0,0 +1,5 @@
+export const IssuableType = {
+ Issue: 'issue',
+ Incident: 'incident',
+ TestCase: 'test_case',
+};
diff --git a/app/assets/javascripts/issuable_show/event_hub.js b/app/assets/javascripts/issuable_show/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/issuable_show/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue
new file mode 100644
index 00000000000..7d1339f833d
--- /dev/null
+++ b/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue
@@ -0,0 +1,88 @@
+<script>
+import Cookies from 'js-cookie';
+import { GlIcon } from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ data() {
+ const userExpanded = !parseBoolean(Cookies.get('collapsed_gutter'));
+
+ // We're deliberately keeping two different props for sidebar status;
+ // 1. userExpanded reflects value based on cookie `collapsed_gutter`.
+ // 2. isExpanded reflect actual sidebar state.
+ return {
+ userExpanded,
+ isExpanded: userExpanded ? bp.isDesktop() : userExpanded,
+ };
+ },
+ watch: {
+ isExpanded(expanded) {
+ this.$emit('sidebar-toggle', {
+ expanded,
+ });
+ },
+ },
+ mounted() {
+ window.addEventListener('resize', this.handleWindowResize);
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', this.handleWindowResize);
+ },
+ methods: {
+ updatePageContainerClass() {
+ const layoutPageEl = document.querySelector('.layout-page');
+
+ if (layoutPageEl) {
+ layoutPageEl.classList.toggle('right-sidebar-expanded', this.isExpanded);
+ layoutPageEl.classList.toggle('right-sidebar-collapsed', !this.isExpanded);
+ }
+ },
+ handleWindowResize() {
+ if (this.userExpanded) {
+ this.isExpanded = bp.isDesktop();
+ this.updatePageContainerClass();
+ }
+ },
+ handleToggleSidebarClick() {
+ this.isExpanded = !this.isExpanded;
+ this.userExpanded = this.isExpanded;
+
+ Cookies.set('collapsed_gutter', !this.userExpanded);
+ this.updatePageContainerClass();
+ },
+ },
+};
+</script>
+
+<template>
+ <aside
+ :class="{ 'right-sidebar-expanded': isExpanded, 'right-sidebar-collapsed': !isExpanded }"
+ class="right-sidebar"
+ aria-live="polite"
+ >
+ <button
+ class="toggle-right-sidebar-button js-toggle-right-sidebar-button w-100 gl-text-decoration-none! gl-display-flex gl-outline-0!"
+ :title="__('Toggle sidebar')"
+ @click="handleToggleSidebarClick"
+ >
+ <span v-if="isExpanded" class="collapse-text gl-flex-grow-1 gl-text-left">{{
+ __('Collapse sidebar')
+ }}</span>
+ <gl-icon v-show="isExpanded" data-testid="icon-collapse" name="chevron-double-lg-right" />
+ <gl-icon
+ v-show="!isExpanded"
+ data-testid="icon-expand"
+ name="chevron-double-lg-left"
+ class="gl-ml-2"
+ />
+ </button>
+ <div data-testid="sidebar-items" class="issuable-sidebar">
+ <slot name="right-sidebar-items" v-bind="{ sidebarExpanded: isExpanded }"></slot>
+ </div>
+ </aside>
+</template>
diff --git a/app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue b/app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue
deleted file mode 100644
index 06c50f62aab..00000000000
--- a/app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue
+++ /dev/null
@@ -1,23 +0,0 @@
-<script>
-export default {
- props: {
- signedIn: {
- type: Boolean,
- required: true,
- },
- sidebarStatusClass: {
- type: String,
- required: false,
- default: '',
- },
- },
-};
-</script>
-
-<template>
- <aside
- :class="sidebarStatusClass"
- class="right-sidebar js-right-sidebar js-issuable-sidebar"
- aria-live="polite"
- ></aside>
-</template>
diff --git a/app/assets/javascripts/issuable_sidebar/sidebar_bundle.js b/app/assets/javascripts/issuable_sidebar/sidebar_bundle.js
deleted file mode 100644
index c8acafa8cd8..00000000000
--- a/app/assets/javascripts/issuable_sidebar/sidebar_bundle.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import Vue from 'vue';
-
-import SidebarApp from './components/sidebar_app.vue';
-
-export default () => {
- const el = document.getElementById('js-vue-issuable-sidebar');
-
- if (!el) {
- return false;
- }
-
- const { sidebarStatusClass } = el.dataset;
- // An empty string is present when user is signed in.
- const signedIn = el.dataset.signedIn === '';
-
- return new Vue({
- el,
- components: { SidebarApp },
- render: createElement =>
- createElement('sidebar-app', {
- props: {
- signedIn,
- sidebarStatusClass,
- },
- }),
- });
-};
diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue
index e1b308c6f57..8a1a8448bb8 100644
--- a/app/assets/javascripts/issue_show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
import $ from 'jquery';
import { GlIcon } from '@gitlab/ui';
import IssuableTemplateSelectors from '../../../templates/issuable_template_selectors';
@@ -62,11 +61,15 @@ export default {
data-toggle="dropdown"
>
<span class="dropdown-toggle-text">{{ __('Choose a template') }}</span>
- <i aria-hidden="true" class="fa fa-chevron-down"> </i>
+ <gl-icon
+ name="chevron-down"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
+ aria-hidden="true"
+ />
</button>
<div class="dropdown-menu dropdown-select">
<div class="dropdown-title gl-display-flex gl-justify-content-center">
- <span class="gl-ml-auto">Choose a template</span>
+ <span class="gl-ml-auto">{{ __('Choose a template') }}</span>
<button
class="dropdown-title-button dropdown-menu-close gl-ml-auto"
:aria-label="__('Close')"
@@ -82,7 +85,7 @@ export default {
:placeholder="__('Filter')"
autocomplete="off"
/>
- <i aria-hidden="true" class="fa fa-search dropdown-input-search"> </i>
+ <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" />
<gl-icon
name="close"
class="dropdown-input-clear js-dropdown-input-clear"
diff --git a/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql b/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql
index 00ddc80432d..bb637dea033 100644
--- a/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql
+++ b/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql
@@ -13,6 +13,7 @@ query getAlert($iid: String!, $fullPath: ID!) {
service
description
endedAt
+ hosts
details
}
}
diff --git a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue b/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue
index a47fe4c84cf..96f187f26dd 100644
--- a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue
+++ b/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue
@@ -1,42 +1,63 @@
<script>
-import { GlLink } from '@gitlab/ui';
+import { GlLink, GlTooltipDirective } from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
export default {
components: {
GlLink,
+ IncidentSla: () => import('ee_component/issue_show/components/incidents/incident_sla.vue'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
alert: {
type: Object,
- required: true,
+ required: false,
+ default: null,
},
},
+ data() {
+ return { childHasData: false };
+ },
computed: {
startTime() {
return formatDate(this.alert.startedAt, 'yyyy-mm-dd Z');
},
+ showHighlightBar() {
+ return this.alert || this.childHasData;
+ },
+ },
+ methods: {
+ update(hasData) {
+ this.childHasData = hasData;
+ },
},
};
</script>
<template>
<div
- class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between"
+ v-show="showHighlightBar"
+ class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column"
>
- <div class="text-truncate gl-pr-3">
+ <div v-if="alert" class="gl-mr-3">
<span class="gl-font-weight-bold">{{ s__('HighlightBar|Original alert:') }}</span>
- <gl-link :href="alert.detailsUrl">{{ alert.title }}</gl-link>
+ <gl-link v-gl-tooltip :title="alert.title" :href="alert.detailsUrl">
+ #{{ alert.iid }}
+ </gl-link>
</div>
- <div class="gl-pr-3 gl-white-space-nowrap">
+ <div v-if="alert" class="gl-mr-3">
<span class="gl-font-weight-bold">{{ s__('HighlightBar|Alert start time:') }}</span>
{{ startTime }}
</div>
- <div class="gl-white-space-nowrap">
+ <div v-if="alert" class="gl-mr-3">
<span class="gl-font-weight-bold">{{ s__('HighlightBar|Alert events:') }}</span>
<span>{{ alert.eventCount }}</span>
</div>
+
+ <incident-sla @update="update" />
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue
index 4104ddbf06f..c593fa33973 100644
--- a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue
+++ b/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue
@@ -5,8 +5,10 @@ import HighlightBar from './highlight_bar.vue';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
+import Tracking from '~/tracking';
import getAlert from './graphql/queries/get_alert.graphql';
+import { trackIncidentDetailsViewsOptions } from '~/incidents/constants';
export default {
components: {
@@ -45,12 +47,14 @@ export default {
loading() {
return this.$apollo.queries.alert.loading;
},
- alertTableFields() {
- if (this.alert) {
- const { detailsUrl, __typename, ...restDetails } = this.alert;
- return restDetails;
- }
- return null;
+ },
+ mounted() {
+ this.trackPageViews();
+ },
+ methods: {
+ trackPageViews() {
+ const { category, action } = trackIncidentDetailsViewsOptions;
+ Tracking.event(category, action);
},
},
};
@@ -60,11 +64,11 @@ export default {
<div>
<gl-tabs content-class="gl-reset-line-height" class="gl-mt-n3" data-testid="incident-tabs">
<gl-tab :title="s__('Incident|Summary')">
- <highlight-bar v-if="alert" :alert="alert" />
+ <highlight-bar :alert="alert" />
<description-component v-bind="$attrs" />
</gl-tab>
<gl-tab v-if="alert" class="alert-management-details" :title="s__('Incident|Alert details')">
- <alert-details-table :alert="alertTableFields" :loading="loading" />
+ <alert-details-table :alert="alert" :loading="loading" />
</gl-tab>
</gl-tabs>
</div>
diff --git a/app/assets/javascripts/issue_show/incident.js b/app/assets/javascripts/issue_show/incident.js
index a34e75ee64a..618fb551f28 100644
--- a/app/assets/javascripts/issue_show/incident.js
+++ b/app/assets/javascripts/issue_show/incident.js
@@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import issuableApp from './components/app.vue';
import incidentTabs from './components/incidents/incident_tabs.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(VueApollo);
@@ -11,7 +12,7 @@ export default function initIssuableApp(issuableData = {}) {
defaultClient: createDefaultClient(),
});
- const { projectNamespace, projectPath, iid } = issuableData;
+ const { iid, projectNamespace, projectPath, slaFeatureAvailable } = issuableData;
return new Vue({
el: document.getElementById('js-issuable-app'),
@@ -22,6 +23,7 @@ export default function initIssuableApp(issuableData = {}) {
provide: {
fullPath: `${projectNamespace}/${projectPath}`,
iid,
+ slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
},
render(createElement) {
return createElement('issuable-app', {
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
index c6f7e892f9b..06bbd406e3a 100644
--- a/app/assets/javascripts/issue_show/stores/index.js
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -1,4 +1,4 @@
-import { sanitize } from 'dompurify';
+import { sanitize } from '~/lib/dompurify';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import updateDescription from '../utils/update_description';
diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js
index a62a5167961..620974901fb 100644
--- a/app/assets/javascripts/issue_show/utils/parse_data.js
+++ b/app/assets/javascripts/issue_show/utils/parse_data.js
@@ -1,4 +1,5 @@
-import { sanitize } from 'dompurify';
+import * as Sentry from '~/sentry/wrapper';
+import { sanitize } from '~/lib/dompurify';
// We currently load + parse the data from the issue app and related merge request
let cachedParsedData;
@@ -7,10 +8,9 @@ export const parseIssuableData = () => {
try {
if (cachedParsedData) return cachedParsedData;
- const initialDataEl = document.getElementById('js-issuable-app-initial-data');
-
- const parsedData = JSON.parse(initialDataEl.textContent.replace(/&quot;/g, '"'));
+ const initialDataEl = document.getElementById('js-issuable-app');
+ const parsedData = JSON.parse(initialDataEl.dataset.initial);
parsedData.initialTitleHtml = sanitize(parsedData.initialTitleHtml);
parsedData.initialDescriptionHtml = sanitize(parsedData.initialDescriptionHtml);
@@ -18,7 +18,7 @@ export const parseIssuableData = () => {
return parsedData;
} catch (e) {
- console.error(e); // eslint-disable-line no-console
+ Sentry.captureException(e);
return {};
}
diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue
index adfb234fe7a..dc63d613b5b 100644
--- a/app/assets/javascripts/issues_list/components/issuable.vue
+++ b/app/assets/javascripts/issues_list/components/issuable.vue
@@ -351,7 +351,7 @@ export default {
:class="{ cred: isOverdue }"
:title="__('Due date')"
>
- <i class="fa fa-calendar"></i>
+ <gl-icon name="calendar" />
{{ dueDateWords }}
</span>
diff --git a/app/assets/javascripts/jira_connect.js b/app/assets/javascripts/jira_connect.js
index 895cdc4562c..0864a3024ac 100644
--- a/app/assets/javascripts/jira_connect.js
+++ b/app/assets/javascripts/jira_connect.js
@@ -18,6 +18,13 @@ function onLoaded() {
alert(res.responseJSON.error);
};
+ AP.getLocation(function(location) {
+ $('.js-jira-connect-sign-in').each(function() {
+ var updatedLink = `${$(this).attr('href')}?return_to=${location}`;
+ $(this).attr('href', updatedLink);
+ });
+ });
+
$('#add-subscription-form').on('submit', function(e) {
var actionUrl = $(this).attr('action');
e.preventDefault();
diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue
index 4339021d9a0..4a1bca110fd 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_form.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue
@@ -301,7 +301,7 @@ export default {
"
@hide="resetDropdown"
>
- <gl-search-box-by-type v-model.trim="searchTerm" class="gl-m-3" />
+ <gl-search-box-by-type v-model.trim="searchTerm" />
<gl-loading-icon v-if="isFetching" />
diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js
index 695a237bf50..003f3c7107e 100644
--- a/app/assets/javascripts/jira_import/index.js
+++ b/app/assets/javascripts/jira_import/index.js
@@ -6,7 +6,7 @@ import App from './components/jira_import_app.vue';
Vue.use(VueApollo);
-const defaultClient = createDefaultClient();
+const defaultClient = createDefaultClient({}, { assumeImmutableResults: true });
const apolloProvider = new VueApollo({
defaultClient,
diff --git a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql
index 8fda8287988..807374bf06c 100644
--- a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql
+++ b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql
@@ -2,7 +2,6 @@
mutation($input: JiraImportStartInput!) {
jiraImportStart(input: $input) {
- clientMutationId
jiraImport {
...JiraImport
}
diff --git a/app/assets/javascripts/jira_import/utils/cache_update.js b/app/assets/javascripts/jira_import/utils/cache_update.js
index 6aaf2010866..65b2e459f03 100644
--- a/app/assets/javascripts/jira_import/utils/cache_update.js
+++ b/app/assets/javascripts/jira_import/utils/cache_update.js
@@ -1,3 +1,4 @@
+import produce from 'immer';
import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql';
import { IMPORT_STATE } from './jira_import_utils';
@@ -13,22 +14,20 @@ export const addInProgressImportToStore = (store, jiraImportStart, fullPath) =>
},
};
- const cacheData = store.readQuery({
+ const sourceData = store.readQuery({
...queryDetails,
});
store.writeQuery({
...queryDetails,
- data: {
- project: {
- ...cacheData.project,
- jiraImportStatus: IMPORT_STATE.SCHEDULED,
- jiraImports: {
- ...cacheData.project.jiraImports,
- nodes: cacheData.project.jiraImports.nodes.concat(jiraImportStart.jiraImport),
- },
- },
- },
+ data: produce(sourceData, draftData => {
+ draftData.project.jiraImportStatus = IMPORT_STATE.SCHEDULED; // eslint-disable-line no-param-reassign
+ // eslint-disable-next-line no-param-reassign
+ draftData.project.jiraImports.nodes = [
+ ...sourceData.project.jiraImports.nodes,
+ jiraImportStart.jiraImport,
+ ];
+ }),
});
};
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue
index c4f180f200c..222fae6d9a8 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/commit_block.vue
@@ -32,26 +32,25 @@ export default {
block: !isLastBlock,
}"
>
- <p class="gl-mb-2">
- <span class="font-weight-bold">{{ __('Commit') }}</span>
+ <span class="font-weight-bold">{{ __('Commit') }}</span>
- <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit">
- {{ commit.short_id }}
- </gl-link>
+ <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit">
+ {{ commit.short_id }}
+ </gl-link>
- <clipboard-button
- :text="commit.id"
- :title="__('Copy commit SHA')"
- css-class="btn btn-clipboard btn-transparent"
- />
+ <clipboard-button
+ :text="commit.id"
+ :title="__('Copy commit SHA')"
+ category="tertiary"
+ size="small"
+ />
- <span v-if="mergeRequest">
- in
- <gl-link :href="mergeRequest.path" class="js-link-commit link-commit"
- >!{{ mergeRequest.iid }}</gl-link
- >
- </span>
- </p>
+ <span v-if="mergeRequest">
+ in
+ <gl-link :href="mergeRequest.path" class="js-link-commit link-commit"
+ >!{{ mergeRequest.iid }}</gl-link
+ >
+ </span>
<p class="gl-mb-0">{{ commit.title }}</p>
</div>
diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue
index 79e6623eca8..6b61dc5902b 100644
--- a/app/assets/javascripts/jobs/components/job_container_item.vue
+++ b/app/assets/javascripts/jobs/components/job_container_item.vue
@@ -1,6 +1,5 @@
<script>
-import { GlLink, GlIcon } from '@gitlab/ui';
-import tooltip from '~/vue_shared/directives/tooltip';
+import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
import { sprintf } from '~/locale';
@@ -12,7 +11,7 @@ export default {
GlLink,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
mixins: [delayedJobMixin],
props: {
@@ -49,10 +48,9 @@ export default {
}"
>
<gl-link
- v-tooltip
+ v-gl-tooltip
:href="job.status.details_path"
:title="tooltipText"
- data-boundary="viewport"
class="js-job-link d-flex"
>
<gl-icon
diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue
index e68d5b8eda4..791664c05d9 100644
--- a/app/assets/javascripts/jobs/components/log/line.vue
+++ b/app/assets/javascripts/jobs/components/log/line.vue
@@ -1,6 +1,24 @@
<script>
+import linkifyHtml from 'linkifyjs/html';
+import { sanitize } from '~/lib/dompurify';
+import { isAbsolute } from '~/lib/utils/url_utility';
import LineNumber from './line_number.vue';
+const linkifyOptions = {
+ attributes: {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ rel: 'nofollow noopener',
+ },
+ className: 'gl-reset-color!',
+ defaultProtocol: 'https',
+ validate: {
+ email: false,
+ url(value) {
+ return isAbsolute(value);
+ },
+ },
+};
+
export default {
functional: true,
props: {
@@ -17,13 +35,15 @@ export default {
const { line, path } = props;
const chars = line.content.map(content => {
- return h(
- 'span',
- {
- class: ['gl-white-space-pre-wrap', content.style],
+ const linkfied = linkifyHtml(content.text, linkifyOptions);
+ return h('span', {
+ class: ['gl-white-space-pre-wrap', content.style],
+ domProps: {
+ innerHTML: sanitize(linkfied, {
+ ALLOWED_TAGS: ['a'],
+ }),
},
- content.text,
- );
+ });
});
return h('div', { class: 'js-line log-line' }, [
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index aa589989e8a..8701e05a01f 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -1,7 +1,7 @@
<script>
import { isEmpty } from 'lodash';
import { mapActions, mapState } from 'vuex';
-import { GlLink, GlDeprecatedButton, GlIcon } from '@gitlab/ui';
+import { GlLink, GlButton, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
@@ -24,7 +24,7 @@ export default {
StagesDropdown,
JobsContainer,
GlLink,
- GlDeprecatedButton,
+ GlButton,
TooltipOnTruncate,
},
mixins: [timeagoMixin],
@@ -143,14 +143,13 @@ export default {
>
</div>
- <gl-deprecated-button
+ <gl-button
:aria-label="__('Toggle Sidebar')"
- type="button"
- class="btn btn-blank gutter-toggle float-right d-block d-md-none js-sidebar-build-toggle"
+ class="d-md-none gl-ml-2 js-sidebar-build-toggle"
+ category="tertiary"
+ icon="chevron-double-lg-right"
@click="toggleSidebar"
- >
- <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i>
- </gl-deprecated-button>
+ />
</div>
<div v-if="job.terminal_path || job.new_issue_path" class="block retry-link">
diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue
index f55429ecdae..3cb5e63fd36 100644
--- a/app/assets/javascripts/jobs/components/trigger_block.vue
+++ b/app/assets/javascripts/jobs/components/trigger_block.vue
@@ -1,12 +1,12 @@
<script>
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
const HIDDEN_VALUE = '••••••';
export default {
components: {
- GlDeprecatedButton,
+ GlButton,
},
props: {
trigger: {
@@ -55,11 +55,12 @@ export default {
<p class="trigger-variables-btn-container d-flex">
<span class="font-weight-bold">{{ __('Trigger variables:') }}</span>
- <gl-deprecated-button
+ <gl-button
v-if="hasValues"
- class="btn-sm group js-reveal-variables trigger-variables-btn"
+ class="group js-reveal-variables trigger-variables-btn"
+ size="small"
@click="toggleValues"
- >{{ getToggleButtonText }}</gl-deprecated-button
+ >{{ getToggleButtonText }}</gl-button
>
</p>
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index 8d6e5aac566..ea9c214de32 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -1,3 +1,5 @@
+import { parseBoolean } from '../../lib/utils/common_utils';
+
/**
* Adds the line number property
* @param Object line
@@ -17,7 +19,7 @@ export const parseLine = (line = {}, lineNumber) => ({
* @param Number lineNumber
*/
export const parseHeaderLine = (line = {}, lineNumber) => ({
- isClosed: false,
+ isClosed: parseBoolean(line.section_options?.collapsed),
isHeader: true,
line: parseLine(line, lineNumber),
lines: [],
diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js
index 4922166acd0..469f7ce94b0 100644
--- a/app/assets/javascripts/label_manager.js
+++ b/app/assets/javascripts/label_manager.js
@@ -6,6 +6,7 @@ import Sortable from 'sortablejs';
import { deprecatedCreateFlash as flash } from './flash';
import axios from './lib/utils/axios_utils';
import { __ } from './locale';
+import { hide, dispose } from '~/tooltips';
export default class LabelManager {
constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) {
@@ -40,14 +41,14 @@ export default class LabelManager {
const $label = $(`#${$btn.data('domId')}`);
const action = $btn.parents('.js-prioritized-labels').length ? 'remove' : 'add';
const $tooltip = $(`#${$btn.find('.has-tooltip:visible').attr('aria-describedby')}`);
- $tooltip.tooltip('dispose');
+ dispose($tooltip);
_this.toggleLabelPriority($label, action);
_this.toggleEmptyState($label, $btn, action);
}
onButtonActionClick(e) {
e.stopPropagation();
- $(e.currentTarget).tooltip('hide');
+ hide(e.currentTarget);
}
toggleEmptyState() {
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 8e172b4827c..8bbd4300c96 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -4,7 +4,7 @@
import $ from 'jquery';
import { difference, isEqual, escape, sortBy, template, union } from 'lodash';
-import { sprintf, s__, __ } from './locale';
+import { sprintf, __ } from './locale';
import axios from './lib/utils/axios_utils';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import CreateLabelDropdown from './create_label';
@@ -43,7 +43,6 @@ export default class LabelsSelect {
const $block = $selectbox.closest('.block');
const $form = $dropdown.closest('form, .js-issuable-update');
const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
- const $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip');
const $value = $block.find('.value');
const $dropdownMenu = $dropdown.parent().find('.dropdown-menu');
// eslint-disable-next-line no-jquery/no-fade
@@ -57,7 +56,6 @@ export default class LabelsSelect {
.get();
const scopedLabels = $dropdown.data('scopedLabels');
const { handleClick } = options;
- $sidebarLabelTooltip.tooltip();
if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) {
new CreateLabelDropdown(
@@ -91,7 +89,6 @@ export default class LabelsSelect {
axios
.put(issueUpdateURL, data)
.then(({ data }) => {
- let labelTooltipTitle;
let template;
// eslint-disable-next-line no-jquery/no-fade
$loading.fadeOut();
@@ -151,23 +148,6 @@ export default class LabelsSelect {
$value.removeAttr('style').html(template);
$sidebarCollapsedValue.text(labelCount);
- if (data.labels.length) {
- let labelTitles = data.labels.map(label => label.title);
-
- if (labelTitles.length > 5) {
- labelTitles = labelTitles.slice(0, 5);
- labelTitles.push(
- sprintf(s__('Labels|and %{count} more'), { count: data.labels.length - 5 }),
- );
- }
-
- labelTooltipTitle = labelTitles.join(', ');
- } else {
- labelTooltipTitle = __('Labels');
- }
-
- $sidebarLabelTooltip.attr('title', labelTooltipTitle).tooltip('_fixTitle');
-
$('.has-tooltip', $value).tooltip({
container: 'body',
});
diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js
index d7f5e6f8a5e..4d2955a8d3d 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -16,6 +16,15 @@ function initDeferred() {
const whatsNewTriggerEl = document.querySelector('.js-whats-new-trigger');
if (whatsNewTriggerEl) {
+ const storageKey = whatsNewTriggerEl.getAttribute('data-storage-key');
+
+ $('.header-help').on('show.bs.dropdown', () => {
+ const displayNotification = JSON.parse(localStorage.getItem(storageKey));
+ if (displayNotification === false) {
+ $('.js-whats-new-notification-count').remove();
+ }
+ });
+
whatsNewTriggerEl.addEventListener('click', () => {
import(/* webpackChunkName: 'whatsNewApp' */ '~/whats_new')
.then(({ default: initWhatsNew }) => {
diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js
new file mode 100644
index 00000000000..d9ea57fbbce
--- /dev/null
+++ b/app/assets/javascripts/lib/dompurify.js
@@ -0,0 +1,53 @@
+import { sanitize as dompurifySanitize, addHook } from 'dompurify';
+import { getBaseURL, relativePathToAbsolute } from '~/lib/utils/url_utility';
+
+// Safely allow SVG <use> tags
+
+const defaultConfig = {
+ ADD_TAGS: ['use'],
+};
+
+// Only icons urls from `gon` are allowed
+const getAllowedIconUrls = (gon = window.gon) =>
+ [gon.sprite_file_icons, gon.sprite_icons].filter(Boolean);
+
+const isUrlAllowed = url => getAllowedIconUrls().some(allowedUrl => url.startsWith(allowedUrl));
+
+const isHrefSafe = url =>
+ isUrlAllowed(url) || isUrlAllowed(relativePathToAbsolute(url, getBaseURL()));
+
+const removeUnsafeHref = (node, attr) => {
+ if (!node.hasAttribute(attr)) {
+ return;
+ }
+
+ if (!isHrefSafe(node.getAttribute(attr))) {
+ node.removeAttribute(attr);
+ }
+};
+
+/**
+ * Sanitize icons' <use> tag attributes, to safely include
+ * svgs such as in:
+ *
+ * <svg viewBox="0 0 100 100">
+ * <use href="/assets/icons-xxx.svg#icon_name"></use>
+ * </svg>
+ *
+ * @param {Object} node - Node to sanitize
+ */
+const sanitizeSvgIcon = node => {
+ removeUnsafeHref(node, 'href');
+
+ // Note: `xlink:href` is deprecated, but still in use
+ // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href
+ removeUnsafeHref(node, 'xlink:href');
+};
+
+addHook('afterSanitizeAttributes', node => {
+ if (node.tagName.toLowerCase() === 'use') {
+ sanitizeSvgIcon(node);
+ }
+});
+
+export const sanitize = (val, config = defaultConfig) => dompurifySanitize(val, config);
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index d2907f401c0..0e07f7d8e44 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -31,6 +31,7 @@ export default (resolvers = {}, config = {}) => {
// We set to `same-origin` which is default value in modern browsers.
// See https://github.com/whatwg/fetch/pull/585 for more information.
credentials: 'same-origin',
+ batchMax: config.batchMax || 10,
};
const uploadsLink = ApolloLink.split(
diff --git a/app/assets/javascripts/lib/utils/axios_startup_calls.js b/app/assets/javascripts/lib/utils/axios_startup_calls.js
index 7e2665b910c..7bb1da5aed5 100644
--- a/app/assets/javascripts/lib/utils/axios_startup_calls.js
+++ b/app/assets/javascripts/lib/utils/axios_startup_calls.js
@@ -7,7 +7,7 @@ const removeGitLabUrl = url => url.replace(gon.gitlab_url, '');
const getFullUrl = req => {
const url = removeGitLabUrl(req.url);
- return mergeUrlParams(req.params || {}, url);
+ return mergeUrlParams(req.params || {}, url, { sort: true });
};
const handleStartupCall = async ({ fetchCall }, req) => {
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index bcf302cc262..fe1ac00fd1d 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -44,6 +44,7 @@ export const checkPageAndAction = (page, action) => {
return pagePath === page && actionPath === action;
};
+export const isInIncidentPage = () => checkPageAndAction('incidents', 'show');
export const isInIssuePage = () => checkPageAndAction('issues', 'show');
export const isInMRPage = () => checkPageAndAction('merge_requests', 'show');
export const isInEpicPage = () => checkPageAndAction('epics', 'show');
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index 993d51370ec..1a4ecc12f01 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -1,4 +1,5 @@
export const BYTES_IN_KIB = 1024;
+export const BYTES_IN_KB = 1000;
export const HIDDEN_CLASS = 'hidden';
export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80;
export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12;
diff --git a/app/assets/javascripts/lib/utils/csrf.js b/app/assets/javascripts/lib/utils/csrf.js
index ca9828c4682..3114a2a0dfb 100644
--- a/app/assets/javascripts/lib/utils/csrf.js
+++ b/app/assets/javascripts/lib/utils/csrf.js
@@ -1,5 +1,3 @@
-import $ from 'jquery';
-
/*
This module provides easy access to the CSRF token and caches
it for re-use. It also exposes some values commonly used in relation
@@ -20,7 +18,6 @@ If you need to compose a headers object, use the spread operator:
see also http://guides.rubyonrails.org/security.html#cross-site-request-forgery-csrf
and https://github.com/rails/jquery-rails/blob/v4.3.1/vendor/assets/javascripts/jquery_ujs.js#L59-L62
*/
-
const csrf = {
init() {
const tokenEl = document.querySelector('meta[name=csrf-token]');
@@ -52,9 +49,4 @@ const csrf = {
csrf.init();
-// use our cached token for any $.rails-generated AJAX requests
-if ($.rails) {
- $.rails.csrfToken = () => csrf.token;
-}
-
export default csrf;
diff --git a/app/assets/javascripts/lib/utils/css_utils.js b/app/assets/javascripts/lib/utils/css_utils.js
new file mode 100644
index 00000000000..90213221443
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/css_utils.js
@@ -0,0 +1,19 @@
+export function loadCSSFile(path) {
+ return new Promise(resolve => {
+ if (document.querySelector(`link[href="${path}"]`)) {
+ resolve();
+ } else {
+ const linkElement = document.createElement('link');
+ linkElement.type = 'text/css';
+ linkElement.rel = 'stylesheet';
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ linkElement.media = 'screen,print';
+ linkElement.onload = () => {
+ resolve();
+ };
+ linkElement.href = path;
+
+ document.head.appendChild(linkElement);
+ }
+ });
+}
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index b193a8b2c9a..6e78dc87c02 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -86,6 +86,21 @@ export const getDayName = date =>
][date.getDay()];
/**
+ * Returns the i18n month name from a given date
+ * @example
+ * formatDateAsMonth(new Date('2020-06-28')) -> 'Jun'
+ * @param {String} datetime where month is extracted from
+ * @param {Object} options
+ * @param {Boolean} options.abbreviated whether to use the abbreviated month string, or not
+ * @return {String} the i18n month name
+ */
+export function formatDateAsMonth(datetime, options = {}) {
+ const { abbreviated = true } = options;
+ const month = new Date(datetime).getMonth();
+ return getMonthNames(abbreviated)[month];
+}
+
+/**
* @example
* dateFormat('2017-12-05','mmm d, yyyy h:MMtt Z' ) -> "Dec 5, 2017 12:00am GMT+0000"
* @param {date} datetime
@@ -730,6 +745,21 @@ export const differenceInSeconds = (startDate, endDate) => {
};
/**
+ * A utility function which computes the difference in months
+ * between 2 dates.
+ *
+ * @param {Date} startDate the start date
+ * @param {Date} endDate the end date
+ *
+ * @return {Int} the difference in months
+ */
+export const differenceInMonths = (startDate, endDate) => {
+ const yearDiff = endDate.getYear() - startDate.getYear();
+ const monthDiff = endDate.getMonth() - startDate.getMonth();
+ return monthDiff + 12 * yearDiff;
+};
+
+/**
* A utility function which computes the difference in milliseconds
* between 2 dates.
*
@@ -743,3 +773,22 @@ export const differenceInMilliseconds = (startDate, endDate = Date.now()) => {
const endDateInMS = endDate instanceof Date ? endDate.getTime() : endDate;
return endDateInMS - startDateInMS;
};
+
+/**
+ * A utility which returns a new date at the first day of the month for any given date.
+ *
+ * @param {Date} date
+ *
+ * @return {Date} the date at the first day of the month
+ */
+export const dateAtFirstDayOfMonth = date => new Date(newDate(date).setDate(1));
+
+/**
+ * A utility function which checks if two dates match.
+ *
+ * @param {Date|Int} date1 Can be either a date object or a unix timestamp.
+ * @param {Date|Int} date2 Can be either a date object or a unix timestamp.
+ *
+ * @return {Boolean} true if the dates match
+ */
+export const datesMatch = (date1, date2) => differenceInMilliseconds(date1, date2) === 0;
diff --git a/app/assets/javascripts/lib/utils/experimentation.js b/app/assets/javascripts/lib/utils/experimentation.js
new file mode 100644
index 00000000000..555e76055e0
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/experimentation.js
@@ -0,0 +1,3 @@
+export function isExperimentEnabled(experimentKey) {
+ return Boolean(window.gon?.experiments?.[experimentKey]);
+}
diff --git a/app/assets/javascripts/lib/utils/highlight.js b/app/assets/javascripts/lib/utils/highlight.js
index 32553af9af3..8fa8af670b3 100644
--- a/app/assets/javascripts/lib/utils/highlight.js
+++ b/app/assets/javascripts/lib/utils/highlight.js
@@ -1,5 +1,5 @@
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import { sanitize } from 'dompurify';
+import { sanitize } from '~/lib/dompurify';
/**
* Wraps substring matches with HTML `<span>` elements.
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index bc87232f40b..2424d6cbf3b 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -1,4 +1,4 @@
-import { BYTES_IN_KIB } from './constants';
+import { BYTES_IN_KIB, BYTES_IN_KB } from './constants';
import { sprintf, __ } from '~/locale';
/**
@@ -35,6 +35,18 @@ export function formatRelevantDigits(number) {
}
/**
+ * Utility function that calculates KB of the given bytes.
+ * Note: This method calculates KiloBytes as opposed to
+ * Kibibytes. For Kibibytes, bytesToKiB should be used.
+ *
+ * @param {Number} number bytes
+ * @return {Number} KiB
+ */
+export function bytesToKB(number) {
+ return number / BYTES_IN_KB;
+}
+
+/**
* Utility function that calculates KiB of the given bytes.
*
* @param {Number} number bytes
diff --git a/app/assets/javascripts/lib/utils/rails_ujs.js b/app/assets/javascripts/lib/utils/rails_ujs.js
new file mode 100644
index 00000000000..8b40cc7bd11
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/rails_ujs.js
@@ -0,0 +1,20 @@
+import Rails from '@rails/ujs';
+
+export const initRails = () => {
+ // eslint-disable-next-line no-underscore-dangle
+ if (!window._rails_loaded) {
+ Rails.start();
+
+ // Count XHR requests for tests. See spec/support/helpers/wait_for_requests.rb
+ window.pendingRailsUJSRequests = 0;
+ document.body.addEventListener('ajax:complete', () => {
+ window.pendingRailsUJSRequests -= 1;
+ });
+
+ document.body.addEventListener('ajax:beforeSend', () => {
+ window.pendingRailsUJSRequests += 1;
+ });
+ }
+};
+
+export { Rails };
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index f4c6e4e3584..dfb86787788 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -42,9 +42,7 @@ function convertMonacoSelectionToAceFormat(sel) {
}
function getEditorSelectionRange(editor) {
- return window.gon.features?.monacoBlobs
- ? convertMonacoSelectionToAceFormat(editor.getSelection())
- : editor.getSelectionRange();
+ return convertMonacoSelectionToAceFormat(editor.getSelection());
}
function editorBlockTagText(text, blockTag, selected, editor) {
@@ -56,9 +54,6 @@ function editorBlockTagText(text, blockTag, selected, editor) {
if (shouldRemoveBlock) {
if (blockTag !== null) {
- // ace is globally defined
- // eslint-disable-next-line no-undef
- const { Range } = ace.require('ace/range');
const lastLine = lines[selectionRange.end.row + 1];
const rangeWithBlockTags = new Range(
lines[selectionRange.start.row - 1],
@@ -110,12 +105,7 @@ function moveCursor({
const endPosition = startPosition + select.length;
return textArea.setSelectionRange(startPosition, endPosition);
} else if (editor) {
- if (window.gon.features?.monacoBlobs) {
- editor.selectWithinSelection(select, tag);
- } else {
- editor.navigateLeft(tag.length - tag.indexOf(select));
- editor.getSelection().selectAWord();
- }
+ editor.selectWithinSelection(select, tag);
return;
}
}
@@ -139,11 +129,7 @@ function moveCursor({
}
} else if (editor && editorSelectionStart.row === editorSelectionEnd.row) {
if (positionBetweenTags) {
- if (window.gon.features?.monacoBlobs) {
- editor.moveCursor(tag.length * -1);
- } else {
- editor.navigateLeft(tag.length);
- }
+ editor.moveCursor(tag.length * -1);
}
}
}
@@ -166,6 +152,7 @@ export function insertMarkdownText({
let editorSelectionEnd;
let lastNewLine;
let textToInsert;
+ selected = selected.toString();
if (editor) {
const selectionRange = getEditorSelectionRange(editor);
@@ -265,11 +252,7 @@ export function insertMarkdownText({
}
if (editor) {
- if (window.gon.features?.monacoBlobs) {
- editor.replaceSelectedText(textToInsert, select);
- } else {
- editor.insert(textToInsert);
- }
+ editor.replaceSelectedText(textToInsert, select);
} else {
insertText(textArea, textToInsert);
}
diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js
index adf374db66c..9f979f7ea4b 100644
--- a/app/assets/javascripts/lib/utils/unit_format/index.js
+++ b/app/assets/javascripts/lib/utils/unit_format/index.js
@@ -61,8 +61,8 @@ export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => {
* @function
* @param {Number} value - Number to format
* @param {Number} fractionDigits - precision decimals
- * @param {Number} maxLength - Max lenght of formatted number
- * if lenght is exceeded, exponential format is used.
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
*/
return numberFormatter();
}
@@ -73,8 +73,8 @@ export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => {
* @function
* @param {Number} value - Number to format, `1` is rendered as `100%`
* @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max lenght of formatted number
- * if lenght is exceeded, exponential format is used.
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
*/
return numberFormatter('percent');
}
@@ -85,8 +85,8 @@ export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => {
* @function
* @param {Number} value - Number to format, `100` is rendered as `100%`
* @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max lenght of formatted number
- * if lenght is exceeded, exponential format is used.
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
*/
return numberFormatter('percent', 1 / 100);
}
@@ -100,8 +100,8 @@ export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => {
* @function
* @param {Number} value - Number to format, `1` is rendered as `1s`
* @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max lenght of formatted number
- * if lenght is exceeded, exponential format is used.
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
*/
return suffixFormatter(s__('Units|s'));
}
@@ -112,8 +112,8 @@ export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => {
* @function
* @param {Number} value - Number to format, `1` is formatted as `1ms`
* @param {Number} fractionDigits - number of precision decimals
- * @param {Number} maxLength - Max lenght of formatted number
- * if lenght is exceeded, exponential format is used.
+ * @param {Number} maxLength - Max length of formatted number
+ * if length is exceeded, exponential format is used.
*/
return suffixFormatter(s__('Units|ms'));
}
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index e9c3fe0a406..a9f6901de32 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -16,7 +16,7 @@ function decodeUrlParameter(val) {
return decodeURIComponent(val.replace(/\+/g, '%20'));
}
-function cleanLeadingSeparator(path) {
+export function cleanLeadingSeparator(path) {
return path.replace(PATH_SEPARATOR_LEADING_REGEX, '');
}
@@ -73,6 +73,7 @@ export function getParameterValues(sParam, url = window.location) {
* @param {String} url
* @param {Object} options
* @param {Boolean} options.spreadArrays - split array values into separate key/value-pairs
+ * @param {Boolean} options.sort - alphabetically sort params in the returned url (in asc order, i.e., a-z)
*/
export function mergeUrlParams(params, url, options = {}) {
const { spreadArrays = false, sort = false } = options;
@@ -255,6 +256,15 @@ export function getBaseURL() {
}
/**
+ * Takes a URL and returns content from the start until the final '/'
+ *
+ * @param {String} url - full url, including protocol and host
+ */
+export function stripFinalUrlSegment(url) {
+ return new URL('.', url).href;
+}
+
+/**
* Returns true if url is an absolute URL
*
* @param {String} url
@@ -282,6 +292,15 @@ export function isBase64DataUrl(url) {
}
/**
+ * Returns true if url is a blob: type url
+ *
+ * @param {String} url
+ */
+export function isBlobUrl(url) {
+ return /^blob:/.test(url);
+}
+
+/**
* Returns true if url is an absolute or root-relative URL
*
* @param {String} url
@@ -434,3 +453,24 @@ export function getHTTPProtocol(url) {
const protocol = url.split(':');
return protocol.length > 1 ? protocol[0] : undefined;
}
+
+/**
+ * Strips the filename from the given path by removing every non-slash character from the end of the
+ * passed parameter.
+ * @param {string} path
+ */
+export function stripPathTail(path = '') {
+ return path.replace(/[^/]+$/, '');
+}
+
+export function getURLOrigin(url) {
+ if (!url) {
+ return window.location.origin;
+ }
+
+ try {
+ return new URL(url).origin;
+ } catch (e) {
+ return null;
+ }
+}
diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue
index 97b96cb5839..f7c0bd5ae13 100644
--- a/app/assets/javascripts/logs/components/environment_logs.vue
+++ b/app/assets/javascripts/logs/components/environment_logs.vue
@@ -3,12 +3,11 @@ import { throttle } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import {
GlSprintf,
- GlIcon,
GlAlert,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownHeader,
- GlDeprecatedDropdownItem,
- GlDeprecatedDropdownDivider,
+ GlDropdown,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ GlDropdownDivider,
GlInfiniteScroll,
} from '@gitlab/ui';
@@ -23,12 +22,11 @@ import { formatDate } from '../utils';
export default {
components: {
GlSprintf,
- GlIcon,
GlAlert,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownHeader,
- GlDeprecatedDropdownItem,
- GlDeprecatedDropdownDivider,
+ GlDropdown,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ GlDropdownDivider,
GlInfiniteScroll,
LogSimpleFilters,
LogAdvancedFilters,
@@ -174,46 +172,38 @@ export default {
<div class="top-bar d-md-flex border bg-secondary-50 pt-2 pr-1 pb-0 pl-2">
<div class="flex-grow-0">
- <gl-deprecated-dropdown
+ <gl-dropdown
id="environments-dropdown"
:text="environments.current || managedApps.current"
:disabled="environments.isLoading"
- class="mb-2 gl-h-32 pr-2 d-flex d-md-block js-environments-dropdown"
+ class="gl-mr-3 gl-mb-3 gl-display-flex gl-display-md-block js-environments-dropdown"
>
- <gl-deprecated-dropdown-header class="gl-text-center">
+ <gl-dropdown-section-header>
{{ s__('Environments|Environments') }}
- </gl-deprecated-dropdown-header>
- <gl-deprecated-dropdown-item
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
v-for="env in environments.options"
:key="env.id"
+ :is-check-item="true"
+ :is-checked="isCurrentEnvironment(env.name)"
@click="showEnvironment(env.name)"
>
- <div class="d-flex">
- <gl-icon
- :class="{ invisible: !isCurrentEnvironment(env.name) }"
- name="status_success_borderless"
- />
- <div class="gl-flex-grow-1">{{ env.name }}</div>
- </div>
- </gl-deprecated-dropdown-item>
- <gl-deprecated-dropdown-divider />
- <gl-deprecated-dropdown-header class="gl-text-center">
+ {{ env.name }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-section-header>
{{ s__('Environments|Managed apps') }}
- </gl-deprecated-dropdown-header>
- <gl-deprecated-dropdown-item
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
v-for="app in managedApps.options"
:key="app.id"
+ :is-check-item="true"
+ :is-checked="isCurrentManagedApp(app.name)"
@click="showManagedApp(app.name)"
>
- <div class="gl-display-flex">
- <gl-icon
- :class="{ invisible: !isCurrentManagedApp(app.name) }"
- name="status_success_borderless"
- />
- <div class="gl-flex-grow-1">{{ app.name }}</div>
- </div>
- </gl-deprecated-dropdown-item>
- </gl-deprecated-dropdown>
+ {{ app.name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
<log-advanced-filters
diff --git a/app/assets/javascripts/logs/components/log_simple_filters.vue b/app/assets/javascripts/logs/components/log_simple_filters.vue
index 2e1270b5428..ba30d4628c9 100644
--- a/app/assets/javascripts/logs/components/log_simple_filters.vue
+++ b/app/assets/javascripts/logs/components/log_simple_filters.vue
@@ -1,19 +1,13 @@
<script>
import { mapActions, mapState } from 'vuex';
-import {
- GlIcon,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownHeader,
- GlDeprecatedDropdownItem,
-} from '@gitlab/ui';
+import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
components: {
- GlIcon,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownHeader,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
},
props: {
disabled: {
@@ -44,35 +38,31 @@ export default {
</script>
<template>
<div>
- <gl-deprecated-dropdown
+ <gl-dropdown
ref="podsDropdown"
:text="podDropdownText"
:disabled="disabled"
- class="mb-2 gl-h-32 pr-2 d-flex d-md-block flex-grow-0 qa-pods-dropdown"
+ class="gl-mr-3 gl-mb-3 gl-display-flex gl-display-md-block qa-pods-dropdown"
>
- <gl-deprecated-dropdown-header class="text-center">
+ <gl-dropdown-section-header>
{{ s__('Environments|Select pod') }}
- </gl-deprecated-dropdown-header>
+ </gl-dropdown-section-header>
- <gl-deprecated-dropdown-item v-if="!pods.options.length" disabled>
+ <gl-dropdown-item v-if="!pods.options.length" disabled>
<span ref="noPodsMsg" class="text-muted">
{{ s__('Environments|No pods to display') }}
</span>
- </gl-deprecated-dropdown-item>
- <gl-deprecated-dropdown-item
+ </gl-dropdown-item>
+ <gl-dropdown-item
v-for="podName in pods.options"
:key="podName"
+ :is-check-item="true"
+ :is-checked="isCurrentPod(podName)"
class="text-nowrap"
@click="showPodLogs(podName)"
>
- <div class="d-flex">
- <gl-icon
- :class="{ invisible: !isCurrentPod(podName) }"
- name="status_success_borderless"
- />
- <div class="flex-grow-1">{{ podName }}</div>
- </div>
- </gl-deprecated-dropdown-item>
- </gl-deprecated-dropdown>
+ {{ podName }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 9fcf881a1ac..d60f949c49d 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -11,6 +11,7 @@ import './behaviors';
// lib/utils
import applyGitLabUIConfig from '@gitlab/ui/dist/config';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import { initRails } from '~/lib/utils/rails_ujs';
import {
handleLocationHash,
addSelectOnFocusBehaviour,
@@ -38,6 +39,8 @@ import initPersistentUserCallouts from './persistent_user_callouts';
import { initUserTracking, initDefaultTrackers } from './tracking';
import { __ } from './locale';
+import * as tooltips from '~/tooltips';
+
import 'ee_else_ce/main_ee';
applyGitLabUIConfig();
@@ -76,7 +79,7 @@ document.addEventListener('beforeunload', () => {
// Unbind scroll events
$(document).off('scroll');
// Close any open tooltips
- $('.has-tooltip, [data-toggle="tooltip"]').tooltip('dispose');
+ tooltips.dispose(document.querySelectorAll('.has-tooltip, [data-toggle="tooltip"]'));
// Close any open popover
$('[data-toggle="popover"]').popover('dispose');
});
@@ -96,6 +99,8 @@ gl.lazyLoader = new LazyLoader({
observerNode: '#content-body',
});
+initRails();
+
// Put all initialisations here that can also wait after everything is rendered and ready
function deferredInitialisation() {
const $body = $('body');
@@ -130,8 +135,10 @@ function deferredInitialisation() {
addSelectOnFocusBehaviour('.js-select-on-focus');
$('.remove-row').on('ajax:success', function removeRowAjaxSuccessCallback() {
+ tooltips.dispose(this);
+
+ // eslint-disable-next-line no-jquery/no-fade
$(this)
- .tooltip('dispose')
.closest('li')
.fadeOut();
});
@@ -151,7 +158,7 @@ function deferredInitialisation() {
const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0;
// Initialize tooltips
- $body.tooltip({
+ tooltips.initTooltips({
selector: '.has-tooltip, [data-toggle="tooltip"]',
trigger: 'hover',
boundary: 'viewport',
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
index c3fbb5d6acf..6dd4018f87a 100644
--- a/app/assets/javascripts/members.js
+++ b/app/assets/javascripts/members.js
@@ -1,6 +1,8 @@
import $ from 'jquery';
+import { Rails } from '~/lib/utils/rails_ujs';
import { disableButtonIfEmptyField } from '~/lib/utils/common_utils';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
+import { __, sprintf } from '~/locale';
export default class Members {
constructor() {
@@ -54,15 +56,43 @@ export default class Members {
formSubmit(e, $el = null) {
const $this = e ? $(e.currentTarget) : $el;
const { $toggle, $dateInput } = this.getMemberListItems($this);
+ const formEl = $this.closest('form').get(0);
- $this.closest('form').trigger('submit.rails');
+ Rails.fire(formEl, 'submit');
$toggle.disable();
$dateInput.disable();
}
formSuccess(e) {
- const { $toggle, $dateInput } = this.getMemberListItems($(e.currentTarget).closest('.member'));
+ const { $toggle, $dateInput, $expiresIn, $expiresInText } = this.getMemberListItems(
+ $(e.currentTarget).closest('.js-member'),
+ );
+
+ const [data] = e.detail;
+ const expiresIn = data?.expires_in;
+
+ if (expiresIn) {
+ $expiresIn.removeClass('gl-display-none');
+
+ $expiresInText.text(sprintf(__('Expires in %{expires_at}'), { expires_at: expiresIn }));
+
+ const { expires_soon: expiresSoon, expires_at_formatted: expiresAtFormatted } = data;
+
+ if (expiresSoon) {
+ $expiresInText.addClass('text-warning');
+ } else {
+ $expiresInText.removeClass('text-warning');
+ }
+
+ // Update tooltip
+ if (expiresAtFormatted) {
+ $expiresInText.attr('title', expiresAtFormatted);
+ $expiresInText.attr('data-original-title', expiresAtFormatted);
+ }
+ } else {
+ $expiresIn.addClass('gl-display-none');
+ }
$toggle.enable();
$dateInput.enable();
@@ -70,10 +100,12 @@ export default class Members {
// eslint-disable-next-line class-methods-use-this
getMemberListItems($el) {
- const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('elId')}`);
+ const $memberListItem = $el.is('.js-member') ? $el : $(`#${$el.data('elId')}`);
return {
$memberListItem,
+ $expiresIn: $memberListItem.find('.js-expires-in'),
+ $expiresInText: $memberListItem.find('.js-expires-in-text'),
$toggle: $memberListItem.find('.dropdown-menu-toggle'),
$dateInput: $memberListItem.find('.js-access-expiration-date'),
};
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 79a4c3700ef..fe4e2cee69f 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -3,11 +3,12 @@
import $ from 'jquery';
import axios from './lib/utils/axios_utils';
import { __ } from '~/locale';
+import eventHub from '~/vue_merge_request_widget/event_hub';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import TaskList from './task_list';
import MergeRequestTabs from './merge_request_tabs';
-import IssuablesHelper from './helpers/issuables_helper';
import { addDelimiter } from './lib/utils/text_utility';
+import { getParameterValues, setUrlParams } from './lib/utils/url_utility';
function MergeRequest(opts) {
// Initialize MergeRequest behavior
@@ -23,7 +24,6 @@ function MergeRequest(opts) {
this.initTabs();
this.initMRBtnListeners();
this.initCommitMessageListeners();
- this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport();
if ($('.description.js-task-list-container').length) {
this.taskList = new TaskList({
@@ -66,13 +66,38 @@ MergeRequest.prototype.showAllCommits = function() {
MergeRequest.prototype.initMRBtnListeners = function() {
const _this = this;
+ const draftToggles = document.querySelectorAll('.js-draft-toggle-button');
- $('.report-abuse-link').on('click', e => {
- // this is needed because of the implementation of
- // the dropdown toggle and Report Abuse needing to be
- // linked to another page.
- e.stopPropagation();
- });
+ if (draftToggles.length) {
+ draftToggles.forEach(draftToggle => {
+ draftToggle.addEventListener('click', e => {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+
+ const url = draftToggle.href;
+ const wipEvent = getParameterValues('merge_request[wip_event]', url)[0];
+ const mobileDropdown = draftToggle.closest('.dropdown.show');
+
+ if (mobileDropdown) {
+ $(mobileDropdown.firstElementChild).dropdown('toggle');
+ }
+
+ draftToggle.setAttribute('disabled', 'disabled');
+
+ axios
+ .put(draftToggle.href, null, { params: { format: 'json' } })
+ .then(({ data }) => {
+ draftToggle.removeAttribute('disabled');
+ eventHub.$emit('MRWidgetUpdateRequested');
+ MergeRequest.toggleDraftStatus(data.title, wipEvent === 'unwip');
+ })
+ .catch(() => {
+ draftToggle.removeAttribute('disabled');
+ createFlash(__('Something went wrong. Please try again.'));
+ });
+ });
+ });
+ }
return $('.btn-close, .btn-reopen').on('click', function(e) {
const $this = $(this);
@@ -89,8 +114,6 @@ MergeRequest.prototype.initMRBtnListeners = function() {
return;
}
- if (this.closeReopenReportToggle) this.closeReopenReportToggle.setDisable();
-
if (shouldSubmit) {
if ($this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen')) {
e.preventDefault();
@@ -151,14 +174,35 @@ MergeRequest.hideCloseButton = function() {
const closeDropdownItem = el.querySelector('li.close-item');
if (closeDropdownItem) {
closeDropdownItem.classList.add('hidden');
- // Selects the next dropdown item
- el.querySelector('li.report-item').click();
- } else {
- // No dropdown just hide the Close button
- el.querySelector('.btn-close').classList.add('hidden');
}
// Dropdown for mobile screen
el.querySelector('li.js-close-item').classList.add('hidden');
};
+MergeRequest.toggleDraftStatus = function(title, isReady) {
+ if (isReady) {
+ createFlash(__('The merge request can now be merged.'), 'notice');
+ }
+ const titleEl = document.querySelector('.merge-request .detail-page-description .title');
+
+ if (titleEl) {
+ titleEl.textContent = title;
+ }
+
+ const draftToggles = document.querySelectorAll('.js-draft-toggle-button');
+
+ if (draftToggles.length) {
+ draftToggles.forEach(el => {
+ const draftToggle = el;
+ const url = setUrlParams(
+ { 'merge_request[wip_event]': isReady ? 'wip' : 'unwip' },
+ draftToggle.href,
+ );
+
+ draftToggle.setAttribute('href', url);
+ draftToggle.textContent = isReady ? __('Mark as draft') : __('Mark as ready');
+ });
+ }
+};
+
export default MergeRequest;
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index b7cf39db00c..bdcdabe8f78 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -396,10 +396,6 @@ export default class MergeRequestTabs {
initChangesDropdown(this.stickyTop);
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
- }
-
localTimeAgo($('.js-timeago', 'div#diffs'));
syntaxHighlight($('#diffs .js-syntax-highlight'));
@@ -482,13 +478,14 @@ export default class MergeRequestTabs {
}
shrinkView() {
- const $gutterIcon = $('.js-sidebar-toggle i:visible');
+ const $gutterBtn = $('.js-sidebar-toggle:visible');
+ const $expandSvg = $gutterBtn.find('.js-sidebar-expand');
// Wait until listeners are set
setTimeout(() => {
// Only when sidebar is expanded
- if ($gutterIcon.is('.fa-angle-double-right')) {
- $gutterIcon.closest('a').trigger('click', [true]);
+ if ($expandSvg.length && $expandSvg.hasClass('hidden')) {
+ $gutterBtn.trigger('click', [true]);
}
}, 0);
}
@@ -498,13 +495,14 @@ export default class MergeRequestTabs {
if (parseBoolean(Cookies.get('collapsed_gutter'))) {
return;
}
- const $gutterIcon = $('.js-sidebar-toggle i:visible');
+ const $gutterBtn = $('.js-sidebar-toggle');
+ const $collapseSvg = $gutterBtn.find('.js-sidebar-collapse');
// Wait until listeners are set
setTimeout(() => {
// Only when sidebar is collapsed
- if ($gutterIcon.is('.fa-angle-double-left')) {
- $gutterIcon.closest('a').trigger('click', [true]);
+ if ($collapseSvg.length && !$collapseSvg.hasClass('hidden')) {
+ $gutterBtn.trigger('click', [true]);
}
}, 0);
}
diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js
index 20d9fb82554..52e9b67c77d 100644
--- a/app/assets/javascripts/milestone.js
+++ b/app/assets/javascripts/milestone.js
@@ -7,11 +7,6 @@ import { __ } from './locale';
export default class Milestone {
constructor() {
this.bindTabsSwitching();
-
- // Load merge request tab if it is active
- // merge request tab is active based on different conditions in the backend
- this.loadTab($('.js-milestone-tabs .active a'));
-
this.loadInitialTab();
}
@@ -23,12 +18,14 @@ export default class Milestone {
this.loadTab($target);
});
}
- // eslint-disable-next-line class-methods-use-this
+
loadInitialTab() {
- const $target = $(`.js-milestone-tabs a[href="${window.location.hash}"]`);
+ const $target = $(`.js-milestone-tabs a:not(.active)[href="${window.location.hash}"]`);
if ($target.length) {
$target.tab('show');
+ } else {
+ this.loadTab($('.js-milestone-tabs a.active'));
}
}
// eslint-disable-next-line class-methods-use-this
diff --git a/app/assets/javascripts/milestones/project_milestone_combobox.vue b/app/assets/javascripts/milestones/project_milestone_combobox.vue
index 5ee917573ce..0fa5585e858 100644
--- a/app/assets/javascripts/milestones/project_milestone_combobox.vue
+++ b/app/assets/javascripts/milestones/project_milestone_combobox.vue
@@ -205,7 +205,6 @@ export default {
<gl-search-box-by-type
ref="searchBox"
v-model.trim="searchQuery"
- class="gl-m-3"
:placeholder="this.$options.translations.searchMilestones"
@input="onSearchBoxInput"
@keydown.enter.prevent="onSearchBoxEnter"
diff --git a/app/assets/javascripts/milestones/stores/actions.js b/app/assets/javascripts/milestones/stores/actions.js
new file mode 100644
index 00000000000..3859771aeba
--- /dev/null
+++ b/app/assets/javascripts/milestones/stores/actions.js
@@ -0,0 +1,58 @@
+import Api from '~/api';
+import * as types from './mutation_types';
+
+export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId);
+
+export const setSelectedMilestones = ({ commit }, selectedMilestones) =>
+ commit(types.SET_SELECTED_MILESTONES, selectedMilestones);
+
+export const toggleMilestones = ({ commit, state }, selectedMilestone) => {
+ const removeMilestone = state.selectedMilestones.includes(selectedMilestone);
+
+ if (removeMilestone) {
+ commit(types.REMOVE_SELECTED_MILESTONE, selectedMilestone);
+ } else {
+ commit(types.ADD_SELECTED_MILESTONE, selectedMilestone);
+ }
+};
+
+export const search = ({ dispatch, commit }, query) => {
+ commit(types.SET_QUERY, query);
+
+ dispatch('searchMilestones');
+};
+
+export const fetchMilestones = ({ commit, state }) => {
+ commit(types.REQUEST_START);
+
+ Api.projectMilestones(state.projectId)
+ .then(response => {
+ commit(types.RECEIVE_PROJECT_MILESTONES_SUCCESS, response);
+ })
+ .catch(error => {
+ commit(types.RECEIVE_PROJECT_MILESTONES_ERROR, error);
+ })
+ .finally(() => {
+ commit(types.REQUEST_FINISH);
+ });
+};
+
+export const searchMilestones = ({ commit, state }) => {
+ commit(types.REQUEST_START);
+
+ const options = {
+ search: state.query,
+ scope: 'milestones',
+ };
+
+ Api.projectSearch(state.projectId, options)
+ .then(response => {
+ commit(types.RECEIVE_PROJECT_MILESTONES_SUCCESS, response);
+ })
+ .catch(error => {
+ commit(types.RECEIVE_PROJECT_MILESTONES_ERROR, error);
+ })
+ .finally(() => {
+ commit(types.REQUEST_FINISH);
+ });
+};
diff --git a/app/assets/javascripts/milestones/stores/getters.js b/app/assets/javascripts/milestones/stores/getters.js
new file mode 100644
index 00000000000..d8a283403ec
--- /dev/null
+++ b/app/assets/javascripts/milestones/stores/getters.js
@@ -0,0 +1,2 @@
+/** Returns `true` if there is at least one in-progress request */
+export const isLoading = ({ requestCount }) => requestCount > 0;
diff --git a/app/assets/javascripts/registry/settings/store/index.js b/app/assets/javascripts/milestones/stores/index.js
index c2500454d8e..2bebffc19ab 100644
--- a/app/assets/javascripts/registry/settings/store/index.js
+++ b/app/assets/javascripts/milestones/stores/index.js
@@ -1,18 +1,16 @@
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
-import mutations from './mutations';
import * as getters from './getters';
-import state from './state';
+import mutations from './mutations';
+import createState from './state';
Vue.use(Vuex);
-export const createStore = () =>
+export default () =>
new Vuex.Store({
- state,
actions,
- mutations,
getters,
+ mutations,
+ state: createState(),
});
-
-export default createStore();
diff --git a/app/assets/javascripts/milestones/stores/mutation_types.js b/app/assets/javascripts/milestones/stores/mutation_types.js
new file mode 100644
index 00000000000..370d386dba2
--- /dev/null
+++ b/app/assets/javascripts/milestones/stores/mutation_types.js
@@ -0,0 +1,13 @@
+export const SET_PROJECT_ID = 'SET_PROJECT_ID';
+
+export const SET_SELECTED_MILESTONES = 'SET_SELECTED_MILESTONES';
+export const ADD_SELECTED_MILESTONE = 'ADD_SELECTED_MILESTONE';
+export const REMOVE_SELECTED_MILESTONE = 'REMOVE_SELECTED_MILESTONE';
+
+export const SET_QUERY = 'SET_QUERY';
+
+export const REQUEST_START = 'REQUEST_START';
+export const REQUEST_FINISH = 'REQUEST_FINISH';
+
+export const RECEIVE_PROJECT_MILESTONES_SUCCESS = 'RECEIVE_PROJECT_MILESTONES_SUCCESS';
+export const RECEIVE_PROJECT_MILESTONES_ERROR = 'RECEIVE_PROJECT_MILESTONES_ERROR';
diff --git a/app/assets/javascripts/milestones/stores/mutations.js b/app/assets/javascripts/milestones/stores/mutations.js
new file mode 100644
index 00000000000..7c75d09766c
--- /dev/null
+++ b/app/assets/javascripts/milestones/stores/mutations.js
@@ -0,0 +1,44 @@
+import Vue from 'vue';
+import * as types from './mutation_types';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+export default {
+ [types.SET_PROJECT_ID](state, projectId) {
+ state.projectId = projectId;
+ },
+ [types.SET_SELECTED_MILESTONES](state, selectedMilestones) {
+ Vue.set(state, 'selectedMilestones', selectedMilestones);
+ },
+ [types.ADD_SELECTED_MILESTONE](state, selectedMilestone) {
+ state.selectedMilestones.push(selectedMilestone);
+ },
+ [types.REMOVE_SELECTED_MILESTONE](state, selectedMilestone) {
+ const filteredMilestones = state.selectedMilestones.filter(
+ milestone => milestone !== selectedMilestone,
+ );
+ Vue.set(state, 'selectedMilestones', filteredMilestones);
+ },
+ [types.SET_QUERY](state, query) {
+ state.query = query;
+ },
+ [types.REQUEST_START](state) {
+ state.requestCount += 1;
+ },
+ [types.REQUEST_FINISH](state) {
+ state.requestCount -= 1;
+ },
+ [types.RECEIVE_PROJECT_MILESTONES_SUCCESS](state, response) {
+ state.matches.projectMilestones = {
+ list: convertObjectPropsToCamelCase(response.data).map(({ title }) => ({ title })),
+ totalCount: parseInt(response.headers['x-total'], 10),
+ error: null,
+ };
+ },
+ [types.RECEIVE_PROJECT_MILESTONES_ERROR](state, error) {
+ state.matches.projectMilestones = {
+ list: [],
+ totalCount: 0,
+ error,
+ };
+ },
+};
diff --git a/app/assets/javascripts/milestones/stores/state.js b/app/assets/javascripts/milestones/stores/state.js
new file mode 100644
index 00000000000..0944539f367
--- /dev/null
+++ b/app/assets/javascripts/milestones/stores/state.js
@@ -0,0 +1,14 @@
+export default () => ({
+ projectId: null,
+ groupId: null,
+ query: '',
+ matches: {
+ projectMilestones: {
+ list: [],
+ totalCount: 0,
+ error: null,
+ },
+ },
+ selectedMilestones: [],
+ requestCount: 0,
+});
diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js
index cc787613c52..818ca8aa847 100644
--- a/app/assets/javascripts/mirrors/mirror_repos.js
+++ b/app/assets/javascripts/mirrors/mirror_repos.js
@@ -4,6 +4,7 @@ import { __ } from '~/locale';
import { deprecatedCreateFlash as Flash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import SSHMirror from './ssh_mirror';
+import { hide } from '~/tooltips';
export default class MirrorRepos {
constructor(container) {
@@ -115,7 +116,7 @@ export default class MirrorRepos {
/* eslint-disable class-methods-use-this */
removeRow($target) {
const row = $target.closest('tr');
- $('.js-delete-mirror', row).tooltip('hide');
+ hide($('.js-delete-mirror', row));
row.remove();
}
/* eslint-enable class-methods-use-this */
diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
index 132df9c9516..6f29b34141d 100644
--- a/app/assets/javascripts/monitoring/components/alert_widget_form.vue
+++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
@@ -3,7 +3,7 @@ import { isEmpty, findKey } from 'lodash';
import Vue from 'vue';
import {
GlLink,
- GlDeprecatedButton,
+ GlButton,
GlButtonGroup,
GlFormGroup,
GlFormInput,
@@ -36,7 +36,7 @@ const SUBMIT_BUTTON_CLASS = {
export default {
components: {
- GlDeprecatedButton,
+ GlButton,
GlButtonGroup,
GlFormGroup,
GlFormInput,
@@ -267,30 +267,27 @@ export default {
</gl-dropdown>
</gl-form-group>
<gl-button-group class="mb-3" :label="s__('PrometheusAlerts|Operator')">
- <gl-deprecated-button
+ <gl-button
:class="{ active: operator === operators.greaterThan }"
:disabled="formDisabled"
- type="button"
@click="operator = operators.greaterThan"
>
{{ operators.greaterThan }}
- </gl-deprecated-button>
- <gl-deprecated-button
+ </gl-button>
+ <gl-button
:class="{ active: operator === operators.equalTo }"
:disabled="formDisabled"
- type="button"
@click="operator = operators.equalTo"
>
{{ operators.equalTo }}
- </gl-deprecated-button>
- <gl-deprecated-button
+ </gl-button>
+ <gl-button
:class="{ active: operator === operators.lessThan }"
:disabled="formDisabled"
- type="button"
@click="operator = operators.lessThan"
>
{{ operators.lessThan }}
- </gl-deprecated-button>
+ </gl-button>
</gl-button-group>
<gl-form-group :label="s__('PrometheusAlerts|Threshold')" label-for="alerts-threshold">
<gl-form-input
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
index e468728a954..0f6a9ce3814 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -192,7 +192,7 @@ export default {
>
<div class="d-flex flex-column overflow-hidden">
<gl-dropdown-section-header>{{ __('Environment') }}</gl-dropdown-section-header>
- <gl-search-box-by-type class="gl-m-3" @input="debouncedEnvironmentsSearch" />
+ <gl-search-box-by-type @input="debouncedEnvironmentsSearch" />
<gl-loading-icon v-if="environmentsLoading" :inline="true" />
<div v-else class="flex-fill overflow-auto">
diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
index 932efeaaf0e..1a349aa154a 100644
--- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
+++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
@@ -80,11 +80,7 @@ export default {
>
<div class="d-flex flex-column overflow-hidden">
<gl-dropdown-section-header>{{ __('Dashboard') }}</gl-dropdown-section-header>
- <gl-search-box-by-type
- ref="monitorDashboardsDropdownSearch"
- v-model="searchTerm"
- class="gl-m-3"
- />
+ <gl-search-box-by-type ref="monitorDashboardsDropdownSearch" v-model="searchTerm" />
<div class="flex-fill overflow-auto">
<gl-dropdown-item
diff --git a/app/assets/javascripts/monitoring/components/group_empty_state.vue b/app/assets/javascripts/monitoring/components/group_empty_state.vue
index 499823fae3f..0365fc66331 100644
--- a/app/assets/javascripts/monitoring/components/group_empty_state.vue
+++ b/app/assets/javascripts/monitoring/components/group_empty_state.vue
@@ -1,6 +1,5 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlEmptyState } from '@gitlab/ui';
+import { GlEmptyState, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { metricStates } from '../constants';
@@ -8,6 +7,9 @@ export default {
components: {
GlEmptyState,
},
+ directives: {
+ SafeHtml,
+ },
props: {
documentationPath: {
type: String,
@@ -100,7 +102,7 @@ export default {
:compact="true"
>
<template v-if="currentState.slottedDescription" #description>
- <div v-html="currentState.slottedDescription"></div>
+ <div v-safe-html="currentState.slottedDescription"></div>
</template>
</gl-empty-state>
</template>
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 16a685305dc..e7391a4c9d1 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -1,4 +1,4 @@
-import * as Sentry from '@sentry/browser';
+import * as Sentry from '~/sentry/wrapper';
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as createFlash } from '~/flash';
diff --git a/app/assets/javascripts/namespaces/leave_by_url.js b/app/assets/javascripts/namespaces/leave_by_url.js
index bf77617d516..7b15253d872 100644
--- a/app/assets/javascripts/namespaces/leave_by_url.js
+++ b/app/assets/javascripts/namespaces/leave_by_url.js
@@ -1,3 +1,4 @@
+import { initRails } from '~/lib/utils/rails_ujs';
import { deprecatedCreateFlash as Flash } from '~/flash';
import { __, sprintf } from '~/locale';
import { getParameterByName } from '~/lib/utils/common_utils';
@@ -11,6 +12,8 @@ export default function leaveByUrl(namespaceType) {
const param = getParameterByName(PARAMETER_NAME);
if (!param) return;
+ initRails();
+
const leaveLink = document.querySelector(LEAVE_LINK_SELECTOR);
if (leaveLink) {
leaveLink.click();
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 3bbaa44ec42..c04f2a2d465 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -1,8 +1,8 @@
<script>
/* eslint-disable vue/no-v-html */
import marked from 'marked';
-import { sanitize } from 'dompurify';
import katex from 'katex';
+import { sanitize } from '~/lib/dompurify';
import Prompt from './prompt.vue';
const renderer = new marked.Renderer();
diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue
index 856c8f31796..4d527baf730 100644
--- a/app/assets/javascripts/notebook/cells/output/html.vue
+++ b/app/assets/javascripts/notebook/cells/output/html.vue
@@ -1,6 +1,6 @@
<script>
/* eslint-disable vue/no-v-html */
-import { sanitize } from 'dompurify';
+import { sanitize } from '~/lib/dompurify';
import Prompt from '../prompt.vue';
export default {
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 340fbe4d887..37bb79defd1 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -479,11 +479,6 @@ export default class Notes {
row = form;
}
- const lineType = this.isParallelView() ? form.find('#line_type').val() : 'old';
- const diffAvatarContainer = row
- .prevAll('.line_holder')
- .first()
- .find(`.js-avatar-container.${lineType}_line`);
// is this the first note of discussion?
discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
if (!discussionContainer.length) {
@@ -519,12 +514,6 @@ export default class Notes {
Notes.animateAppendNote(noteEntity.html, discussionContainer);
}
- if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) {
- gl.diffNotesCompileComponents();
-
- this.renderDiscussionAvatar(diffAvatarContainer, noteEntity);
- }
-
localTimeAgo($('.js-timeago'), false);
Notes.checkMergeRequestStatus();
return this.updateNotesCount(1);
@@ -538,19 +527,6 @@ export default class Notes {
.get(0);
}
- renderDiscussionAvatar(diffAvatarContainer, noteEntity) {
- let avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders');
-
- if (!avatarHolder.length) {
- avatarHolder = document.createElement('diff-note-avatars');
- avatarHolder.setAttribute('discussion-id', noteEntity.discussion_id);
-
- diffAvatarContainer.append(avatarHolder);
-
- gl.diffNotesCompileComponents();
- }
- }
-
/**
* Called in response the main target form has been successfully submitted.
*
@@ -605,10 +581,6 @@ export default class Notes {
form.find('#note_type').val('');
form.find('#note_project_id').remove();
form.find('#in_reply_to_discussion_id').remove();
- form
- .find('.js-comment-resolve-button')
- .closest('comment-and-resolve-btn')
- .remove();
this.parentTimeline = form.parents('.timeline');
if (form.length) {
@@ -714,10 +686,6 @@ export default class Notes {
$note_li.replaceWith($noteEntityEl);
this.setupNewNote($noteEntityEl);
-
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
- }
}
checkContentToAllowEditing($el) {
@@ -844,12 +812,6 @@ export default class Notes {
const $notes = $note.closest('.discussion-notes');
const discussionId = $('.notes', $notes).data('discussionId');
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- if (gl.diffNoteApps[noteElId]) {
- gl.diffNoteApps[noteElId].$destroy();
- }
- }
-
$note.remove();
// check if this is the last note for this line
@@ -979,13 +941,6 @@ export default class Notes {
form.removeClass('js-main-target-form').addClass('discussion-form js-discussion-note-form');
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- const $commentBtn = form.find('comment-and-resolve-btn');
- $commentBtn.attr(':discussion-id', `'${discussionID}'`);
-
- gl.diffNotesCompileComponents();
- }
-
form.find('.js-note-text').focus();
form.find('.js-comment-resolve-button').attr('data-discussion-id', discussionID);
}
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 54fcf41ca50..cfdadbceaf6 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -371,6 +371,7 @@ export default {
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false"
+ :textarea-value="note"
>
<textarea
id="note-body"
@@ -380,7 +381,8 @@ export default {
dir="auto"
:disabled="isSubmitting"
name="note[note]"
- class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area qa-comment-input"
+ class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
+ data-qa-selector="comment_field"
data-supports-quick-actions="true"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@@ -425,7 +427,8 @@ export default {
>
<gl-button
:disabled="isSubmitButtonDisabled"
- class="js-comment-button js-comment-submit-button qa-comment-button"
+ class="js-comment-button js-comment-submit-button"
+ data-qa-selector="comment_button"
type="submit"
category="primary"
variant="success"
@@ -439,7 +442,8 @@ export default {
name="button"
category="primary"
variant="success"
- class="note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown"
+ class="note-type-toggle js-note-new-discussion dropdown-toggle"
+ data-qa-selector="note_dropdown"
data-display="static"
data-toggle="dropdown"
icon="chevron-down"
@@ -468,7 +472,10 @@ export default {
</li>
<li class="divider droplab-item-ignore"></li>
<li :class="{ 'droplab-item-selected': noteType === 'discussion' }">
- <button class="qa-discussion-option" @click.prevent="setNoteType('discussion')">
+ <button
+ data-qa-selector="discussion_menu_item"
+ @click.prevent="setNoteType('discussion')"
+ >
<i aria-hidden="true" class="fa fa-check icon"></i>
<div class="description">
<strong>{{ __('Start thread') }}</strong>
diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue
index 8e6c01ba63f..ee39a529345 100644
--- a/app/assets/javascripts/notes/components/diff_discussion_header.vue
+++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue
@@ -1,7 +1,7 @@
<script>
-/* eslint-disable vue/no-v-html */
import { mapActions } from 'vuex';
import { escape } from 'lodash';
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
import { truncateSha } from '~/lib/utils/text_utility';
@@ -17,6 +17,9 @@ export default {
noteEditedText,
noteHeader,
},
+ directives: {
+ SafeHtml,
+ },
props: {
discussion: {
type: Object,
@@ -113,7 +116,7 @@ export default {
:expanded="discussion.expanded"
@toggleHandler="toggleDiscussionHandler"
>
- <span v-html="headerText"></span>
+ <span v-safe-html="headerText"></span>
</note-header>
<note-edited-text
v-if="discussion.resolved"
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index c01cd8f8037..a4271852563 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -76,7 +76,7 @@ export default {
:discussion-path="discussion.discussion_path"
:diff-file="discussion.diff_file"
:can-current-user-fork="false"
- :expanded="!discussion.diff_file.viewer.collapsed"
+ :expanded="!discussion.diff_file.viewer.automaticallyCollapsed"
/>
<div v-if="isTextFile" class="diff-content">
<table class="code js-syntax-highlight" :class="$options.userColorSchemeClass">
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index c6fab271376..2427a3f98ad 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -1,6 +1,7 @@
<script>
import { mapGetters, mapActions } from 'vuex';
-import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlTooltipDirective, GlIcon, GlButton, GlButtonGroup } from '@gitlab/ui';
+import { __ } from '~/locale';
import discussionNavigation from '../mixins/discussion_navigation';
export default {
@@ -9,6 +10,8 @@ export default {
},
components: {
GlIcon,
+ GlButton,
+ GlButtonGroup,
},
mixins: [discussionNavigation],
computed: {
@@ -34,6 +37,12 @@ export default {
allExpanded() {
return this.toggeableDiscussions.every(discussion => discussion.expanded);
},
+ lineResolveClass() {
+ return this.allResolved ? 'line-resolve-btn is-active' : 'line-resolve-text';
+ },
+ toggleThreadsLabel() {
+ return this.allExpanded ? __('Collapse all threads') : __('Expand all threads');
+ },
},
methods: {
...mapActions(['setExpandDiscussions']),
@@ -51,59 +60,49 @@ export default {
<div
v-if="resolvableDiscussionsCount > 0"
ref="discussionCounter"
- class="line-resolve-all-container full-width-mobile"
+ class="line-resolve-all-container full-width-mobile gl-display-flex d-sm-flex"
>
- <div class="full-width-mobile d-flex d-sm-flex">
- <div class="line-resolve-all">
- <span
- :class="{ 'line-resolve-btn is-active': allResolved, 'line-resolve-text': !allResolved }"
- >
- <template v-if="allResolved">
- <gl-icon name="check-circle-filled" />
- {{ __('All threads resolved') }}
- </template>
- <template v-else>
- {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }}
- </template>
- </span>
- </div>
- <div
- v-if="resolveAllDiscussionsIssuePath && !allResolved"
- class="btn-group btn-group-sm"
- role="group"
- >
- <a
- v-gl-tooltip
- :href="resolveAllDiscussionsIssuePath"
- :title="s__('Resolve all threads in new issue')"
- class="new-issue-for-discussion btn btn-default discussion-create-issue-btn"
- >
- <gl-icon name="issue-new" />
- </a>
- </div>
- <div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group">
- <button
- v-gl-tooltip
- :title="__('Jump to next unresolved thread')"
- class="btn btn-default discussion-next-btn"
- data-track-event="click_button"
- data-track-label="mr_next_unresolved_thread"
- data-track-property="click_next_unresolved_thread_top"
- @click="jumpToNextDiscussion"
- >
- <gl-icon name="comment-next" />
- </button>
- </div>
- <div class="btn-group btn-group-sm" role="group">
- <button
- v-gl-tooltip
- :title="__('Toggle all threads')"
- class="btn btn-default toggle-all-discussions-btn"
- @click="handleExpandDiscussions"
- >
- <gl-icon :name="allExpanded ? 'angle-up' : 'angle-down'" />
- </button>
- </div>
+ <div class="line-resolve-all">
+ <span :class="lineResolveClass">
+ <template v-if="allResolved">
+ <gl-icon name="check-circle-filled" />
+ {{ __('All threads resolved') }}
+ </template>
+ <template v-else>
+ {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }}
+ </template>
+ </span>
</div>
+ <gl-button-group>
+ <gl-button
+ v-if="resolveAllDiscussionsIssuePath && !allResolved"
+ v-gl-tooltip
+ :href="resolveAllDiscussionsIssuePath"
+ :title="s__('Resolve all threads in new issue')"
+ :aria-label="s__('Resolve all threads in new issue')"
+ class="new-issue-for-discussion discussion-create-issue-btn"
+ icon="issue-new"
+ />
+ <gl-button
+ v-if="isLoggedIn && !allResolved"
+ v-gl-tooltip
+ :title="__('Jump to next unresolved thread')"
+ :aria-label="__('Jump to next unresolved thread')"
+ class="discussion-next-btn"
+ data-track-event="click_button"
+ data-track-label="mr_next_unresolved_thread"
+ data-track-property="click_next_unresolved_thread_top"
+ icon="comment-next"
+ @click="jumpToNextDiscussion"
+ />
+ <gl-button
+ v-gl-tooltip
+ :title="toggleThreadsLabel"
+ :aria-label="toggleThreadsLabel"
+ class="toggle-all-discussions-btn"
+ :icon="allExpanded ? 'angle-up' : 'angle-down'"
+ @click="handleExpandDiscussions"
+ />
+ </gl-button-group>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index 989ce9ff144..e4b191b55a7 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -1,11 +1,11 @@
<script>
-import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex';
-import { GlIcon } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility';
import {
DISCUSSION_FILTERS_DEFAULT_VALUE,
HISTORY_ONLY_FILTER_VALUE,
+ COMMENTS_ONLY_FILTER_VALUE,
DISCUSSION_TAB_LABEL,
DISCUSSION_FILTER_TYPES,
NOTE_UNDERSCORE,
@@ -14,7 +14,9 @@ import notesEventHub from '../event_hub';
export default {
components: {
- GlIcon,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
},
props: {
filters: {
@@ -37,7 +39,7 @@ export default {
};
},
computed: {
- ...mapGetters(['getNotesDataByProp']),
+ ...mapGetters(['getNotesDataByProp', 'timelineEnabled']),
currentFilter() {
if (!this.currentValue) return this.filters[0];
return this.filters.find(filter => filter.value === this.currentValue);
@@ -62,14 +64,20 @@ export default {
window.removeEventListener('hashchange', this.handleLocationHash);
},
methods: {
- ...mapActions(['filterDiscussion', 'setCommentsDisabled', 'setTargetNoteHash']),
+ ...mapActions([
+ 'filterDiscussion',
+ 'setCommentsDisabled',
+ 'setTargetNoteHash',
+ 'setTimelineView',
+ ]),
selectFilter(value, persistFilter = true) {
const filter = parseInt(value, 10);
- // close dropdown
- this.toggleDropdown();
-
if (filter === this.currentValue) return;
+
+ if (this.timelineEnabled && filter !== COMMENTS_ONLY_FILTER_VALUE) {
+ this.setTimelineView(false);
+ }
this.currentValue = filter;
this.filterDiscussion({
path: this.getNotesDataByProp('discussionsPath'),
@@ -78,9 +86,6 @@ export default {
});
this.toggleCommentsForm();
},
- toggleDropdown() {
- $(this.$refs.dropdownToggle).dropdown('toggle');
- },
toggleCommentsForm() {
this.setCommentsDisabled(this.currentValue === HISTORY_ONLY_FILTER_VALUE);
},
@@ -92,7 +97,6 @@ export default {
if (/^note_/.test(hash) && this.currentValue !== DISCUSSION_FILTERS_DEFAULT_VALUE) {
this.selectFilter(this.defaultValue, false);
- this.toggleDropdown(); // close dropdown
this.setTargetNoteHash(hash);
}
},
@@ -109,43 +113,24 @@ export default {
</script>
<template>
- <div
+ <gl-dropdown
v-if="displayFilters"
- class="discussion-filter-container js-discussion-filter-container d-inline-block align-bottom full-width-mobile"
+ id="discussion-filter-dropdown"
+ class="gl-mr-3 full-width-mobile discussion-filter-container js-discussion-filter-container qa-discussion-filter"
+ :text="currentFilter.title"
>
- <button
- id="discussion-filter-dropdown"
- ref="dropdownToggle"
- class="btn btn-sm qa-discussion-filter"
- data-toggle="dropdown"
- aria-expanded="false"
- >
- {{ currentFilter.title }} <gl-icon name="chevron-down" />
- </button>
- <div
- ref="dropdownMenu"
- class="dropdown-menu dropdown-menu-selectable dropdown-menu-right"
- aria-labelledby="discussion-filter-dropdown"
- >
- <div class="dropdown-content">
- <ul>
- <li
- v-for="filter in filters"
- :key="filter.value"
- :data-filter-type="filterType(filter.value)"
- >
- <button
- :class="{ 'is-active': filter.value === currentValue }"
- class="qa-filter-options"
- type="button"
- @click="selectFilter(filter.value)"
- >
- {{ filter.title }}
- </button>
- <div v-if="filter.value === defaultValue" class="dropdown-divider"></div>
- </li>
- </ul>
- </div>
+ <div v-for="filter in filters" :key="filter.value" class="dropdown-item-wrapper">
+ <gl-dropdown-item
+ :is-check-item="true"
+ :is-checked="filter.value === currentValue"
+ :class="{ 'is-active': filter.value === currentValue }"
+ :data-filter-type="filterType(filter.value)"
+ class="qa-filter-options"
+ @click.prevent="selectFilter(filter.value)"
+ >
+ {{ filter.title }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider v-if="filter.value === defaultValue" />
</div>
- </div>
+ </gl-dropdown>
</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index a8057276f1a..c2f40b2d21a 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -160,7 +160,7 @@ export default {
});
},
displayMemberBadgeText() {
- return sprintf(__('This user is a %{access} of the %{name} project.'), {
+ return sprintf(__('This user has the %{access} role in the %{name} project.'), {
access: this.accessLevel.toLowerCase(),
name: this.projectName,
});
@@ -275,7 +275,8 @@ export default {
v-gl-tooltip
type="button"
title="Edit comment"
- class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
+ class="note-action-button js-note-edit btn btn-transparent"
+ data-qa-selector="note_edit_button"
@click="onEdit"
>
<gl-icon name="pencil" class="link-highlight" />
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 314fa762768..65b89b94eaa 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -45,7 +45,7 @@ export default {
},
},
computed: {
- ...mapGetters(['getDiscussion']),
+ ...mapGetters(['getDiscussion', 'suggestionsCount']),
discussion() {
if (!this.note.isDraft) return {};
@@ -125,6 +125,7 @@ export default {
<suggestions
v-if="hasSuggestion && !isEditing"
:suggestions="note.suggestions"
+ :suggestions-count="suggestionsCount"
:batch-suggestions-info="batchSuggestionsInfo"
:note-html="note.note_html"
:line-type="lineType"
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 88b4461cf38..4b3f23e742d 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -328,6 +328,7 @@ export default {
:add-spacing-classes="false"
:help-page-path="helpPagePath"
:show-suggest-popover="showSuggestPopover"
+ :textarea-value="updatedNoteBody"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
>
<textarea
@@ -337,7 +338,8 @@ export default {
v-model="updatedNoteBody"
:data-supports-quick-actions="!isEditing"
name="note[note]"
- class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form qa-reply-input"
+ 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="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@@ -376,7 +378,8 @@ export default {
<button
:disabled="isDisabled"
type="button"
- class="btn btn-success qa-start-review"
+ class="btn btn-success"
+ data-qa-selector="start_review_button"
@click="handleAddToReview"
>
<template v-if="hasDrafts">{{ __('Add to review') }}</template>
@@ -385,7 +388,8 @@ export default {
<button
:disabled="isDisabled"
type="button"
- class="btn qa-comment-now js-comment-button"
+ class="btn js-comment-button"
+ data-qa-selector="comment_now_button"
@click="handleUpdate()"
>
{{ __('Add comment now') }}
@@ -404,7 +408,8 @@ export default {
<button
:disabled="isDisabled"
type="button"
- class="js-vue-issue-save btn btn-success js-comment-button qa-reply-comment-button"
+ class="js-vue-issue-save btn btn-success js-comment-button"
+ data-qa-selector="reply_comment_button"
@click="handleUpdate()"
>
{{ saveButtonTitle }}
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index fb18be9386e..9eaa4e422d5 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -73,6 +73,7 @@ export default {
'userCanReply',
'discussionTabCounter',
'sortDirection',
+ 'timelineEnabled',
]),
sortDirDesc() {
return this.sortDirection === constants.DESC;
@@ -95,7 +96,7 @@ export default {
return this.discussions;
},
canReply() {
- return this.userCanReply && !this.commentsDisabled;
+ return this.userCanReply && !this.commentsDisabled && !this.timelineEnabled;
},
slotKeys() {
return this.sortDirDesc ? ['form', 'comments'] : ['comments', 'form'];
@@ -252,7 +253,7 @@ export default {
<ordered-layout :slot-keys="slotKeys">
<template #form>
<comment-form
- v-if="!commentsDisabled"
+ v-if="!(commentsDisabled || timelineEnabled)"
class="js-comment-form"
:noteable-type="noteableType"
/>
diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue
index 60b531d7597..c279a7107c7 100644
--- a/app/assets/javascripts/notes/components/sort_discussion.vue
+++ b/app/assets/javascripts/notes/components/sort_discussion.vue
@@ -1,6 +1,5 @@
-gs
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { mapActions, mapGetters } from 'vuex';
import { __ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
@@ -15,12 +14,13 @@ const SORT_OPTIONS = [
export default {
SORT_OPTIONS,
components: {
- GlIcon,
+ GlDropdown,
+ GlDropdownItem,
LocalStorageSync,
},
mixins: [Tracking.mixin()],
computed: {
- ...mapGetters(['sortDirection', 'noteableType']),
+ ...mapGetters(['sortDirection', 'persistSortOrder', 'noteableType']),
selectedOption() {
return SORT_OPTIONS.find(({ key }) => this.sortDirection === key);
},
@@ -38,7 +38,7 @@ export default {
return;
}
- this.setDiscussionSortDirection(direction);
+ this.setDiscussionSortDirection({ direction });
this.track('change_discussion_sort_direction', { property: direction });
},
isDropdownItemActive(sortDir) {
@@ -49,33 +49,28 @@ export default {
</script>
<template>
- <div
- data-testid="sort-discussion-filter"
- class="gl-mr-2 gl-display-inline-block gl-vertical-align-bottom full-width-mobile"
- >
+ <div class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile">
<local-storage-sync
:value="sortDirection"
:storage-key="storageKey"
- @input="setDiscussionSortDirection"
+ :persist="persistSortOrder"
+ @input="setDiscussionSortDirection({ direction: $event })"
/>
- <button class="btn btn-sm js-dropdown-text" data-toggle="dropdown" aria-expanded="false">
- {{ dropdownText }}
- <gl-icon name="chevron-down" />
- </button>
- <div ref="dropdownMenu" class="dropdown-menu dropdown-menu-selectable dropdown-menu-right">
- <div class="dropdown-content">
- <ul>
- <li v-for="{ text, key, cls } in $options.SORT_OPTIONS" :key="key">
- <button
- :class="[cls, { 'is-active': isDropdownItemActive(key) }]"
- type="button"
- @click="fetchSortedDiscussions(key)"
- >
- {{ text }}
- </button>
- </li>
- </ul>
- </div>
- </div>
+ <gl-dropdown
+ :text="dropdownText"
+ data-testid="sort-discussion-filter"
+ class="js-dropdown-text full-width-mobile"
+ >
+ <gl-dropdown-item
+ v-for="{ text, key, cls } in $options.SORT_OPTIONS"
+ :key="key"
+ :class="cls"
+ :is-check-item="true"
+ :is-checked="isDropdownItemActive(key)"
+ @click="fetchSortedDiscussions(key)"
+ >
+ {{ text }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/timeline_toggle.vue b/app/assets/javascripts/notes/components/timeline_toggle.vue
new file mode 100644
index 00000000000..d1ffe0a3601
--- /dev/null
+++ b/app/assets/javascripts/notes/components/timeline_toggle.vue
@@ -0,0 +1,60 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { mapActions, mapGetters } from 'vuex';
+import { s__ } from '~/locale';
+import { COMMENTS_ONLY_FILTER_VALUE, DESC } from '../constants';
+import notesEventHub from '../event_hub';
+import TrackEventDirective from '~/vue_shared/directives/track_event';
+import { trackToggleTimelineView } from '../utils';
+
+export const timelineEnabledTooltip = s__('Timeline|Turn timeline view off');
+export const timelineDisabledTooltip = s__('Timeline|Turn timeline view on');
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ TrackEvent: TrackEventDirective,
+ },
+ computed: {
+ ...mapGetters(['timelineEnabled', 'sortDirection']),
+ tooltip() {
+ return this.timelineEnabled ? timelineEnabledTooltip : timelineDisabledTooltip;
+ },
+ },
+ methods: {
+ ...mapActions(['setTimelineView', 'setDiscussionSortDirection']),
+ trackToggleTimelineView,
+ setSort() {
+ if (this.timelineEnabled && this.sortDirection !== DESC) {
+ this.setDiscussionSortDirection({ direction: DESC, persist: false });
+ }
+ },
+ setFilter() {
+ notesEventHub.$emit('dropdownSelect', COMMENTS_ONLY_FILTER_VALUE, false);
+ },
+ toggleTimeline(event) {
+ event.currentTarget.blur();
+ this.setTimelineView(!this.timelineEnabled);
+ this.setSort();
+ this.setFilter();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip
+ v-track-event="trackToggleTimelineView(timelineEnabled)"
+ icon="comments"
+ size="small"
+ :selected="timelineEnabled"
+ :title="tooltip"
+ :aria-label="tooltip"
+ class="gl-mr-3"
+ @click="toggleTimeline"
+ />
+</template>
diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
index bddac60647d..f49fd2c3fa3 100644
--- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue
+++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
@@ -57,7 +57,12 @@ export default {
tooltip-placement="bottom"
/>
</div>
- <button class="btn btn-link js-replies-text qa-expand-replies" type="button" @click="toggle">
+ <button
+ class="btn btn-link js-replies-text"
+ data-qa-selector="expand_replies_button"
+ type="button"
+ @click="toggle"
+ >
{{ replies.length }} {{ n__('reply', 'replies', replies.length) }}
</button>
{{ __('Last reply by') }}
@@ -68,7 +73,8 @@ export default {
</template>
<span
v-else
- class="collapse-replies-btn js-collapse-replies qa-collapse-replies"
+ class="collapse-replies-btn js-collapse-replies"
+ data-qa-selector="collapse_replies_button"
@click="toggle"
>
<gl-icon name="chevron-down" /> {{ s__('Notes|Collapse replies') }}
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index b81aae7c257..7acf2ad57c8 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -14,8 +14,9 @@ export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest';
export const UNRESOLVE_NOTE_METHOD_NAME = 'delete';
export const RESOLVE_NOTE_METHOD_NAME = 'post';
export const DESCRIPTION_TYPE = 'changed the description';
-export const HISTORY_ONLY_FILTER_VALUE = 2;
export const DISCUSSION_FILTERS_DEFAULT_VALUE = 0;
+export const COMMENTS_ONLY_FILTER_VALUE = 1;
+export const HISTORY_ONLY_FILTER_VALUE = 2;
export const DISCUSSION_TAB_LABEL = 'show';
export const NOTE_UNDERSCORE = 'note_';
export const TIME_DIFFERENCE_VALUE = 10;
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 7bf465482b3..1f0b2afab9e 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -2,18 +2,21 @@ import Vue from 'vue';
import notesApp from './components/notes_app.vue';
import initDiscussionFilters from './discussion_filters';
import initSortDiscussions from './sort_discussions';
+import initTimelineToggle from './timeline';
import { store } from './stores';
-document.addEventListener('DOMContentLoaded', () => {
+const el = document.getElementById('js-vue-notes');
+
+if (el) {
// eslint-disable-next-line no-new
new Vue({
- el: '#js-vue-notes',
+ el,
components: {
notesApp,
},
store,
data() {
- const notesDataset = document.getElementById('js-vue-notes').dataset;
+ const notesDataset = el.dataset;
const parsedUserData = JSON.parse(notesDataset.currentUserData);
const noteableData = JSON.parse(notesDataset.noteableData);
let currentUserData = {};
@@ -55,4 +58,5 @@ document.addEventListener('DOMContentLoaded', () => {
initDiscussionFilters(store);
initSortDiscussions(store);
-});
+ initTimelineToggle(store);
+}
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 9c63a7e3cd4..37986c8a02d 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -99,8 +99,12 @@ export const updateDiscussion = ({ commit, state }, discussion) => {
return utils.findNoteObjectById(state.discussions, discussion.id);
};
-export const setDiscussionSortDirection = ({ commit }, direction) => {
- commit(types.SET_DISCUSSIONS_SORT, direction);
+export const setDiscussionSortDirection = ({ commit }, { direction, persist = true }) => {
+ commit(types.SET_DISCUSSIONS_SORT, { direction, persist });
+};
+
+export const setTimelineView = ({ commit }, enabled) => {
+ commit(types.SET_TIMELINE_VIEW, enabled);
};
export const setSelectedCommentPosition = ({ commit }, position) => {
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 7d60fbffb10..5b3ffa425a0 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -5,6 +5,23 @@ import { collapseSystemNotes } from './collapse_utils';
export const discussions = state => {
let discussionsInState = clone(state.discussions);
// NOTE: not testing bc will be removed when backend is finished.
+
+ if (state.isTimelineEnabled) {
+ discussionsInState = discussionsInState
+ .reduce((acc, discussion) => {
+ const transformedToIndividualNotes = discussion.notes.map(note => ({
+ ...discussion,
+ id: note.id,
+ created_at: note.created_at,
+ individual_note: true,
+ notes: [note],
+ }));
+
+ return acc.concat(transformedToIndividualNotes);
+ }, [])
+ .sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
+ }
+
if (state.discussionSortOrder === constants.DESC) {
discussionsInState = discussionsInState.reverse();
}
@@ -27,6 +44,10 @@ export const isNotesFetched = state => state.isNotesFetched;
export const sortDirection = state => state.discussionSortOrder;
+export const persistSortOrder = state => state.persistSortOrder;
+
+export const timelineEnabled = state => state.isTimelineEnabled;
+
export const isLoading = state => state.isLoading;
export const getNotesDataByProp = state => prop => state.notesData[prop];
@@ -231,3 +252,6 @@ export const getDiscussion = state => discussionId =>
state.discussions.find(discussion => discussion.id === discussionId);
export const commentsDisabled = state => state.commentsDisabled;
+
+export const suggestionsCount = (state, getters) =>
+ Object.values(getters.notesById).filter(n => n.suggestions.length).length;
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index 161c9b8b1b5..a8738fa7c5f 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -7,6 +7,7 @@ export default () => ({
state: {
discussions: [],
discussionSortOrder: ASC,
+ persistSortOrder: true,
convertedDisscussionIds: [],
targetNoteHash: null,
lastFetchedAt: null,
@@ -45,6 +46,7 @@ export default () => ({
resolvableDiscussionsCount: 0,
unresolvedDiscussionsCount: 0,
descriptionVersions: {},
+ isTimelineEnabled: false,
},
actions,
getters,
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index 23515cdd9e3..7496dd630f6 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -34,6 +34,7 @@ export const SET_EXPAND_DISCUSSIONS = 'SET_EXPAND_DISCUSSIONS';
export const UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS = 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS';
export const SET_CURRENT_DISCUSSION_ID = 'SET_CURRENT_DISCUSSION_ID';
export const SET_DISCUSSIONS_SORT = 'SET_DISCUSSIONS_SORT';
+export const SET_TIMELINE_VIEW = 'SET_TIMELINE_VIEW';
export const SET_SELECTED_COMMENT_POSITION = 'SET_SELECTED_COMMENT_POSITION';
export const SET_SELECTED_COMMENT_POSITION_HOVER = 'SET_SELECTED_COMMENT_POSITION_HOVER';
export const SET_FETCHING_DISCUSSIONS = 'SET_FETCHING_DISCUSSIONS';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index a8bd94cc763..6c11d53dba3 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -313,8 +313,13 @@ export default {
discussion.truncated_diff_lines = utils.prepareDiffLines(diffLines);
},
- [types.SET_DISCUSSIONS_SORT](state, sort) {
- state.discussionSortOrder = sort;
+ [types.SET_DISCUSSIONS_SORT](state, { direction, persist }) {
+ state.discussionSortOrder = direction;
+ state.persistSortOrder = persist;
+ },
+
+ [types.SET_TIMELINE_VIEW](state, value) {
+ state.isTimelineEnabled = value;
},
[types.SET_SELECTED_COMMENT_POSITION](state, position) {
diff --git a/app/assets/javascripts/notes/timeline.js b/app/assets/javascripts/notes/timeline.js
new file mode 100644
index 00000000000..df6d1b21400
--- /dev/null
+++ b/app/assets/javascripts/notes/timeline.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import TimelineToggle from './components/timeline_toggle.vue';
+
+export default function initTimelineToggle(store) {
+ const el = document.getElementById('js-incidents-timeline-toggle');
+
+ if (!el) return null;
+
+ return new Vue({
+ el,
+ store,
+ render(createElement) {
+ return createElement(TimelineToggle);
+ },
+ });
+}
diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js
new file mode 100644
index 00000000000..e6c2eb06a51
--- /dev/null
+++ b/app/assets/javascripts/notes/utils.js
@@ -0,0 +1,12 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+
+/**
+ * Tracks snowplow event when User toggles timeline view
+ * @param {Boolean} enabled that will be send as a property for the event
+ */
+export const trackToggleTimelineView = enabled => ({
+ category: 'Incident Management',
+ action: 'toggle_incident_comments_into_timeline_view',
+ label: 'Status',
+ property: enabled,
+});
diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js
index 47fb5b271d1..ae992dd5dc5 100644
--- a/app/assets/javascripts/notifications_dropdown.js
+++ b/app/assets/javascripts/notifications_dropdown.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { Rails } from '~/lib/utils/rails_ujs';
import { deprecatedCreateFlash as Flash } from './flash';
import { __ } from '~/locale';
@@ -21,10 +22,12 @@ export default function notificationsDropdown() {
form.find('.js-notifications-icon').toggleClass('hidden');
}
form.find('#notification_setting_level').val(notificationLevel);
- form.submit();
+ Rails.fire(form[0], 'submit');
});
- $(document).on('ajax:success', '.notification-form', (e, data) => {
+ $(document).on('ajax:success', '.notification-form', e => {
+ const data = e.detail[0];
+
if (data.saved) {
$(e.currentTarget)
.closest('.js-notification-dropdown')
diff --git a/app/assets/javascripts/operation_settings/components/metrics_settings.vue b/app/assets/javascripts/operation_settings/components/metrics_settings.vue
index 9df6a412930..2e972dd7154 100644
--- a/app/assets/javascripts/operation_settings/components/metrics_settings.vue
+++ b/app/assets/javascripts/operation_settings/components/metrics_settings.vue
@@ -44,11 +44,9 @@ export default {
<form>
<dashboard-timezone />
<external-dashboard />
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button variant="success" category="primary" @click="saveChanges">
- {{ __('Save Changes') }}
- </gl-button>
- </div>
+ <gl-button variant="success" category="primary" @click="saveChanges">
+ {{ __('Save Changes') }}
+ </gl-button>
</form>
</div>
</section>
diff --git a/app/assets/javascripts/packages/details/components/additional_metadata.vue b/app/assets/javascripts/packages/details/components/additional_metadata.vue
index 76e0976ac05..4e99099b0a1 100644
--- a/app/assets/javascripts/packages/details/components/additional_metadata.vue
+++ b/app/assets/javascripts/packages/details/components/additional_metadata.vue
@@ -2,7 +2,6 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
-import { generateConanRecipe } from '../utils';
import { PackageType } from '../../shared/constants';
export default {
@@ -25,9 +24,6 @@ export default {
},
},
computed: {
- conanRecipe() {
- return generateConanRecipe(this.packageEntity);
- },
showMetadata() {
const visibilityConditions = {
[PackageType.NUGET]: this.packageEntity.nuget_metadatum,
@@ -73,7 +69,7 @@ export default {
data-testid="conan-recipe"
>
<gl-sprintf :message="$options.i18n.recipeText">
- <template #recipe>{{ conanRecipe }}</template>
+ <template #recipe>{{ packageEntity.name }}</template>
</gl-sprintf>
</details-row>
diff --git a/app/assets/javascripts/packages/details/components/composer_installation.vue b/app/assets/javascripts/packages/details/components/composer_installation.vue
index 60ad468c293..9d87ae8f836 100644
--- a/app/assets/javascripts/packages/details/components/composer_installation.vue
+++ b/app/assets/javascripts/packages/details/components/composer_installation.vue
@@ -14,12 +14,12 @@ export default {
},
computed: {
...mapState(['composerHelpPath']),
- ...mapGetters(['composerRegistryInclude', 'composerPackageInclude']),
+ ...mapGetters(['composerRegistryInclude', 'composerPackageInclude', 'groupExists']),
},
i18n: {
- registryInclude: s__('PackageRegistry|composer.json registry include'),
+ registryInclude: s__('PackageRegistry|Add composer registry'),
copyRegistryInclude: s__('PackageRegistry|Copy registry include'),
- packageInclude: s__('PackageRegistry|composer.json require package include'),
+ packageInclude: s__('PackageRegistry|Install package version'),
copyPackageInclude: s__('PackageRegistry|Copy require package include'),
infoLine: s__(
'PackageRegistry|For more information on Composer packages in GitLab, %{linkStart}see the documentation.%{linkEnd}',
@@ -31,7 +31,7 @@ export default {
</script>
<template>
- <div>
+ <div v-if="groupExists" data-testid="root-node">
<h3 class="gl-font-lg">{{ __('Installation') }}</h3>
<code-instruction
diff --git a/app/assets/javascripts/packages/details/components/package_title.vue b/app/assets/javascripts/packages/details/components/package_title.vue
index 69dd494f11a..2789be30818 100644
--- a/app/assets/javascripts/packages/details/components/package_title.vue
+++ b/app/assets/javascripts/packages/details/components/package_title.vue
@@ -54,15 +54,15 @@ export default {
</gl-sprintf>
</template>
- <template v-if="packageTypeDisplay" #metadata_type>
+ <template v-if="packageTypeDisplay" #metadata-type>
<metadata-item data-testid="package-type" icon="package" :text="packageTypeDisplay" />
</template>
- <template #metadata_size>
+ <template #metadata-size>
<metadata-item data-testid="package-size" icon="disk" :text="totalSize" />
</template>
- <template v-if="packagePipeline" #metadata_pipeline>
+ <template v-if="packagePipeline" #metadata-pipeline>
<metadata-item
data-testid="pipeline-project"
icon="review-list"
@@ -71,11 +71,11 @@ export default {
/>
</template>
- <template v-if="packagePipeline" #metadata_ref>
+ <template v-if="packagePipeline" #metadata-ref>
<metadata-item data-testid="package-ref" icon="branch" :text="packagePipeline.ref" />
</template>
- <template v-if="hasTagsToDisplay" #metadata_tags>
+ <template v-if="hasTagsToDisplay" #metadata-tags>
<package-tags :tag-display-limit="2" :tags="packageEntity.tags" hide-label />
</template>
diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js
index ede6d39bde7..14e76ac84bd 100644
--- a/app/assets/javascripts/packages/details/store/getters.js
+++ b/app/assets/javascripts/packages/details/store/getters.js
@@ -1,4 +1,3 @@
-import { generateConanRecipe } from '../utils';
import { PackageType } from '../../shared/constants';
import { getPackageTypeLabel } from '../../shared/utils';
import { NpmManager } from '../constants';
@@ -20,10 +19,8 @@ export const packageIcon = ({ packageEntity }) => {
};
export const conanInstallationCommand = ({ packageEntity }) => {
- const recipe = generateConanRecipe(packageEntity);
-
// eslint-disable-next-line @gitlab/require-i18n-strings
- return `conan install ${recipe} --remote=gitlab`;
+ return `conan install ${packageEntity.name} --remote=gitlab`;
};
export const conanSetupCommand = ({ conanPath }) =>
@@ -98,18 +95,19 @@ export const nugetSetupCommand = ({ nugetPath }) =>
export const pypiPipCommand = ({ pypiPath, packageEntity }) =>
// eslint-disable-next-line @gitlab/require-i18n-strings
- `pip install ${packageEntity.name} --index-url ${pypiPath}`;
+ `pip install ${packageEntity.name} --extra-index-url ${pypiPath}`;
export const pypiSetupCommand = ({ pypiSetupPath }) => `[gitlab]
repository = ${pypiSetupPath}
username = __token__
password = <your personal access token>`;
-export const composerRegistryInclude = ({ composerPath }) => {
- const base = { type: 'composer', url: composerPath };
- return JSON.stringify(base);
-};
-export const composerPackageInclude = ({ packageEntity }) => {
- const base = { [packageEntity.name]: packageEntity.version };
- return JSON.stringify(base);
-};
+export const composerRegistryInclude = ({ composerPath, composerConfigRepositoryName }) =>
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ `composer config repositories.${composerConfigRepositoryName} '{"type": "composer", "url": "${composerPath}"}'`;
+
+export const composerPackageInclude = ({ packageEntity }) =>
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ `composer req ${[packageEntity.name]}:${packageEntity.version}`;
+
+export const groupExists = ({ groupListUrl }) => groupListUrl.length > 0;
diff --git a/app/assets/javascripts/packages/details/utils.js b/app/assets/javascripts/packages/details/utils.js
index 454c83c9ccd..27cc95566d3 100644
--- a/app/assets/javascripts/packages/details/utils.js
+++ b/app/assets/javascripts/packages/details/utils.js
@@ -8,16 +8,3 @@ export const trackInstallationTabChange = {
},
},
};
-
-export function generateConanRecipe(packageEntity = {}) {
- const {
- name = '',
- version = '',
- conan_metadatum: {
- package_username: packageUsername = '',
- package_channel: packageChannel = '',
- } = {},
- } = packageEntity;
-
- return `${name}/${version}@${packageUsername}/${packageChannel}`;
-}
diff --git a/app/assets/javascripts/packages/list/coming_soon/helpers.js b/app/assets/javascripts/packages/list/coming_soon/helpers.js
deleted file mode 100644
index 5b6a4b3aa87..00000000000
--- a/app/assets/javascripts/packages/list/coming_soon/helpers.js
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * Context:
- * https://gitlab.com/gitlab-org/gitlab/-/issues/198524
- * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29491
- *
- */
-
-/**
- * Constants
- *
- * LABEL_NAMES - an array of labels to filter issues in the GraphQL query
- * WORKFLOW_PREFIX - the prefix for workflow labels
- * ACCEPTING_CONTRIBUTIONS_TITLE - the accepting contributions label
- */
-export const LABEL_NAMES = ['Package::Coming soon'];
-const WORKFLOW_PREFIX = 'workflow::';
-const ACCEPTING_CONTRIBUTIONS_TITLE = 'accepting merge requests';
-
-const setScoped = (label, scoped) => (label ? { ...label, scoped } : label);
-
-/**
- * Finds workflow:: scoped labels and returns the first or null.
- * @param {Object[]} labels Labels from the issue
- */
-export const findWorkflowLabel = (labels = []) =>
- labels.find(l => l.title.toLowerCase().includes(WORKFLOW_PREFIX.toLowerCase()));
-
-/**
- * Determines if an issue is accepting community contributions by checking if
- * the "Accepting merge requests" label is present.
- * @param {Object[]} labels
- */
-export const findAcceptingContributionsLabel = (labels = []) =>
- labels.find(l => l.title.toLowerCase() === ACCEPTING_CONTRIBUTIONS_TITLE.toLowerCase());
-
-/**
- * Formats the GraphQL response into the format that the view template expects.
- * @param {Object} data GraphQL response
- */
-export const toViewModel = data => {
- // This just flatterns the issues -> nodes and labels -> nodes hierarchy
- // into an array of objects.
- const issues = (data.project?.issues?.nodes || []).map(i => ({
- ...i,
- labels: (i.labels?.nodes || []).map(node => node),
- }));
-
- return issues.map(x => ({
- ...x,
- labels: [
- setScoped(findWorkflowLabel(x.labels), true),
- setScoped(findAcceptingContributionsLabel(x.labels), false),
- ].filter(Boolean),
- }));
-};
diff --git a/app/assets/javascripts/packages/list/coming_soon/packages_coming_soon.vue b/app/assets/javascripts/packages/list/coming_soon/packages_coming_soon.vue
deleted file mode 100644
index 766402d3619..00000000000
--- a/app/assets/javascripts/packages/list/coming_soon/packages_coming_soon.vue
+++ /dev/null
@@ -1,172 +0,0 @@
-<script>
-import {
- GlAlert,
- GlEmptyState,
- GlIcon,
- GlLabel,
- GlLink,
- GlSkeletonLoader,
- GlSprintf,
-} from '@gitlab/ui';
-import { ApolloQuery } from 'vue-apollo';
-import Tracking from '~/tracking';
-import { TrackingActions } from '../../shared/constants';
-import { s__ } from '~/locale';
-import comingSoonIssuesQuery from './queries/issues.graphql';
-import { toViewModel, LABEL_NAMES } from './helpers';
-
-export default {
- name: 'ComingSoon',
- components: {
- GlAlert,
- GlEmptyState,
- GlIcon,
- GlLabel,
- GlLink,
- GlSkeletonLoader,
- GlSprintf,
- ApolloQuery,
- },
- mixins: [Tracking.mixin()],
- props: {
- illustration: {
- type: String,
- required: true,
- },
- projectPath: {
- type: String,
- required: true,
- },
- suggestedContributionsPath: {
- type: String,
- required: true,
- },
- },
- computed: {
- variables() {
- return {
- projectPath: this.projectPath,
- labelNames: LABEL_NAMES,
- };
- },
- },
- mounted() {
- this.track(TrackingActions.COMING_SOON_REQUESTED);
- },
- methods: {
- onIssueLinkClick(issueIid, label) {
- this.track(TrackingActions.COMING_SOON_LIST, {
- label,
- value: issueIid,
- });
- },
- onDocsLinkClick() {
- this.track(TrackingActions.COMING_SOON_HELP);
- },
- },
- loadingRows: 5,
- i18n: {
- alertTitle: s__('PackageRegistry|Upcoming package managers'),
- alertIntro: s__(
- "PackageRegistry|Is your favorite package manager missing? We'd love your help in building first-class support for it into GitLab! %{contributionLinkStart}Visit the contribution documentation%{contributionLinkEnd} to learn more about how to build support for new package managers into GitLab. Below is a list of package managers that are on our radar.",
- ),
- emptyStateTitle: s__('PackageRegistry|No upcoming issues'),
- emptyStateDescription: s__('PackageRegistry|There are no upcoming issues to display.'),
- },
- comingSoonIssuesQuery,
- toViewModel,
-};
-</script>
-
-<template>
- <apollo-query
- :query="$options.comingSoonIssuesQuery"
- :variables="variables"
- :update="$options.toViewModel"
- >
- <template #default="{ result: { data }, isLoading }">
- <div>
- <gl-alert :title="$options.i18n.alertTitle" :dismissible="false" variant="tip">
- <gl-sprintf :message="$options.i18n.alertIntro">
- <template #contributionLink="{ content }">
- <gl-link
- :href="suggestedContributionsPath"
- target="_blank"
- @click="onDocsLinkClick"
- >{{ content }}</gl-link
- >
- </template>
- </gl-sprintf>
- </gl-alert>
- </div>
-
- <div v-if="isLoading" class="gl-display-flex gl-flex-direction-column">
- <gl-skeleton-loader
- v-for="index in $options.loadingRows"
- :key="index"
- :width="1000"
- :height="80"
- preserve-aspect-ratio="xMinYMax meet"
- >
- <rect width="700" height="10" x="0" y="16" rx="4" />
- <rect width="60" height="10" x="0" y="45" rx="4" />
- <rect width="60" height="10" x="70" y="45" rx="4" />
- </gl-skeleton-loader>
- </div>
-
- <template v-else-if="data && data.length">
- <div
- v-for="issue in data"
- :key="issue.iid"
- data-testid="issue-row"
- class="gl-responsive-table-row gl-flex-direction-column gl-align-items-baseline"
- >
- <div class="table-section section-100 gl-white-space-normal text-truncate">
- <gl-link
- data-testid="issue-title-link"
- :href="issue.webUrl"
- class="gl-text-gray-900 gl-font-weight-bold"
- @click="onIssueLinkClick(issue.iid, issue.title)"
- >
- {{ issue.title }}
- </gl-link>
- </div>
-
- <div class="table-section section-100 gl-white-space-normal mt-md-3">
- <div class="gl-display-flex gl-text-gray-400">
- <gl-icon name="issues" class="gl-mr-2" />
- <gl-link
- data-testid="issue-id-link"
- :href="issue.webUrl"
- class="gl-text-gray-400 gl-mr-5"
- @click="onIssueLinkClick(issue.iid, issue.title)"
- >#{{ issue.iid }}</gl-link
- >
-
- <div v-if="issue.milestone" class="gl-display-flex gl-align-items-center gl-mr-5">
- <gl-icon name="clock" class="gl-mr-2" />
- <span data-testid="milestone">{{ issue.milestone.title }}</span>
- </div>
-
- <gl-label
- v-for="label in issue.labels"
- :key="label.title"
- class="gl-mr-3"
- size="sm"
- :background-color="label.color"
- :title="label.title"
- :scoped="Boolean(label.scoped)"
- />
- </div>
- </div>
- </div>
- </template>
-
- <gl-empty-state v-else :title="$options.i18n.emptyStateTitle" :svg-path="illustration">
- <template #description>
- <p>{{ $options.i18n.emptyStateDescription }}</p>
- </template>
- </gl-empty-state>
- </template>
- </apollo-query>
-</template>
diff --git a/app/assets/javascripts/packages/list/coming_soon/queries/issues.graphql b/app/assets/javascripts/packages/list/coming_soon/queries/issues.graphql
deleted file mode 100644
index 36c27d9ad70..00000000000
--- a/app/assets/javascripts/packages/list/coming_soon/queries/issues.graphql
+++ /dev/null
@@ -1,20 +0,0 @@
-query getComingSoonIssues($projectPath: ID!, $labelNames: [String]) {
- project(fullPath: $projectPath) {
- issues(state: opened, labelName: $labelNames) {
- nodes {
- iid
- title
- webUrl
- labels {
- nodes {
- title
- color
- }
- }
- milestone {
- title
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/packages/list/components/package_title.vue b/app/assets/javascripts/packages/list/components/package_title.vue
new file mode 100644
index 00000000000..f94a98e4ca7
--- /dev/null
+++ b/app/assets/javascripts/packages/list/components/package_title.vue
@@ -0,0 +1,47 @@
+<script>
+import { n__ } from '~/locale';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '../constants';
+
+export default {
+ name: 'PackageTitle',
+ components: {
+ TitleArea,
+ MetadataItem,
+ },
+ props: {
+ packagesCount: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ packageHelpUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ showPackageCount() {
+ return Number.isInteger(this.packagesCount);
+ },
+ packageAmountText() {
+ return n__(`%d Package`, `%d Packages`, this.packagesCount);
+ },
+ infoMessages() {
+ return [{ text: LIST_INTRO_TEXT, link: this.packageHelpUrl }];
+ },
+ },
+ i18n: {
+ LIST_TITLE_TEXT,
+ },
+};
+</script>
+
+<template>
+ <title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages">
+ <template #metadata-amount>
+ <metadata-item v-if="showPackageCount" icon="package" :text="packageAmountText" />
+ </template>
+ </title-area>
+</template>
diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages/list/components/packages_list_app.vue
index 6304f723f6a..cbb3bfd35ac 100644
--- a/app/assets/javascripts/packages/list/components/packages_list_app.vue
+++ b/app/assets/javascripts/packages/list/components/packages_list_app.vue
@@ -3,13 +3,13 @@ import { mapActions, mapState } from 'vuex';
import { GlEmptyState, GlTab, GlTabs, GlLink, GlSprintf } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import createFlash from '~/flash';
+import { historyReplaceState } from '~/lib/utils/common_utils';
+import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
import PackageFilter from './packages_filter.vue';
import PackageList from './packages_list.vue';
import PackageSort from './packages_sort.vue';
import { PACKAGE_REGISTRY_TABS, DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants';
-import PackagesComingSoon from '../coming_soon/packages_coming_soon.vue';
-import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
-import { historyReplaceState } from '~/lib/utils/common_utils';
+import PackageTitle from './package_title.vue';
export default {
components: {
@@ -21,15 +21,16 @@ export default {
PackageFilter,
PackageList,
PackageSort,
- PackagesComingSoon,
+ PackageTitle,
},
computed: {
...mapState({
emptyListIllustration: state => state.config.emptyListIllustration,
emptyListHelpUrl: state => state.config.emptyListHelpUrl,
- comingSoon: state => state.config.comingSoon,
filterQuery: state => state.filterQuery,
selectedType: state => state.selectedType,
+ packageHelpUrl: state => state.config.packageHelpUrl,
+ packagesCount: state => state.pagination?.total,
}),
tabsToRender() {
return PACKAGE_REGISTRY_TABS;
@@ -89,39 +90,35 @@ export default {
</script>
<template>
- <gl-tabs @input="tabChanged">
- <template #tabs-end>
- <div
- class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end"
- >
- <package-filter class="mr-1" @filter="requestPackagesList" />
- <package-sort @sort:changed="requestPackagesList" />
- </div>
- </template>
+ <div>
+ <package-title :package-help-url="packageHelpUrl" :packages-count="packagesCount" />
- <gl-tab v-for="(tab, index) in tabsToRender" :key="index" :title="tab.title">
- <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
- <template #empty-state>
- <gl-empty-state :title="emptyStateTitle(tab)" :svg-path="emptyListIllustration">
- <template #description>
- <gl-sprintf v-if="filterQuery" :message="$options.i18n.widenFilters" />
- <gl-sprintf v-else :message="$options.i18n.noResults">
- <template #noPackagesLink="{content}">
- <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </template>
- </gl-empty-state>
- </template>
- </package-list>
- </gl-tab>
+ <gl-tabs @input="tabChanged">
+ <template #tabs-end>
+ <div
+ class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end"
+ >
+ <package-filter class="gl-mr-2" @filter="requestPackagesList" />
+ <package-sort @sort:changed="requestPackagesList" />
+ </div>
+ </template>
- <gl-tab v-if="comingSoon" :title="__('Coming soon')" lazy>
- <packages-coming-soon
- :illustration="emptyListIllustration"
- :project-path="comingSoon.projectPath"
- :suggested-contributions-path="comingSoon.suggestedContributions"
- />
- </gl-tab>
- </gl-tabs>
+ <gl-tab v-for="(tab, index) in tabsToRender" :key="index" :title="tab.title">
+ <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
+ <template #empty-state>
+ <gl-empty-state :title="emptyStateTitle(tab)" :svg-path="emptyListIllustration">
+ <template #description>
+ <gl-sprintf v-if="filterQuery" :message="$options.i18n.widenFilters" />
+ <gl-sprintf v-else :message="$options.i18n.noResults">
+ <template #noPackagesLink="{content}">
+ <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-empty-state>
+ </template>
+ </package-list>
+ </gl-tab>
+ </gl-tabs>
+ </div>
</template>
diff --git a/app/assets/javascripts/packages/list/components/packages_sort.vue b/app/assets/javascripts/packages/list/components/packages_sort.vue
index fa8f4f39d54..47e51bbdca5 100644
--- a/app/assets/javascripts/packages/list/components/packages_sort.vue
+++ b/app/assets/javascripts/packages/list/components/packages_sort.vue
@@ -51,7 +51,7 @@ export default {
<gl-sorting-item
v-for="item in sortableFields"
ref="packageListSortItem"
- :key="item.key"
+ :key="item.orderBy"
@click="onSortItemClick(item.orderBy)"
>
{{ item.label }}
diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js
index 0ff8c86362d..6a0e92bff2d 100644
--- a/app/assets/javascripts/packages/list/constants.js
+++ b/app/assets/javascripts/packages/list/constants.js
@@ -15,7 +15,7 @@ export const GROUP_PAGE_TYPE = 'groups';
export const LIST_KEY_NAME = 'name';
export const LIST_KEY_PROJECT = 'project_path';
export const LIST_KEY_VERSION = 'version';
-export const LIST_KEY_PACKAGE_TYPE = 'package_type';
+export const LIST_KEY_PACKAGE_TYPE = 'type';
export const LIST_KEY_CREATED_AT = 'created_at';
export const LIST_KEY_ACTIONS = 'actions';
@@ -23,47 +23,35 @@ export const LIST_LABEL_NAME = __('Name');
export const LIST_LABEL_PROJECT = __('Project');
export const LIST_LABEL_VERSION = __('Version');
export const LIST_LABEL_PACKAGE_TYPE = __('Type');
-export const LIST_LABEL_CREATED_AT = __('Created');
+export const LIST_LABEL_CREATED_AT = __('Published');
export const LIST_LABEL_ACTIONS = '';
-export const LIST_ORDER_BY_PACKAGE_TYPE = 'type';
-
export const ASCENDING_ODER = 'asc';
export const DESCENDING_ORDER = 'desc';
// The following is not translated because it is used to build a JavaScript exception error message
export const MISSING_DELETE_PATH_ERROR = 'Missing delete_api_path link';
-export const TABLE_HEADER_FIELDS = [
+export const SORT_FIELDS = [
{
- key: LIST_KEY_NAME,
- label: LIST_LABEL_NAME,
orderBy: LIST_KEY_NAME,
- class: ['text-left'],
+ label: LIST_LABEL_NAME,
},
{
- key: LIST_KEY_PROJECT,
- label: LIST_LABEL_PROJECT,
orderBy: LIST_KEY_PROJECT,
- class: ['text-left'],
+ label: LIST_LABEL_PROJECT,
},
{
- key: LIST_KEY_VERSION,
- label: LIST_LABEL_VERSION,
orderBy: LIST_KEY_VERSION,
- class: ['text-center'],
+ label: LIST_LABEL_VERSION,
},
{
- key: LIST_KEY_PACKAGE_TYPE,
+ orderBy: LIST_KEY_PACKAGE_TYPE,
label: LIST_LABEL_PACKAGE_TYPE,
- orderBy: LIST_ORDER_BY_PACKAGE_TYPE,
- class: ['text-center'],
},
{
- key: LIST_KEY_CREATED_AT,
- label: LIST_LABEL_CREATED_AT,
orderBy: LIST_KEY_CREATED_AT,
- class: ['text-center'],
+ label: LIST_LABEL_CREATED_AT,
},
];
@@ -94,7 +82,13 @@ export const PACKAGE_REGISTRY_TABS = [
type: PackageType.NUGET,
},
{
- title: s__('PackageRegistry|PyPi'),
+ title: s__('PackageRegistry|PyPI'),
type: PackageType.PYPI,
},
];
+
+export const LIST_TITLE_TEXT = s__('PackageRegistry|Package Registry');
+
+export const LIST_INTRO_TEXT = s__(
+ 'PackageRegistry|Publish and share packages for a variety of common package managers. %{docLinkStart}More information%{docLinkEnd}',
+);
diff --git a/app/assets/javascripts/packages/list/stores/mutations.js b/app/assets/javascripts/packages/list/stores/mutations.js
index a47ba356c0a..2fe7981b3d9 100644
--- a/app/assets/javascripts/packages/list/stores/mutations.js
+++ b/app/assets/javascripts/packages/list/stores/mutations.js
@@ -1,19 +1,12 @@
import * as types from './mutation_types';
-import {
- parseIntPagination,
- normalizeHeaders,
- convertObjectPropsToCamelCase,
-} from '~/lib/utils/common_utils';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { GROUP_PAGE_TYPE } from '../constants';
export default {
[types.SET_INITIAL_STATE](state, config) {
const { comingSoonJson, ...rest } = config;
- const comingSoonObj = JSON.parse(comingSoonJson);
-
state.config = {
...rest,
- comingSoon: comingSoonObj && convertObjectPropsToCamelCase(comingSoonObj),
isGroupPage: config.pageType === GROUP_PAGE_TYPE,
};
},
diff --git a/app/assets/javascripts/packages/list/utils.js b/app/assets/javascripts/packages/list/utils.js
index 98d78db8706..6a300d7bfe6 100644
--- a/app/assets/javascripts/packages/list/utils.js
+++ b/app/assets/javascripts/packages/list/utils.js
@@ -1,7 +1,6 @@
-import { LIST_KEY_PROJECT, TABLE_HEADER_FIELDS } from './constants';
+import { LIST_KEY_PROJECT, SORT_FIELDS } from './constants';
-export default isGroupPage =>
- TABLE_HEADER_FIELDS.filter(f => f.key !== LIST_KEY_PROJECT || isGroupPage);
+export default isGroupPage => SORT_FIELDS.filter(f => f.key !== LIST_KEY_PROJECT || isGroupPage);
/**
* A small util function that works out if the delete action has deleted the
diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages/shared/components/package_list_row.vue
index f93bc51d185..d55ca80a7fc 100644
--- a/app/assets/javascripts/packages/shared/components/package_list_row.vue
+++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlIcon, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
import PackageTags from './package_tags.vue';
+import PackagePath from './package_path.vue';
import PublishMethod from './publish_method.vue';
import { getPackageTypeLabel } from '../utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -15,6 +16,7 @@ export default {
GlSprintf,
GlTruncate,
PackageTags,
+ PackagePath,
PublishMethod,
ListItem,
},
@@ -92,22 +94,12 @@ export default {
</gl-sprintf>
</div>
- <div v-if="hasProjectLink" class="gl-display-flex gl-align-items-center">
- <gl-icon name="review-list" class="gl-ml-3 gl-mr-2 gl-min-w-0" />
-
- <gl-link
- class="gl-text-body gl-min-w-0"
- data-testid="packages-row-project"
- :href="`/${packageEntity.project_path}`"
- >
- <gl-truncate :text="packageEntity.projectPathName" />
- </gl-link>
- </div>
-
<div v-if="showPackageType" class="d-flex align-items-center" data-testid="package-type">
<gl-icon name="package" class="gl-ml-3 gl-mr-2" />
<span>{{ packageType }}</span>
</div>
+
+ <package-path v-if="hasProjectLink" :path="packageEntity.project_path" />
</div>
</template>
diff --git a/app/assets/javascripts/packages/shared/components/package_path.vue b/app/assets/javascripts/packages/shared/components/package_path.vue
new file mode 100644
index 00000000000..9afe06ab497
--- /dev/null
+++ b/app/assets/javascripts/packages/shared/components/package_path.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui';
+import { joinPaths } from '~/lib/utils/url_utility';
+
+export default {
+ name: 'PackagePath',
+ components: {
+ GlIcon,
+ GlLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ pathPieces() {
+ return this.path.split('/');
+ },
+ root() {
+ // we skip the first part of the path since is the 'base' group
+ return this.pathPieces[1];
+ },
+ rootLink() {
+ return joinPaths(this.pathPieces[0], this.root);
+ },
+ leaf() {
+ return this.pathPieces[this.pathPieces.length - 1];
+ },
+ deeplyNested() {
+ return this.pathPieces.length > 3;
+ },
+ hasGroup() {
+ return this.root !== this.leaf;
+ },
+ },
+};
+</script>
+
+<template>
+ <div data-qa-selector="package-path" class="gl-display-flex gl-align-items-center">
+ <gl-icon data-testid="base-icon" name="project" class="gl-mx-3 gl-min-w-0" />
+
+ <gl-link data-testid="root-link" class="gl-text-gray-500 gl-min-w-0" :href="`/${rootLink}`">
+ {{ root }}
+ </gl-link>
+
+ <template v-if="hasGroup">
+ <gl-icon data-testid="root-chevron" name="chevron-right" class="gl-mx-2 gl-min-w-0" />
+
+ <template v-if="deeplyNested">
+ <span
+ v-gl-tooltip="{ title: path }"
+ data-testid="ellipsis-icon"
+ class="gl-inset-border-1-gray-200 gl-rounded-base gl-px-2 gl-min-w-0"
+ >
+ <gl-icon name="ellipsis_h" />
+ </span>
+ <gl-icon data-testid="ellipsis-chevron" name="chevron-right" class="gl-mx-2 gl-min-w-0" />
+ </template>
+
+ <gl-link data-testid="leaf-link" class="gl-text-gray-500 gl-min-w-0" :href="`/${path}`">
+ {{ leaf }}
+ </gl-link>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages/shared/components/publish_method.vue b/app/assets/javascripts/packages/shared/components/publish_method.vue
index d17e23c4032..8a66a33f2ab 100644
--- a/app/assets/javascripts/packages/shared/components/publish_method.vue
+++ b/app/assets/javascripts/packages/shared/components/publish_method.vue
@@ -49,7 +49,8 @@ export default {
<clipboard-button
:text="packageEntity.pipeline.sha"
:title="__('Copy commit SHA')"
- css-class="gl-border-0 gl-py-0 gl-px-2"
+ category="tertiary"
+ size="small"
/>
</template>
diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js
index e5131db59bf..c481abd8658 100644
--- a/app/assets/javascripts/packages/shared/constants.js
+++ b/app/assets/javascripts/packages/shared/constants.js
@@ -14,9 +14,6 @@ export const TrackingActions = {
REQUEST_DELETE_PACKAGE: 'request_delete_package',
CANCEL_DELETE_PACKAGE: 'cancel_delete_package',
PULL_PACKAGE: 'pull_package',
- COMING_SOON_REQUESTED: 'activate_coming_soon_requested',
- COMING_SOON_LIST: 'click_coming_soon_issue_link',
- COMING_SOON_HELP: 'click_coming_soon_documentation_link',
};
export const TrackingCategories = {
diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js
index a0c7389651d..b0807558266 100644
--- a/app/assets/javascripts/packages/shared/utils.js
+++ b/app/assets/javascripts/packages/shared/utils.js
@@ -18,7 +18,7 @@ export const getPackageTypeLabel = packageType => {
case PackageType.NUGET:
return s__('PackageType|NuGet');
case PackageType.PYPI:
- return s__('PackageType|PyPi');
+ return s__('PackageType|PyPI');
case PackageType.COMPOSER:
return s__('PackageType|Composer');
diff --git a/app/assets/javascripts/pages/admin/credentials/index.js b/app/assets/javascripts/pages/admin/credentials/index.js
new file mode 100644
index 00000000000..868c8e33077
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/credentials/index.js
@@ -0,0 +1,3 @@
+import initConfirmModal from '~/confirm_modal';
+
+initConfirmModal();
diff --git a/app/assets/javascripts/pages/admin/instance_statistics/index.js b/app/assets/javascripts/pages/admin/instance_statistics/index.js
new file mode 100644
index 00000000000..d6b0a834ce3
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/instance_statistics/index.js
@@ -0,0 +1,3 @@
+import initInstanceStatisticsApp from '~/analytics/instance_statistics';
+
+document.addEventListener('DOMContentLoaded', () => initInstanceStatisticsApp());
diff --git a/app/assets/javascripts/pages/admin/keys/index.js b/app/assets/javascripts/pages/admin/keys/index.js
new file mode 100644
index 00000000000..45b83ffcd67
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/keys/index.js
@@ -0,0 +1,5 @@
+import initConfirmModal from '~/confirm_modal';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initConfirmModal();
+});
diff --git a/app/assets/javascripts/pages/admin/users/keys/index.js b/app/assets/javascripts/pages/admin/users/keys/index.js
new file mode 100644
index 00000000000..45b83ffcd67
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/users/keys/index.js
@@ -0,0 +1,5 @@
+import initConfirmModal from '~/confirm_modal';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initConfirmModal();
+});
diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
index 10df18c85e7..7adae2cdb05 100644
--- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js
+++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
@@ -5,7 +5,7 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered
import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
- addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
+ addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys, true);
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 3fa3a132dfa..dc647f5d3cb 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -4,7 +4,8 @@ import memberExpirationDate from '~/member_expiration_date';
import UsersSelect from '~/users_select';
import groupsSelect from '~/groups_select';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
-import initGroupMembersApp from '~/groups/members';
+import { initGroupMembersApp } from '~/groups/members';
+import { memberRequestFormatter, groupLinkRequestFormatter } from '~/groups/members/utils';
function mountRemoveMemberModal() {
const el = document.querySelector('.js-remove-member-modal');
@@ -26,10 +27,28 @@ document.addEventListener('DOMContentLoaded', () => {
memberExpirationDate('.js-access-expiration-date-groups');
mountRemoveMemberModal();
- initGroupMembersApp(document.querySelector('.js-group-members-list'));
- initGroupMembersApp(document.querySelector('.js-group-linked-list'));
- initGroupMembersApp(document.querySelector('.js-group-invited-members-list'));
- initGroupMembersApp(document.querySelector('.js-group-access-requests-list'));
+ const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
+
+ initGroupMembersApp(
+ document.querySelector('.js-group-members-list'),
+ SHARED_FIELDS.concat(['source', 'granted']),
+ memberRequestFormatter,
+ );
+ initGroupMembersApp(
+ document.querySelector('.js-group-linked-list'),
+ SHARED_FIELDS.concat('granted'),
+ groupLinkRequestFormatter,
+ );
+ initGroupMembersApp(
+ document.querySelector('.js-group-invited-members-list'),
+ SHARED_FIELDS.concat('invited'),
+ memberRequestFormatter,
+ );
+ initGroupMembersApp(
+ document.querySelector('.js-group-access-requests-list'),
+ SHARED_FIELDS.concat('requested'),
+ memberRequestFormatter,
+ );
new Members(); // eslint-disable-line no-new
new UsersSelect(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js
index ae481d16ee9..4d0a03e151a 100644
--- a/app/assets/javascripts/pages/groups/issues/index.js
+++ b/app/assets/javascripts/pages/groups/issues/index.js
@@ -8,18 +8,16 @@ import initManualOrdering from '~/manual_ordering';
const ISSUE_BULK_UPDATE_PREFIX = 'issue_';
-document.addEventListener('DOMContentLoaded', () => {
- IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
- issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX);
+IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
+issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX);
- initIssuablesList();
+initIssuablesList();
- initFilteredSearch({
- page: FILTERED_SEARCH.ISSUES,
- isGroupDecendent: true,
- useDefaultState: true,
- filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
- });
- projectSelect();
- initManualOrdering();
+initFilteredSearch({
+ page: FILTERED_SEARCH.ISSUES,
+ isGroupDecendent: true,
+ useDefaultState: true,
+ filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
+projectSelect();
+initManualOrdering();
diff --git a/app/assets/javascripts/pages/groups/registry/repositories/index.js b/app/assets/javascripts/pages/groups/registry/repositories/index.js
index cdafe838994..6fd32321568 100644
--- a/app/assets/javascripts/pages/groups/registry/repositories/index.js
+++ b/app/assets/javascripts/pages/groups/registry/repositories/index.js
@@ -1,10 +1,8 @@
import registryExplorer from '~/registry/explorer/index';
-document.addEventListener('DOMContentLoaded', () => {
- const explorer = registryExplorer();
+const explorer = registryExplorer();
- if (explorer) {
- explorer.attachBreadcrumb();
- explorer.attachMainComponent();
- }
-});
+if (explorer) {
+ explorer.attachBreadcrumb();
+ explorer.attachMainComponent();
+}
diff --git a/app/assets/javascripts/pages/groups/security/credentials/index.js b/app/assets/javascripts/pages/groups/security/credentials/index.js
new file mode 100644
index 00000000000..868c8e33077
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/security/credentials/index.js
@@ -0,0 +1,3 @@
+import initConfirmModal from '~/confirm_modal';
+
+initConfirmModal();
diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
index add483843df..67eb09da5e0 100644
--- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
@@ -4,6 +4,7 @@ import initVariableList from '~/ci_variable_list';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import GroupRunnersFilteredSearchTokenKeys from '~/filtered_search/group_runners_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
+import initSharedRunnersForm from '~/group_settings/mount_shared_runners';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
@@ -29,4 +30,6 @@ document.addEventListener('DOMContentLoaded', () => {
maskableRegex: variableListEl.dataset.maskableRegex,
});
}
+
+ initSharedRunnersForm();
});
diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
index 983062c79f1..93fe38831be 100644
--- a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
+++ b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
@@ -1,16 +1,15 @@
<script>
-import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { GlSafeHtmlDirective as SafeHtml, GlModal } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as Flash } from '~/flash';
-import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
-import { n__, s__, sprintf } from '~/locale';
+import { __, n__, s__, sprintf } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
export default {
components: {
- DeprecatedModal,
+ GlModal,
},
directives: {
SafeHtml,
@@ -115,20 +114,24 @@ Once deleted, it cannot be undone or recovered.`),
});
},
},
+ primaryProps: {
+ text: s__('Milestones|Delete milestone'),
+ attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ },
+ cancelProps: {
+ text: __('Cancel'),
+ },
};
</script>
<template>
- <deprecated-modal
- id="delete-milestone-modal"
+ <gl-modal
+ modal-id="delete-milestone-modal"
:title="title"
- :text="text"
- :primary-button-label="s__('Milestones|Delete milestone')"
- kind="danger"
- @submit="onSubmit"
+ :action-primary="$options.primaryProps"
+ :action-cancel="$options.cancelProps"
+ @primary="onSubmit"
>
- <template #body="props">
- <p v-safe-html="props.text"></p>
- </template>
- </deprecated-modal>
+ <p v-safe-html="text"></p>
+ </gl-modal>
</template>
diff --git a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js
index 1d559dc6e41..6e68114e04b 100644
--- a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js
+++ b/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
-import deleteMilestoneModal from './components/delete_milestone_modal.vue';
+import DeleteMilestoneModal from './components/delete_milestone_modal.vue';
import eventHub from './event_hub';
export default () => {
@@ -18,6 +18,8 @@ export default () => {
button.querySelector('.js-loading-icon').classList.add('hidden');
};
+ const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button');
+
const onRequestStarted = milestoneUrl => {
const button = document.querySelector(
`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`,
@@ -27,35 +29,8 @@ export default () => {
eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished);
};
- const onDeleteButtonClick = event => {
- const button = event.currentTarget;
- const modalProps = {
- milestoneId: parseInt(button.dataset.milestoneId, 10),
- milestoneTitle: button.dataset.milestoneTitle,
- milestoneUrl: button.dataset.milestoneUrl,
- issueCount: parseInt(button.dataset.milestoneIssueCount, 10),
- mergeRequestCount: parseInt(button.dataset.milestoneMergeRequestCount, 10),
- };
- eventHub.$once('deleteMilestoneModal.requestStarted', onRequestStarted);
- eventHub.$emit('deleteMilestoneModal.props', modalProps);
- };
-
- const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button');
- deleteMilestoneButtons.forEach(button => {
- button.addEventListener('click', onDeleteButtonClick);
- });
-
- eventHub.$once('deleteMilestoneModal.mounted', () => {
- deleteMilestoneButtons.forEach(button => {
- button.removeAttribute('disabled');
- });
- });
-
return new Vue({
- el: '#delete-milestone-modal',
- components: {
- deleteMilestoneModal,
- },
+ el: '#js-delete-milestone-modal',
data() {
return {
modalProps: {
@@ -69,10 +44,21 @@ export default () => {
},
mounted() {
eventHub.$on('deleteMilestoneModal.props', this.setModalProps);
- eventHub.$emit('deleteMilestoneModal.mounted');
- },
- beforeDestroy() {
- eventHub.$off('deleteMilestoneModal.props', this.setModalProps);
+ deleteMilestoneButtons.forEach(button => {
+ button.removeAttribute('disabled');
+ button.addEventListener('click', () => {
+ this.$root.$emit('bv::show::modal', 'delete-milestone-modal');
+ eventHub.$once('deleteMilestoneModal.requestStarted', onRequestStarted);
+
+ this.setModalProps({
+ milestoneId: parseInt(button.dataset.milestoneId, 10),
+ milestoneTitle: button.dataset.milestoneTitle,
+ milestoneUrl: button.dataset.milestoneUrl,
+ issueCount: parseInt(button.dataset.milestoneIssueCount, 10),
+ mergeRequestCount: parseInt(button.dataset.milestoneMergeRequestCount, 10),
+ });
+ });
+ });
},
methods: {
setModalProps(modalProps) {
@@ -80,7 +66,7 @@ export default () => {
},
},
render(createElement) {
- return createElement(deleteMilestoneModal, {
+ return createElement(DeleteMilestoneModal, {
props: this.modalProps,
});
},
diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js
index d3dcd21f456..4214d5bffb2 100644
--- a/app/assets/javascripts/pages/profiles/keys/index.js
+++ b/app/assets/javascripts/pages/profiles/keys/index.js
@@ -1,6 +1,9 @@
+import initConfirmModal from '~/confirm_modal';
import AddSshKeyValidation from '~/profile/add_ssh_key_validation';
document.addEventListener('DOMContentLoaded', () => {
+ initConfirmModal();
+
const input = document.querySelector('.js-add-ssh-key-validation-input');
if (!input) return;
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 46e59cd6572..f2e8cb38ef5 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -3,34 +3,33 @@ import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_sta
import BlobViewer from '~/blob/viewer/index';
import initBlob from '~/pages/projects/init_blob';
import GpgBadges from '~/gpg_badges';
+import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import '~/sourcegraph/load';
import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { isExperimentEnabled } from '~/lib/utils/experimentation';
const createGitlabCiYmlVisualization = (containerId = '#js-blob-toggle-graph-preview') => {
const el = document.querySelector(containerId);
- const { filename, blobData } = el?.dataset;
+ const { isCiConfigFile, blobData } = el?.dataset;
- const nameRegexp = /\.gitlab-ci.yml/;
-
- if (!el || !nameRegexp.test(filename)) {
- return;
+ if (el && parseBoolean(isCiConfigFile)) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ GitlabCiYamlVisualization: () =>
+ import('~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue'),
+ },
+ render(createElement) {
+ return createElement('gitlabCiYamlVisualization', {
+ props: {
+ blobData,
+ },
+ });
+ },
+ });
}
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- components: {
- GitlabCiYamlVisualization: () =>
- import('~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue'),
- },
- render(createElement) {
- return createElement('gitlabCiYamlVisualization', {
- props: {
- blobData,
- },
- });
- },
- });
};
document.addEventListener('DOMContentLoaded', () => {
@@ -57,11 +56,13 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
+ initWebIdeLink({ el: document.getElementById('js-blob-web-ide-link') });
+
GpgBadges.fetch();
const codeNavEl = document.getElementById('js-code-navigation');
- if (gon.features?.codeNavigation && codeNavEl) {
+ if (codeNavEl) {
const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset;
// eslint-disable-next-line promise/catch-or-return
@@ -73,7 +74,7 @@ document.addEventListener('DOMContentLoaded', () => {
);
}
- if (gon.features?.suggestPipeline) {
+ if (isExperimentEnabled('suggestPipeline')) {
const successPipelineEl = document.querySelector('.js-success-pipeline-modal');
if (successPipelineEl) {
diff --git a/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js b/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js
index d270bee25c7..df635522e94 100644
--- a/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js
+++ b/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js
@@ -1,13 +1,11 @@
-import createFlash from '~/flash';
-import { BLOB_EDITOR_ERROR } from '~/blob_edit/constants';
+import EditorLite from '~/editor/editor_lite';
export default class CILintEditor {
constructor() {
- const monacoEnabled = window?.gon?.features?.monacoCi;
this.clearYml = document.querySelector('.clear-yml');
this.clearYml.addEventListener('click', this.clear.bind(this));
- return monacoEnabled ? this.initEditorLite() : this.initAce();
+ return this.initEditorLite();
}
clear() {
@@ -15,34 +13,20 @@ export default class CILintEditor {
}
initEditorLite() {
- import(/* webpackChunkName: 'monaco_editor_lite' */ '~/editor/editor_lite')
- .then(({ default: EditorLite }) => {
- const editorEl = document.getElementById('editor');
- const fileContentEl = document.getElementById('content');
- const form = document.querySelector('.js-ci-lint-form');
+ const editorEl = document.getElementById('editor');
+ const fileContentEl = document.getElementById('content');
+ const form = document.querySelector('.js-ci-lint-form');
- const rootEditor = new EditorLite();
+ const rootEditor = new EditorLite();
- this.editor = rootEditor.createInstance({
- el: editorEl,
- blobPath: '.gitlab-ci.yml',
- blobContent: editorEl.innerText,
- });
-
- form.addEventListener('submit', () => {
- fileContentEl.value = this.editor.getValue();
- });
- })
- .catch(() => createFlash({ message: BLOB_EDITOR_ERROR }));
- }
-
- initAce() {
- this.editor = window.ace.edit('ci-editor');
- this.textarea = document.getElementById('content');
+ this.editor = rootEditor.createInstance({
+ el: editorEl,
+ blobPath: '.gitlab-ci.yml',
+ blobContent: editorEl.innerText,
+ });
- this.editor.getSession().setMode('ace/mode/yaml');
- this.editor.on('input', () => {
- this.textarea.value = this.editor.getSession().getValue();
+ form.addEventListener('submit', () => {
+ fileContentEl.value = this.editor.getValue();
});
}
}
diff --git a/app/assets/javascripts/pages/projects/ci/lints/new/index.js b/app/assets/javascripts/pages/projects/ci/lints/new/index.js
index 02bfee9810f..957801320c9 100644
--- a/app/assets/javascripts/pages/projects/ci/lints/new/index.js
+++ b/app/assets/javascripts/pages/projects/ci/lints/new/index.js
@@ -1,11 +1,17 @@
-import CILintEditor from '../ci_lint_editor';
-import initCILint from '~/ci_lint/index';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+
+const ERROR = __('An error occurred while rendering the linter');
document.addEventListener('DOMContentLoaded', () => {
if (gon?.features?.ciLintVue) {
- initCILint();
+ import(/* webpackChunkName: 'ciLintIndex' */ '~/ci_lint/index')
+ .then(module => module.default())
+ .catch(() => createFlash(ERROR));
} else {
- // eslint-disable-next-line no-new
- new CILintEditor();
+ import(/* webpackChunkName: 'ciLintEditor' */ '../ci_lint_editor')
+ // eslint-disable-next-line new-cap
+ .then(module => new module.default())
+ .catch(() => createFlash(ERROR));
}
});
diff --git a/app/assets/javascripts/pages/projects/ci/lints/show/index.js b/app/assets/javascripts/pages/projects/ci/lints/show/index.js
index 8e8a843da0b..957801320c9 100644
--- a/app/assets/javascripts/pages/projects/ci/lints/show/index.js
+++ b/app/assets/javascripts/pages/projects/ci/lints/show/index.js
@@ -1,3 +1,17 @@
-import CILintEditor from '../ci_lint_editor';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
-document.addEventListener('DOMContentLoaded', () => new CILintEditor());
+const ERROR = __('An error occurred while rendering the linter');
+
+document.addEventListener('DOMContentLoaded', () => {
+ if (gon?.features?.ciLintVue) {
+ import(/* webpackChunkName: 'ciLintIndex' */ '~/ci_lint/index')
+ .then(module => module.default())
+ .catch(() => createFlash(ERROR));
+ } else {
+ import(/* webpackChunkName: 'ciLintEditor' */ '../ci_lint_editor')
+ // eslint-disable-next-line new-cap
+ .then(module => new module.default())
+ .catch(() => createFlash(ERROR));
+ }
+});
diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js
index 744be65bfbe..1124eb5d939 100644
--- a/app/assets/javascripts/pages/projects/clusters/index/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/index/index.js
@@ -1,5 +1,5 @@
+import initClustersListApp from 'ee_else_ce/clusters_list';
import PersistentUserCallout from '~/persistent_user_callout';
-import initClustersListApp from '~/clusters_list';
document.addEventListener('DOMContentLoaded', () => {
const callout = document.querySelector('.gcp-signup-offer');
diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
index 1415a6f60c8..26dea17ca8a 100644
--- a/app/assets/javascripts/pages/projects/commit/pipelines/index.js
+++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
@@ -1,14 +1,8 @@
-import $ from 'jquery';
-import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
+import { initCommitBoxInfo } from '~/projects/commit_box/info';
import initPipelines from '~/commit/pipelines/pipelines_bundle';
-import { fetchCommitMergeRequests } from '~/commit_merge_requests';
document.addEventListener('DOMContentLoaded', () => {
- new MiniPipelineGraph({
- container: '.js-commit-pipeline-graph',
- }).bindEvents();
- // eslint-disable-next-line no-jquery/no-load
- $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
- fetchCommitMergeRequests();
+ initCommitBoxInfo();
+
initPipelines();
});
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index d5fb2a8be3c..32fb35f97e3 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -4,10 +4,8 @@ import $ from 'jquery';
import Diff from '~/diff';
import ZenMode from '~/zen_mode';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
import initNotes from '~/init_notes';
import initChangesDropdown from '~/init_changes_dropdown';
-import { fetchCommitMergeRequests } from '~/commit_merge_requests';
import '~/sourcegraph/load';
import { handleLocationHash } from '~/lib/utils/common_utils';
import axios from '~/lib/utils/axios_utils';
@@ -15,6 +13,7 @@ import syntaxHighlight from '~/syntax_highlight';
import flash from '~/flash';
import { __ } from '~/locale';
import loadAwardsHandler from '~/awards_handler';
+import { initCommitBoxInfo } from '~/projects/commit_box/info';
document.addEventListener('DOMContentLoaded', () => {
const hasPerfBar = document.querySelector('.with-performance-bar');
@@ -22,13 +21,10 @@ document.addEventListener('DOMContentLoaded', () => {
initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight);
new ZenMode();
new ShortcutsNavigation();
- new MiniPipelineGraph({
- container: '.js-commit-pipeline-graph',
- }).bindEvents();
+
+ initCommitBoxInfo();
+
initNotes();
- // eslint-disable-next-line no-jquery/no-load
- $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
- fetchCommitMergeRequests();
const filesContainer = $('.js-diffs-batch');
diff --git a/app/assets/javascripts/pages/projects/environments/index/index.js b/app/assets/javascripts/pages/projects/environments/index/index.js
index ace8af00ece..4d5106f6d5f 100644
--- a/app/assets/javascripts/pages/projects/environments/index/index.js
+++ b/app/assets/javascripts/pages/projects/environments/index/index.js
@@ -1,3 +1,3 @@
-import initEnviroments from '~/environments/';
+import initEnvironments from '~/environments/';
-document.addEventListener('DOMContentLoaded', initEnviroments);
+document.addEventListener('DOMContentLoaded', initEnvironments);
diff --git a/app/assets/javascripts/pages/projects/feature_flags/edit/index.js b/app/assets/javascripts/pages/projects/feature_flags/edit/index.js
new file mode 100644
index 00000000000..36b1d800103
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/feature_flags/edit/index.js
@@ -0,0 +1,3 @@
+import initEditFeatureFlags from '~/feature_flags/edit';
+
+initEditFeatureFlags();
diff --git a/app/assets/javascripts/pages/projects/feature_flags/index/index.js b/app/assets/javascripts/pages/projects/feature_flags/index/index.js
new file mode 100644
index 00000000000..c11a5c929ee
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/feature_flags/index/index.js
@@ -0,0 +1,3 @@
+import initFeatureFlags from '~/feature_flags';
+
+initFeatureFlags();
diff --git a/app/assets/javascripts/pages/projects/feature_flags/new/index.js b/app/assets/javascripts/pages/projects/feature_flags/new/index.js
new file mode 100644
index 00000000000..d598f6b31dd
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/feature_flags/new/index.js
@@ -0,0 +1,3 @@
+import initNewFeatureFlags from '~/feature_flags/new';
+
+initNewFeatureFlags();
diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js
new file mode 100644
index 00000000000..bbe84322462
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js
@@ -0,0 +1,19 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import EditUserList from '~/user_lists/components/edit_user_list.vue';
+import createStore from '~/user_lists/store/edit';
+
+Vue.use(Vuex);
+
+document.addEventListener('DOMContentLoaded', () => {
+ const el = document.getElementById('js-edit-user-list');
+ const { userListsDocsPath } = el.dataset;
+ return new Vue({
+ el,
+ store: createStore(el.dataset),
+ provide: { userListsDocsPath },
+ render(h) {
+ return h(EditUserList, {});
+ },
+ });
+});
diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js
new file mode 100644
index 00000000000..679f0af8efc
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import NewUserList from '~/user_lists/components/new_user_list.vue';
+import createStore from '~/user_lists/store/new';
+
+Vue.use(Vuex);
+
+document.addEventListener('DOMContentLoaded', () => {
+ const el = document.getElementById('js-new-user-list');
+ const { userListsDocsPath, featureFlagsPath } = el.dataset;
+ return new Vue({
+ el,
+ store: createStore(el.dataset),
+ provide: {
+ userListsDocsPath,
+ featureFlagsPath,
+ },
+ render(h) {
+ return h(NewUserList);
+ },
+ });
+});
diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js
new file mode 100644
index 00000000000..bccd9dce2ec
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import UserList from '~/user_lists/components/user_list.vue';
+import createStore from '~/user_lists/store/show';
+
+Vue.use(Vuex);
+
+document.addEventListener('DOMContentLoaded', () => {
+ const el = document.getElementById('js-edit-user-list');
+ return new Vue({
+ el,
+ store: createStore(el.dataset),
+ render(h) {
+ const { emptyStatePath } = el.dataset;
+ return h(UserList, { props: { emptyStatePath } });
+ },
+ });
+});
diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js
index 384216f29eb..74abd1f67a5 100644
--- a/app/assets/javascripts/pages/projects/graphs/charts/index.js
+++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js
@@ -1,6 +1,6 @@
-import Vue from 'vue';
import { GlColumnChart } from '@gitlab/ui/dist/charts';
-import { waitForCSSLoaded } from '../../../../helpers/startup_css_helper';
+import Vue from 'vue';
+import { waitForCSSLoaded } from '~/helpers/startup_css_helper';
import { __ } from '~/locale';
import CodeCoverage from '../components/code_coverage.vue';
import SeriesDataMixin from './series_data_mixin';
diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
index 5d59880d497..a9079f91f50 100644
--- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
+++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
@@ -1,11 +1,5 @@
<script>
-import {
- GlAlert,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
- GlIcon,
- GlSprintf,
-} from '@gitlab/ui';
+import { GlAlert, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
import { get } from 'lodash';
@@ -17,9 +11,8 @@ export default {
components: {
GlAlert,
GlAreaChart,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
- GlIcon,
+ GlDropdown,
+ GlDropdownItem,
GlSprintf,
},
props: {
@@ -140,25 +133,18 @@ export default {
{{ __('It seems that there is currently no available data for code coverage') }}
</span>
</gl-alert>
- <gl-deprecated-dropdown v-if="canShowData" :text="selectedDailyCoverageName">
- <gl-deprecated-dropdown-item
+ <gl-dropdown v-if="canShowData" :text="selectedDailyCoverageName">
+ <gl-dropdown-item
v-for="({ group_name }, index) in dailyCoverageData"
:key="index"
:value="group_name"
+ :is-check-item="true"
+ :is-checked="index === selectedCoverageIndex"
@click="setSelectedCoverage(index)"
>
- <div class="gl-display-flex">
- <gl-icon
- v-if="index === selectedCoverageIndex"
- name="mobile-issue-close"
- class="gl-absolute"
- />
- <span class="gl-display-flex align-items-center ml-4">
- {{ group_name }}
- </span>
- </div>
- </gl-deprecated-dropdown-item>
- </gl-deprecated-dropdown>
+ {{ group_name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
<gl-area-chart
v-if="!isLoading"
diff --git a/app/assets/javascripts/pages/projects/incidents/show/index.js b/app/assets/javascripts/pages/projects/incidents/show/index.js
new file mode 100644
index 00000000000..3324cfc0335
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/incidents/show/index.js
@@ -0,0 +1,11 @@
+import initSidebarBundle from '~/sidebar/sidebar_bundle';
+import initRelatedIssues from '~/related_issues';
+import initShow from '../../issues/show';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initShow();
+ if (!gon.features?.vueIssuableSidebar) {
+ initSidebarBundle();
+ }
+ initRelatedIssues();
+});
diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js
index 8e0af018b61..3e9962a4e72 100644
--- a/app/assets/javascripts/pages/projects/index.js
+++ b/app/assets/javascripts/pages/projects/index.js
@@ -1,7 +1,5 @@
import Project from './project';
import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation';
-document.addEventListener('DOMContentLoaded', () => {
- new Project(); // eslint-disable-line no-new
- new ShortcutsNavigation(); // eslint-disable-line no-new
-});
+new Project(); // eslint-disable-line no-new
+new ShortcutsNavigation(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
index e1add4a2af3..f3ccedc47c8 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -11,20 +11,18 @@ import initIssuablesList from '~/issues_list';
import initManualOrdering from '~/manual_ordering';
import { showLearnGitLabIssuesPopover } from '~/onboarding_issues';
-document.addEventListener('DOMContentLoaded', () => {
- IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
+IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
- initFilteredSearch({
- page: FILTERED_SEARCH.ISSUES,
- filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
- useDefaultState: true,
- });
+initFilteredSearch({
+ page: FILTERED_SEARCH.ISSUES,
+ filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
+ useDefaultState: true,
+});
- new IssuableIndex(ISSUABLE_INDEX.ISSUE);
- new ShortcutsNavigation();
- new UsersSelect();
+new IssuableIndex(ISSUABLE_INDEX.ISSUE);
+new ShortcutsNavigation();
+new UsersSelect();
- initManualOrdering();
- initIssuablesList();
- showLearnGitLabIssuesPopover();
-});
+initManualOrdering();
+initIssuablesList();
+showLearnGitLabIssuesPopover();
diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js
index aecc6484b26..48afd2142ee 100644
--- a/app/assets/javascripts/pages/projects/issues/new/index.js
+++ b/app/assets/javascripts/pages/projects/issues/new/index.js
@@ -1,3 +1,3 @@
import initForm from 'ee_else_ce/pages/projects/issues/form';
-document.addEventListener('DOMContentLoaded', initForm);
+initForm();
diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js
index e0c1332796f..231ee6732e9 100644
--- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js
+++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js
@@ -1,17 +1,15 @@
import FilteredSearchServiceDesk from './filtered_search';
import initIssuablesList from '~/issues_list';
-document.addEventListener('DOMContentLoaded', () => {
- const supportBotData = JSON.parse(
- document.querySelector('.js-service-desk-issues').dataset.supportBot,
- );
+const supportBotData = JSON.parse(
+ document.querySelector('.js-service-desk-issues').dataset.supportBot,
+);
- if (document.querySelector('.filtered-search')) {
- const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData);
- filteredSearchManager.setup();
- }
+if (document.querySelector('.filtered-search')) {
+ const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData);
+ filteredSearchManager.setup();
+}
- if (gon.features?.vueIssuablesList) {
- initIssuablesList();
- }
-});
+if (gon.features?.vueIssuablesList) {
+ initIssuablesList();
+}
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 98ae4e26257..a58b5d3f37c 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -10,16 +10,24 @@ import initIncidentApp from '~/issue_show/incident';
import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning';
import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace';
import initRelatedMergeRequestsApp from '~/related_merge_requests';
-import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
import { parseIssuableData } from '~/issue_show/utils/parse_data';
+import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
+import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
+
+import { IssuableType } from '~/issuable_show/constants';
export default function() {
const { issueType, ...issuableData } = parseIssuableData();
- if (issueType === 'incident') {
- initIncidentApp(issuableData);
- } else {
- initIssueApp(issuableData);
+ switch (issueType) {
+ case IssuableType.Incident:
+ initIncidentApp(issuableData);
+ break;
+ case IssuableType.Issue:
+ initIssueApp(issuableData);
+ break;
+ default:
+ break;
}
initIssuableHeaderWarning(store);
@@ -30,14 +38,14 @@ export default function() {
.then(module => module.default())
.catch(() => {});
- new Issue(); // eslint-disable-line no-new
- new ShortcutsIssuable(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new
- if (gon.features && gon.features.vueIssuableSidebar) {
- initVueIssuableSidebarApp();
- } else {
+
+ if (issueType !== IssuableType.TestCase) {
+ new Issue(); // eslint-disable-line no-new
+ new ShortcutsIssuable(); // eslint-disable-line no-new
initIssuableSidebar();
+ loadAwardsHandler();
+ initInviteMemberModal();
+ initInviteMemberTrigger();
}
-
- loadAwardsHandler();
}
diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js
index aef4feef42c..630add51a97 100644
--- a/app/assets/javascripts/pages/projects/issues/show/index.js
+++ b/app/assets/javascripts/pages/projects/issues/show/index.js
@@ -2,10 +2,8 @@ import initSidebarBundle from '~/sidebar/sidebar_bundle';
import initRelatedIssues from '~/related_issues';
import initShow from '../show';
-document.addEventListener('DOMContentLoaded', () => {
- initShow();
- if (gon.features && !gon.features.vueIssuableSidebar) {
- initSidebarBundle();
- }
- initRelatedIssues();
-});
+initShow();
+if (gon.features && !gon.features.vueIssuableSidebar) {
+ initSidebarBundle();
+}
+initRelatedIssues();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
index ce0b5c80927..94a12cc2706 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
@@ -7,16 +7,15 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
-document.addEventListener('DOMContentLoaded', () => {
- addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
+new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new
- initFilteredSearch({
- page: FILTERED_SEARCH.MERGE_REQUESTS,
- filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
- useDefaultState: true,
- });
+addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
- new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new
- new ShortcutsNavigation(); // eslint-disable-line no-new
- new UsersSelect(); // eslint-disable-line no-new
+initFilteredSearch({
+ page: FILTERED_SEARCH.MERGE_REQUESTS,
+ filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
+ useDefaultState: true,
});
+
+new UsersSelect(); // eslint-disable-line no-new
+new ShortcutsNavigation(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
index 11af50169f5..868e001b182 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -4,21 +4,20 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import { handleLocationHash } from '~/lib/utils/common_utils';
import howToMerge from '~/how_to_merge';
import initPipelines from '~/commit/pipelines/pipelines_bundle';
-import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
import initSourcegraph from '~/sourcegraph';
import loadAwardsHandler from '~/awards_handler';
+import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
+import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
export default function() {
new ZenMode(); // eslint-disable-line no-new
- if (gon.features && gon.features.vueIssuableSidebar) {
- initVueIssuableSidebarApp();
- } else {
- initIssuableSidebar();
- }
+ initIssuableSidebar();
initPipelines();
new ShortcutsIssuable(true); // eslint-disable-line no-new
handleLocationHash();
howToMerge();
initSourcegraph();
loadAwardsHandler();
+ initInviteMemberModal();
+ initInviteMemberTrigger();
}
diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
index 29ebf656fe1..602d749ee07 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -5,12 +5,10 @@ import initShow from '../init_merge_request_show';
import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning';
import store from '~/mr_notes/stores';
-document.addEventListener('DOMContentLoaded', () => {
- initShow();
- if (gon.features && !gon.features.vueIssuableSidebar) {
- initSidebarBundle();
- }
- initMrNotes();
- initReviewBar();
- initIssuableHeaderWarning(store);
-});
+initShow();
+if (gon.features && !gon.features.vueIssuableSidebar) {
+ initSidebarBundle();
+}
+initMrNotes();
+initReviewBar();
+initIssuableHeaderWarning(store);
diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js
index 637ed28a758..477a1ab887b 100644
--- a/app/assets/javascripts/pages/projects/new/index.js
+++ b/app/assets/javascripts/pages/projects/new/index.js
@@ -3,13 +3,14 @@ import initProjectNew from '../../../projects/project_new';
import { __ } from '~/locale';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import Tracking from '~/tracking';
+import { isExperimentEnabled } from '~/lib/utils/experimentation';
document.addEventListener('DOMContentLoaded', () => {
initProjectVisibilitySelector();
initProjectNew.bindEvents();
const { category, property } = gon.tracking_data ?? { category: 'projects:new' };
- const hasNewCreateProjectUi = 'newCreateProjectUi' in gon?.features;
+ const hasNewCreateProjectUi = isExperimentEnabled('newCreateProjectUi');
if (!hasNewCreateProjectUi) {
// Setting additional tracking for HAML template
diff --git a/app/assets/javascripts/pages/projects/packages/packages/index/index.js b/app/assets/javascripts/pages/projects/packages/packages/index/index.js
index 4836900aa28..c94782fdf1b 100644
--- a/app/assets/javascripts/pages/projects/packages/packages/index/index.js
+++ b/app/assets/javascripts/pages/projects/packages/packages/index/index.js
@@ -1,7 +1,3 @@
import initPackageList from '~/packages/list/packages_list_app_bundle';
-document.addEventListener('DOMContentLoaded', () => {
- if (document.getElementById('js-vue-packages-list')) {
- initPackageList();
- }
-});
+initPackageList();
diff --git a/app/assets/javascripts/pages/projects/packages/packages/show/index.js b/app/assets/javascripts/pages/projects/packages/packages/show/index.js
index 1fde4ddfc1d..1afb900ed88 100644
--- a/app/assets/javascripts/pages/projects/packages/packages/show/index.js
+++ b/app/assets/javascripts/pages/projects/packages/packages/show/index.js
@@ -1,3 +1,3 @@
import initPackageDetail from '~/packages/details/';
-document.addEventListener('DOMContentLoaded', initPackageDetail);
+initPackageDetail();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
index 7a3923dfefd..a138a3a3425 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
@@ -1,11 +1,8 @@
<script>
-/* eslint-disable vue/no-v-html */
import Vue from 'vue';
import Cookies from 'js-cookie';
import { GlIcon } from '@gitlab/ui';
import Translate from '../../../../../vue_shared/translate';
-// Full path is needed for Jest to be able to correctly mock this file
-import illustrationSvg from '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(Translate);
@@ -20,12 +17,10 @@ export default {
data() {
return {
docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl,
+ imageUrl: document.getElementById('pipeline-schedules-callout').dataset.imageUrl,
calloutDismissed: parseBoolean(Cookies.get(cookieKey)),
};
},
- created() {
- this.illustrationSvg = illustrationSvg;
- },
methods: {
dismissCallout() {
this.calloutDismissed = true;
@@ -40,7 +35,9 @@ export default {
<button id="dismiss-callout-btn" class="btn btn-default close" @click="dismissCallout">
<gl-icon name="close" aria-hidden="true" />
</button>
- <div class="svg-container" v-html="illustrationSvg"></div>
+ <div class="svg-container">
+ <img :src="imageUrl" />
+ </div>
<div class="user-callout-copy">
<h4>{{ __('Scheduling Pipelines') }}</h4>
<p>
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg
deleted file mode 100644
index 26d1ff97b3e..00000000000
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="140" height="102" viewBox="0 0 140 102" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>illustration</title><defs><rect id="a" width="12.033" height="40.197" rx="3"/><rect id="b" width="12.033" height="40.197" rx="3"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(-.446)"><path d="M91.747 35.675v-6.039a2.996 2.996 0 0 0-2.999-3.005H54.635a2.997 2.997 0 0 0-2.999 3.005v6.039H40.092a3.007 3.007 0 0 0-2.996 3.005v34.187a2.995 2.995 0 0 0 2.996 3.005h11.544V79.9a2.996 2.996 0 0 0 2.999 3.005h34.113a2.997 2.997 0 0 0 2.999-3.005v-4.03h11.544a3.007 3.007 0 0 0 2.996-3.004V38.68a2.995 2.995 0 0 0-2.996-3.005H91.747z" stroke="#B5A7DD" stroke-width="2"/><rect stroke="#E5E5E5" stroke-width="2" fill="#FFF" x="21.556" y="38.69" width="98.27" height="34.167" rx="3"/><path d="M121.325 38.19c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zM121.325 71.854a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038z" fill="#E5E5E5"/><g transform="translate(110.3 35.675)"><use fill="#FFF" xlink:href="#a"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="9.547" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.099" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="30.65" rx="1.504" ry="1.507"/></g><path d="M6.008 38.19c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zM6.008 71.854a1.004 1.004 0 0 1 0 2.006H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039z" fill="#E5E5E5"/><g transform="translate(19.05 35.675)"><use fill="#FFF" xlink:href="#b"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="10.049" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.601" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="31.153" rx="1.504" ry="1.507"/></g><g transform="translate(47.096)"><g transform="translate(7.05)"><ellipse fill="#FC8A51" cx="17.548" cy="5.025" rx="4.512" ry="4.522"/><rect stroke="#B5A7DD" stroke-width="2" fill="#FFF" x="13.036" y="4.02" width="9.025" height="20.099" rx="1.5"/><rect stroke="#FDE5D8" stroke-width="2" fill="#FFF" y="4.02" width="35.096" height="4.02" rx="2.01"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.512" y="18.089" width="26.072" height="17.084" rx="1.5"/></g><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(-45 43.117 35.117)" x="38.168" y="31.416" width="9.899" height="7.403" rx="3.702"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="25" ry="25"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="21" ry="21"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="43.05" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.305" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 74.422)" x="23.677" y="73.653" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 35.51)" x="23.844" y="34.742" width="2.616" height="1.538" rx=".769"/><path d="M13.362 42.502c-.124-.543.198-.854.74-.69l2.321.704c.533.161.643.592.235.972l-.22.206 7.06 7.572a1.002 1.002 0 1 1-1.467 1.368l-7.06-7.573-.118.11c-.402.375-.826.248-.952-.304l-.54-2.365zM21.606 67.576c-.408.38-.84.255-.968-.295l-.551-2.363c-.127-.542.191-.852.725-.69l.288.089 3.027-9.901a1.002 1.002 0 1 1 1.918.586l-3.027 9.901.154.047c.525.16.627.592.213.977l-1.779 1.65z" fill="#FC8A51"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25.099" cy="54.768" rx="2.507" ry="2.512"/></g></g><path d="M52.697 96.966a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zM86.29 96.966c0-.55.444-.996 1.002-.996.554 0 1.003.454 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044c0-.55.444-.996 1.002-.996.554 0 1.003.453 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038z" fill="#E5E5E5"/></g></svg> \ No newline at end of file
diff --git a/app/assets/javascripts/pages/projects/registry/repositories/index.js b/app/assets/javascripts/pages/projects/registry/repositories/index.js
index cdafe838994..6fd32321568 100644
--- a/app/assets/javascripts/pages/projects/registry/repositories/index.js
+++ b/app/assets/javascripts/pages/projects/registry/repositories/index.js
@@ -1,10 +1,8 @@
import registryExplorer from '~/registry/explorer/index';
-document.addEventListener('DOMContentLoaded', () => {
- const explorer = registryExplorer();
+const explorer = registryExplorer();
- if (explorer) {
- explorer.attachBreadcrumb();
- explorer.attachMainComponent();
- }
-});
+if (explorer) {
+ explorer.attachBreadcrumb();
+ explorer.attachMainComponent();
+}
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index ab2a7c099c4..40816420eef 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -4,6 +4,7 @@ import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
import registrySettingsApp from '~/registry/settings/registry_settings_bundle';
import initVariableList from '~/ci_variable_list';
import initDeployFreeze from '~/deploy_freeze';
+import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
@@ -42,4 +43,6 @@ document.addEventListener('DOMContentLoaded', () => {
registrySettingsApp();
initDeployFreeze();
+
+ initSettingsPipelinesTriggers();
});
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
index 533065b2d4d..0f145dbc170 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
@@ -1,17 +1,17 @@
<script>
+import { GlIcon } from '@gitlab/ui';
import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue';
import { featureAccessLevelNone } from '../constants';
export default {
components: {
+ GlIcon,
projectFeatureToggle,
},
-
model: {
prop: 'value',
event: 'change',
},
-
props: {
name: {
type: String,
@@ -34,7 +34,6 @@ export default {
default: false,
},
},
-
computed: {
featureEnabled() {
return this.value !== 0;
@@ -51,7 +50,6 @@ export default {
return this.disabledInput || !this.featureEnabled || this.displayOptions.length < 2;
},
},
-
methods: {
toggleFeature(featureEnabled) {
if (featureEnabled === false || this.options.length < 1) {
@@ -70,14 +68,18 @@ export default {
</script>
<template>
- <div :data-for="name" class="project-feature-controls">
+ <div
+ :data-for="name"
+ class="project-feature-controls gl-display-flex gl-align-items-center gl-my-3 gl-mx-0"
+ >
<input v-if="name" :name="name" :value="value" type="hidden" />
<project-feature-toggle
+ class="gl-flex-grow-0 gl-mr-3"
:value="featureEnabled"
:disabled-input="disabledInput"
@change="toggleFeature"
/>
- <div class="select-wrapper">
+ <div class="select-wrapper gl-flex-fill-1">
<select
:disabled="displaySelectInput"
class="form-control project-repo-select select-control"
@@ -92,7 +94,11 @@ export default {
{{ optionName }}
</option>
</select>
- <i aria-hidden="true" class="fa fa-chevron-down"> </i>
+ <gl-icon
+ name="chevron-down"
+ aria-hidden="true"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index a95f0af46cd..bcf82e264d1 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui';
+import { GlIcon, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui';
import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin';
import { s__ } from '~/locale';
@@ -22,6 +22,7 @@ export default {
projectFeatureSetting,
projectFeatureToggle,
projectSettingRow,
+ GlIcon,
GlSprintf,
GlLink,
GlFormCheckbox,
@@ -292,14 +293,16 @@ export default {
<template>
<div>
- <div class="project-visibility-setting">
+ <div
+ class="project-visibility-setting gl-border-1 gl-border-solid gl-border-gray-100 gl-py-3 gl-px-7 gl-sm-pr-5 gl-sm-pl-5"
+ >
<project-setting-row
ref="project-visibility-settings"
:help-path="visibilityHelpPath"
:label="s__('ProjectSettings|Project visibility')"
>
- <div class="project-feature-controls">
- <div class="select-wrapper">
+ <div class="project-feature-controls gl-display-flex gl-align-items-center gl-my-3 gl-mx-0">
+ <div class="select-wrapper gl-flex-fill-1">
<select
v-model="visibilityLevel"
:disabled="!canChangeVisibilityLevel"
@@ -323,11 +326,16 @@ export default {
>{{ s__('ProjectSettings|Public') }}</option
>
</select>
- <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
+ <gl-icon
+ name="chevron-down"
+ aria-hidden="true"
+ data-hidden="true"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
+ />
</div>
</div>
<span class="form-text text-muted">{{ visibilityLevelDescription }}</span>
- <label v-if="visibilityLevel !== visibilityOptions.PRIVATE" class="request-access">
+ <label v-if="visibilityLevel !== visibilityOptions.PRIVATE" class="gl-line-height-28">
<input
:value="requestAccessEnabled"
type="hidden"
@@ -338,7 +346,10 @@ export default {
</label>
</project-setting-row>
</div>
- <div :class="{ 'highlight-changes': highlightChangesClass }" class="project-feature-settings">
+ <div
+ :class="{ 'highlight-changes': highlightChangesClass }"
+ class="gl-border-1 gl-border-solid gl-border-t-none gl-border-gray-100 gl-mb-5 gl-py-3 gl-px-7 gl-sm-pr-5 gl-sm-pl-5 gl-bg-gray-10"
+ >
<project-setting-row
ref="issues-settings"
:label="s__('ProjectSettings|Issues')"
@@ -361,7 +372,7 @@ export default {
name="project[project_feature_attributes][repository_access_level]"
/>
</project-setting-row>
- <div class="project-feature-setting-group">
+ <div class="project-feature-setting-group gl-pl-7 gl-sm-pl-5">
<project-setting-row
ref="merge-request-settings"
:label="s__('ProjectSettings|Merge requests')"
@@ -516,8 +527,8 @@ export default {
)
"
>
- <div class="project-feature-controls">
- <div class="select-wrapper">
+ <div class="project-feature-controls gl-display-flex gl-align-items-center gl-my-3 gl-mx-0">
+ <div class="select-wrapper gl-flex-fill-1">
<select
v-model="metricsDashboardAccessLevel"
:disabled="metricsOptionsDropdownEnabled"
@@ -535,7 +546,12 @@ export default {
>{{ featureAccessLevelEveryone[1] }}</option
>
</select>
- <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
+ <gl-icon
+ name="chevron-down"
+ aria-hidden="true"
+ data-hidden="true"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
+ />
</div>
</div>
</project-setting-row>
diff --git a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js
new file mode 100644
index 00000000000..5f08943d211
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { joinPaths, webIDEUrl } from '~/lib/utils/url_utility';
+import WebIdeButton from '~/vue_shared/components/web_ide_link.vue';
+
+export default ({ el, router }) => {
+ if (!el) return;
+
+ const { projectPath, ref, isBlob, webIdeUrl, ...options } = convertObjectPropsToCamelCase(
+ JSON.parse(el.dataset.options),
+ );
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ router,
+ render(h) {
+ return h(WebIdeButton, {
+ props: {
+ isBlob,
+ webIdeUrl: isBlob
+ ? webIdeUrl
+ : webIDEUrl(
+ joinPaths('/', projectPath, 'edit', ref, '-', this.$route?.params.path || '', '/'),
+ ),
+ ...options,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index fd522b975a6..dd8141d34c7 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -11,34 +11,34 @@ import Star from '../../../star';
import notificationsDropdown from '../../../notifications_dropdown';
import { showLearnGitLabProjectPopover } from '~/onboarding_issues';
-document.addEventListener('DOMContentLoaded', () => {
- initReadMore();
- new Star(); // eslint-disable-line no-new
- notificationsDropdown();
- new ShortcutsNavigation(); // eslint-disable-line no-new
- new NotificationsForm(); // eslint-disable-line no-new
- // eslint-disable-next-line no-new
- new UserCallout({
- setCalloutPerProject: false,
- className: 'js-autodevops-banner',
- });
-
- // Project show page loads different overview content based on user preferences
- const treeSlider = document.getElementById('js-tree-list');
- if (treeSlider) {
- initBlob();
- initTree();
- }
-
- if (document.querySelector('.blob-viewer')) {
- new BlobViewer(); // eslint-disable-line no-new
- }
-
- if (document.querySelector('.project-show-activity')) {
- new Activities(); // eslint-disable-line no-new
- }
-
- leaveByUrl('project');
-
- showLearnGitLabProjectPopover();
+initReadMore();
+new Star(); // eslint-disable-line no-new
+
+new NotificationsForm(); // eslint-disable-line no-new
+// eslint-disable-next-line no-new
+new UserCallout({
+ setCalloutPerProject: false,
+ className: 'js-autodevops-banner',
});
+
+// Project show page loads different overview content based on user preferences
+const treeSlider = document.getElementById('js-tree-list');
+if (treeSlider) {
+ initBlob();
+ initTree();
+}
+
+if (document.querySelector('.blob-viewer')) {
+ new BlobViewer(); // eslint-disable-line no-new
+}
+
+if (document.querySelector('.project-show-activity')) {
+ new Activities(); // eslint-disable-line no-new
+}
+
+leaveByUrl('project');
+
+showLearnGitLabProjectPopover();
+
+notificationsDropdown();
+new ShortcutsNavigation(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/tags/index/index.js b/app/assets/javascripts/pages/projects/tags/index/index.js
new file mode 100644
index 00000000000..ec56fa3e075
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tags/index/index.js
@@ -0,0 +1,12 @@
+import { initRemoveTag } from '../remove_tag';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initRemoveTag({
+ onDelete: path => {
+ document
+ .querySelector(`[data-path="${path}"]`)
+ .closest('.js-tag-list')
+ .remove();
+ },
+ });
+});
diff --git a/app/assets/javascripts/pages/projects/tags/remove_tag.js b/app/assets/javascripts/pages/projects/tags/remove_tag.js
new file mode 100644
index 00000000000..7e83dbe0565
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tags/remove_tag.js
@@ -0,0 +1,16 @@
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import initConfirmModal from '~/confirm_modal';
+
+export const initRemoveTag = ({ onDelete = () => {} }) => {
+ return initConfirmModal({
+ handleSubmit: (path = '') =>
+ axios
+ .delete(path)
+ .then(() => onDelete(path))
+ .catch(({ response: { data } }) => {
+ const { message } = data;
+ createFlash({ message });
+ }),
+ });
+};
diff --git a/app/assets/javascripts/pages/projects/tags/show/index.js b/app/assets/javascripts/pages/projects/tags/show/index.js
new file mode 100644
index 00000000000..651cc05ca4f
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tags/show/index.js
@@ -0,0 +1,10 @@
+import { redirectTo, getBaseURL, stripFinalUrlSegment } from '~/lib/utils/url_utility';
+import { initRemoveTag } from '../remove_tag';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initRemoveTag({
+ onDelete: (path = '') => {
+ redirectTo(stripFinalUrlSegment([getBaseURL(), path].join('')));
+ },
+ });
+});
diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js
index b19abda2821..4bb461aadad 100644
--- a/app/assets/javascripts/pages/projects/tree/show/index.js
+++ b/app/assets/javascripts/pages/projects/tree/show/index.js
@@ -4,9 +4,7 @@ import initBlob from '~/blob_edit/blob_bundle';
import ShortcutsNavigation from '../../../../behaviors/shortcuts/shortcuts_navigation';
import NewCommitForm from '../../../../new_commit_form';
-document.addEventListener('DOMContentLoaded', () => {
- new ShortcutsNavigation(); // eslint-disable-line no-new
- new NewCommitForm($('.js-create-dir-form')); // eslint-disable-line no-new
- initBlob();
- initTree();
-});
+new NewCommitForm($('.js-create-dir-form')); // eslint-disable-line no-new
+initBlob();
+initTree();
+new ShortcutsNavigation(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/search/show/index.js b/app/assets/javascripts/pages/search/show/index.js
index 92d01343bd5..721219874cf 100644
--- a/app/assets/javascripts/pages/search/show/index.js
+++ b/app/assets/javascripts/pages/search/show/index.js
@@ -1,7 +1,7 @@
import Search from './search';
-import initStateFilter from '~/search/state_filter';
+import initSearchApp from '~/search';
document.addEventListener('DOMContentLoaded', () => {
- initStateFilter();
+ initSearchApp();
return new Search();
});
diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js
index 6ff74325a5e..2cd333f26e1 100644
--- a/app/assets/javascripts/pages/search/show/search.js
+++ b/app/assets/javascripts/pages/search/show/search.js
@@ -4,6 +4,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
import Api from '~/api';
import { __ } from '~/locale';
import Project from '~/pages/projects/project';
+import { visitUrl } from '~/lib/utils/url_utility';
import refreshCounts from './refresh_counts';
import setHighlightClass from './highlight_blob_search_result';
@@ -86,6 +87,10 @@ export default class Search {
$(document)
.off('click', this.searchClear)
.on('click', this.searchClear, this.clearSearchField.bind(this));
+
+ $('a.js-search-clear')
+ .off('click', this.clearSearchFilter)
+ .on('click', this.clearSearchFilter);
}
static submitSearch() {
@@ -108,6 +113,17 @@ export default class Search {
.focus();
}
+ // We need to manually follow the link on the anchors
+ // that have this event bound, as their `click` default
+ // behavior is prevented by the toggle logic.
+ /* eslint-disable-next-line class-methods-use-this */
+ clearSearchFilter(ev) {
+ const $target = $(ev.currentTarget);
+
+ visitUrl($target.href);
+ ev.stopPropagation();
+ }
+
getProjectsData(term) {
return new Promise(resolve => {
if (this.groupId) {
diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js
index 41d43812b5d..ab948fd106f 100644
--- a/app/assets/javascripts/pages/shared/wikis/wikis.js
+++ b/app/assets/javascripts/pages/shared/wikis/wikis.js
@@ -10,7 +10,7 @@ const MARKDOWN_LINK_TEXT = {
};
const TRACKING_EVENT_NAME = 'view_wiki_page';
-const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-0';
+const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-1';
export default class Wikis {
constructor() {
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 165feb1b6aa..e38771785b7 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -36,7 +36,7 @@ export default {
metric: 'active-record',
title: 'pg',
header: s__('PerformanceBar|SQL queries'),
- keys: ['sql'],
+ keys: ['sql', 'cached'],
},
{
metric: 'bullet',
diff --git a/app/assets/javascripts/performance_bar/performance_bar_log.js b/app/assets/javascripts/performance_bar/performance_bar_log.js
index 638c544f2e1..55b4d626e56 100644
--- a/app/assets/javascripts/performance_bar/performance_bar_log.js
+++ b/app/assets/javascripts/performance_bar/performance_bar_log.js
@@ -1,5 +1,6 @@
/* eslint-disable no-console */
import { getCLS, getFID, getLCP } from 'web-vitals';
+import { PERFORMANCE_TYPE_MARK, PERFORMANCE_TYPE_MEASURE } from '~/performance_constants';
const initVitalsLog = () => {
const reportVital = data => {
@@ -16,6 +17,29 @@ const initVitalsLog = () => {
getLCP(reportVital);
};
+const logUserTimingMetrics = () => {
+ const metricsProcessor = list => {
+ const entries = list.getEntries();
+ entries.forEach(entry => {
+ const { name, entryType, startTime, duration } = entry;
+ const typeMapper = {
+ [PERFORMANCE_TYPE_MARK]: String.fromCodePoint(0x1f3af),
+ [PERFORMANCE_TYPE_MEASURE]: String.fromCodePoint(0x1f4d0),
+ };
+ console.group(`${typeMapper[entryType]} ${name}`);
+ if (entryType === PERFORMANCE_TYPE_MARK) {
+ console.log(`Start time: ${startTime}`);
+ } else if (entryType === PERFORMANCE_TYPE_MEASURE) {
+ console.log(`Duration: ${duration}`);
+ }
+ console.log(entry);
+ console.groupEnd();
+ });
+ };
+ const observer = new PerformanceObserver(metricsProcessor);
+ observer.observe({ entryTypes: [PERFORMANCE_TYPE_MEASURE, PERFORMANCE_TYPE_MARK] });
+};
+
const initPerformanceBarLog = () => {
console.log(
`%c ${String.fromCodePoint(0x1f98a)} GitLab performance bar`,
@@ -23,6 +47,7 @@ const initPerformanceBarLog = () => {
);
initVitalsLog();
+ logUserTimingMetrics();
};
export default initPerformanceBarLog;
diff --git a/app/assets/javascripts/performance_constants.js b/app/assets/javascripts/performance_constants.js
index 1a53b925aa4..6b6b6f1da40 100644
--- a/app/assets/javascripts/performance_constants.js
+++ b/app/assets/javascripts/performance_constants.js
@@ -1,12 +1,31 @@
+export const PERFORMANCE_TYPE_MARK = 'mark';
+export const PERFORMANCE_TYPE_MEASURE = 'measure';
+
//
// SNIPPET namespace
//
-// marks
+// Marks
export const SNIPPET_MARK_VIEW_APP_START = 'snippet-view-app-start';
export const SNIPPET_MARK_EDIT_APP_START = 'snippet-edit-app-start';
export const SNIPPET_MARK_BLOBS_CONTENT = 'snippet-blobs-content-finished';
// Measures
export const SNIPPET_MEASURE_BLOBS_CONTENT = 'snippet-blobs-content';
-export const SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP = 'snippet-blobs-content-within-app';
+
+//
+// WebIDE namespace
+//
+
+// Marks
+export const WEBIDE_MARK_APP_START = 'webide-app-start';
+export const WEBIDE_MARK_TREE_START = 'webide-tree-start';
+export const WEBIDE_MARK_TREE_FINISH = 'webide-tree-finished';
+export const WEBIDE_MARK_FILE_START = 'webide-file-start';
+export const WEBIDE_MARK_FILE_CLICKED = 'webide-file-clicked';
+export const WEBIDE_MARK_FILE_FINISH = 'webide-file-finished';
+
+// Measures
+export const WEBIDE_MEASURE_TREE_FROM_REQUEST = 'webide-tree-loading-from-request';
+export const WEBIDE_MEASURE_FILE_FROM_REQUEST = 'webide-file-loading-from-request';
+export const WEBIDE_MEASURE_FILE_AFTER_INTERACTION = 'webide-file-loading-after-interaction';
diff --git a/app/assets/javascripts/performance_utils.js b/app/assets/javascripts/performance_utils.js
new file mode 100644
index 00000000000..1c87ee2086e
--- /dev/null
+++ b/app/assets/javascripts/performance_utils.js
@@ -0,0 +1,10 @@
+export const performanceMarkAndMeasure = ({ mark, measures = [] } = {}) => {
+ window.requestAnimationFrame(() => {
+ if (mark && !performance.getEntriesByName(mark).length) {
+ performance.mark(mark);
+ }
+ measures.forEach(measure => {
+ performance.measure(measure.name, measure.start, measure.end);
+ });
+ });
+};
diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
index be8ce832d20..20067f6646f 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
@@ -3,6 +3,7 @@ import Vue from 'vue';
import { uniqueId } from 'lodash';
import {
GlAlert,
+ GlIcon,
GlButton,
GlForm,
GlFormGroup,
@@ -27,12 +28,13 @@ export default {
variablesDescription: s__(
'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.',
),
- formElementClasses: 'gl-mr-3 gl-mb-3 table-section section-15',
+ formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0',
errorTitle: __('The form contains the following error:'),
warningTitle: __('The form contains the following warning:'),
maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
components: {
GlAlert,
+ GlIcon,
GlButton,
GlForm,
GlFormGroup,
@@ -49,6 +51,10 @@ export default {
type: String,
required: true,
},
+ configVariablesPath: {
+ type: String,
+ required: true,
+ },
projectId: {
type: String,
required: true,
@@ -85,7 +91,7 @@ export default {
return {
searchTerm: '',
refValue: this.refParam,
- variables: {},
+ form: {},
error: null,
warnings: [],
totalWarnings: 0,
@@ -97,9 +103,6 @@ export default {
const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
return this.refs.filter(ref => ref.toLowerCase().includes(lowerCasedSearchTerm));
},
- variablesLength() {
- return Object.keys(this.variables).length;
- },
overMaxWarningsLimit() {
return this.totalWarnings > this.maxWarnings;
},
@@ -112,64 +115,135 @@ export default {
shouldShowWarning() {
return this.warnings.length > 0 && !this.isWarningDismissed;
},
+ variables() {
+ return this.form[this.refValue]?.variables ?? [];
+ },
+ descriptions() {
+ return this.form[this.refValue]?.descriptions ?? {};
+ },
},
created() {
- if (this.variableParams) {
- this.setVariableParams(VARIABLE_TYPE, this.variableParams);
- }
-
- if (this.fileParams) {
- this.setVariableParams(FILE_TYPE, this.fileParams);
- }
-
- this.addEmptyVariable();
+ this.setRefSelected(this.refValue);
},
methods: {
- addEmptyVariable() {
- this.variables[uniqueId('var')] = {
+ addEmptyVariable(refValue) {
+ const { variables } = this.form[refValue];
+
+ const lastVar = variables[variables.length - 1];
+ if (lastVar?.key === '' && lastVar?.value === '') {
+ return;
+ }
+
+ variables.push({
+ uniqueId: uniqueId(`var-${refValue}`),
variable_type: VARIABLE_TYPE,
key: '',
value: '',
- };
+ });
},
- setVariableParams(type, paramsObj) {
- Object.entries(paramsObj).forEach(([key, value]) => {
- this.variables[uniqueId('var')] = {
+ setVariable(refValue, type, key, value) {
+ const { variables } = this.form[refValue];
+
+ const variable = variables.find(v => v.key === key);
+ if (variable) {
+ variable.type = type;
+ variable.value = value;
+ } else {
+ variables.push({
+ uniqueId: uniqueId(`var-${refValue}`),
key,
value,
variable_type: type,
- };
+ });
+ }
+ },
+ setVariableParams(refValue, type, paramsObj) {
+ Object.entries(paramsObj).forEach(([key, value]) => {
+ this.setVariable(refValue, type, key, value);
});
},
- setRefSelected(ref) {
- this.refValue = ref;
+ setRefSelected(refValue) {
+ this.refValue = refValue;
+
+ if (!this.form[refValue]) {
+ this.fetchConfigVariables(refValue)
+ .then(({ descriptions, params }) => {
+ Vue.set(this.form, refValue, {
+ variables: [],
+ descriptions,
+ });
+
+ // Add default variables from yml
+ this.setVariableParams(refValue, VARIABLE_TYPE, params);
+ })
+ .catch(() => {
+ Vue.set(this.form, refValue, {
+ variables: [],
+ descriptions: {},
+ });
+ })
+ .finally(() => {
+ // Add/update variables, e.g. from query string
+ if (this.variableParams) {
+ this.setVariableParams(refValue, VARIABLE_TYPE, this.variableParams);
+ }
+ if (this.fileParams) {
+ this.setVariableParams(refValue, FILE_TYPE, this.fileParams);
+ }
+
+ // Adds empty var at the end of the form
+ this.addEmptyVariable(refValue);
+ });
+ }
},
+
isSelected(ref) {
return ref === this.refValue;
},
- insertNewVariable() {
- Vue.set(this.variables, uniqueId('var'), {
- variable_type: VARIABLE_TYPE,
- key: '',
- value: '',
- });
+ removeVariable(index) {
+ this.variables.splice(index, 1);
},
- removeVariable(key) {
- Vue.delete(this.variables, key);
+ canRemove(index) {
+ return index < this.variables.length - 1;
},
- canRemove(index) {
- return index < this.variablesLength - 1;
+ fetchConfigVariables(refValue) {
+ if (gon?.features?.newPipelineFormPrefilledVars) {
+ return axios
+ .get(this.configVariablesPath, {
+ params: {
+ sha: refValue,
+ },
+ })
+ .then(({ data }) => {
+ const params = {};
+ const descriptions = {};
+
+ Object.entries(data).forEach(([key, { value, description }]) => {
+ if (description !== null) {
+ params[key] = value;
+ descriptions[key] = description;
+ }
+ });
+
+ return { params, descriptions };
+ });
+ }
+ return Promise.resolve({ params: {}, descriptions: {} });
},
createPipeline() {
- const filteredVariables = Object.values(this.variables).filter(
- ({ key, value }) => key !== '' && value !== '',
- );
+ const filteredVariables = this.variables
+ .filter(({ key, value }) => key !== '' && value !== '')
+ .map(({ variable_type, key, value }) => ({
+ variable_type,
+ key,
+ secret_value: value,
+ }));
return axios
.post(this.pipelinesPath, {
ref: this.refValue,
- variables: filteredVariables,
+ variables_attributes: filteredVariables,
})
.then(({ data }) => {
redirectTo(`${this.pipelinesPath}/${data.id}`);
@@ -230,7 +304,6 @@ export default {
<gl-search-box-by-type
v-model.trim="searchTerm"
:placeholder="__('Search branches and tags')"
- class="gl-p-2"
/>
<gl-dropdown-item
v-for="(ref, index) in filteredRefs"
@@ -253,35 +326,55 @@ export default {
<gl-form-group :label="s__('Pipeline|Variables')">
<div
- v-for="(value, key, index) in variables"
- :key="key"
- class="gl-display-flex gl-align-items-center gl-mb-4 gl-pb-2 gl-border-b-solid gl-border-gray-200 gl-border-b-1 gl-flex-direction-column gl-md-flex-direction-row"
+ v-for="(variable, index) in variables"
+ :key="variable.uniqueId"
+ class="gl-mb-3 gl-ml-n3 gl-pb-2"
data-testid="ci-variable-row"
>
- <gl-form-select
- v-model="variables[key].variable_type"
- :class="$options.formElementClasses"
- :options="$options.typeOptions"
- />
- <gl-form-input
- v-model="variables[key].key"
- :placeholder="s__('CiVariables|Input variable key')"
- :class="$options.formElementClasses"
- data-testid="pipeline-form-ci-variable-key"
- @change.once="insertNewVariable()"
- />
- <gl-form-input
- v-model="variables[key].value"
- :placeholder="s__('CiVariables|Input variable value')"
- class="gl-mr-5 gl-mb-3 table-section section-15"
- />
- <gl-button
- v-if="canRemove(index)"
- icon="issue-close"
- class="gl-mb-3"
- data-testid="remove-ci-variable-row"
- @click="removeVariable(key)"
- />
+ <div
+ class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row"
+ >
+ <gl-form-select
+ v-model="variable.variable_type"
+ :class="$options.formElementClasses"
+ :options="$options.typeOptions"
+ />
+ <gl-form-input
+ v-model="variable.key"
+ :placeholder="s__('CiVariables|Input variable key')"
+ :class="$options.formElementClasses"
+ data-testid="pipeline-form-ci-variable-key"
+ @change="addEmptyVariable(refValue)"
+ />
+ <gl-form-input
+ v-model="variable.value"
+ :placeholder="s__('CiVariables|Input variable value')"
+ class="gl-mb-3"
+ data-testid="pipeline-form-ci-variable-value"
+ />
+
+ <template v-if="variables.length > 1">
+ <gl-button
+ v-if="canRemove(index)"
+ class="gl-md-ml-3 gl-mb-3"
+ data-testid="remove-ci-variable-row"
+ variant="danger"
+ category="secondary"
+ @click="removeVariable(index)"
+ >
+ <gl-icon class="gl-mr-0! gl-display-none gl-display-md-block" name="clear" />
+ <span class="gl-display-md-none">{{ s__('CiVariables|Remove variable') }}</span>
+ </gl-button>
+ <gl-button
+ v-else
+ class="gl-md-ml-3 gl-mb-3 gl-display-none gl-display-md-block gl-visibility-hidden"
+ icon="clear"
+ />
+ </template>
+ </div>
+ <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3">
+ {{ descriptions[variable.key] }}
+ </div>
</div>
<template #description
@@ -295,9 +388,14 @@ export default {
<div
class="gl-border-t-solid gl-border-gray-100 gl-border-t-1 gl-p-5 gl-bg-gray-10 gl-display-flex gl-justify-content-space-between"
>
- <gl-button type="submit" category="primary" variant="success">{{
- s__('Pipeline|Run Pipeline')
- }}</gl-button>
+ <gl-button
+ type="submit"
+ category="primary"
+ variant="success"
+ class="js-no-auto-disable"
+ data-qa-selector="run_pipeline_button"
+ >{{ s__('Pipeline|Run Pipeline') }}</gl-button
+ >
<gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button>
</div>
</gl-form>
diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js
index f1ea86f8c5f..ff4f677654e 100644
--- a/app/assets/javascripts/pipeline_new/index.js
+++ b/app/assets/javascripts/pipeline_new/index.js
@@ -6,6 +6,7 @@ export default () => {
const {
projectId,
pipelinesPath,
+ configVariablesPath,
refParam,
varParam,
fileParam,
@@ -25,6 +26,7 @@ export default () => {
props: {
projectId,
pipelinesPath,
+ configVariablesPath,
refParam,
variableParams,
fileParams,
diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/pipelines/components/dag/constants.js
index b6a98fdc488..cd89055737f 100644
--- a/app/assets/javascripts/pipelines/components/dag/constants.js
+++ b/app/assets/javascripts/pipelines/components/dag/constants.js
@@ -1,9 +1,3 @@
-/* Error constants */
-export const PARSE_FAILURE = 'parse_failure';
-export const LOAD_FAILURE = 'load_failure';
-export const UNSUPPORTED_DATA = 'unsupported_data';
-export const DEFAULT = 'default';
-
/* Interaction handles */
export const IS_HIGHLIGHTED = 'dag-highlighted';
export const LINK_SELECTOR = 'dag-link';
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue
index 8487da3d621..6267b63328c 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag.vue
@@ -6,16 +6,9 @@ import { fetchPolicies } from '~/lib/graphql';
import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql';
import DagGraph from './dag_graph.vue';
import DagAnnotations from './dag_annotations.vue';
-import {
- DEFAULT,
- PARSE_FAILURE,
- LOAD_FAILURE,
- UNSUPPORTED_DATA,
- ADD_NOTE,
- REMOVE_NOTE,
- REPLACE_NOTES,
-} from './constants';
-import { parseData } from './parsing_utils';
+import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants';
+import { parseData } from '../parsing_utils';
+import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from '../../constants';
export default {
// eslint-disable-next-line @gitlab/require-i18n-strings
diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
index d12baa9617e..42d1debcddf 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
@@ -1,14 +1,7 @@
<script>
import * as d3 from 'd3';
import { uniqueId } from 'lodash';
-import {
- LINK_SELECTOR,
- NODE_SELECTOR,
- PARSE_FAILURE,
- ADD_NOTE,
- REMOVE_NOTE,
- REPLACE_NOTES,
-} from './constants';
+import { LINK_SELECTOR, NODE_SELECTOR, ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants';
import {
currentIsLive,
getLiveLinksAsDict,
@@ -17,8 +10,9 @@ import {
toggleLinkHighlight,
togglePathHighlights,
} from './interactions';
-import { getMaxNodes, removeOrphanNodes } from './parsing_utils';
+import { getMaxNodes, removeOrphanNodes } from '../parsing_utils';
import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils';
+import { PARSE_FAILURE } from '../../constants';
export default {
viewOptions: {
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
index efa11580c41..a580ee11627 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -88,7 +88,7 @@ export default {
:class="cssClass"
:disabled="isDisabled"
class="js-ci-action ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center"
- @click="onClickAction"
+ @click.stop="onClickAction"
>
<gl-loading-icon v-if="isLoading" class="js-action-icon-loading" />
<gl-icon v-else :name="actionIcon" class="gl-mr-0!" />
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 924cdeebba1..0f5a8cb8fbf 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,7 +1,7 @@
<script>
+import { escape, capitalize } from 'lodash';
import { GlLoadingIcon } from '@gitlab/ui';
import StageColumnComponent from './stage_column_component.vue';
-import GraphMixin from '../../mixins/graph_component_mixin';
import GraphWidthMixin from '../../mixins/graph_width_mixin';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
@@ -13,7 +13,7 @@ export default {
GlLoadingIcon,
LinkedPipelinesColumn,
},
- mixins: [GraphMixin, GraphWidthMixin, GraphBundleMixin],
+ mixins: [GraphWidthMixin, GraphBundleMixin],
props: {
isLoading: {
type: Boolean,
@@ -51,6 +51,9 @@ export default {
};
},
computed: {
+ graph() {
+ return this.pipeline.details?.stages;
+ },
hasTriggeredBy() {
return (
this.type !== this.$options.downstream &&
@@ -92,6 +95,39 @@ export default {
},
},
methods: {
+ capitalizeStageName(name) {
+ const escapedName = escape(name);
+ return capitalize(escapedName);
+ },
+ isFirstColumn(index) {
+ return index === 0;
+ },
+ stageConnectorClass(index, stage) {
+ let className;
+
+ // If it's the first stage column and only has one job
+ if (this.isFirstColumn(index) && stage.groups.length === 1) {
+ className = 'no-margin';
+ } else if (index > 0) {
+ // If it is not the first column
+ className = 'left-margin';
+ }
+
+ return className;
+ },
+ refreshPipelineGraph() {
+ this.$emit('refreshPipelineGraph');
+ },
+ /**
+ * CSS class is applied:
+ * - if pipeline graph contains only one stage column component
+ *
+ * @param {number} index
+ * @returns {boolean}
+ */
+ shouldAddRightMargin(index) {
+ return !(index === this.graph.length - 1);
+ },
handleClickedDownstream(pipeline, clickedIndex, downstreamNode) {
/**
* Calculates the margin top of the clicked downstream pipeline by
diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
index 11fb2b18e9d..49591a80752 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -1,5 +1,4 @@
<script>
-import $ from 'jquery';
import { GlTooltipDirective } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import JobItem from './job_item.vue';
@@ -30,27 +29,7 @@ export default {
return `${name} - ${status.label}`;
},
},
- mounted() {
- this.stopDropdownClickPropagation();
- },
methods: {
- /**
- * When the user right clicks or cmd/ctrl + click in the group name or the action icon
- * the dropdown should not be closed so we stop propagation
- * of the click event inside the dropdown.
- *
- * Since this component is rendered multiple times per page we need to guarantee we only
- * target the click event of this component.
- */
- stopDropdownClickPropagation() {
- $(
- '.js-grouped-pipeline-dropdown button, .js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item',
- this.$el,
- ).on('click', e => {
- e.stopPropagation();
- });
- },
-
pipelineActionRequestComplete() {
this.$emit('pipelineActionRequestComplete');
},
@@ -69,7 +48,9 @@ export default {
>
<ci-icon :status="group.status" />
- <span class="ci-status-text text-truncate mw-70p gl-pl-2 d-inline-block align-bottom">
+ <span
+ class="gl-text-truncate mw-70p gl-pl-2 gl-display-inline-block gl-vertical-align-bottom"
+ >
{{ group.name }}
</span>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 0fe0b671273..7aee2573ce1 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -126,7 +126,7 @@ export default {
};
</script>
<template>
- <div class="ci-job-component">
+ <div class="ci-job-component" data-qa-selector="job_item_container">
<gl-link
v-if="status.has_details"
v-gl-tooltip="{ boundary, placement: 'bottom' }"
@@ -135,6 +135,7 @@ export default {
:class="jobClasses"
class="js-pipeline-graph-job-link qa-job-link menu-item"
data-testid="job-with-link"
+ @click.stop
>
<job-name-component :name="job.name" :status="job.status" />
</gl-link>
@@ -155,6 +156,7 @@ export default {
:tooltip-text="status.action.title"
:link="status.action.path"
:action-icon="status.action.icon"
+ data-qa-selector="action_button"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
</div>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
index 30ba243077e..1b71949784a 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
@@ -27,7 +27,7 @@ export default {
<template>
<span class="ci-job-name-component mw-100">
<ci-icon :status="status" />
- <span class="ci-status-text text-truncate mw-70p gl-pl-2 d-inline-block align-bottom">
+ <span class="gl-text-truncate mw-70p gl-pl-2 gl-display-inline-block gl-vertical-align-bottom">
{{ name }}
</span>
</span>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index 1453c349f44..a75ec585b95 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -71,7 +71,7 @@ export default {
:action-icon="action.icon"
:tooltip-text="action.title"
:link="action.path"
- class="js-stage-action stage-action position-absolute position-top-0 rounded"
+ class="js-stage-action stage-action rounded"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
</div>
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index c7b72be36ad..b26f28fa6af 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -1,8 +1,11 @@
<script>
-import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
-import ciHeader from '~/vue_shared/components/header_ci_component.vue';
-import eventHub from '../event_hub';
+import { GlAlert, GlButton, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
import { __ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import ciHeader from '~/vue_shared/components/header_ci_component.vue';
+import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
+import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql';
+import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants';
const DELETE_MODAL_ID = 'pipeline-delete-modal';
@@ -10,57 +13,143 @@ export default {
name: 'PipelineHeaderSection',
components: {
ciHeader,
+ GlAlert,
+ GlButton,
GlLoadingIcon,
GlModal,
- GlButton,
},
directives: {
GlModal: GlModalDirective,
},
- props: {
- pipeline: {
- type: Object,
- required: true,
+ errorTexts: {
+ [LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'),
+ [POST_FAILURE]: __('An error occurred while making the request.'),
+ [DELETE_FAILURE]: __('An error occurred while deleting the pipeline.'),
+ [DEFAULT]: __('An unknown error occurred.'),
+ },
+ inject: {
+ // Receive `cancel`, `delete`, `fullProject` and `retry`
+ paths: {
+ default: {},
+ },
+ pipelineId: {
+ default: '',
},
- isLoading: {
- type: Boolean,
- required: true,
+ pipelineIid: {
+ default: '',
+ },
+ },
+ apollo: {
+ pipeline: {
+ query: getPipelineQuery,
+ variables() {
+ return {
+ fullPath: this.paths.fullProject,
+ iid: this.pipelineIid,
+ };
+ },
+ update: data => data.project.pipeline,
+ error() {
+ this.reportFailure(LOAD_FAILURE);
+ },
+ pollInterval: 10000,
+ watchLoading(isLoading) {
+ if (!isLoading) {
+ // To ensure apollo has updated the cache,
+ // we only remove the loading state in sync with GraphQL
+ this.isCanceling = false;
+ this.isRetrying = false;
+ }
+ },
},
},
data() {
return {
+ pipeline: null,
+ failureType: null,
isCanceling: false,
isRetrying: false,
isDeleting: false,
};
},
-
computed: {
- status() {
- return this.pipeline.details && this.pipeline.details.status;
- },
- shouldRenderContent() {
- return !this.isLoading && Object.keys(this.pipeline).length;
- },
deleteModalConfirmationText() {
return __(
'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.',
);
},
+ hasError() {
+ return this.failureType;
+ },
+ hasPipelineData() {
+ return Boolean(this.pipeline);
+ },
+ isLoadingInitialQuery() {
+ return this.$apollo.queries.pipeline.loading && !this.hasPipelineData;
+ },
+ status() {
+ return this.pipeline?.status;
+ },
+ shouldRenderContent() {
+ return !this.isLoadingInitialQuery && this.hasPipelineData;
+ },
+ failure() {
+ switch (this.failureType) {
+ case LOAD_FAILURE:
+ return {
+ text: this.$options.errorTexts[LOAD_FAILURE],
+ variant: 'danger',
+ };
+ case POST_FAILURE:
+ return {
+ text: this.$options.errorTexts[POST_FAILURE],
+ variant: 'danger',
+ };
+ case DELETE_FAILURE:
+ return {
+ text: this.$options.errorTexts[DELETE_FAILURE],
+ variant: 'danger',
+ };
+ default:
+ return {
+ text: this.$options.errorTexts[DEFAULT],
+ variant: 'danger',
+ };
+ }
+ },
},
-
methods: {
- cancelPipeline() {
+ reportFailure(errorType) {
+ this.failureType = errorType;
+ },
+ async postAction(path) {
+ try {
+ await axios.post(path);
+ this.$apollo.queries.pipeline.refetch();
+ } catch {
+ this.reportFailure(POST_FAILURE);
+ }
+ },
+ async cancelPipeline() {
this.isCanceling = true;
- eventHub.$emit('headerPostAction', this.pipeline.cancel_path);
+ this.postAction(this.paths.cancel);
},
- retryPipeline() {
+ async retryPipeline() {
this.isRetrying = true;
- eventHub.$emit('headerPostAction', this.pipeline.retry_path);
+ this.postAction(this.paths.retry);
},
- deletePipeline() {
+ async deletePipeline() {
this.isDeleting = true;
- eventHub.$emit('headerDeleteAction', this.pipeline.delete_path);
+ this.$apollo.queries.pipeline.stopPolling();
+
+ try {
+ const { request } = await axios.delete(this.paths.delete);
+ redirectTo(setUrlFragment(request.responseURL, 'delete_success'));
+ } catch {
+ this.$apollo.queries.pipeline.startPolling();
+ this.reportFailure(DELETE_FAILURE);
+ this.isDeleting = false;
+ }
},
},
DELETE_MODAL_ID,
@@ -68,54 +157,53 @@ export default {
</script>
<template>
<div class="pipeline-header-container">
+ <gl-alert v-if="hasError" :variant="failure.variant">{{ failure.text }}</gl-alert>
<ci-header
v-if="shouldRenderContent"
- :status="status"
- :item-id="pipeline.id"
- :time="pipeline.created_at"
+ :status="pipeline.detailedStatus"
+ :time="pipeline.createdAt"
:user="pipeline.user"
+ :item-id="Number(pipelineId)"
item-name="Pipeline"
>
<gl-button
- v-if="pipeline.retry_path"
+ v-if="pipeline.retryable"
:loading="isRetrying"
:disabled="isRetrying"
- data-testid="retryButton"
category="secondary"
variant="info"
+ data-testid="retryPipeline"
+ class="js-retry-button"
@click="retryPipeline()"
>
{{ __('Retry') }}
</gl-button>
<gl-button
- v-if="pipeline.cancel_path"
+ v-if="pipeline.cancelable"
:loading="isCanceling"
:disabled="isCanceling"
- data-testid="cancelPipeline"
- class="gl-ml-3"
- category="primary"
variant="danger"
+ data-testid="cancelPipeline"
@click="cancelPipeline()"
>
{{ __('Cancel running') }}
</gl-button>
<gl-button
- v-if="pipeline.delete_path"
+ v-if="pipeline.userPermissions.destroyPipeline"
v-gl-modal="$options.DELETE_MODAL_ID"
:loading="isDeleting"
:disabled="isDeleting"
- data-testid="deletePipeline"
class="gl-ml-3"
- category="secondary"
variant="danger"
+ category="secondary"
+ data-testid="deletePipeline"
>
{{ __('Delete') }}
</gl-button>
</ci-header>
-
- <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" />
+ <gl-loading-icon v-if="isLoadingInitialQuery" size="lg" class="gl-mt-3 gl-mb-3" />
<gl-modal
:modal-id="$options.DELETE_MODAL_ID"
diff --git a/app/assets/javascripts/pipelines/components/legacy_header_component.vue b/app/assets/javascripts/pipelines/components/legacy_header_component.vue
new file mode 100644
index 00000000000..c7b72be36ad
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/legacy_header_component.vue
@@ -0,0 +1,132 @@
+<script>
+import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
+import ciHeader from '~/vue_shared/components/header_ci_component.vue';
+import eventHub from '../event_hub';
+import { __ } from '~/locale';
+
+const DELETE_MODAL_ID = 'pipeline-delete-modal';
+
+export default {
+ name: 'PipelineHeaderSection',
+ components: {
+ ciHeader,
+ GlLoadingIcon,
+ GlModal,
+ GlButton,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isCanceling: false,
+ isRetrying: false,
+ isDeleting: false,
+ };
+ },
+
+ computed: {
+ status() {
+ return this.pipeline.details && this.pipeline.details.status;
+ },
+ shouldRenderContent() {
+ return !this.isLoading && Object.keys(this.pipeline).length;
+ },
+ deleteModalConfirmationText() {
+ return __(
+ 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.',
+ );
+ },
+ },
+
+ methods: {
+ cancelPipeline() {
+ this.isCanceling = true;
+ eventHub.$emit('headerPostAction', this.pipeline.cancel_path);
+ },
+ retryPipeline() {
+ this.isRetrying = true;
+ eventHub.$emit('headerPostAction', this.pipeline.retry_path);
+ },
+ deletePipeline() {
+ this.isDeleting = true;
+ eventHub.$emit('headerDeleteAction', this.pipeline.delete_path);
+ },
+ },
+ DELETE_MODAL_ID,
+};
+</script>
+<template>
+ <div class="pipeline-header-container">
+ <ci-header
+ v-if="shouldRenderContent"
+ :status="status"
+ :item-id="pipeline.id"
+ :time="pipeline.created_at"
+ :user="pipeline.user"
+ item-name="Pipeline"
+ >
+ <gl-button
+ v-if="pipeline.retry_path"
+ :loading="isRetrying"
+ :disabled="isRetrying"
+ data-testid="retryButton"
+ category="secondary"
+ variant="info"
+ @click="retryPipeline()"
+ >
+ {{ __('Retry') }}
+ </gl-button>
+
+ <gl-button
+ v-if="pipeline.cancel_path"
+ :loading="isCanceling"
+ :disabled="isCanceling"
+ data-testid="cancelPipeline"
+ class="gl-ml-3"
+ category="primary"
+ variant="danger"
+ @click="cancelPipeline()"
+ >
+ {{ __('Cancel running') }}
+ </gl-button>
+
+ <gl-button
+ v-if="pipeline.delete_path"
+ v-gl-modal="$options.DELETE_MODAL_ID"
+ :loading="isDeleting"
+ :disabled="isDeleting"
+ data-testid="deletePipeline"
+ class="gl-ml-3"
+ category="secondary"
+ variant="danger"
+ >
+ {{ __('Delete') }}
+ </gl-button>
+ </ci-header>
+
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" />
+
+ <gl-modal
+ :modal-id="$options.DELETE_MODAL_ID"
+ :title="__('Delete pipeline')"
+ :ok-title="__('Delete pipeline')"
+ ok-variant="danger"
+ @ok="deletePipeline()"
+ >
+ <p>
+ {{ deleteModalConfirmationText }}
+ </p>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js
index 1ed415688f2..1ed415688f2 100644
--- a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js
+++ b/app/assets/javascripts/pipelines/components/parsing_utils.js
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js b/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js
new file mode 100644
index 00000000000..45940d4a39c
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js
@@ -0,0 +1,96 @@
+import * as d3 from 'd3';
+import { createUniqueJobId } from '../../utils';
+/**
+ * This function expects its first argument data structure
+ * to be the same shaped as the one generated by `parseData`,
+ * which contains nodes and links. For each link,
+ * we find the nodes in the graph, calculate their coordinates and
+ * trace the lines that represent the needs of each job.
+ * @param {Object} nodeDict - Resulting object of `parseData` with nodes and links
+ * @param {Object} jobs - An object where each key is the job name that contains the job data
+ * @param {ref} svg - Reference to the svg we draw in
+ * @returns {Array} Links that contain all the information about them
+ */
+
+export const generateLinksData = ({ links }, jobs, containerID) => {
+ const containerEl = document.getElementById(containerID);
+ return links.map(link => {
+ const path = d3.path();
+
+ const sourceId = jobs[link.source].id;
+ const targetId = jobs[link.target].id;
+
+ const sourceNodeEl = document.getElementById(sourceId);
+ const targetNodeEl = document.getElementById(targetId);
+
+ const sourceNodeCoordinates = sourceNodeEl.getBoundingClientRect();
+ const targetNodeCoordinates = targetNodeEl.getBoundingClientRect();
+ const containerCoordinates = containerEl.getBoundingClientRect();
+
+ // Because we add the svg dynamically and calculate the coordinates
+ // with plain JS and not D3, we need to account for the fact that
+ // the coordinates we are getting are absolutes, but we want to draw
+ // relative to the svg container, which starts at `containerCoordinates(x,y)`
+ // so we substract these from the total. We also need to remove the padding
+ // from the total to make sure it's aligned properly. We then make the line
+ // positioned in the center of the job node by adding half the height
+ // of the job pill.
+ const paddingLeft = Number(
+ window
+ .getComputedStyle(containerEl, null)
+ .getPropertyValue('padding-left')
+ .replace('px', ''),
+ );
+ const paddingTop = Number(
+ window
+ .getComputedStyle(containerEl, null)
+ .getPropertyValue('padding-top')
+ .replace('px', ''),
+ );
+
+ const sourceNodeX = sourceNodeCoordinates.right - containerCoordinates.x - paddingLeft;
+ const sourceNodeY =
+ sourceNodeCoordinates.top -
+ containerCoordinates.y -
+ paddingTop +
+ sourceNodeCoordinates.height / 2;
+ const targetNodeX = targetNodeCoordinates.x - containerCoordinates.x - paddingLeft;
+ const targetNodeY =
+ targetNodeCoordinates.y -
+ containerCoordinates.y -
+ paddingTop +
+ sourceNodeCoordinates.height / 2;
+
+ // Start point
+ path.moveTo(sourceNodeX, sourceNodeY);
+
+ // Make cross-stages lines a straight line all the way
+ // until we can safely draw the bezier to look nice.
+ const straightLineDestinationX = targetNodeX - 100;
+ const controlPointX = straightLineDestinationX + (targetNodeX - straightLineDestinationX) / 2;
+
+ if (straightLineDestinationX > 0) {
+ path.lineTo(straightLineDestinationX, sourceNodeY);
+ }
+
+ // Add bezier curve. The first 4 coordinates are the 2 control
+ // points to create the curve, and the last one is the end point (x, y).
+ // We want our control points to be in the middle of the line
+ path.bezierCurveTo(
+ controlPointX,
+ sourceNodeY,
+ controlPointX,
+ targetNodeY,
+ targetNodeX,
+ targetNodeY,
+ );
+
+ return {
+ ...link,
+ source: sourceId,
+ target: targetId,
+ ref: createUniqueJobId(sourceId, targetId),
+ path: path.toString(),
+ };
+ });
+};
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
index 19d41b166c3..8eec0110865 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
@@ -10,13 +10,57 @@ export default {
type: String,
required: true,
},
+ jobId: {
+ type: String,
+ required: true,
+ },
+ isHighlighted: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isFadedOut: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ handleMouseOver: {
+ type: Function,
+ required: false,
+ default: () => {},
+ },
+ handleMouseLeave: {
+ type: Function,
+ required: false,
+ default: () => {},
+ },
+ },
+ computed: {
+ jobPillClasses() {
+ return [
+ { 'gl-opacity-3': this.isFadedOut },
+ this.isHighlighted ? 'gl-shadow-blue-200-x0-y0-b4-s2' : 'gl-inset-border-2-green-400',
+ ];
+ },
+ },
+ methods: {
+ onMouseEnter() {
+ this.$emit('on-mouse-enter', this.jobId);
+ },
+ onMouseLeave() {
+ this.$emit('on-mouse-leave');
+ },
},
};
</script>
<template>
<tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
<div
- class="gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-inset-border-1-green-600 gl-mb-3 gl-px-5 gl-py-2 pipeline-job-pill "
+ :id="jobId"
+ class="pipeline-job-pill gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease"
+ :class="jobPillClasses"
+ @mouseover="onMouseEnter"
+ @mouseleave="onMouseLeave"
>
{{ jobName }}
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
index 6a0d3cce1f3..3a2b8a20bae 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
@@ -1,8 +1,13 @@
<script>
import { isEmpty } from 'lodash';
import { GlAlert } from '@gitlab/ui';
+import { __ } from '~/locale';
import JobPill from './job_pill.vue';
import StagePill from './stage_pill.vue';
+import { generateLinksData } from './drawing_utils';
+import { parseData } from '../parsing_utils';
+import { DRAW_FAILURE, DEFAULT } from '../../constants';
+import { generateJobNeedsDict } from '../../utils';
export default {
components: {
@@ -10,28 +15,174 @@ export default {
JobPill,
StagePill,
},
+ CONTAINER_REF: 'PIPELINE_GRAPH_CONTAINER_REF',
+ CONTAINER_ID: 'pipeline-graph-container',
+ STROKE_WIDTH: 2,
+ errorTexts: {
+ [DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
+ [DEFAULT]: __('An unknown error occurred.'),
+ },
props: {
pipelineData: {
required: true,
type: Object,
},
},
+ data() {
+ return {
+ failureType: null,
+ highlightedJob: null,
+ links: [],
+ needsObject: null,
+ height: 0,
+ width: 0,
+ };
+ },
computed: {
isPipelineDataEmpty() {
return isEmpty(this.pipelineData);
},
- emptyClass() {
- return !this.isPipelineDataEmpty ? 'gl-py-7' : '';
+ hasError() {
+ return this.failureType;
+ },
+ hasHighlightedJob() {
+ return Boolean(this.highlightedJob);
+ },
+ failure() {
+ const text = this.$options.errorTexts[this.failureType] || this.$options.errorTexts[DEFAULT];
+
+ return { text, variant: 'danger' };
+ },
+ viewBox() {
+ return [0, 0, this.width, this.height];
+ },
+ highlightedJobs() {
+ // If you are hovering on a job, then the jobs we want to highlight are:
+ // The job you are currently hovering + all of its needs.
+ return this.hasHighlightedJob
+ ? [this.highlightedJob, ...this.needsObject[this.highlightedJob]]
+ : [];
+ },
+ highlightedLinks() {
+ // If you are hovering on a job, then the links we want to highlight are:
+ // All the links whose `source` and `target` are highlighted jobs.
+ if (this.hasHighlightedJob) {
+ const filteredLinks = this.links.filter(link => {
+ return (
+ this.highlightedJobs.includes(link.source) && this.highlightedJobs.includes(link.target)
+ );
+ });
+
+ return filteredLinks.map(link => link.ref);
+ }
+
+ return [];
+ },
+ },
+ mounted() {
+ if (!this.isPipelineDataEmpty) {
+ this.getGraphDimensions();
+ this.drawJobLinks();
+ }
+ },
+ methods: {
+ drawJobLinks() {
+ const { stages, jobs } = this.pipelineData;
+ const unwrappedGroups = this.unwrapPipelineData(stages);
+
+ try {
+ const parsedData = parseData(unwrappedGroups);
+ this.links = generateLinksData(parsedData, jobs, this.$options.CONTAINER_ID);
+ } catch {
+ this.reportFailure(DRAW_FAILURE);
+ }
+ },
+ getStageBackgroundClass(index) {
+ const { length } = this.pipelineData.stages;
+
+ if (length === 1) {
+ return 'stage-rounded';
+ } else if (index === 0) {
+ return 'stage-left-rounded';
+ } else if (index === length - 1) {
+ return 'stage-right-rounded';
+ }
+
+ return '';
+ },
+ highlightNeeds(uniqueJobId) {
+ // The first time we hover, we create the object where
+ // we store all the data to properly highlight the needs.
+ if (!this.needsObject) {
+ this.needsObject = generateJobNeedsDict(this.pipelineData) ?? {};
+ }
+
+ this.highlightedJob = uniqueJobId;
+ },
+ removeHighlightNeeds() {
+ this.highlightedJob = null;
+ },
+ unwrapPipelineData(stages) {
+ return stages
+ .map(({ name, groups }) => {
+ return groups.map(group => {
+ return { category: name, ...group };
+ });
+ })
+ .flat(2);
+ },
+ getGraphDimensions() {
+ this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}px`;
+ this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}px`;
+ },
+ reportFailure(errorType) {
+ this.failureType = errorType;
+ },
+ resetFailure() {
+ this.failureType = null;
+ },
+ isJobHighlighted(jobName) {
+ return this.highlightedJobs.includes(jobName);
+ },
+ isLinkHighlighted(linkRef) {
+ return this.highlightedLinks.includes(linkRef);
+ },
+ getLinkClasses(link) {
+ return [
+ this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : 'gl-stroke-gray-200',
+ { 'gl-opacity-3': this.hasHighlightedJob && !this.isLinkHighlighted(link.ref) },
+ ];
},
},
};
</script>
<template>
- <div class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto" :class="emptyClass">
+ <div>
+ <gl-alert v-if="hasError" :variant="failure.variant" @dismiss="resetFailure">
+ {{ failure.text }}
+ </gl-alert>
<gl-alert v-if="isPipelineDataEmpty" variant="tip" :dismissible="false">
{{ __('No content to show') }}
</gl-alert>
- <template v-else>
+ <div
+ v-else
+ :id="$options.CONTAINER_ID"
+ :ref="$options.CONTAINER_REF"
+ class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto gl-relative gl-py-7"
+ >
+ <svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute">
+ <template>
+ <path
+ v-for="link in links"
+ :key="link.path"
+ :ref="link.ref"
+ :d="link.path"
+ class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease"
+ :class="getLinkClasses(link)"
+ :stroke-width="$options.STROKE_WIDTH"
+ />
+ </template>
+ </svg>
<div
v-for="(stage, index) in pipelineData.stages"
:key="`${stage.name}-${index}`"
@@ -39,19 +190,25 @@ export default {
>
<div
class="gl-display-flex gl-align-items-center gl-bg-white gl-w-full gl-px-8 gl-py-4 gl-mb-5"
- :class="{
- 'stage-left-rounded': index === 0,
- 'stage-right-rounded': index === pipelineData.stages.length - 1,
- }"
+ :class="getStageBackgroundClass(index)"
>
<stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" />
</div>
<div
class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8"
>
- <job-pill v-for="group in stage.groups" :key="group.name" :job-name="group.name" />
+ <job-pill
+ v-for="group in stage.groups"
+ :key="group.name"
+ :job-id="group.id"
+ :job-name="group.name"
+ :is-highlighted="hasHighlightedJob && isJobHighlighted(group.id)"
+ :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.id)"
+ @on-mouse-enter="highlightNeeds"
+ @on-mouse-leave="removeHighlightNeeds"
+ />
</div>
</div>
- </template>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
index fe8e3bd2b78..c5f30c8aef0 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
@@ -48,6 +48,7 @@ export default {
variant="info"
category="primary"
class="js-get-started-pipelines"
+ data-testid="get-started-pipelines"
>
{{ s__('Pipelines|Get started with Pipelines') }}
</gl-button>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
index d7b6e033bd1..cf0849751df 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
@@ -46,6 +46,8 @@ export default {
variant="success"
category="primary"
class="js-run-pipeline"
+ data-testid="run-pipeline-button"
+ data-qa-selector="run_pipeline_button"
>
{{ s__('Pipelines|Run Pipeline') }}
</gl-button>
@@ -54,12 +56,13 @@ export default {
v-if="resetCachePath"
:loading="isResetCacheButtonLoading"
class="js-clear-cache"
+ data-testid="clear-cache-button"
@click="onClickResetCache"
>
{{ s__('Pipelines|Clear Runner Caches') }}
</gl-button>
- <gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint">
+ <gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint" data-testid="ci-lint-button">
{{ s__('Pipelines|CI Lint') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
index 43a54090e18..1569b326b31 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
@@ -1,10 +1,9 @@
<script>
/* eslint-disable vue/no-v-html */
import { isEmpty } from 'lodash';
-import { GlLink } from '@gitlab/ui';
-import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
+import { GlLink, GlModal } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
/**
* Pipeline Stop Modal.
@@ -13,7 +12,7 @@ import { s__, sprintf } from '~/locale';
*/
export default {
components: {
- GlModal: DeprecatedModal2,
+ GlModal,
GlLink,
CiIcon,
},
@@ -46,6 +45,17 @@ export default {
hasRef() {
return !isEmpty(this.pipeline.ref);
},
+ primaryProps() {
+ return {
+ text: s__('Pipeline|Stop pipeline'),
+ attributes: [{ variant: 'danger' }],
+ };
+ },
+ cancelProps() {
+ return {
+ text: __('Cancel'),
+ };
+ },
},
methods: {
emitSubmit(event) {
@@ -56,11 +66,11 @@ export default {
</script>
<template>
<gl-modal
- id="confirmation-modal"
- :header-title-text="modalTitle"
- :footer-primary-button-text="s__('Pipeline|Stop pipeline')"
- footer-primary-button-variant="danger"
- @submit="emitSubmit($event)"
+ modal-id="confirmation-modal"
+ :title="modalTitle"
+ :action-primary="primaryProps"
+ :action-cancel="cancelProps"
+ @primary="emitSubmit($event)"
>
<p v-html="modalText"></p>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
index 35fd9837b3e..6ac60727f23 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
@@ -19,7 +19,7 @@ export default {
};
</script>
<template>
- <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-triggerer">
+ <div class="table-section section-10 d-none d-md-block pipeline-triggerer">
<user-avatar-link
v-if="user"
:link-href="user.path"
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 f0614298bd3..63262cc79fd 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -36,7 +36,7 @@ export default {
};
</script>
<template>
- <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-tags">
+ <div class="table-section section-10 d-none d-md-block pipeline-tags">
<gl-link
:href="pipeline.path"
class="js-pipeline-url-link js-onboarding-pipeline-item"
@@ -98,7 +98,7 @@ export default {
placement="top"
>
<template #title>
- <div class="autodevops-title">
+ <div class="gl-font-weight-normal gl-line-height-normal">
<gl-sprintf
:message="
__(
@@ -112,12 +112,7 @@ export default {
</gl-sprintf>
</div>
</template>
- <gl-link
- class="autodevops-link"
- :href="autoDevopsHelpPath"
- target="_blank"
- rel="noopener noreferrer nofollow"
- >
+ <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow">
{{ __('Learn more about Auto DevOps') }}
</gl-link>
</gl-popover>
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 b8112149778..6c60594efca 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -91,6 +91,10 @@ export default {
<div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader">
{{ s__('Pipeline|Stages') }}
</div>
+ <div class="table-section section-15" role="rowheader"></div>
+ <div class="table-section section-20" role="rowheader">
+ <slot name="table-header-actions"></slot>
+ </div>
</div>
<pipelines-table-row-component
v-for="model in pipelines"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
index 1bdb7d18f04..7224ec455f6 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
@@ -1,6 +1,7 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
import eventHub from '../../event_hub';
+import { __ } from '~/locale';
import PipelinesActionsComponent from './pipelines_actions.vue';
import PipelinesArtifactsComponent from './pipelines_artifacts.vue';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
@@ -17,6 +18,14 @@ import { PIPELINES_TABLE } from '../../constants';
* Given the received object renders a table row in the pipelines' table.
*/
export default {
+ i18n: {
+ cancelTitle: __('Cancel'),
+ redeployTitle: __('Retry'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModalDirective,
+ },
components: {
PipelinesActionsComponent,
PipelinesArtifactsComponent,
@@ -321,7 +330,11 @@ export default {
</div>
</div>
- <pipelines-timeago :duration="pipelineDuration" :finished-time="pipelineFinishedAt" />
+ <pipelines-timeago
+ class="gl-text-right"
+ :duration="pipelineDuration"
+ :finished-time="pipelineFinishedAt"
+ />
<div
v-if="displayPipelineActions"
@@ -338,8 +351,11 @@ export default {
<gl-button
v-if="pipeline.flags.retryable"
- :loading="isRetrying"
+ v-gl-tooltip.hover
+ :aria-label="$options.i18n.redeployTitle"
+ :title="$options.i18n.redeployTitle"
:disabled="isRetrying"
+ :loading="isRetrying"
class="js-pipelines-retry-button btn-retry"
data-qa-selector="pipeline_retry_button"
icon="repeat"
@@ -350,10 +366,12 @@ export default {
<gl-button
v-if="pipeline.flags.cancelable"
+ v-gl-tooltip.hover
+ v-gl-modal-directive="'confirmation-modal'"
+ :aria-label="$options.i18n.cancelTitle"
+ :title="$options.i18n.cancelTitle"
:loading="isCancelling"
:disabled="isCancelling"
- data-toggle="modal"
- data-target="#confirmation-modal"
icon="close"
variant="danger"
category="primary"
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 7d13ee582c6..dd09247337c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -1,12 +1,11 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import '~/lib/utils/datetime_utility';
-import tooltip from '~/vue_shared/directives/tooltip';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: { GlIcon },
mixins: [timeagoMixin],
@@ -51,7 +50,7 @@ export default {
};
</script>
<template>
- <div class="table-section section-15 pipelines-time-ago">
+ <div class="table-section section-15">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Duration') }}</div>
<div class="table-mobile-content">
<p v-if="hasDuration" class="duration">
@@ -59,11 +58,11 @@ export default {
{{ durationFormatted }}
</p>
- <p v-if="hasFinishedTime" class="finished-at d-none d-sm-none d-md-block">
+ <p v-if="hasFinishedTime" class="finished-at d-none d-md-block">
<gl-icon name="calendar" class="gl-vertical-align-baseline!" aria-hidden="true" />
<time
- v-tooltip
+ v-gl-tooltip
:title="tooltipTitle(finishedTime)"
data-placement="top"
data-container="body"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
index dfa6d8c13a5..ae5758233bc 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
@@ -3,7 +3,7 @@ import {
GlFilteredSearchToken,
GlAvatar,
GlFilteredSearchSuggestion,
- GlDeprecatedDropdownDivider,
+ GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
@@ -21,7 +21,7 @@ export default {
GlFilteredSearchToken,
GlAvatar,
GlFilteredSearchSuggestion,
- GlDeprecatedDropdownDivider,
+ GlDropdownDivider,
GlLoadingIcon,
},
props: {
@@ -94,7 +94,7 @@ export default {
<gl-filtered-search-suggestion :value="$options.anyTriggerAuthor">{{
$options.anyTriggerAuthor
}}</gl-filtered-search-suggestion>
- <gl-deprecated-dropdown-divider />
+ <gl-dropdown-divider />
<gl-loading-icon v-if="loading" />
<template v-else>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
index aa53c5040e8..2b92ffc3f26 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
@@ -1,6 +1,6 @@
<script>
import { mapGetters } from 'vuex';
-import { GlTooltipDirective, GlFriendlyWrap, GlIcon } from '@gitlab/ui';
+import { GlTooltipDirective, GlFriendlyWrap, GlIcon, GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
@@ -8,6 +8,7 @@ export default {
components: {
GlIcon,
GlFriendlyWrap,
+ GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -45,6 +46,9 @@ export default {
<div role="rowheader" class="table-section section-20">
{{ __('Name') }}
</div>
+ <div role="rowheader" class="table-section section-10">
+ {{ __('Filename') }}
+ </div>
<div role="rowheader" class="table-section section-10 text-center">
{{ __('Status') }}
</div>
@@ -63,18 +67,30 @@ export default {
>
<div class="table-section section-20 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ __('Suite') }}</div>
- <div class="table-mobile-content pr-md-1 gl-overflow-wrap-break">
+ <div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break">
<gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.classname" />
</div>
</div>
<div class="table-section section-20 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div>
- <div class="table-mobile-content pr-md-1 gl-overflow-wrap-break">
- <gl-friendly-wrap
- data-testid="caseName"
- :symbols="$options.wrapSymbols"
- :text="testCase.name"
+ <div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break">
+ <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.name" />
+ </div>
+ </div>
+
+ <div class="table-section section-10 section-wrap">
+ <div role="rowheader" class="table-mobile-header">{{ __('Filename') }}</div>
+ <div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break">
+ <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.file" />
+ <gl-button
+ v-gl-tooltip
+ size="small"
+ category="tertiary"
+ icon="copy-to-clipboard"
+ :title="__('Copy to clipboard')"
+ :data-clipboard-text="testCase.file"
+ :aria-label="__('Copy to clipboard')"
/>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index abe5e1060c8..607e7a66f44 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -13,6 +13,8 @@ export const TestStatus = {
FAILED: 'failed',
SKIPPED: 'skipped',
SUCCESS: 'success',
+ ERROR: 'error',
+ UNKNOWN: 'unknown',
};
export const FETCH_AUTHOR_ERROR_MESSAGE = __('There was a problem fetching project users.');
@@ -21,3 +23,12 @@ export const FETCH_TAG_ERROR_MESSAGE = __('There was a problem fetching project
export const RAW_TEXT_WARNING = s__(
'Pipeline|Raw text search is not currently supported. Please use the available search tokens.',
);
+
+/* Error constants shared across graphs */
+export const DEFAULT = 'default';
+export const DELETE_FAILURE = 'delete_pipeline_failure';
+export const DRAW_FAILURE = 'draw_failure';
+export const LOAD_FAILURE = 'load_failure';
+export const PARSE_FAILURE = 'parse_failure';
+export const POST_FAILURE = 'post_failure';
+export const UNSUPPORTED_DATA = 'unsupported_data';
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
new file mode 100644
index 00000000000..06083daeca0
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
@@ -0,0 +1,30 @@
+query getPipelineHeaderData($fullPath: ID!, $iid: ID!) {
+ project(fullPath: $fullPath) {
+ pipeline(iid: $iid) {
+ id
+ status
+ retryable
+ cancelable
+ userPermissions {
+ destroyPipeline
+ }
+ detailedStatus {
+ detailsPath
+ icon
+ group
+ text
+ }
+ createdAt
+ user {
+ name
+ webPath
+ email
+ avatarUrl
+ status {
+ message
+ emoji
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js
deleted file mode 100644
index 53b7a174517..00000000000
--- a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import { escape } from 'lodash';
-
-export default {
- props: {
- isLoading: {
- type: Boolean,
- required: true,
- },
- pipeline: {
- type: Object,
- required: true,
- },
- },
- computed: {
- graph() {
- return this.pipeline.details && this.pipeline.details.stages;
- },
- },
- methods: {
- capitalizeStageName(name) {
- const escapedName = escape(name);
- return escapedName.charAt(0).toUpperCase() + escapedName.slice(1);
- },
- isFirstColumn(index) {
- return index === 0;
- },
- stageConnectorClass(index, stage) {
- let className;
-
- // If it's the first stage column and only has one job
- if (index === 0 && stage.groups.length === 1) {
- className = 'no-margin';
- } else if (index > 0) {
- // If it is not the first column
- className = 'left-margin';
- }
-
- return className;
- },
- refreshPipelineGraph() {
- this.$emit('refreshPipelineGraph');
- },
- /**
- * CSS class is applied:
- * - if pipeline graph contains only one stage column component
- *
- * @param {number} index
- * @returns {boolean}
- */
- shouldAddRightMargin(index) {
- return !(index === this.graph.length - 1);
- },
- },
-};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 745f5b886a5..67aec12655a 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -7,10 +7,11 @@ import pipelineGraph from './components/graph/graph_component.vue';
import createDagApp from './pipeline_details_dag';
import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator';
-import pipelineHeader from './components/header_component.vue';
+import legacyPipelineHeader from './components/legacy_header_component.vue';
import eventHub from './event_hub';
import TestReports from './components/test_reports/test_reports.vue';
import createTestReportsStore from './stores/test_reports';
+import { createPipelineHeaderApp } from './pipeline_details_header';
Vue.use(Translate);
@@ -56,7 +57,7 @@ const createPipelinesDetailApp = mediator => {
});
};
-const createPipelineHeaderApp = mediator => {
+const createLegacyPipelineHeaderApp = mediator => {
if (!document.querySelector(SELECTORS.PIPELINE_HEADER)) {
return;
}
@@ -64,7 +65,7 @@ const createPipelineHeaderApp = mediator => {
new Vue({
el: SELECTORS.PIPELINE_HEADER,
components: {
- pipelineHeader,
+ legacyPipelineHeader,
},
data() {
return {
@@ -95,7 +96,7 @@ const createPipelineHeaderApp = mediator => {
},
},
render(createElement) {
- return createElement('pipeline-header', {
+ return createElement('legacy-pipeline-header', {
props: {
isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline,
@@ -132,7 +133,12 @@ export default () => {
mediator.fetchPipeline();
createPipelinesDetailApp(mediator);
- createPipelineHeaderApp(mediator);
+
+ if (gon.features.graphqlPipelineHeader) {
+ createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER);
+ } else {
+ createLegacyPipelineHeaderApp(mediator);
+ }
createTestDetails();
createDagApp();
};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js
new file mode 100644
index 00000000000..27fe9ba3f19
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_header.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import pipelineHeader from './components/header_component.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export const createPipelineHeaderApp = elSelector => {
+ const el = document.querySelector(elSelector);
+
+ if (!el) {
+ return;
+ }
+
+ const { cancelPath, deletePath, fullPath, pipelineId, pipelineIid, retryPath } = el?.dataset;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ pipelineHeader,
+ },
+ apolloProvider,
+ provide: {
+ paths: {
+ cancel: cancelPath,
+ delete: deletePath,
+ fullProject: fullPath,
+ retry: retryPath,
+ },
+ pipelineId,
+ pipelineIid,
+ },
+ render(createElement) {
+ return createElement('pipeline-header', {});
+ },
+ });
+};
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js
index 8f1ac305cda..42406e5a67a 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/utils.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js
@@ -1,13 +1,19 @@
import { __, sprintf } from '../../../locale';
+import { TestStatus } from '../../constants';
export function iconForTestStatus(status) {
switch (status) {
- case 'success':
+ case TestStatus.SUCCESS:
return 'status_success_borderless';
- case 'failed':
+ case TestStatus.FAILED:
return 'status_failed_borderless';
- default:
+ case TestStatus.ERROR:
+ return 'status_warning_borderless';
+ case TestStatus.SKIPPED:
return 'status_skipped_borderless';
+ case TestStatus.UNKNOWN:
+ default:
+ return 'status_notfound_borderless';
}
}
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
index bd53b22784c..7d1a1762e0d 100644
--- a/app/assets/javascripts/pipelines/utils.js
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -5,6 +5,8 @@ export const validateParams = params => {
return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val);
};
+export const createUniqueJobId = (stageName, jobName) => `${stageName}-${jobName}`;
+
/**
* This function takes a json payload that comes from a yml
* file converted to json through `jsyaml` library. Because we
@@ -18,6 +20,16 @@ export const validateParams = params => {
export const preparePipelineGraphData = jsonData => {
const jsonKeys = Object.keys(jsonData);
const jobNames = jsonKeys.filter(job => jsonData[job]?.stage);
+ // Creates an object with only the valid jobs
+ const jobs = jsonKeys.reduce((acc, val) => {
+ if (jobNames.includes(val)) {
+ return {
+ ...acc,
+ [val]: { ...jsonData[val], id: createUniqueJobId(jsonData[val].stage, val) },
+ };
+ }
+ return { ...acc };
+ }, {});
// We merge both the stages from the "stages" key in the yaml and the stage associated
// with each job to show the user both the stages they explicitly defined, and those
@@ -40,10 +52,45 @@ export const preparePipelineGraphData = jsonData => {
return {
name: stage,
groups: stageJobs.map(job => {
- return { name: job, jobs: [{ ...jsonData[job] }] };
+ return {
+ name: job,
+ jobs: [{ ...jsonData[job] }],
+ id: createUniqueJobId(stage, job),
+ };
}),
};
});
- return { stages: pipelineData };
+ return { stages: pipelineData, jobs };
+};
+
+export const generateJobNeedsDict = ({ jobs }) => {
+ const arrOfJobNames = Object.keys(jobs);
+
+ return arrOfJobNames.reduce((acc, value) => {
+ const recursiveNeeds = jobName => {
+ if (!jobs[jobName]?.needs) {
+ return [];
+ }
+
+ return jobs[jobName].needs
+ .map(job => {
+ const { id } = jobs[job];
+ // If we already have the needs of a job in the accumulator,
+ // then we use the memoized data instead of the recursive call
+ // to save some performance.
+ const newNeeds = acc[id] ?? recursiveNeeds(job);
+
+ return [id, ...newNeeds];
+ })
+ .flat(Infinity);
+ };
+
+ // To ensure we don't have duplicates job relationship when 2 jobs
+ // needed by another both depends on the same jobs, we remove any
+ // duplicates from the array.
+ const uniqueValues = Array.from(new Set(recursiveNeeds(value)));
+
+ return { ...acc, [jobs[value].id]: uniqueValues };
+ }, {});
};
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
index 4aaa2cff2ac..200e5ba255f 100644
--- a/app/assets/javascripts/profile/account/components/update_username.vue
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -1,6 +1,7 @@
<script>
/* eslint-disable vue/no-v-html */
import { escape } from 'lodash';
+import { GlButton } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { s__, sprintf } from '~/locale';
@@ -9,6 +10,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
export default {
components: {
GlModal: DeprecatedModal2,
+ GlButton,
},
props: {
actionUrl: {
@@ -100,15 +102,15 @@ Please update your Git repository remotes as soon as possible.`),
</div>
<p class="form-text text-muted">{{ path }}</p>
</div>
- <button
+ <gl-button
:data-target="`#${$options.modalId}`"
:disabled="isRequestPending || newUsername === username"
- class="btn btn-warning"
- type="button"
+ category="primary"
+ variant="warning"
data-toggle="modal"
>
{{ $options.buttonText }}
- </button>
+ </gl-button>
<gl-modal
:id="$options.modalId"
:header-title-text="s__('Profiles|Change username') + '?'"
diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js
index 55bc9fb8955..ecb69422287 100644
--- a/app/assets/javascripts/profile/gl_crop.js
+++ b/app/assets/javascripts/profile/gl_crop.js
@@ -3,6 +3,7 @@
import $ from 'jquery';
import 'cropper';
import { isString } from 'lodash';
+import { loadCSSFile } from '../lib/utils/css_utils';
(() => {
// Matches everything but the file name
@@ -180,6 +181,9 @@ import { isString } from 'lodash';
}
}
+ const cropModal = document.querySelector('.modal-profile-crop');
+ if (cropModal) loadCSSFile(cropModal.dataset.cropperCssPath);
+
$.fn.glCrop = function(opts) {
return this.each(function() {
return $(this).data('glcrop', new GitLabCrop(this, opts));
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index 6822fa8f7c7..4755a4aa9ba 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
+import { Rails } from '~/lib/utils/rails_ujs';
import { deprecatedCreateFlash as flash } from '../flash';
import { parseBoolean } from '~/lib/utils/common_utils';
import TimezoneDropdown, {
@@ -48,9 +49,13 @@ export default class Profile {
}
submitForm() {
- return $(this)
- .parents('form')
- .submit();
+ const $form = $(this).parents('form');
+
+ if ($form.data('remote')) {
+ Rails.fire($form[0], 'submit');
+ } else {
+ $form.submit();
+ }
}
onSubmitForm(e) {
diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js
index 70fce4a4d09..2f35c4485f9 100644
--- a/app/assets/javascripts/project_find_file.js
+++ b/app/assets/javascripts/project_find_file.js
@@ -2,9 +2,10 @@
import $ from 'jquery';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import { sanitize } from 'dompurify';
+import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
+import { spriteIcon } from '~/lib/utils/common_utils';
import { deprecatedCreateFlash as flash } from '~/flash';
import { __ } from '~/locale';
@@ -125,7 +126,10 @@ export default class ProjectFindFile {
// make tbody row html
static makeHtml(filePath, matches, blobItemUrl) {
const $tr = $(
- "<tr class='tree-item'><td class='tree-item-file-name link-container'><a><i class='fa fa-file-text-o fa-fw'></i><span class='str-truncated'></span></a></td></tr>",
+ `<tr class='tree-item'><td class='tree-item-file-name link-container'><a>${spriteIcon(
+ 'doc-text',
+ 's16 vertical-align-middle gl-mr-1',
+ )}<span class='str-truncated'></span></a></td></tr>`,
);
if (matches) {
$tr
diff --git a/app/assets/javascripts/project_label_subscription.js b/app/assets/javascripts/project_label_subscription.js
index 12c77b09b64..4fefc2ed569 100644
--- a/app/assets/javascripts/project_label_subscription.js
+++ b/app/assets/javascripts/project_label_subscription.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import { __ } from './locale';
import axios from './lib/utils/axios_utils';
import { deprecatedCreateFlash as flash } from './flash';
+import { fixTitle } from '~/tooltips';
const tooltipTitles = {
group: {
@@ -66,6 +67,7 @@ export default class ProjectLabelSubscription {
const type = /group/.test(originalTitle) ? 'group' : 'project';
const newTitle = tooltipTitles[type][newStatus];
- $button.attr('title', newTitle).tooltip('_fixTitle');
+ $button.attr('title', newTitle);
+ fixTitle($button);
}
}
diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js
new file mode 100644
index 00000000000..352ac39f3c4
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/index.js
@@ -0,0 +1,18 @@
+import { loadBranches } from './load_branches';
+import { fetchCommitMergeRequests } from '~/commit_merge_requests';
+import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
+
+export const initCommitBoxInfo = (containerSelector = '.js-commit-box-info') => {
+ const containerEl = document.querySelector(containerSelector);
+
+ // Display commit related branches
+ loadBranches(containerEl);
+
+ // Related merge requests to this commit
+ fetchCommitMergeRequests();
+
+ // Display pipeline info for this commit
+ new MiniPipelineGraph({
+ container: '.js-commit-pipeline-graph',
+ }).bindEvents();
+};
diff --git a/app/assets/javascripts/projects/commit_box/info/load_branches.js b/app/assets/javascripts/projects/commit_box/info/load_branches.js
new file mode 100644
index 00000000000..0efa1998507
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/load_branches.js
@@ -0,0 +1,20 @@
+import axios from 'axios';
+import { sanitize } from '~/lib/dompurify';
+import { __ } from '~/locale';
+
+export const loadBranches = containerEl => {
+ if (!containerEl) {
+ return;
+ }
+
+ const { commitPath } = containerEl.dataset;
+ const branchesEl = containerEl.querySelector('.commit-info.branches');
+ axios
+ .get(commitPath)
+ .then(({ data }) => {
+ branchesEl.innerHTML = sanitize(data);
+ })
+ .catch(() => {
+ branchesEl.textContent = __('Failed to load branches. Please try again.');
+ });
+};
diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue
index 2204ec3cbe7..3bc772fe60a 100644
--- a/app/assets/javascripts/projects/commits/components/author_select.vue
+++ b/app/assets/javascripts/projects/commits/components/author_select.vue
@@ -119,7 +119,6 @@ export default {
<gl-dropdown-divider />
<gl-search-box-by-type
v-model.trim="authorInput"
- class="gl-m-3"
:placeholder="__('Search')"
@input="searchAuthors"
/>
diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js
index 927501748a5..157e2409f7f 100644
--- a/app/assets/javascripts/projects/commits/store/actions.js
+++ b/app/assets/javascripts/projects/commits/store/actions.js
@@ -1,4 +1,4 @@
-import * as Sentry from '@sentry/browser';
+import * as Sentry from '~/sentry/wrapper';
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as createFlash } from '~/flash';
diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js
index 2d321ead33e..a6019e9c01b 100644
--- a/app/assets/javascripts/projects/default_project_templates.js
+++ b/app/assets/javascripts/projects/default_project_templates.js
@@ -57,6 +57,10 @@ export default {
text: s__('ProjectTemplates|Static Site Editor/Middleman'),
icon: '.template-option .icon-sse_middleman',
},
+ gitpod_spring_petclinic: {
+ text: s__('ProjectTemplates|Gitpod/Spring Petclinic'),
+ icon: '.template-option .icon-gitpod_spring_petclinic',
+ },
nfhugo: {
text: s__('ProjectTemplates|Netlify/Hugo'),
icon: '.template-option .icon-nfhugo',
diff --git a/app/assets/javascripts/projects/default_sample_data_templates.js b/app/assets/javascripts/projects/default_sample_data_templates.js
new file mode 100644
index 00000000000..7c45e7ac62f
--- /dev/null
+++ b/app/assets/javascripts/projects/default_sample_data_templates.js
@@ -0,0 +1,12 @@
+import { s__ } from '~/locale';
+
+export default {
+ basic: {
+ text: s__('ProjectTemplates|Basic'),
+ icon: '.template-option .icon-basic',
+ },
+ serenity_valley: {
+ text: s__('ProjectTemplates|Serenity Valley'),
+ icon: '.template-option .icon-serenity_valley',
+ },
+};
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 599aa52831b..d74a2d06786 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates';
+import DEFAULT_SAMPLE_DATA_TEMPLATES from '~/projects/default_sample_data_templates';
import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils';
import {
convertToTitleCase,
@@ -146,7 +147,8 @@ const bindEvents = () => {
$selectedIcon.empty();
const value = $(this).val();
- const selectedTemplate = DEFAULT_PROJECT_TEMPLATES[value];
+ const selectedTemplate =
+ DEFAULT_PROJECT_TEMPLATES[value] || DEFAULT_SAMPLE_DATA_TEMPLATES[value];
$selectedTemplateText.text(selectedTemplate.text);
$(selectedTemplate.icon)
.clone()
diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js
index 5d51b7ea57b..3ca5bca4bf2 100644
--- a/app/assets/javascripts/projects/settings/access_dropdown.js
+++ b/app/assets/javascripts/projects/settings/access_dropdown.js
@@ -1,9 +1,9 @@
/* eslint-disable no-underscore-dangle, class-methods-use-this */
import { escape, find, countBy } from 'lodash';
import axios from '~/lib/utils/axios_utils';
-import { deprecatedCreateFlash as Flash } from '~/flash';
-import { n__, s__, __ } from '~/locale';
-import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVEL_NONE } from './constants';
+import createFlash from '~/flash';
+import { n__, s__, __, sprintf } from '~/locale';
+import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
export default class AccessDropdown {
@@ -11,6 +11,7 @@ export default class AccessDropdown {
const { $dropdown, accessLevel, accessLevelsData, hasLicense = true } = options;
this.options = options;
this.hasLicense = hasLicense;
+ this.deployKeysOnProtectedBranchesEnabled = gon.features.deployKeysOnProtectedBranches;
this.groups = [];
this.accessLevel = accessLevel;
this.accessLevelsData = accessLevelsData.roles;
@@ -18,6 +19,7 @@ export default class AccessDropdown {
this.$wrap = this.$dropdown.closest(`.${this.accessLevel}-container`);
this.usersPath = '/-/autocomplete/users.json';
this.groupsPath = '/-/autocomplete/project_groups.json';
+ this.deployKeysPath = '/-/autocomplete/deploy_keys_with_owners.json';
this.defaultLabel = this.$dropdown.data('defaultLabel');
this.setSelectedItems([]);
@@ -146,6 +148,8 @@ export default class AccessDropdown {
obj.access_level = item.access_level;
} else if (item.type === LEVEL_TYPES.USER) {
obj.user_id = item.user_id;
+ } else if (item.type === LEVEL_TYPES.DEPLOY_KEY) {
+ obj.deploy_key_id = item.deploy_key_id;
} else if (item.type === LEVEL_TYPES.GROUP) {
obj.group_id = item.group_id;
}
@@ -177,6 +181,9 @@ export default class AccessDropdown {
case LEVEL_TYPES.GROUP:
comparator = LEVEL_ID_PROP.GROUP;
break;
+ case LEVEL_TYPES.DEPLOY_KEY:
+ comparator = LEVEL_ID_PROP.DEPLOY_KEY;
+ break;
case LEVEL_TYPES.USER:
comparator = LEVEL_ID_PROP.USER;
break;
@@ -218,6 +225,11 @@ export default class AccessDropdown {
group_id: selectedItem.id,
type: LEVEL_TYPES.GROUP,
};
+ } else if (selectedItem.type === LEVEL_TYPES.DEPLOY_KEY) {
+ itemToAdd = {
+ deploy_key_id: selectedItem.id,
+ type: LEVEL_TYPES.DEPLOY_KEY,
+ };
}
this.items.push(itemToAdd);
@@ -233,11 +245,12 @@ export default class AccessDropdown {
return true;
}
- if (item.type === LEVEL_TYPES.USER && item.user_id === itemToDelete.id) {
- index = i;
- } else if (item.type === LEVEL_TYPES.ROLE && item.access_level === itemToDelete.id) {
- index = i;
- } else if (item.type === LEVEL_TYPES.GROUP && item.group_id === itemToDelete.id) {
+ if (
+ (item.type === LEVEL_TYPES.USER && item.user_id === itemToDelete.id) ||
+ (item.type === LEVEL_TYPES.ROLE && item.access_level === itemToDelete.id) ||
+ (item.type === LEVEL_TYPES.DEPLOY_KEY && item.deploy_key_id === itemToDelete.id) ||
+ (item.type === LEVEL_TYPES.GROUP && item.group_id === itemToDelete.id)
+ ) {
index = i;
}
@@ -289,6 +302,10 @@ export default class AccessDropdown {
labelPieces.push(n__('1 user', '%d users', counts[LEVEL_TYPES.USER]));
}
+ if (counts[LEVEL_TYPES.DEPLOY_KEY] > 0) {
+ labelPieces.push(n__('1 deploy key', '%d deploy keys', counts[LEVEL_TYPES.DEPLOY_KEY]));
+ }
+
if (counts[LEVEL_TYPES.GROUP] > 0) {
labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP]));
}
@@ -299,20 +316,31 @@ export default class AccessDropdown {
getData(query, callback) {
if (this.hasLicense) {
Promise.all([
+ this.getDeployKeys(query),
this.getUsers(query),
this.groupsData ? Promise.resolve(this.groupsData) : this.getGroups(),
])
- .then(([usersResponse, groupsResponse]) => {
+ .then(([deployKeysResponse, usersResponse, groupsResponse]) => {
this.groupsData = groupsResponse;
- callback(this.consolidateData(usersResponse.data, groupsResponse.data));
+ callback(
+ this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data),
+ );
})
- .catch(() => Flash(__('Failed to load groups & users.')));
+ .catch(() => {
+ if (this.deployKeysOnProtectedBranchesEnabled) {
+ createFlash({ message: __('Failed to load groups, users and deploy keys.') });
+ } else {
+ createFlash({ message: __('Failed to load groups & users.') });
+ }
+ });
} else {
- callback(this.consolidateData());
+ this.getDeployKeys(query)
+ .then(deployKeysResponse => callback(this.consolidateData(deployKeysResponse.data)))
+ .catch(() => createFlash({ message: __('Failed to load deploy keys.') }));
}
}
- consolidateData(usersResponse = [], groupsResponse = []) {
+ consolidateData(deployKeysResponse, usersResponse = [], groupsResponse = []) {
let consolidatedData = [];
// ID property is handled differently locally from the server
@@ -328,6 +356,10 @@ export default class AccessDropdown {
// For Users
// In dropdown: `id`
// For submit: `user_id`
+ //
+ // For Deploy Keys
+ // In dropdown: `id`
+ // For submit: `deploy_key_id`
/*
* Build roles
@@ -410,6 +442,38 @@ export default class AccessDropdown {
}
}
+ if (this.deployKeysOnProtectedBranchesEnabled) {
+ const deployKeys = deployKeysResponse.map(response => {
+ const {
+ id,
+ fingerprint,
+ title,
+ owner: { avatar_url, name, username },
+ } = response;
+
+ const shortFingerprint = `(${fingerprint.substring(0, 14)}...)`;
+
+ return {
+ id,
+ title: title.concat(' ', shortFingerprint),
+ avatar_url,
+ fullname: name,
+ username,
+ type: LEVEL_TYPES.DEPLOY_KEY,
+ };
+ });
+
+ if (this.accessLevel === ACCESS_LEVELS.PUSH) {
+ if (deployKeys.length) {
+ consolidatedData = consolidatedData.concat(
+ [{ type: 'divider' }],
+ [{ type: 'header', content: s__('AccessDropdown|Deploy Keys') }],
+ deployKeys,
+ );
+ }
+ }
+ }
+
return consolidatedData;
}
@@ -433,6 +497,22 @@ export default class AccessDropdown {
});
}
+ getDeployKeys(query) {
+ if (this.deployKeysOnProtectedBranchesEnabled) {
+ return axios.get(this.buildUrl(gon.relative_url_root, this.deployKeysPath), {
+ params: {
+ search: query,
+ per_page: 20,
+ active: true,
+ project_id: gon.current_project_id,
+ push_code: true,
+ },
+ });
+ }
+
+ return Promise.resolve({ data: [] });
+ }
+
buildUrl(urlRoot, url) {
let newUrl;
if (urlRoot != null) {
@@ -454,6 +534,9 @@ export default class AccessDropdown {
case LEVEL_TYPES.ROLE:
criteria = { access_level: item.id };
break;
+ case LEVEL_TYPES.DEPLOY_KEY:
+ criteria = { deploy_key_id: item.id };
+ break;
case LEVEL_TYPES.GROUP:
criteria = { group_id: item.id };
break;
@@ -470,6 +553,10 @@ export default class AccessDropdown {
case LEVEL_TYPES.ROLE:
groupRowEl = this.roleRowHtml(item, isActive);
break;
+ case LEVEL_TYPES.DEPLOY_KEY:
+ groupRowEl =
+ this.accessLevel === ACCESS_LEVELS.PUSH ? this.deployKeyRowHtml(item, isActive) : '';
+ break;
case LEVEL_TYPES.GROUP:
groupRowEl = this.groupRowHtml(item, isActive);
break;
@@ -495,6 +582,31 @@ export default class AccessDropdown {
`;
}
+ deployKeyRowHtml(key, isActive) {
+ const isActiveClass = isActive || '';
+
+ return `
+ <li>
+ <a href="#" class="${isActiveClass}">
+ <strong>${key.title}</strong>
+ <p>
+ ${sprintf(
+ __('Owned by %{image_tag}'),
+ {
+ image_tag: `<img src="${key.avatar_url}" class="avatar avatar-inline s26" width="30">`,
+ },
+ false,
+ )}
+ <strong class="dropdown-menu-user-full-name gl-display-inline">${escape(
+ key.fullname,
+ )}</strong>
+ <span class="dropdown-menu-user-username gl-display-inline">${key.username}</span>
+ </p>
+ </a>
+ </li>
+ `;
+ }
+
groupRowHtml(group, isActive) {
const isActiveClass = isActive || '';
const avatarEl = group.avatar_url
diff --git a/app/assets/javascripts/projects/settings/constants.js b/app/assets/javascripts/projects/settings/constants.js
index fadb1f4f178..f5591c43dc4 100644
--- a/app/assets/javascripts/projects/settings/constants.js
+++ b/app/assets/javascripts/projects/settings/constants.js
@@ -1,13 +1,20 @@
export const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
+ DEPLOY_KEY: 'deploy_key',
GROUP: 'group',
};
export const LEVEL_ID_PROP = {
ROLE: 'access_level',
USER: 'user_id',
+ DEPLOY_KEY: 'deploy_key_id',
GROUP: 'group_id',
};
+export const ACCESS_LEVELS = {
+ MERGE: 'merge_access_levels',
+ PUSH: 'push_access_levels',
+};
+
export const ACCESS_LEVEL_NONE = 0;
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 81367f7d6b4..4bfed6d489d 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
@@ -1,6 +1,6 @@
<script>
import { GlAlert } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
import ServiceDeskSetting from './service_desk_setting.vue';
import ServiceDeskService from '../services/service_desk_service';
import eventHub from '../event_hub';
@@ -122,11 +122,13 @@ export default {
this.incomingEmail = data?.service_desk_address;
this.showAlert(__('Changes were successfully made.'), 'success');
})
- .catch(() =>
+ .catch(err => {
this.showAlert(
- __('An error occurred while saving the template. Please check if the template exists.'),
- ),
- )
+ sprintf(__('An error occured while making the changes: %{error}'), {
+ error: err?.response?.data?.message,
+ }),
+ );
+ })
.finally(() => {
this.isTemplateSaving = false;
});
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 6a0810ad3a1..e18cfefc3ca 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
@@ -1,22 +1,19 @@
<script>
-import { GlButton, GlFormSelect, GlToggle, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlFormSelect, GlToggle, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import eventHub from '../event_hub';
export default {
name: 'ServiceDeskSetting',
- directives: {
- tooltip,
- },
components: {
ClipboardButton,
GlButton,
GlFormSelect,
GlToggle,
GlLoadingIcon,
+ GlSprintf,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -60,6 +57,7 @@ export default {
selectedTemplate: this.initialSelectedTemplate,
outgoingName: this.initialOutgoingName || __('GitLab Support Bot'),
projectKey: this.initialProjectKey,
+ baseEmail: this.incomingEmail.replace(this.initialProjectKey, ''),
};
},
computed: {
@@ -108,7 +106,7 @@ export default {
<input
ref="service-desk-incoming-email"
type="text"
- class="form-control incoming-email h-auto"
+ class="form-control incoming-email"
:placeholder="__('Incoming email')"
:aria-label="__('Incoming email')"
aria-describedby="incoming-email-describer"
@@ -119,16 +117,37 @@ export default {
<clipboard-button
:title="__('Copy')"
:text="incomingEmail"
- css-class="btn qa-clipboard-button"
+ css-class="input-group-text qa-clipboard-button"
/>
</div>
</div>
+ <span v-if="projectKey" class="form-text text-muted">
+ <gl-sprintf :message="__('Emails sent to %{email} will still be supported')">
+ <template #email>
+ <code>{{ baseEmail }}</code>
+ </template>
+ </gl-sprintf>
+ </span>
</template>
<template v-else>
<gl-loading-icon :inline="true" />
<span class="sr-only">{{ __('Fetching incoming email') }}</span>
</template>
+ <template v-if="hasProjectKeySupport">
+ <label for="service-desk-project-suffix" class="mt-3">
+ {{ __('Project name suffix') }}
+ </label>
+ <input id="service-desk-project-suffix" v-model.trim="projectKey" class="form-control" />
+ <span class="form-text text-muted">
+ {{
+ __(
+ 'Project name suffix is a user-defined string which will be appended to the project path, and will form the Service Desk email address.',
+ )
+ }}
+ </span>
+ </template>
+
<label for="service-desk-template-select" class="mt-3">
{{ __('Template to append to all Service Desk issues') }}
</label>
@@ -144,19 +163,6 @@ export default {
<span class="form-text text-muted">
{{ __('Emails sent from Service Desk will have this name') }}
</span>
- <template v-if="hasProjectKeySupport">
- <label for="service-desk-project-suffix" class="mt-3">
- {{ __('Project name suffix') }}
- </label>
- <input id="service-desk-project-suffix" v-model.trim="projectKey" class="form-control" />
- <span class="form-text text-muted mb-3">
- {{
- __(
- 'Project name suffix is a user-defined string which will be appended to the project path, and will form the Service Desk email address.',
- )
- }}
- </span>
- </template>
<div class="gl-display-flex gl-justify-content-end">
<gl-button
variant="success"
diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
index e691f675e59..e582d5c3e47 100644
--- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
+++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
@@ -1,16 +1,15 @@
<script>
import Visibility from 'visibilityjs';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import ciIcon from '~/vue_shared/components/ci_icon.vue';
import Poll from '~/lib/utils/poll';
import { deprecatedCreateFlash as Flash } from '~/flash';
import { __, s__, sprintf } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
import CommitPipelineService from '../services/commit_pipeline_service';
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
ciIcon,
@@ -97,7 +96,7 @@ export default {
<gl-loading-icon v-if="isLoading" size="lg" label="Loading pipeline status" />
<a v-else :href="ciStatus.details_path">
<ci-icon
- v-tooltip
+ v-gl-tooltip
:title="statusTitle"
:aria-label="statusTitle"
:status="ciStatus"
diff --git a/app/assets/javascripts/protected_branches/constants.js b/app/assets/javascripts/protected_branches/constants.js
index a17ae6811b7..ae5eaa8e622 100644
--- a/app/assets/javascripts/protected_branches/constants.js
+++ b/app/assets/javascripts/protected_branches/constants.js
@@ -7,12 +7,14 @@ export const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
GROUP: 'group',
+ DEPLOY_KEY: 'deploy_key',
};
export const LEVEL_ID_PROP = {
ROLE: 'access_level',
USER: 'user_id',
GROUP: 'group_id',
+ DEPLOY_KEY: 'deploy_key_id',
};
export const ACCESS_LEVEL_NONE = 0;
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index 5ccffe9700e..19f6666fd52 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -108,6 +108,10 @@ export default class ProtectedBranchCreate {
levelAttributes.push({
group_id: item.group_id,
});
+ } else if (item.type === LEVEL_TYPES.DEPLOY_KEY) {
+ levelAttributes.push({
+ deploy_key_id: item.deploy_key_id,
+ });
}
});
diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
index 85b123530b5..0084450c9b0 100644
--- a/app/assets/javascripts/ref/components/ref_selector.vue
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -139,7 +139,6 @@ export default {
<gl-search-box-by-type
ref="searchBox"
v-model.trim="query"
- class="gl-m-3"
:placeholder="i18n.searchPlaceholder"
@input="onSearchBoxInput"
@keydown.enter.prevent="onSearchBoxEnter"
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue b/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue
new file mode 100644
index 00000000000..d13d815a59e
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui';
+
+import { DELETE_ALERT_TITLE, DELETE_ALERT_LINK_TEXT } from '../../constants/index';
+
+export default {
+ components: {
+ GlSprintf,
+ GlAlert,
+ GlLink,
+ },
+ props: {
+ runCleanupPoliciesHelpPagePath: { type: String, required: false, default: '' },
+ cleanupPoliciesHelpPagePath: { type: String, required: false, default: '' },
+ },
+ i18n: {
+ DELETE_ALERT_TITLE,
+ DELETE_ALERT_LINK_TEXT,
+ },
+};
+</script>
+
+<template>
+ <gl-alert variant="warning" :title="$options.i18n.DELETE_ALERT_TITLE" @dismiss="$emit('dismiss')">
+ <gl-sprintf :message="$options.i18n.DELETE_ALERT_LINK_TEXT">
+ <template #adminLink="{content}">
+ <gl-link data-testid="run-link" :href="runCleanupPoliciesHelpPagePath" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ <template #docLink="{content}">
+ <gl-link data-testid="help-link" :href="cleanupPoliciesHelpPagePath" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
index 661213733ac..0f6297ca406 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
@@ -123,7 +123,7 @@ export default {
v-if="tag.location"
:title="tag.location"
:text="tag.location"
- css-class="btn-default btn-transparent btn-clipboard"
+ category="tertiary"
/>
<gl-icon
@@ -171,7 +171,7 @@ export default {
/>
</template>
- <template v-if="!invalidTag" #details_published>
+ <template v-if="!invalidTag" #details-published>
<details-row icon="clock" data-testid="published-date-detail">
<gl-sprintf :message="$options.i18n.PUBLISHED_DETAILS_ROW_TEXT">
<template #repositoryPath>
@@ -186,7 +186,7 @@ export default {
</gl-sprintf>
</details-row>
</template>
- <template v-if="!invalidTag" #details_manifest_digest>
+ <template v-if="!invalidTag" #details-manifest-digest>
<details-row icon="log" data-testid="manifest-detail">
<gl-sprintf :message="$options.i18n.MANIFEST_DETAILS_ROW_TEST">
<template #digest>
@@ -197,11 +197,12 @@ export default {
v-if="tag.digest"
:title="tag.digest"
:text="tag.digest"
- css-class="btn-default btn-transparent btn-clipboard gl-p-0"
+ category="tertiary"
+ size="small"
/>
</details-row>
</template>
- <template v-if="!invalidTag" #details_configuration_digest>
+ <template v-if="!invalidTag" #details-configuration-digest>
<details-row icon="cloud-gear" data-testid="configuration-detail">
<gl-sprintf :message="$options.i18n.CONFIGURATION_DETAILS_ROW_TEST">
<template #digest>
@@ -212,7 +213,8 @@ export default {
v-if="formattedRevision"
:title="formattedRevision"
:text="formattedRevision"
- css-class="btn-default btn-transparent btn-clipboard gl-p-0"
+ category="tertiary"
+ size="small"
/>
</details-row>
</template>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
index 32bf27f1143..cfd787b3f52 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
@@ -10,6 +10,7 @@ import {
LIST_DELETE_BUTTON_DISABLED,
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
+ CLEANUP_TIMED_OUT_ERROR_MESSAGE,
} from '../../constants/index';
export default {
@@ -34,7 +35,6 @@ export default {
LIST_DELETE_BUTTON_DISABLED,
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
- ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
},
computed: {
encodedItem() {
@@ -42,6 +42,7 @@ export default {
name: this.item.path,
tags_path: this.item.tags_path,
id: this.item.id,
+ cleanup_policy_started_at: this.item.cleanup_policy_started_at,
});
return window.btoa(params);
},
@@ -55,6 +56,14 @@ export default {
this.item.tags_count,
);
},
+ warningIconText() {
+ if (this.item.failedDelete) {
+ return ASYNC_DELETE_IMAGE_ERROR_MESSAGE;
+ } else if (this.item.cleanup_policy_started_at) {
+ return CLEANUP_TIMED_OUT_ERROR_MESSAGE;
+ }
+ return null;
+ },
},
};
</script>
@@ -82,11 +91,12 @@ export default {
:disabled="item.deleting"
:text="item.location"
:title="item.location"
- css-class="btn-default btn-transparent btn-clipboard gl-text-gray-300"
+ category="tertiary"
/>
<gl-icon
- v-if="item.failedDelete"
- v-gl-tooltip="{ title: $options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE }"
+ v-if="warningIconText"
+ v-gl-tooltip="{ title: warningIconText }"
+ data-testid="warning-icon"
name="warning"
class="gl-text-orange-500"
/>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue
index 7be68e77def..c2bd01701df 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue
@@ -1,5 +1,4 @@
<script>
-import { GlSprintf, GlLink } from '@gitlab/ui';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import { n__, sprintf } from '~/locale';
@@ -15,8 +14,6 @@ import {
export default {
components: {
- GlSprintf,
- GlLink,
TitleArea,
MetadataItem,
},
@@ -54,8 +51,6 @@ export default {
},
i18n: {
CONTAINER_REGISTRY_TITLE,
- LIST_INTRO_TEXT,
- EXPIRATION_POLICY_DISABLED_MESSAGE,
},
computed: {
imagesCountText() {
@@ -83,52 +78,40 @@ export default {
!this.expirationPolicyEnabled && this.imagesCount > 0 && !this.hideExpirationPolicyData
);
},
+ infoMessages() {
+ const base = [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }];
+ return this.showExpirationPolicyTip
+ ? [
+ ...base,
+ { text: EXPIRATION_POLICY_DISABLED_MESSAGE, link: this.expirationPolicyHelpPagePath },
+ ]
+ : base;
+ },
},
};
</script>
<template>
- <div>
- <title-area :title="$options.i18n.CONTAINER_REGISTRY_TITLE">
- <template #right-actions>
- <slot name="commands"></slot>
- </template>
- <template #metadata_count>
- <metadata-item
- v-if="imagesCount"
- data-testid="images-count"
- icon="container-image"
- :text="imagesCountText"
- />
- </template>
- <template #metadata_exp_policies>
- <metadata-item
- v-if="!hideExpirationPolicyData"
- data-testid="expiration-policy"
- icon="expire"
- :text="expirationPolicyText"
- size="xl"
- />
- </template>
- </title-area>
-
- <div data-testid="info-area">
- <p>
- <span data-testid="default-intro">
- <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT">
- <template #docLink="{content}">
- <gl-link :href="helpPagePath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </span>
- <span v-if="showExpirationPolicyTip" data-testid="expiration-disabled-message">
- <gl-sprintf :message="$options.i18n.EXPIRATION_POLICY_DISABLED_MESSAGE">
- <template #docLink="{content}">
- <gl-link :href="expirationPolicyHelpPagePath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </span>
- </p>
- </div>
- </div>
+ <title-area :title="$options.i18n.CONTAINER_REGISTRY_TITLE" :info-messages="infoMessages">
+ <template #right-actions>
+ <slot name="commands"></slot>
+ </template>
+ <template #metadata-count>
+ <metadata-item
+ v-if="imagesCount"
+ data-testid="images-count"
+ icon="container-image"
+ :text="imagesCountText"
+ />
+ </template>
+ <template #metadata-exp-policies>
+ <metadata-item
+ v-if="!hideExpirationPolicyData"
+ data-testid="expiration-policy"
+ icon="expire"
+ :text="expirationPolicyText"
+ size="xl"
+ />
+ </template>
+ </title-area>
</template>
diff --git a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js
index 8af25ca6ecc..48a6a015461 100644
--- a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js
+++ b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js
@@ -9,3 +9,10 @@ export const EXPIRATION_POLICY_DISABLED_TEXT = s__(
export const EXPIRATION_POLICY_DISABLED_MESSAGE = s__(
'ContainerRegistry|Expiration policies help manage the storage space used by the Container Registry, but the expiration policies for this registry are disabled. Contact your administrator to enable. %{docLinkStart}More information%{docLinkEnd}',
);
+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}',
+);
+export const CLEANUP_TIMED_OUT_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Cleanup timed out before it could delete all tags',
+);
diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue
index b697bca6259..d2fb695dbfa 100644
--- a/app/assets/javascripts/registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/registry/explorer/pages/details.vue
@@ -4,6 +4,7 @@ import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import Tracking from '~/tracking';
import DeleteAlert from '../components/details_page/delete_alert.vue';
+import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue';
import DeleteModal from '../components/details_page/delete_modal.vue';
import DetailsHeader from '../components/details_page/details_header.vue';
import TagsList from '../components/details_page/tags_list.vue';
@@ -21,6 +22,7 @@ import {
export default {
components: {
DeleteAlert,
+ PartialCleanupAlert,
DetailsHeader,
GlPagination,
DeleteModal,
@@ -37,13 +39,16 @@ export default {
itemsToBeDeleted: [],
isDesktop: true,
deleteAlertType: null,
+ dismissPartialCleanupWarning: false,
};
},
computed: {
...mapState(['tagsPagination', 'isLoading', 'config', 'tags']),
- imageName() {
- const { name } = decodeAndParse(this.$route.params.id);
- return name;
+ queryParameters() {
+ return decodeAndParse(this.$route.params.id);
+ },
+ showPartialCleanupWarning() {
+ return this.queryParameters.cleanup_policy_started_at && !this.dismissPartialCleanupWarning;
},
tracking() {
return {
@@ -120,7 +125,14 @@ export default {
class="gl-my-2"
/>
- <details-header :image-name="imageName" />
+ <partial-cleanup-alert
+ v-if="showPartialCleanupWarning"
+ :run-cleanup-policies-help-page-path="config.runCleanupPoliciesHelpPagePath"
+ :cleanup-policies-help-page-path="config.cleanupPoliciesHelpPagePath"
+ @dismiss="dismissPartialCleanupWarning = true"
+ />
+
+ <details-header :image-name="queryParameters.name" />
<tags-loader v-if="isLoading" />
<template v-else>
diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
index 2ee7bbef4c6..264d39a406a 100644
--- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
+++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
@@ -1,7 +1,7 @@
<script>
-import { mapActions, mapGetters, mapState } from 'vuex';
import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
-
+import { isEqual, get } from 'lodash';
+import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.graphql';
import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants';
import SettingsForm from './settings_form.vue';
@@ -19,21 +19,39 @@ export default {
GlSprintf,
GlLink,
},
+ inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries'],
i18n: {
UNAVAILABLE_FEATURE_TITLE,
UNAVAILABLE_FEATURE_INTRO_TEXT,
FETCH_SETTINGS_ERROR_MESSAGE,
},
+ apollo: {
+ containerExpirationPolicy: {
+ query: expirationPolicyQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update: data => data.project?.containerExpirationPolicy,
+ result({ data }) {
+ this.workingCopy = { ...get(data, 'project.containerExpirationPolicy', {}) };
+ },
+ error(e) {
+ this.fetchSettingsError = e;
+ },
+ },
+ },
data() {
return {
fetchSettingsError: false,
+ containerExpirationPolicy: null,
+ workingCopy: {},
};
},
computed: {
- ...mapState(['isAdmin', 'adminSettingsPath']),
- ...mapGetters({ isDisabled: 'getIsDisabled' }),
- showSettingForm() {
- return !this.isDisabled && !this.fetchSettingsError;
+ isDisabled() {
+ return !(this.containerExpirationPolicy || this.enableHistoricEntries);
},
showDisabledFormMessage() {
return this.isDisabled && !this.fetchSettingsError;
@@ -41,21 +59,27 @@ export default {
unavailableFeatureMessage() {
return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT;
},
- },
- mounted() {
- this.fetchSettings().catch(() => {
- this.fetchSettingsError = true;
- });
+ isEdited() {
+ return !isEqual(this.containerExpirationPolicy, this.workingCopy);
+ },
},
methods: {
- ...mapActions(['fetchSettings']),
+ restoreOriginal() {
+ this.workingCopy = { ...this.containerExpirationPolicy };
+ },
},
};
</script>
<template>
<div>
- <settings-form v-if="showSettingForm" />
+ <settings-form
+ v-if="!isDisabled"
+ v-model="workingCopy"
+ :is-loading="$apollo.queries.containerExpirationPolicy.loading"
+ :is-edited="isEdited"
+ @reset="restoreOriginal"
+ />
<template v-else>
<gl-alert
v-if="showDisabledFormMessage"
diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue
index 7a26fb5cbee..a9b35d4e29f 100644
--- a/app/assets/javascripts/registry/settings/components/settings_form.vue
+++ b/app/assets/javascripts/registry/settings/components/settings_form.vue
@@ -1,28 +1,45 @@
<script>
-import { get } from 'lodash';
-import { mapActions, mapState, mapGetters } from 'vuex';
-import { GlCard, GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlCard, GlButton } from '@gitlab/ui';
import Tracking from '~/tracking';
-import { mapComputed } from '~/vuex_shared/bindings';
import {
UPDATE_SETTINGS_ERROR_MESSAGE,
UPDATE_SETTINGS_SUCCESS_MESSAGE,
} from '../../shared/constants';
import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue';
import { SET_CLEANUP_POLICY_BUTTON, CLEANUP_POLICY_CARD_HEADER } from '../constants';
+import { formOptionsGenerator } from '~/registry/shared/utils';
+import updateContainerExpirationPolicyMutation from '../graphql/mutations/update_container_expiration_policy.graphql';
+import { updateContainerExpirationPolicy } from '../graphql/utils/cache_update';
export default {
components: {
GlCard,
GlButton,
- GlLoadingIcon,
ExpirationPolicyFields,
},
mixins: [Tracking.mixin()],
+ inject: ['projectPath'],
+ props: {
+ value: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isEdited: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
labelsConfig: {
cols: 3,
align: 'right',
},
+ formOptions: formOptionsGenerator(),
i18n: {
CLEANUP_POLICY_CARD_HEADER,
SET_CLEANUP_POLICY_BUTTON,
@@ -34,49 +51,85 @@ export default {
},
fieldsAreValid: true,
apiErrors: null,
+ mutationLoading: false,
};
},
computed: {
- ...mapState(['formOptions', 'isLoading']),
- ...mapGetters({ isEdited: 'getIsEdited' }),
- ...mapComputed([{ key: 'settings', getter: 'getSettings' }], 'updateSettings'),
+ prefilledForm() {
+ return {
+ ...this.value,
+ cadence: this.findDefaultOption('cadence'),
+ keepN: this.findDefaultOption('keepN'),
+ olderThan: this.findDefaultOption('olderThan'),
+ };
+ },
+ showLoadingIcon() {
+ return this.isLoading || this.mutationLoading;
+ },
isSubmitButtonDisabled() {
- return !this.fieldsAreValid || this.isLoading;
+ return !this.fieldsAreValid || this.showLoadingIcon;
},
isCancelButtonDisabled() {
- return !this.isEdited || this.isLoading;
+ return !this.isEdited || this.isLoading || this.mutationLoading;
+ },
+ mutationVariables() {
+ return {
+ projectPath: this.projectPath,
+ enabled: this.value.enabled,
+ cadence: this.value.cadence,
+ olderThan: this.value.olderThan,
+ keepN: this.value.keepN,
+ nameRegex: this.value.nameRegex,
+ nameRegexKeep: this.value.nameRegexKeep,
+ };
},
},
methods: {
- ...mapActions(['resetSettings', 'saveSettings']),
+ findDefaultOption(option) {
+ return this.value[option] || this.$options.formOptions[option].find(f => f.default)?.key;
+ },
reset() {
this.track('reset_form');
this.apiErrors = null;
- this.resetSettings();
+ this.$emit('reset');
},
setApiErrors(response) {
- const messages = get(response, 'data.message', []);
-
- this.apiErrors = Object.keys(messages).reduce((acc, curr) => {
- if (curr.startsWith('container_expiration_policy.')) {
- const key = curr.replace('container_expiration_policy.', '');
- acc[key] = get(messages, [curr, 0], '');
- }
+ this.apiErrors = response.graphQLErrors.reduce((acc, curr) => {
+ curr.extensions.problems.forEach(item => {
+ acc[item.path[0]] = item.message;
+ });
return acc;
}, {});
},
submit() {
this.track('submit_form');
this.apiErrors = null;
- this.saveSettings()
- .then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' }))
- .catch(({ response }) => {
- this.setApiErrors(response);
+ this.mutationLoading = true;
+ return this.$apollo
+ .mutate({
+ mutation: updateContainerExpirationPolicyMutation,
+ variables: {
+ input: this.mutationVariables,
+ },
+ update: updateContainerExpirationPolicy(this.projectPath),
+ })
+ .then(({ data }) => {
+ const errorMessage = data?.updateContainerExpirationPolicy?.errors[0];
+ if (errorMessage) {
+ this.$toast.show(errorMessage, { type: 'error' });
+ }
+ this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' });
+ })
+ .catch(error => {
+ this.setApiErrors(error);
this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' });
+ })
+ .finally(() => {
+ this.mutationLoading = false;
});
},
onModelChange(changePayload) {
- this.settings = changePayload.newValue;
+ this.$emit('input', changePayload.newValue);
if (this.apiErrors) {
this.apiErrors[changePayload.modified] = undefined;
}
@@ -93,8 +146,8 @@ export default {
</template>
<template #default>
<expiration-policy-fields
- :value="settings"
- :form-options="formOptions"
+ :value="prefilledForm"
+ :form-options="$options.formOptions"
:is-loading="isLoading"
:api-errors="apiErrors"
@validated="fieldsAreValid = true"
@@ -103,27 +156,25 @@ export default {
/>
</template>
<template #footer>
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button
- ref="cancel-button"
- type="reset"
- class="gl-mr-3 gl-display-block"
- :disabled="isCancelButtonDisabled"
- >
- {{ __('Cancel') }}
- </gl-button>
- <gl-button
- ref="save-button"
- type="submit"
- :disabled="isSubmitButtonDisabled"
- variant="success"
- category="primary"
- class="gl-display-flex gl-justify-content-center gl-align-items-center js-no-auto-disable"
- >
- {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}
- <gl-loading-icon v-if="isLoading" class="gl-ml-3" />
- </gl-button>
- </div>
+ <gl-button
+ ref="cancel-button"
+ type="reset"
+ class="gl-mr-3 gl-display-block float-right"
+ :disabled="isCancelButtonDisabled"
+ >
+ {{ __('Cancel') }}
+ </gl-button>
+ <gl-button
+ ref="save-button"
+ type="submit"
+ :disabled="isSubmitButtonDisabled"
+ :loading="showLoadingIcon"
+ variant="success"
+ category="primary"
+ class="js-no-auto-disable"
+ >
+ {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}
+ </gl-button>
</template>
</gl-card>
</form>
diff --git a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql
new file mode 100644
index 00000000000..224e0ed9472
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql
@@ -0,0 +1,8 @@
+fragment ContainerExpirationPolicyFields on ContainerExpirationPolicy {
+ cadence
+ enabled
+ keepN
+ nameRegex
+ nameRegexKeep
+ olderThan
+}
diff --git a/app/assets/javascripts/registry/settings/graphql/index.js b/app/assets/javascripts/registry/settings/graphql/index.js
new file mode 100644
index 00000000000..16152eb81f6
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/graphql/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+Vue.use(VueApollo);
+
+export const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
+});
diff --git a/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql
new file mode 100644
index 00000000000..c40cd115ab0
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/container_expiration_policy.fragment.graphql"
+
+mutation updateContainerExpirationPolicy($input: UpdateContainerExpirationPolicyInput!) {
+ updateContainerExpirationPolicy(input: $input) {
+ containerExpirationPolicy {
+ ...ContainerExpirationPolicyFields
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql
new file mode 100644
index 00000000000..c171be0ad07
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql
@@ -0,0 +1,9 @@
+#import "../fragments/container_expiration_policy.fragment.graphql"
+
+query getProjectExpirationPolicy($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ containerExpirationPolicy {
+ ...ContainerExpirationPolicyFields
+ }
+ }
+}
diff --git a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
new file mode 100644
index 00000000000..88067d52b51
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
@@ -0,0 +1,22 @@
+import { produce } from 'immer';
+import expirationPolicyQuery from '../queries/get_expiration_policy.graphql';
+
+export const updateContainerExpirationPolicy = projectPath => (client, { data: updatedData }) => {
+ const queryAndParams = {
+ query: expirationPolicyQuery,
+ variables: { projectPath },
+ };
+ const sourceData = client.readQuery(queryAndParams);
+
+ const data = produce(sourceData, draftState => {
+ // eslint-disable-next-line no-param-reassign
+ draftState.project.containerExpirationPolicy = {
+ ...updatedData.updateContainerExpirationPolicy.containerExpirationPolicy,
+ };
+ });
+
+ client.writeQuery({
+ ...queryAndParams,
+ data,
+ });
+};
diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
index a318aa2a694..f7b1c5abd3a 100644
--- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js
+++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
@@ -1,8 +1,9 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
import Translate from '~/vue_shared/translate';
-import store from './store';
+import { parseBoolean } from '~/lib/utils/common_utils';
import RegistrySettingsApp from './components/registry_settings_app.vue';
+import { apolloProvider } from './graphql/index';
Vue.use(GlToast);
Vue.use(Translate);
@@ -12,13 +13,19 @@ export default () => {
if (!el) {
return null;
}
- store.dispatch('setInitialState', el.dataset);
+ const { projectPath, isAdmin, adminSettingsPath, enableHistoricEntries } = el.dataset;
return new Vue({
el,
- store,
+ apolloProvider,
components: {
RegistrySettingsApp,
},
+ provide: {
+ projectPath,
+ isAdmin: parseBoolean(isAdmin),
+ adminSettingsPath,
+ enableHistoricEntries: parseBoolean(enableHistoricEntries),
+ },
render(createElement) {
return createElement('registry-settings-app', {});
},
diff --git a/app/assets/javascripts/registry/settings/store/actions.js b/app/assets/javascripts/registry/settings/store/actions.js
deleted file mode 100644
index 0530a870ecc..00000000000
--- a/app/assets/javascripts/registry/settings/store/actions.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import Api from '~/api';
-import * as types from './mutation_types';
-
-export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
-export const updateSettings = ({ commit }, data) => commit(types.UPDATE_SETTINGS, data);
-export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING);
-export const receiveSettingsSuccess = ({ commit }, data) => {
- commit(types.SET_SETTINGS, data);
-};
-export const resetSettings = ({ commit }) => commit(types.RESET_SETTINGS);
-
-export const fetchSettings = ({ dispatch, state }) => {
- dispatch('toggleLoading');
- return Api.project(state.projectId)
- .then(({ data: { container_expiration_policy } }) =>
- dispatch('receiveSettingsSuccess', container_expiration_policy),
- )
- .finally(() => dispatch('toggleLoading'));
-};
-
-export const saveSettings = ({ dispatch, state }) => {
- dispatch('toggleLoading');
- return Api.updateProject(state.projectId, {
- container_expiration_policy_attributes: state.settings,
- })
- .then(({ data: { container_expiration_policy } }) =>
- dispatch('receiveSettingsSuccess', container_expiration_policy),
- )
- .finally(() => dispatch('toggleLoading'));
-};
diff --git a/app/assets/javascripts/registry/settings/store/getters.js b/app/assets/javascripts/registry/settings/store/getters.js
deleted file mode 100644
index ac1a931d8e0..00000000000
--- a/app/assets/javascripts/registry/settings/store/getters.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { isEqual } from 'lodash';
-import { findDefaultOption } from '../../shared/utils';
-
-export const getCadence = state =>
- state.settings.cadence || findDefaultOption(state.formOptions.cadence);
-
-export const getKeepN = state =>
- state.settings.keep_n || findDefaultOption(state.formOptions.keepN);
-
-export const getOlderThan = state =>
- state.settings.older_than || findDefaultOption(state.formOptions.olderThan);
-
-export const getSettings = (state, getters) => ({
- enabled: state.settings.enabled,
- cadence: getters.getCadence,
- older_than: getters.getOlderThan,
- keep_n: getters.getKeepN,
- name_regex: state.settings.name_regex,
- name_regex_keep: state.settings.name_regex_keep,
-});
-
-export const getIsEdited = state => !isEqual(state.original, state.settings);
-
-export const getIsDisabled = state => {
- return !(state.original || state.enableHistoricEntries);
-};
diff --git a/app/assets/javascripts/registry/settings/store/mutation_types.js b/app/assets/javascripts/registry/settings/store/mutation_types.js
deleted file mode 100644
index db499ffa761..00000000000
--- a/app/assets/javascripts/registry/settings/store/mutation_types.js
+++ /dev/null
@@ -1,5 +0,0 @@
-export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
-export const UPDATE_SETTINGS = 'UPDATE_SETTINGS';
-export const TOGGLE_LOADING = 'TOGGLE_LOADING';
-export const SET_SETTINGS = 'SET_SETTINGS';
-export const RESET_SETTINGS = 'RESET_SETTINGS';
diff --git a/app/assets/javascripts/registry/settings/store/mutations.js b/app/assets/javascripts/registry/settings/store/mutations.js
deleted file mode 100644
index 3ba13419b98..00000000000
--- a/app/assets/javascripts/registry/settings/store/mutations.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { parseBoolean } from '~/lib/utils/common_utils';
-import * as types from './mutation_types';
-
-export default {
- [types.SET_INITIAL_STATE](state, initialState) {
- state.projectId = initialState.projectId;
- state.formOptions = {
- cadence: JSON.parse(initialState.cadenceOptions),
- keepN: JSON.parse(initialState.keepNOptions),
- olderThan: JSON.parse(initialState.olderThanOptions),
- };
- state.enableHistoricEntries = parseBoolean(initialState.enableHistoricEntries);
- state.isAdmin = parseBoolean(initialState.isAdmin);
- state.adminSettingsPath = initialState.adminSettingsPath;
- },
- [types.UPDATE_SETTINGS](state, data) {
- state.settings = { ...state.settings, ...data.settings };
- },
- [types.SET_SETTINGS](state, settings) {
- state.settings = settings ?? state.settings;
- state.original = Object.freeze(settings);
- },
- [types.RESET_SETTINGS](state) {
- state.settings = { ...state.original };
- },
- [types.TOGGLE_LOADING](state) {
- state.isLoading = !state.isLoading;
- },
-};
diff --git a/app/assets/javascripts/registry/settings/store/state.js b/app/assets/javascripts/registry/settings/store/state.js
deleted file mode 100644
index fccc0991c1c..00000000000
--- a/app/assets/javascripts/registry/settings/store/state.js
+++ /dev/null
@@ -1,42 +0,0 @@
-export default () => ({
- /*
- * Project Id used to build the API call
- */
- projectId: '',
- /*
- * Boolean to determine if the UI is loading data from the API
- */
- isLoading: false,
- /*
- * Boolean to determine if the user is an admin
- */
- isAdmin: false,
- /*
- * String containing the full path to the admin config page for CI/CD
- */
- adminSettingsPath: '',
- /*
- * Boolean to determine if project created before 12.8 can use this feature
- */
- enableHistoricEntries: false,
- /*
- * This contains the data shown and manipulated in the UI
- * Has the following structure:
- * {
- * enabled: Boolean
- * cadence: String,
- * older_than: String,
- * keep_n: String,
- * name_regex: String
- * }
- */
- settings: {},
- /*
- * Same structure as settings, above but Frozen object and used only in case the user clicks 'cancel', initialized to null
- */
- original: null,
- /*
- * Contains the options used to populate the form selects
- */
- formOptions: {},
-});
diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
index 1ff2f6f99e5..2b8e9f6ff64 100644
--- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
+++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
@@ -68,34 +68,31 @@ export default {
{
name: 'expiration-policy-interval',
label: EXPIRATION_INTERVAL_LABEL,
- model: 'older_than',
- optionKey: 'olderThan',
+ model: 'olderThan',
},
{
name: 'expiration-policy-schedule',
label: EXPIRATION_SCHEDULE_LABEL,
model: 'cadence',
- optionKey: 'cadence',
},
{
name: 'expiration-policy-latest',
label: KEEP_N_LABEL,
- model: 'keep_n',
- optionKey: 'keepN',
+ model: 'keepN',
},
],
textAreaList: [
{
name: 'expiration-policy-name-matching',
label: NAME_REGEX_LABEL,
- model: 'name_regex',
+ model: 'nameRegex',
placeholder: NAME_REGEX_PLACEHOLDER,
description: NAME_REGEX_DESCRIPTION,
},
{
name: 'expiration-policy-keep-name',
label: NAME_REGEX_KEEP_LABEL,
- model: 'name_regex_keep',
+ model: 'nameRegexKeep',
placeholder: NAME_REGEX_KEEP_PLACEHOLDER,
description: NAME_REGEX_KEEP_DESCRIPTION,
},
@@ -107,17 +104,16 @@ export default {
},
computed: {
...mapComputedToEvent(
- ['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex', 'name_regex_keep'],
+ ['enabled', 'cadence', 'olderThan', 'keepN', 'nameRegex', 'nameRegexKeep'],
'value',
),
policyEnabledText() {
return this.enabled ? ENABLED_TEXT : DISABLED_TEXT;
},
textAreaValidation() {
- const nameRegexErrors =
- this.apiErrors?.name_regex || this.validateRegexLength(this.name_regex);
+ const nameRegexErrors = this.apiErrors?.nameRegex || this.validateRegexLength(this.nameRegex);
const nameKeepRegexErrors =
- this.apiErrors?.name_regex_keep || this.validateRegexLength(this.name_regex_keep);
+ this.apiErrors?.nameRegexKeep || this.validateRegexLength(this.nameRegexKeep);
return {
/*
@@ -127,11 +123,11 @@ export default {
* false: red border, error message
* So in this function we keep null if the are no message otherwise we 'invert' the error message
*/
- name_regex: {
+ nameRegex: {
state: nameRegexErrors === null ? null : !nameRegexErrors,
message: nameRegexErrors,
},
- name_regex_keep: {
+ nameRegexKeep: {
state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors,
message: nameKeepRegexErrors,
},
@@ -139,8 +135,8 @@ export default {
},
fieldsValidity() {
return (
- this.textAreaValidation.name_regex.state !== false &&
- this.textAreaValidation.name_regex_keep.state !== false
+ this.textAreaValidation.nameRegex.state !== false &&
+ this.textAreaValidation.nameRegexKeep.state !== false
);
},
isFormElementDisabled() {
@@ -216,11 +212,7 @@ export default {
:disabled="isFormElementDisabled"
@input="updateModel($event, select.model)"
>
- <option
- v-for="option in formOptions[select.optionKey]"
- :key="option.key"
- :value="option.key"
- >
+ <option v-for="option in formOptions[select.model]" :key="option.key" :value="option.key">
{{ option.label }}
</option>
</gl-form-select>
diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js
index 36d55c7610e..735d72972e6 100644
--- a/app/assets/javascripts/registry/shared/constants.js
+++ b/app/assets/javascripts/registry/shared/constants.js
@@ -43,3 +43,27 @@ export const NAME_REGEX_KEEP_PLACEHOLDER = '';
export const NAME_REGEX_KEEP_DESCRIPTION = s__(
'ContainerRegistry|Wildcards such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported',
);
+
+export const KEEP_N_OPTIONS = [
+ { variable: 1, key: 'ONE_TAG', default: false },
+ { variable: 5, key: 'FIVE_TAGS', default: false },
+ { variable: 10, key: 'TEN_TAGS', default: true },
+ { variable: 25, key: 'TWENTY_FIVE_TAGS', default: false },
+ { variable: 50, key: 'FIFTY_TAGS', default: false },
+ { variable: 100, key: 'ONE_HUNDRED_TAGS', default: false },
+];
+
+export const CADENCE_OPTIONS = [
+ { key: 'EVERY_DAY', label: __('Every day'), default: true },
+ { key: 'EVERY_WEEK', label: __('Every week'), default: false },
+ { key: 'EVERY_TWO_WEEKS', label: __('Every two weeks'), default: false },
+ { key: 'EVERY_MONTH', label: __('Every month'), default: false },
+ { key: 'EVERY_THREE_MONTHS', label: __('Every three months'), default: false },
+];
+
+export const OLDER_THAN_OPTIONS = [
+ { key: 'SEVEN_DAYS', variable: 7, default: false },
+ { key: 'FOURTEEN_DAYS', variable: 14, default: false },
+ { key: 'THIRTY_DAYS', variable: 30, default: false },
+ { key: 'NINETY_DAYS', variable: 90, default: true },
+];
diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js
index a7377773842..bdf1ab9507d 100644
--- a/app/assets/javascripts/registry/shared/utils.js
+++ b/app/assets/javascripts/registry/shared/utils.js
@@ -1,3 +1,6 @@
+import { n__ } from '~/locale';
+import { KEEP_N_OPTIONS, CADENCE_OPTIONS, OLDER_THAN_OPTIONS } from './constants';
+
export const findDefaultOption = options => {
const item = options.find(o => o.default);
return item ? item.key : null;
@@ -17,3 +20,27 @@ export const mapComputedToEvent = (list, root) => {
});
return result;
};
+
+export const olderThanTranslationGenerator = variable =>
+ n__(
+ '%d day until tags are automatically removed',
+ '%d days until tags are automatically removed',
+ variable,
+ );
+
+export const keepNTranslationGenerator = variable =>
+ n__('%d tag per image name', '%d tags per image name', variable);
+
+export const optionLabelGenerator = (collection, translationFn) =>
+ collection.map(option => ({
+ ...option,
+ label: translationFn(option.variable),
+ }));
+
+export const formOptionsGenerator = () => {
+ return {
+ olderThan: optionLabelGenerator(OLDER_THAN_OPTIONS, olderThanTranslationGenerator),
+ cadence: CADENCE_OPTIONS,
+ keepN: optionLabelGenerator(KEEP_N_OPTIONS, keepNTranslationGenerator),
+ };
+};
diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
index 63d61989cba..6fbae95094a 100644
--- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue
+++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
@@ -195,7 +195,8 @@ export default {
:disabled="isSubmitButtonDisabled"
:loading="isSubmitting"
type="submit"
- class="js-add-issuable-form-add-button float-left qa-add-issue-button"
+ class="js-add-issuable-form-add-button float-left"
+ data-qa-selector="add_issue_button"
>
{{ __('Add') }}
</gl-button>
diff --git a/app/assets/javascripts/related_issues/components/issue_token.vue b/app/assets/javascripts/related_issues/components/issue_token.vue
index 31d0c7dbbb0..bbbdf2cdb49 100644
--- a/app/assets/javascripts/related_issues/components/issue_token.vue
+++ b/app/assets/javascripts/related_issues/components/issue_token.vue
@@ -90,6 +90,7 @@ export default {
:size="12"
:title="stateTitle"
:aria-label="state"
+ data-testid="referenceIcon"
/>
{{ displayReference }}
</component>
@@ -105,6 +106,7 @@ export default {
:title="removeButtonLabel"
:aria-label="removeButtonLabel"
:disabled="removeDisabled"
+ data-testid="removeBtn"
type="button"
class="js-issue-token-remove-button"
@click="onRemoveRequest"
diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
index 1931cfb2c00..9809b228308 100644
--- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue
+++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
@@ -219,7 +219,8 @@ export default {
:value="inputValue"
:placeholder="inputPlaceholder"
type="text"
- class="js-add-issuable-form-input add-issuable-form-input qa-add-issue-input"
+ class="js-add-issuable-form-input add-issuable-form-input"
+ data-qa-selector="add_issue_field"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index e1edf3d689d..1a07e0ed762 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -1,13 +1,11 @@
<script>
-/* eslint-disable vue/no-v-html */
import { mapState, mapActions, mapGetters } from 'vuex';
-import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
+import { GlButton, GlFormInput, GlFormGroup, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { BACK_URL_PARAM } from '~/releases/constants';
import { getParameterByName } from '~/lib/utils/common_utils';
import AssetLinksForm from './asset_links_form.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
import TagField from './tag_field.vue';
@@ -17,12 +15,12 @@ export default {
GlFormInput,
GlFormGroup,
GlButton,
+ GlSprintf,
MarkdownField,
AssetLinksForm,
MilestoneCombobox,
TagField,
},
- mixins: [glFeatureFlagsMixin()],
computed: {
...mapState('detail', [
'isFetchingRelease',
@@ -41,18 +39,6 @@ export default {
showForm() {
return Boolean(!this.isFetchingRelease && !this.fetchError && this.release);
},
- subtitleText() {
- return sprintf(
- __(
- 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.',
- ),
- {
- codeStart: '<code>',
- codeEnd: '</code>',
- },
- false,
- );
- },
releaseTitle: {
get() {
return this.$store.state.detail.release.name;
@@ -80,9 +66,6 @@ export default {
cancelPath() {
return getParameterByName(BACK_URL_PARAM) || this.releasesPagePath;
},
- showAssetLinksForm() {
- return this.glFeatures.releaseAssetLinkEditing;
- },
saveButtonLabel() {
return this.isExistingRelease ? __('Save changes') : __('Create release');
},
@@ -127,7 +110,19 @@ export default {
</script>
<template>
<div class="d-flex flex-column">
- <p class="pt-3 js-subtitle-text" v-html="subtitleText"></p>
+ <p class="pt-3 js-subtitle-text">
+ <gl-sprintf
+ :message="
+ __(
+ 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.',
+ )
+ "
+ >
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
<form v-if="showForm" class="js-quick-submit" @submit.prevent="submitForm">
<tag-field />
<gl-form-group>
@@ -150,7 +145,7 @@ export default {
/>
</div>
</gl-form-group>
- <gl-form-group>
+ <gl-form-group data-testid="release-notes">
<label for="release-notes">{{ __('Release notes') }}</label>
<div class="bordered-box pr-3 pl-3">
<markdown-field
@@ -158,6 +153,7 @@ export default {
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:add-spacing-classes="false"
+ :textarea-value="releaseNotes"
class="gl-mt-3 gl-mb-3"
>
<template #textarea>
@@ -175,7 +171,7 @@ export default {
</div>
</gl-form-group>
- <asset-links-form v-if="showAssetLinksForm" />
+ <asset-links-form />
<div class="d-flex pt-3">
<gl-button
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index b8cf6ce478f..422d8bf630d 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -1,29 +1,21 @@
<script>
import { mapState, mapActions } from 'vuex';
-import {
- GlDeprecatedSkeletonLoading as GlSkeletonLoading,
- GlEmptyState,
- GlLink,
- GlButton,
-} from '@gitlab/ui';
-import {
- getParameterByName,
- historyPushState,
- buildUrlWithCurrentLocation,
-} from '~/lib/utils/common_utils';
+import { GlEmptyState, GlLink, GlButton } from '@gitlab/ui';
+import { getParameterByName } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
-import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import ReleaseBlock from './release_block.vue';
+import ReleasesPagination from './releases_pagination.vue';
+import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
export default {
name: 'ReleasesApp',
components: {
- GlSkeletonLoading,
GlEmptyState,
- ReleaseBlock,
- TablePagination,
GlLink,
GlButton,
+ ReleaseBlock,
+ ReleasesPagination,
+ ReleaseSkeletonLoader,
},
computed: {
...mapState('list', [
@@ -33,7 +25,6 @@ export default {
'isLoading',
'releases',
'hasError',
- 'pageInfo',
]),
shouldRenderEmptyState() {
return !this.releases.length && !this.hasError && !this.isLoading;
@@ -48,15 +39,23 @@ export default {
},
},
created() {
- this.fetchReleases({
- page: getParameterByName('page'),
- });
+ this.fetchReleases();
+
+ window.addEventListener('popstate', this.fetchReleases);
},
methods: {
- ...mapActions('list', ['fetchReleases']),
- onChangePage(page) {
- historyPushState(buildUrlWithCurrentLocation(`?page=${page}`));
- this.fetchReleases({ page });
+ ...mapActions('list', {
+ fetchReleasesStoreAction: 'fetchReleases',
+ }),
+ fetchReleases() {
+ this.fetchReleasesStoreAction({
+ // these two parameters are only used in "GraphQL mode"
+ before: getParameterByName('before'),
+ after: getParameterByName('after'),
+
+ // this parameter is only used when in "REST mode"
+ page: getParameterByName('page'),
+ });
},
},
};
@@ -74,7 +73,7 @@ export default {
{{ __('New release') }}
</gl-button>
- <gl-skeleton-loading v-if="isLoading" class="js-loading" />
+ <release-skeleton-loader v-if="isLoading" class="js-loading" />
<gl-empty-state
v-else-if="shouldRenderEmptyState"
@@ -105,7 +104,7 @@ export default {
/>
</div>
- <table-pagination v-if="!isLoading" :change="onChangePage" :page-info="pageInfo" />
+ <releases-pagination v-if="!isLoading" />
</div>
</template>
<style>
diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue
index 8b89f0cf3fc..9ef38503c10 100644
--- a/app/assets/javascripts/releases/components/app_show.vue
+++ b/app/assets/javascripts/releases/components/app_show.vue
@@ -1,13 +1,13 @@
<script>
import { mapState, mapActions } from 'vuex';
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import ReleaseBlock from './release_block.vue';
+import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
export default {
name: 'ReleaseShowApp',
components: {
- GlSkeletonLoading,
ReleaseBlock,
+ ReleaseSkeletonLoader,
},
computed: {
...mapState('detail', ['isFetchingRelease', 'fetchError', 'release']),
@@ -22,7 +22,7 @@ export default {
</script>
<template>
<div class="gl-mt-3">
- <gl-skeleton-loading v-if="isFetchingRelease" />
+ <release-skeleton-loader v-if="isFetchingRelease" />
<release-block v-else-if="!fetchError" :release="release" />
</div>
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index 07fab840067..331cc8ade6c 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -10,7 +10,6 @@ import {
GlFormInput,
GlFormSelect,
} from '@gitlab/ui';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { DEFAULT_ASSET_LINK_TYPE, ASSET_LINK_TYPE } from '../constants';
import { s__ } from '~/locale';
@@ -26,7 +25,6 @@ export default {
GlFormSelect,
},
directives: { GlTooltip: GlTooltipDirective },
- mixins: [glFeatureFlagsMixin()],
computed: {
...mapState('detail', ['release', 'releaseAssetsDocsPath']),
...mapGetters('detail', ['validationErrors']),
@@ -195,7 +193,6 @@ export default {
</gl-form-group>
<gl-form-group
- v-if="glFeatures.releaseAssetLinkType"
class="link-type-field col-auto px-sm-2"
:label="__('Type')"
:label-for="`asset-type-${index}`"
diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue
index 3724162f6d5..6e6017637d4 100644
--- a/app/assets/javascripts/releases/components/evidence_block.vue
+++ b/app/assets/javascripts/releases/components/evidence_block.vue
@@ -83,11 +83,7 @@ export default {
<span class="js-expanded monospace gl-pl-2">{{ sha(index) }}</span>
</template>
</expand-button>
- <clipboard-button
- :title="__('Copy evidence SHA')"
- :text="sha(index)"
- css-class="btn-default btn-transparent btn-clipboard"
- />
+ <clipboard-button :title="__('Copy evidence SHA')" :text="sha(index)" category="tertiary" />
</div>
<div class="d-flex align-items-center text-muted">
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue
index 2629df08be7..e9163a52792 100644
--- a/app/assets/javascripts/releases/components/release_block.vue
+++ b/app/assets/javascripts/releases/components/release_block.vue
@@ -11,7 +11,6 @@ import EvidenceBlock from './evidence_block.vue';
import ReleaseBlockAssets from './release_block_assets.vue';
import ReleaseBlockFooter from './release_block_footer.vue';
import ReleaseBlockHeader from './release_block_header.vue';
-import ReleaseBlockMetadata from './release_block_metadata.vue';
import ReleaseBlockMilestoneInfo from './release_block_milestone_info.vue';
export default {
@@ -21,7 +20,6 @@ export default {
ReleaseBlockAssets,
ReleaseBlockFooter,
ReleaseBlockHeader,
- ReleaseBlockMetadata,
ReleaseBlockMilestoneInfo,
},
mixins: [glFeatureFlagsMixin()],
@@ -54,22 +52,13 @@ export default {
milestones() {
return this.release.milestones || [];
},
- shouldShowEvidence() {
- return this.glFeatures.releaseEvidenceCollection;
- },
- shouldShowFooter() {
- return this.glFeatures.releaseIssueSummary;
- },
shouldRenderAssets() {
return Boolean(
this.assets.links.length || (this.assets.sources && this.assets.sources.length),
);
},
- shouldRenderReleaseMetaData() {
- return !this.glFeatures.releaseIssueSummary;
- },
shouldRenderMilestoneInfo() {
- return Boolean(this.glFeatures.releaseIssueSummary && !isEmpty(this.release.milestones));
+ return Boolean(!isEmpty(this.release.milestones));
},
},
@@ -105,9 +94,8 @@ export default {
<hr class="mb-3 mt-0" />
</div>
- <release-block-metadata v-if="shouldRenderReleaseMetaData" :release="release" />
<release-block-assets v-if="shouldRenderAssets" :assets="assets" />
- <evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" />
+ <evidence-block v-if="hasEvidence" :release="release" />
<div ref="gfm-content" class="card-text gl-mt-3">
<div class="md" v-html="release.descriptionHtml"></div>
@@ -115,7 +103,6 @@ export default {
</div>
<release-block-footer
- v-if="shouldShowFooter"
class="card-footer"
:commit="release.commit"
:commit-path="release.commitPath"
diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue
index 8824cbefd7e..eb83d8657c0 100644
--- a/app/assets/javascripts/releases/components/release_block_assets.vue
+++ b/app/assets/javascripts/releases/components/release_block_assets.vue
@@ -1,7 +1,6 @@
<script>
import { GlTooltipDirective, GlLink, GlButton, GlCollapse, GlIcon, GlBadge } from '@gitlab/ui';
import { difference, get } from 'lodash';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ASSET_LINK_TYPE } from '../constants';
import { __, s__, sprintf } from '~/locale';
@@ -17,7 +16,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagsMixin()],
props: {
assets: {
type: Object,
@@ -30,9 +28,6 @@ export default {
};
},
computed: {
- hasAssets() {
- return Boolean(this.assets.count);
- },
imageLinks() {
return this.linksForType(ASSET_LINK_TYPE.IMAGE);
},
@@ -95,94 +90,50 @@ export default {
<template>
<div class="card-text gl-mt-3">
- <template v-if="glFeatures.releaseAssetLinkType">
- <gl-button
- data-testid="accordion-button"
- variant="link"
- class="gl-font-weight-bold"
- @click="toggleAssetsExpansion"
- >
- <gl-icon
- name="chevron-right"
- class="gl-transition-medium"
- :class="{ 'gl-rotate-90': isAssetsExpanded }"
- />
- {{ __('Assets') }}
- <gl-badge size="sm" variant="neutral" class="gl-display-inline-block">{{
- assets.count
- }}</gl-badge>
- </gl-button>
- <gl-collapse v-model="isAssetsExpanded">
- <div class="gl-pl-6 gl-pt-3 js-assets-list">
- <template v-for="(section, index) in sections">
- <h5 v-if="section.title" :key="`section-header-${index}`" class="gl-mb-2">
- {{ section.title }}
- </h5>
- <ul :key="`section-body-${index}`" class="list-unstyled gl-m-0">
- <li v-for="link in section.links" :key="link.url">
- <gl-link
- :href="link.directAssetUrl || link.url"
- class="gl-display-flex gl-align-items-center gl-line-height-24"
- >
- <gl-icon
- :name="section.iconName"
- class="gl-mr-2 gl-flex-shrink-0 gl-flex-grow-0"
- />
- {{ link.name }}
- <gl-icon
- v-if="link.external"
- v-gl-tooltip
- name="external-link"
- :aria-label="$options.externalLinkTooltipText"
- :title="$options.externalLinkTooltipText"
- data-testid="external-link-indicator"
- class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0 gl-text-gray-400"
- />
- </gl-link>
- </li>
- </ul>
- </template>
- </div>
- </gl-collapse>
- </template>
-
- <template v-else>
- <b>
- {{ __('Assets') }}
- <span class="js-assets-count badge badge-pill">{{ assets.count }}</span>
- </b>
-
- <ul v-if="assets.links.length" class="pl-0 mb-0 gl-mt-3 list-unstyled js-assets-list">
- <li v-for="link in assets.links" :key="link.name" class="gl-mb-3">
- <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.directAssetUrl">
- <gl-icon name="package" class="align-middle gl-mr-2 align-text-bottom" />
- {{ link.name }}
- <span v-if="link.external" data-testid="external-link-indicator">{{
- __('(external source)')
- }}</span>
- </gl-link>
- </li>
- </ul>
-
- <div v-if="hasAssets" class="dropdown">
- <button
- type="button"
- class="btn btn-link"
- data-toggle="dropdown"
- aria-haspopup="true"
- aria-expanded="false"
- >
- <gl-icon name="doc-code" class="align-top gl-mr-2" />
- {{ __('Source code') }}
- <gl-icon name="chevron-down" />
- </button>
-
- <div class="js-sources-dropdown dropdown-menu">
- <li v-for="asset in assets.sources" :key="asset.url">
- <gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link>
- </li>
- </div>
+ <gl-button
+ data-testid="accordion-button"
+ variant="link"
+ class="gl-font-weight-bold"
+ @click="toggleAssetsExpansion"
+ >
+ <gl-icon
+ name="chevron-right"
+ class="gl-transition-medium"
+ :class="{ 'gl-rotate-90': isAssetsExpanded }"
+ />
+ {{ __('Assets') }}
+ <gl-badge size="sm" variant="neutral" class="gl-display-inline-block">{{
+ assets.count
+ }}</gl-badge>
+ </gl-button>
+ <gl-collapse v-model="isAssetsExpanded">
+ <div class="gl-pl-6 gl-pt-3 js-assets-list">
+ <template v-for="(section, index) in sections">
+ <h5 v-if="section.title" :key="`section-header-${index}`" class="gl-mb-2">
+ {{ section.title }}
+ </h5>
+ <ul :key="`section-body-${index}`" class="list-unstyled gl-m-0">
+ <li v-for="link in section.links" :key="link.url" class="gl-display-flex">
+ <gl-link
+ :href="link.directAssetUrl || link.url"
+ class="gl-display-flex gl-align-items-center gl-line-height-24"
+ >
+ <gl-icon :name="section.iconName" class="gl-mr-2 gl-flex-shrink-0 gl-flex-grow-0" />
+ {{ link.name }}
+ <gl-icon
+ v-if="link.external"
+ v-gl-tooltip
+ name="external-link"
+ :aria-label="$options.externalLinkTooltipText"
+ :title="$options.externalLinkTooltipText"
+ data-testid="external-link-indicator"
+ class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0 gl-text-gray-400"
+ />
+ </gl-link>
+ </li>
+ </ul>
+ </template>
</div>
- </template>
+ </gl-collapse>
</div>
</template>
diff --git a/app/assets/javascripts/releases/components/release_block_author.vue b/app/assets/javascripts/releases/components/release_block_author.vue
deleted file mode 100644
index 72c578068cd..00000000000
--- a/app/assets/javascripts/releases/components/release_block_author.vue
+++ /dev/null
@@ -1,42 +0,0 @@
-<script>
-import { GlSprintf } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
-import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-
-export default {
- name: 'ReleaseBlockAuthor',
- components: {
- GlSprintf,
- UserAvatarLink,
- },
- props: {
- author: {
- type: Object,
- required: true,
- },
- },
- computed: {
- userImageAltDescription() {
- return this.author && this.author.username
- ? sprintf(__("%{username}'s avatar"), { username: this.author.username })
- : null;
- },
- },
-};
-</script>
-
-<template>
- <div class="d-flex">
- <gl-sprintf :message="__('by %{user}')">
- <template #user>
- <user-avatar-link
- class="gl-ml-2"
- :link-href="author.webUrl"
- :img-src="author.avatarUrl"
- :img-alt="userImageAltDescription"
- :tooltip-text="author.username"
- />
- </template>
- </gl-sprintf>
- </div>
-</template>
diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue
index 95292a26bce..87538244f1a 100644
--- a/app/assets/javascripts/releases/components/release_block_header.vue
+++ b/app/assets/javascripts/releases/components/release_block_header.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlLink, GlBadge, GlButton, GlIcon } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlBadge, GlButton } from '@gitlab/ui';
import { BACK_URL_PARAM } from '~/releases/constants';
import { setUrlParams } from '~/lib/utils/url_utility';
@@ -8,7 +8,6 @@ export default {
components: {
GlLink,
GlBadge,
- GlIcon,
GlButton,
},
directives: {
@@ -55,11 +54,10 @@ export default {
v-gl-tooltip
category="primary"
variant="default"
+ icon="pencil"
class="gl-mr-3 js-edit-button ml-2 pb-2"
:title="__('Edit this release')"
:href="editLink"
- >
- <gl-icon name="pencil" />
- </gl-button>
+ />
</div>
</template>
diff --git a/app/assets/javascripts/releases/components/release_block_metadata.vue b/app/assets/javascripts/releases/components/release_block_metadata.vue
deleted file mode 100644
index 2247b4c0064..00000000000
--- a/app/assets/javascripts/releases/components/release_block_metadata.vue
+++ /dev/null
@@ -1,90 +0,0 @@
-<script>
-import { GlTooltipDirective, GlLink, GlIcon } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
-import ReleaseBlockAuthor from './release_block_author.vue';
-import ReleaseBlockMilestones from './release_block_milestones.vue';
-
-export default {
- name: 'ReleaseBlockMetadata',
- components: {
- GlIcon,
- GlLink,
- ReleaseBlockAuthor,
- ReleaseBlockMilestones,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- mixins: [timeagoMixin],
- props: {
- release: {
- type: Object,
- required: true,
- },
- },
- computed: {
- author() {
- return this.release.author;
- },
- commit() {
- return this.release.commit || {};
- },
- commitUrl() {
- return this.release.commitPath;
- },
- hasAuthor() {
- return Boolean(this.author);
- },
- releasedTimeAgo() {
- const now = new Date();
- const isFuture = now < new Date(this.release.releasedAt);
- const time = this.timeFormatted(this.release.releasedAt);
- return isFuture
- ? sprintf(__('will be released %{time}'), { time })
- : sprintf(__('released %{time}'), { time });
- },
- shouldRenderMilestones() {
- return Boolean(this.release.milestones?.length);
- },
- tagUrl() {
- return this.release.tagPath;
- },
- },
-};
-</script>
-
-<template>
- <div class="card-subtitle d-flex flex-wrap text-secondary">
- <div class="gl-mr-3">
- <gl-icon name="commit" class="align-middle" />
- <gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl">
- {{ commit.shortId }}
- </gl-link>
- <span v-else v-gl-tooltip.bottom :title="commit.title">{{ commit.shortId }}</span>
- </div>
-
- <div class="gl-mr-3">
- <gl-icon name="tag" class="align-middle" />
- <gl-link v-if="tagUrl" v-gl-tooltip.bottom :title="__('Tag')" :href="tagUrl">
- {{ release.tagName }}
- </gl-link>
- <span v-else v-gl-tooltip.bottom :title="__('Tag')">{{ release.tagName }}</span>
- </div>
-
- <release-block-milestones v-if="shouldRenderMilestones" :milestones="release.milestones" />
-
- <div class="gl-mr-2">
- &bull;
- <span
- v-gl-tooltip.bottom
- class="js-release-date-info"
- :title="tooltipTitle(release.releasedAt)"
- >
- {{ releasedTimeAgo }}
- </span>
- </div>
-
- <release-block-author v-if="hasAuthor" :author="author" />
- </div>
-</template>
diff --git a/app/assets/javascripts/releases/components/release_block_milestones.vue b/app/assets/javascripts/releases/components/release_block_milestones.vue
deleted file mode 100644
index 1da683764b3..00000000000
--- a/app/assets/javascripts/releases/components/release_block_milestones.vue
+++ /dev/null
@@ -1,50 +0,0 @@
-<script>
-import { GlTooltipDirective, GlLink, GlIcon } from '@gitlab/ui';
-import { n__ } from '~/locale';
-
-export default {
- name: 'ReleaseBlockMilestones',
- components: {
- GlLink,
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- milestones: {
- type: Array,
- required: true,
- },
- },
- computed: {
- labelText() {
- return n__('Milestone', 'Milestones', this.milestones.length);
- },
- },
-};
-</script>
-
-<template>
- <div>
- <div class="js-milestone-list-label">
- <gl-icon name="flag" class="align-middle" />
- <span class="js-label-text">{{ labelText }}</span>
- </div>
-
- <template v-for="(milestone, index) in milestones">
- <gl-link
- :key="milestone.id"
- v-gl-tooltip
- :title="milestone.description"
- :href="milestone.webUrl"
- class="mx-1 js-milestone-link"
- >
- {{ milestone.title }}
- </gl-link>
- <template v-if="index !== milestones.length - 1">
- &bull;
- </template>
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/releases/components/release_skeleton_loader.vue b/app/assets/javascripts/releases/components/release_skeleton_loader.vue
new file mode 100644
index 00000000000..054620af636
--- /dev/null
+++ b/app/assets/javascripts/releases/components/release_skeleton_loader.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ name: 'ReleaseSkeletonLoader',
+ components: { GlSkeletonLoader },
+};
+</script>
+<template>
+ <gl-skeleton-loader :width="1248" :height="420">
+ <!-- Outside border -->
+ <path
+ d="M 4.5 0 C 2.0156486 0 0 2.0156486 0 4.5 L 0 415.5 C 0 417.98435 2.0156486 420 4.5 420 L 1243.5 420 C 1245.9844 420 1248 417.98435 1248 415.5 L 1248 4.5 C 1248 2.0156486 1245.9844 0 1243.5 0 L 4.5 0 z M 4.5 1 L 1243.5 1 C 1245.4476 1 1247 2.5523514 1247 4.5 L 1247 415.5 C 1247 417.44765 1245.4476 419 1243.5 419 L 4.5 419 C 2.5523514 419 1 417.44765 1 415.5 L 1 4.5 C 1 2.5523514 2.5523514 1 4.5 1 z "
+ />
+
+ <!-- Header bottom border -->
+ <rect x="0" y="63.5" width="1248" height="1" />
+
+ <!-- Release title -->
+ <rect x="16" y="20" width="293" height="24" />
+
+ <!-- Edit (pencil) button -->
+ <rect x="1207" y="16" rx="4" width="32" height="32" />
+
+ <!-- Asset link 1 -->
+ <rect x="40" y="121" rx="4" width="16" height="16" />
+ <rect x="60" y="125" width="116" height="8" />
+
+ <!-- Asset link 2 -->
+ <rect x="40" y="145" rx="4" width="16" height="16" />
+ <rect x="60" y="149" width="132" height="8" />
+
+ <!-- Asset link 3 -->
+ <rect x="40" y="169" rx="4" width="16" height="16" />
+ <rect x="60" y="173" width="140" height="8" />
+
+ <!-- Asset link 4 -->
+ <rect x="40" y="193" rx="4" width="16" height="16" />
+ <rect x="60" y="197" width="112" height="8" />
+
+ <!-- Release notes -->
+ <rect x="16" y="228" width="480" height="8" />
+ <rect x="16" y="252" width="560" height="8" />
+ <rect x="16" y="276" width="480" height="8" />
+ <rect x="16" y="300" width="560" height="8" />
+ <rect x="16" y="324" width="320" height="8" />
+
+ <!-- Footer top border -->
+ <rect x="0" y="373" width="1248" height="1" />
+ </gl-skeleton-loader>
+</template>
diff --git a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue
index a4fe407a5bd..cb6f1fa18a1 100644
--- a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue
+++ b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue
@@ -13,14 +13,14 @@ export default {
},
},
methods: {
- ...mapActions('list', ['fetchReleasesGraphQl']),
+ ...mapActions('list', ['fetchReleases']),
onPrev(before) {
historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
- this.fetchReleasesGraphQl({ before });
+ this.fetchReleases({ before });
},
onNext(after) {
historyPushState(buildUrlWithCurrentLocation(`?after=${after}`));
- this.fetchReleasesGraphQl({ after });
+ this.fetchReleases({ after });
},
},
};
diff --git a/app/assets/javascripts/releases/components/releases_pagination_rest.vue b/app/assets/javascripts/releases/components/releases_pagination_rest.vue
index 992cc4cd469..334458a2302 100644
--- a/app/assets/javascripts/releases/components/releases_pagination_rest.vue
+++ b/app/assets/javascripts/releases/components/releases_pagination_rest.vue
@@ -7,18 +7,18 @@ export default {
name: 'ReleasesPaginationRest',
components: { TablePagination },
computed: {
- ...mapState('list', ['pageInfo']),
+ ...mapState('list', ['restPageInfo']),
},
methods: {
- ...mapActions('list', ['fetchReleasesRest']),
+ ...mapActions('list', ['fetchReleases']),
onChangePage(page) {
historyPushState(buildUrlWithCurrentLocation(`?page=${page}`));
- this.fetchReleasesRest({ page });
+ this.fetchReleases({ page });
},
},
};
</script>
<template>
- <table-pagination :change="onChangePage" :page-info="pageInfo" />
+ <table-pagination :change="onChangePage" :page-info="restPageInfo" />
</template>
diff --git a/app/assets/javascripts/releases/constants.js b/app/assets/javascripts/releases/constants.js
index 361cee70747..953e7b4189c 100644
--- a/app/assets/javascripts/releases/constants.js
+++ b/app/assets/javascripts/releases/constants.js
@@ -10,3 +10,5 @@ export const ASSET_LINK_TYPE = Object.freeze({
});
export const DEFAULT_ASSET_LINK_TYPE = ASSET_LINK_TYPE.OTHER;
+
+export const PAGE_SIZE = 20;
diff --git a/app/assets/javascripts/releases/mount_edit.js b/app/assets/javascripts/releases/mount_edit.js
index 623b18591a0..2f4b0e64e36 100644
--- a/app/assets/javascripts/releases/mount_edit.js
+++ b/app/assets/javascripts/releases/mount_edit.js
@@ -13,9 +13,6 @@ export default () => {
modules: {
detail: createDetailModule(el.dataset),
},
- featureFlags: {
- releaseShowPage: Boolean(gon.features?.releaseShowPage),
- },
});
return new Vue({
diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js
index 10725e47740..5c481498ffb 100644
--- a/app/assets/javascripts/releases/mount_new.js
+++ b/app/assets/javascripts/releases/mount_new.js
@@ -13,9 +13,6 @@ export default () => {
modules: {
detail: createDetailModule(el.dataset),
},
- featureFlags: {
- releaseShowPage: Boolean(gon.features?.releaseShowPage),
- },
});
return new Vue({
diff --git a/app/assets/javascripts/releases/mount_show.js b/app/assets/javascripts/releases/mount_show.js
index eef015ee0a6..b09ecc9fb55 100644
--- a/app/assets/javascripts/releases/mount_show.js
+++ b/app/assets/javascripts/releases/mount_show.js
@@ -13,6 +13,9 @@ export default () => {
modules: {
detail: createDetailModule(el.dataset),
},
+ featureFlags: {
+ graphqlIndividualReleasePage: Boolean(gon.features?.graphqlIndividualReleasePage),
+ },
});
return new Vue({
diff --git a/app/assets/javascripts/releases/queries/all_releases.query.graphql b/app/assets/javascripts/releases/queries/all_releases.query.graphql
index 7a99f32fdfa..c35306f163d 100644
--- a/app/assets/javascripts/releases/queries/all_releases.query.graphql
+++ b/app/assets/javascripts/releases/queries/all_releases.query.graphql
@@ -1,68 +1,16 @@
-query allReleases($fullPath: ID!) {
+#import "./release.fragment.graphql"
+
+query allReleases($fullPath: ID!, $first: Int, $last: Int, $before: String, $after: String) {
project(fullPath: $fullPath) {
- releases(first: 20) {
- count
+ releases(first: $first, last: $last, before: $before, after: $after) {
nodes {
- name
- tagName
- tagPath
- descriptionHtml
- releasedAt
- upcomingRelease
- assets {
- count
- sources {
- nodes {
- format
- url
- }
- }
- links {
- nodes {
- id
- name
- url
- directAssetUrl
- linkType
- external
- }
- }
- }
- evidences {
- nodes {
- filepath
- collectedAt
- sha
- }
- }
- links {
- editUrl
- issuesUrl
- mergeRequestsUrl
- selfUrl
- }
- commit {
- sha
- webUrl
- title
- }
- author {
- webUrl
- avatarUrl
- username
- }
- milestones {
- nodes {
- id
- title
- description
- webPath
- stats {
- totalIssuesCount
- closedIssuesCount
- }
- }
- }
+ ...Release
+ }
+ pageInfo {
+ startCursor
+ hasPreviousPage
+ hasNextPage
+ endCursor
}
}
}
diff --git a/app/assets/javascripts/releases/queries/one_release.query.graphql b/app/assets/javascripts/releases/queries/one_release.query.graphql
new file mode 100644
index 00000000000..b893aea94b0
--- /dev/null
+++ b/app/assets/javascripts/releases/queries/one_release.query.graphql
@@ -0,0 +1,9 @@
+#import "./release.fragment.graphql"
+
+query oneRelease($fullPath: ID!, $tagName: String!) {
+ project(fullPath: $fullPath) {
+ release(tagName: $tagName) {
+ ...Release
+ }
+ }
+}
diff --git a/app/assets/javascripts/releases/queries/release.fragment.graphql b/app/assets/javascripts/releases/queries/release.fragment.graphql
new file mode 100644
index 00000000000..445ed616348
--- /dev/null
+++ b/app/assets/javascripts/releases/queries/release.fragment.graphql
@@ -0,0 +1,62 @@
+fragment Release on Release {
+ name
+ tagName
+ tagPath
+ descriptionHtml
+ releasedAt
+ upcomingRelease
+ assets {
+ count
+ sources {
+ nodes {
+ format
+ url
+ }
+ }
+ links {
+ nodes {
+ id
+ name
+ url
+ directAssetUrl
+ linkType
+ external
+ }
+ }
+ }
+ evidences {
+ nodes {
+ filepath
+ collectedAt
+ sha
+ }
+ }
+ links {
+ editUrl
+ issuesUrl
+ mergeRequestsUrl
+ selfUrl
+ }
+ commit {
+ sha
+ webUrl
+ title
+ }
+ author {
+ webUrl
+ avatarUrl
+ username
+ }
+ milestones {
+ nodes {
+ id
+ title
+ description
+ webPath
+ stats {
+ totalIssuesCount
+ closedIssuesCount
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/releases/stores/getters.js b/app/assets/javascripts/releases/stores/getters.js
new file mode 100644
index 00000000000..6a1da63289c
--- /dev/null
+++ b/app/assets/javascripts/releases/stores/getters.js
@@ -0,0 +1,11 @@
+/**
+ * @returns {Boolean} `true` if all the feature flags
+ * required to enable the GraphQL endpoint are enabled
+ */
+export const useGraphQLEndpoint = rootState => {
+ return Boolean(
+ rootState.featureFlags.graphqlReleaseData &&
+ rootState.featureFlags.graphqlReleasesPage &&
+ rootState.featureFlags.graphqlMilestoneStats,
+ );
+};
diff --git a/app/assets/javascripts/releases/stores/index.js b/app/assets/javascripts/releases/stores/index.js
index b2e93d789d7..cc8b586964f 100644
--- a/app/assets/javascripts/releases/stores/index.js
+++ b/app/assets/javascripts/releases/stores/index.js
@@ -1,7 +1,9 @@
import Vuex from 'vuex';
+import * as getters from './getters';
export default ({ modules, featureFlags }) =>
new Vuex.Store({
modules,
state: { featureFlags },
+ getters,
});
diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js
index 5b682a0ab0f..e8a46f40d20 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js
@@ -3,7 +3,13 @@ import api from '~/api';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
-import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
+import {
+ releaseToApiJson,
+ apiJsonToRelease,
+ gqClient,
+ convertOneReleaseGraphQLResponse,
+} from '~/releases/util';
+import oneReleaseQuery from '~/releases/queries/one_release.query.graphql';
export const initializeRelease = ({ commit, dispatch, getters }) => {
if (getters.isExistingRelease) {
@@ -18,9 +24,29 @@ export const initializeRelease = ({ commit, dispatch, getters }) => {
return Promise.resolve();
};
-export const fetchRelease = ({ commit, state }) => {
+export const fetchRelease = ({ commit, state, rootState }) => {
commit(types.REQUEST_RELEASE);
+ if (rootState.featureFlags?.graphqlIndividualReleasePage) {
+ return gqClient
+ .query({
+ query: oneReleaseQuery,
+ variables: {
+ fullPath: state.projectPath,
+ tagName: state.tagName,
+ },
+ })
+ .then(response => {
+ const { data: release } = convertOneReleaseGraphQLResponse(response);
+
+ commit(types.RECEIVE_RELEASE_SUCCESS, release);
+ })
+ .catch(error => {
+ commit(types.RECEIVE_RELEASE_ERROR, error);
+ createFlash(s__('Release|Something went wrong while getting the release details'));
+ });
+ }
+
return api
.release(state.projectId, state.tagName)
.then(({ data }) => {
@@ -45,6 +71,9 @@ export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_REL
export const updateReleaseMilestones = ({ commit }, milestones) =>
commit(types.UPDATE_RELEASE_MILESTONES, milestones);
+export const updateReleaseGroupMilestones = ({ commit }, groupMilestones) =>
+ commit(types.UPDATE_RELEASE_GROUP_MILESTONES, groupMilestones);
+
export const addEmptyAssetLink = ({ commit }) => {
commit(types.ADD_EMPTY_ASSET_LINK);
};
@@ -65,9 +94,9 @@ export const removeAssetLink = ({ commit }, linkIdToRemove) => {
commit(types.REMOVE_ASSET_LINK, linkIdToRemove);
};
-export const receiveSaveReleaseSuccess = ({ commit, state, rootState }, release) => {
+export const receiveSaveReleaseSuccess = ({ commit }, release) => {
commit(types.RECEIVE_SAVE_RELEASE_SUCCESS);
- redirectTo(rootState.featureFlags.releaseShowPage ? release._links.self : state.releasesPagePath);
+ redirectTo(release._links.self);
};
export const saveRelease = ({ commit, dispatch, getters }) => {
diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
index 7784e0cc741..1b2f5f33f02 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
@@ -9,6 +9,7 @@ export const UPDATE_CREATE_FROM = 'UPDATE_CREATE_FROM';
export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES';
+export const UPDATE_RELEASE_GROUP_MILESTONES = 'UPDATE_RELEASE_GROUP_MILESTONES';
export const REQUEST_SAVE_RELEASE = 'REQUEST_SAVE_RELEASE';
export const RECEIVE_SAVE_RELEASE_SUCCESS = 'RECEIVE_SAVE_RELEASE_SUCCESS';
diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js
index 750f496665d..58a1958c5e2 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js
@@ -13,6 +13,7 @@ export default {
name: '',
description: '',
milestones: [],
+ groupMilestones: [],
assets: {
links: [],
},
@@ -51,6 +52,10 @@ export default {
state.release.milestones = milestones;
},
+ [types.UPDATE_RELEASE_GROUP_MILESTONES](state, groupMilestones) {
+ state.release.groupMilestones = groupMilestones;
+ },
+
[types.REQUEST_SAVE_RELEASE](state) {
state.isUpdatingRelease = true;
},
diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js
index a46e750df53..782a5c46d6c 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/state.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/state.js
@@ -1,5 +1,6 @@
export default ({
projectId,
+ projectPath,
markdownDocsPath,
markdownPreviewPath,
updateReleaseApiDocsPath,
@@ -12,6 +13,7 @@ export default ({
defaultBranch = null,
}) => ({
projectId,
+ projectPath,
markdownDocsPath,
markdownPreviewPath,
updateReleaseApiDocsPath,
diff --git a/app/assets/javascripts/releases/stores/modules/list/actions.js b/app/assets/javascripts/releases/stores/modules/list/actions.js
index 945b093b983..02e67415e63 100644
--- a/app/assets/javascripts/releases/stores/modules/list/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/list/actions.js
@@ -8,55 +8,90 @@ import {
convertObjectPropsToCamelCase,
} from '~/lib/utils/common_utils';
import allReleasesQuery from '~/releases/queries/all_releases.query.graphql';
-import { gqClient, convertGraphQLResponse } from '../../../util';
+import { gqClient, convertAllReleasesGraphQLResponse } from '../../../util';
+import { PAGE_SIZE } from '../../../constants';
/**
- * Commits a mutation to update the state while the main endpoint is being requested.
+ * Gets a paginated list of releases from the server
+ *
+ * @param {Object} vuexParams
+ * @param {Object} actionParams
+ * @param {Number} [actionParams.page] The page number of results to fetch
+ * (this parameter is only used when fetching results from the REST API)
+ * @param {String} [actionParams.before] A GraphQL cursor. If provided,
+ * the items returned will proceed the provided cursor (this parameter is only
+ * used when fetching results from the GraphQL API).
+ * @param {String} [actionParams.after] A GraphQL cursor. If provided,
+ * the items returned will follow the provided cursor (this parameter is only
+ * used when fetching results from the GraphQL API).
*/
-export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES);
+export const fetchReleases = ({ dispatch, rootGetters }, { page = 1, before, after }) => {
+ if (rootGetters.useGraphQLEndpoint) {
+ dispatch('fetchReleasesGraphQl', { before, after });
+ } else {
+ dispatch('fetchReleasesRest', { page });
+ }
+};
/**
- * Fetches the main endpoint.
- * Will dispatch requestNamespace action before starting the request.
- * Will dispatch receiveNamespaceSuccess if the request is successful
- * Will dispatch receiveNamesapceError if the request returns an error
- *
- * @param {String} projectId
+ * Gets a paginated list of releases from the GraphQL endpoint
*/
-export const fetchReleases = ({ dispatch, rootState, state }, { page = '1' }) => {
- dispatch('requestReleases');
+export const fetchReleasesGraphQl = (
+ { dispatch, commit, state },
+ { before = null, after = null },
+) => {
+ commit(types.REQUEST_RELEASES);
- if (
- rootState.featureFlags.graphqlReleaseData &&
- rootState.featureFlags.graphqlReleasesPage &&
- rootState.featureFlags.graphqlMilestoneStats
- ) {
- gqClient
- .query({
- query: allReleasesQuery,
- variables: {
- fullPath: state.projectPath,
- },
- })
- .then(response => {
- dispatch('receiveReleasesSuccess', convertGraphQLResponse(response));
- })
- .catch(() => dispatch('receiveReleasesError'));
+ let paginationParams;
+ if (!before && !after) {
+ paginationParams = { first: PAGE_SIZE };
+ } else if (before && !after) {
+ paginationParams = { last: PAGE_SIZE, before };
+ } else if (!before && after) {
+ paginationParams = { first: PAGE_SIZE, after };
} else {
- api
- .releases(state.projectId, { page })
- .then(response => dispatch('receiveReleasesSuccess', response))
- .catch(() => dispatch('receiveReleasesError'));
+ throw new Error(
+ 'Both a `before` and an `after` parameter were provided to fetchReleasesGraphQl. These parameters cannot be used together.',
+ );
}
+
+ gqClient
+ .query({
+ query: allReleasesQuery,
+ variables: {
+ fullPath: state.projectPath,
+ ...paginationParams,
+ },
+ })
+ .then(response => {
+ const { data, paginationInfo: graphQlPageInfo } = convertAllReleasesGraphQLResponse(response);
+
+ commit(types.RECEIVE_RELEASES_SUCCESS, {
+ data,
+ graphQlPageInfo,
+ });
+ })
+ .catch(() => dispatch('receiveReleasesError'));
};
-export const receiveReleasesSuccess = ({ commit }, { data, headers }) => {
- const pageInfo = parseIntPagination(normalizeHeaders(headers));
- const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true });
- commit(types.RECEIVE_RELEASES_SUCCESS, {
- data: camelCasedReleases,
- pageInfo,
- });
+/**
+ * Gets a paginated list of releases from the REST endpoint
+ */
+export const fetchReleasesRest = ({ dispatch, commit, state }, { page }) => {
+ commit(types.REQUEST_RELEASES);
+
+ api
+ .releases(state.projectId, { page })
+ .then(({ data, headers }) => {
+ const restPageInfo = parseIntPagination(normalizeHeaders(headers));
+ const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true });
+
+ commit(types.RECEIVE_RELEASES_SUCCESS, {
+ data: camelCasedReleases,
+ restPageInfo,
+ });
+ })
+ .catch(() => dispatch('receiveReleasesError'));
};
export const receiveReleasesError = ({ commit }) => {
diff --git a/app/assets/javascripts/releases/stores/modules/list/mutations.js b/app/assets/javascripts/releases/stores/modules/list/mutations.js
index 99fc096264a..296487cfee2 100644
--- a/app/assets/javascripts/releases/stores/modules/list/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/list/mutations.js
@@ -17,11 +17,12 @@ export default {
* @param {Object} state
* @param {Object} resp
*/
- [types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) {
+ [types.RECEIVE_RELEASES_SUCCESS](state, { data, restPageInfo, graphQlPageInfo }) {
state.hasError = false;
state.isLoading = false;
state.releases = data;
- state.pageInfo = pageInfo;
+ state.restPageInfo = restPageInfo;
+ state.graphQlPageInfo = graphQlPageInfo;
},
/**
@@ -35,5 +36,7 @@ export default {
state.isLoading = false;
state.releases = [];
state.hasError = true;
+ state.restPageInfo = {};
+ state.graphQlPageInfo = {};
},
};
diff --git a/app/assets/javascripts/releases/stores/modules/list/state.js b/app/assets/javascripts/releases/stores/modules/list/state.js
index 9fe313745fc..0bffaa0f9db 100644
--- a/app/assets/javascripts/releases/stores/modules/list/state.js
+++ b/app/assets/javascripts/releases/stores/modules/list/state.js
@@ -14,5 +14,6 @@ export default ({
isLoading: false,
hasError: false,
releases: [],
- pageInfo: {},
+ restPageInfo: {},
+ graphQlPageInfo: {},
});
diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js
index d7fac7a9b65..445c429fd96 100644
--- a/app/assets/javascripts/releases/util.js
+++ b/app/assets/javascripts/releases/util.js
@@ -107,7 +107,24 @@ const convertMilestones = graphQLRelease => ({
});
/**
- * Converts the response from the GraphQL endpoint into the
+ * Converts a single release object fetched from GraphQL
+ * into a release object that matches the shape of the REST API
+ * (the same shape that is returned by `apiJsonToRelease` above.)
+ *
+ * @param graphQLRelease The release object returned from a GraphQL query
+ */
+export const convertGraphQLRelease = graphQLRelease => ({
+ ...convertScalarProperties(graphQLRelease),
+ ...convertAssets(graphQLRelease),
+ ...convertEvidences(graphQLRelease),
+ ...convertLinks(graphQLRelease),
+ ...convertCommit(graphQLRelease),
+ ...convertAuthor(graphQLRelease),
+ ...convertMilestones(graphQLRelease),
+});
+
+/**
+ * Converts the response from all_releases.query.graphql into the
* same shape as is returned from the Releases REST API.
*
* This allows the release components to use the response
@@ -115,16 +132,27 @@ const convertMilestones = graphQLRelease => ({
*
* @param response The response received from the GraphQL endpoint
*/
-export const convertGraphQLResponse = response => {
- const releases = response.data.project.releases.nodes.map(r => ({
- ...convertScalarProperties(r),
- ...convertAssets(r),
- ...convertEvidences(r),
- ...convertLinks(r),
- ...convertCommit(r),
- ...convertAuthor(r),
- ...convertMilestones(r),
- }));
-
- return { data: releases };
+export const convertAllReleasesGraphQLResponse = response => {
+ const releases = response.data.project.releases.nodes.map(convertGraphQLRelease);
+
+ const paginationInfo = {
+ ...response.data.project.releases.pageInfo,
+ };
+
+ return { data: releases, paginationInfo };
+};
+
+/**
+ * Converts the response from one_release.query.graphql into the
+ * same shape as is returned from the Releases REST API.
+ *
+ * This allows the release components to use the response
+ * from either endpoint interchangeably.
+ *
+ * @param response The response received from the GraphQL endpoint
+ */
+export const convertOneReleaseGraphQLResponse = response => {
+ const release = convertGraphQLRelease(response.data.project.release);
+
+ return { data: release };
};
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index 74437f286b4..677cb265942 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -1,9 +1,9 @@
<script>
import {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownHeader,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
GlIcon,
} from '@gitlab/ui';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
@@ -20,10 +20,10 @@ const ROW_TYPES = {
export default {
components: {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownHeader,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
GlIcon,
},
apollo: {
@@ -226,11 +226,11 @@ export default {
getComponent(type) {
switch (type) {
case ROW_TYPES.divider:
- return 'gl-deprecated-dropdown-divider';
+ return 'gl-dropdown-divider';
case ROW_TYPES.header:
- return 'gl-deprecated-dropdown-header';
+ return 'gl-dropdown-section-header';
default:
- return 'gl-deprecated-dropdown-item';
+ return 'gl-dropdown-item';
}
},
},
@@ -246,7 +246,7 @@ export default {
</router-link>
</li>
<li v-if="renderAddToTreeDropdown" class="breadcrumb-item">
- <gl-deprecated-dropdown toggle-class="add-to-tree qa-add-to-tree ml-1">
+ <gl-dropdown toggle-class="add-to-tree qa-add-to-tree gl-ml-2">
<template #button-content>
<span class="sr-only">{{ __('Add to tree') }}</span>
<gl-icon name="plus" :size="16" class="float-left" />
@@ -257,7 +257,7 @@ export default {
{{ item.text }}
</component>
</template>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
</li>
</ol>
</nav>
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 59831890a4e..0e2bccfabdd 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -1,7 +1,8 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlTooltipDirective, GlLink, GlDeprecatedButton, GlLoadingIcon, GlIcon } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
+import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql';
import { sprintf, s__ } from '~/locale';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
@@ -9,17 +10,16 @@ import CiIcon from '../../vue_shared/components/ci_icon.vue';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
-import pathLastCommitQuery from '../queries/path_last_commit.query.graphql';
export default {
components: {
- GlIcon,
UserAvatarLink,
TimeagoTooltip,
ClipboardButton,
CiIcon,
+ GlButton,
+ GlButtonGroup,
GlLink,
- GlDeprecatedButton,
GlLoadingIcon,
},
directives: {
@@ -123,15 +123,14 @@ export default {
class="commit-row-message item-title"
v-html="commit.titleHtml"
/>
- <gl-deprecated-button
+ <gl-button
v-if="commit.descriptionHtml"
:class="{ open: showDescription }"
:aria-label="__('Show commit description')"
- class="text-expander"
+ class="text-expander gl-vertical-align-bottom!"
+ icon="ellipsis_h"
@click="toggleShowDescription"
- >
- <gl-icon name="ellipsis_h" :size="10" />
- </gl-deprecated-button>
+ />
<div class="committer">
<gl-link
v-if="commit.author"
@@ -169,16 +168,19 @@ export default {
/>
</gl-link>
</div>
- <div class="commit-sha-group d-flex">
- <div class="label label-monospace monospace">
- {{ showCommitId }}
- </div>
+ <gl-button-group class="gl-ml-4 js-commit-sha-group">
+ <gl-button
+ label
+ class="gl-font-monospace"
+ data-testid="last-commit-id-label"
+ v-text="showCommitId"
+ />
<clipboard-button
:text="commit.sha"
:title="__('Copy commit SHA')"
- tooltip-placement="bottom"
+ class="input-group-text"
/>
- </div>
+ </gl-button-group>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue
index eca53f73a7f..4e2c8332f37 100644
--- a/app/assets/javascripts/repository/components/preview/index.vue
+++ b/app/assets/javascripts/repository/components/preview/index.vue
@@ -2,7 +2,7 @@
/* eslint-disable vue/no-v-html */
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
-import { GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui';
import { handleLocationHash } from '~/lib/utils/common_utils';
import readmeQuery from '../../queries/readme.query.graphql';
@@ -19,6 +19,7 @@ export default {
},
},
components: {
+ GlIcon,
GlLink,
GlLoadingIcon,
},
@@ -51,7 +52,7 @@ export default {
<article class="file-holder limited-width-container readme-holder">
<div class="js-file-title file-title-flex-parent">
<div class="file-header-content">
- <i aria-hidden="true" class="fa fa-file-text-o fa-fw"></i>
+ <gl-icon name="doc-text" aria-hidden="true" />
<gl-link :href="blob.webPath">
<strong>{{ blob.name }}</strong>
</gl-link>
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index 365b6cbb550..78b8baaa75e 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -75,6 +75,7 @@ export default {
},
methods: {
fetchFiles() {
+ const originalPath = this.path || '/';
this.isLoadingFiles = true;
return this.$apollo
@@ -83,14 +84,14 @@ export default {
variables: {
projectPath: this.projectPath,
ref: this.ref,
- path: this.path || '/',
+ path: originalPath,
nextPageCursor: this.nextPageCursor,
pageSize: this.pageSize,
},
})
.then(({ data }) => {
if (data.errors) throw data.errors;
- if (!data?.project?.repository) return;
+ if (!data?.project?.repository || originalPath !== (this.path || '/')) return;
const pageInfo = this.hasNextPage(data.project.repository.tree);
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 7f72524b6fe..a62b2d96c54 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -1,16 +1,17 @@
import Vue from 'vue';
-import { escapeFileUrl, joinPaths, webIDEUrl } from '../lib/utils/url_utility';
+import PathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql';
+import { escapeFileUrl } from '../lib/utils/url_utility';
import createRouter from './router';
import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import LastCommit from './components/last_commit.vue';
import TreeActionLink from './components/tree_action_link.vue';
-import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
+import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
import apolloProvider from './graphql';
import { setTitle } from './utils/title';
import { updateFormAction } from './utils/dom';
-import { convertObjectPropsToCamelCase, parseBoolean } from '../lib/utils/common_utils';
+import { parseBoolean } from '../lib/utils/common_utils';
import { __ } from '../locale';
export default function setupVueRepositoryList() {
@@ -18,6 +19,10 @@ export default function setupVueRepositoryList() {
const { dataset } = el;
const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset;
const router = createRouter(projectPath, escapedRef);
+ const pathRegex = /-\/tree\/[^/]+\/(.+$)/;
+ const matches = window.location.href.match(pathRegex);
+
+ const currentRoutePath = matches ? matches[1] : '';
apolloProvider.clients.defaultClient.cache.writeData({
data: {
@@ -29,6 +34,43 @@ export default function setupVueRepositoryList() {
},
});
+ const initLastCommitApp = () =>
+ new Vue({
+ el: document.getElementById('js-last-commit'),
+ router,
+ apolloProvider,
+ render(h) {
+ return h(LastCommit, {
+ props: {
+ currentPath: this.$route.params.path,
+ },
+ });
+ },
+ });
+
+ if (window.gl.startup_graphql_calls) {
+ const query = window.gl.startup_graphql_calls.find(
+ call => call.operationName === 'pathLastCommit',
+ );
+ query.fetchCall
+ .then(res => res.json())
+ .then(res => {
+ apolloProvider.clients.defaultClient.writeQuery({
+ query: PathLastCommitQuery,
+ data: res.data,
+ variables: {
+ projectPath,
+ ref,
+ path: currentRoutePath,
+ },
+ });
+ })
+ .catch(() => {})
+ .finally(() => initLastCommitApp());
+ } else {
+ initLastCommitApp();
+ }
+
router.afterEach(({ params: { path } }) => {
setTitle(path, ref, fullName);
});
@@ -77,20 +119,6 @@ export default function setupVueRepositoryList() {
});
}
- // eslint-disable-next-line no-new
- new Vue({
- el: document.getElementById('js-last-commit'),
- router,
- apolloProvider,
- render(h) {
- return h(LastCommit, {
- props: {
- currentPath: this.$route.params.path,
- },
- });
- },
- });
-
const treeHistoryLinkEl = document.getElementById('js-tree-history-link');
const { historyLink } = treeHistoryLinkEl.dataset;
@@ -110,29 +138,7 @@ export default function setupVueRepositoryList() {
},
});
- const webIdeLinkEl = document.getElementById('js-tree-web-ide-link');
-
- if (webIdeLinkEl) {
- const { ideBasePath, ...options } = convertObjectPropsToCamelCase(
- JSON.parse(webIdeLinkEl.dataset.options),
- );
-
- // eslint-disable-next-line no-new
- new Vue({
- el: webIdeLinkEl,
- router,
- render(h) {
- return h(WebIdeLink, {
- props: {
- webIdeUrl: webIDEUrl(
- joinPaths('/', ideBasePath, 'edit', ref, '-', this.$route.params.path || '', '/'),
- ),
- ...options,
- },
- });
- },
- });
- }
+ initWebIdeLink({ el: document.getElementById('js-tree-web-ide-link'), router });
const directoryDownloadLinks = document.getElementById('js-directory-downloads');
diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js
index 361e0b62bb7..fc8fa40a855 100644
--- a/app/assets/javascripts/repository/log_tree.js
+++ b/app/assets/javascripts/repository/log_tree.js
@@ -5,8 +5,8 @@ import commitsQuery from './queries/commits.query.graphql';
import projectPathQuery from './queries/project_path.query.graphql';
import refQuery from './queries/ref.query.graphql';
-let fetchpromise;
-let resolvers = [];
+const fetchpromises = {};
+const resolvers = {};
export function resolveCommit(commits, path, { resolve, entry }) {
const commit = commits.find(c => c.filePath === `${path}/${entry.name}` && c.type === entry.type);
@@ -18,15 +18,19 @@ export function resolveCommit(commits, path, { resolve, entry }) {
export function fetchLogsTree(client, path, offset, resolver = null) {
if (resolver) {
- resolvers.push(resolver);
+ if (!resolvers[path]) {
+ resolvers[path] = [resolver];
+ } else {
+ resolvers[path].push(resolver);
+ }
}
- if (fetchpromise) return fetchpromise;
+ if (fetchpromises[path]) return fetchpromises[path];
const { projectPath } = client.readQuery({ query: projectPathQuery });
const { escapedRef } = client.readQuery({ query: refQuery });
- fetchpromise = axios
+ fetchpromises[path] = axios
.get(
`${gon.relative_url_root}/${projectPath}/-/refs/${escapedRef}/logs_tree/${encodeURIComponent(
path.replace(/^\//, ''),
@@ -46,16 +50,16 @@ export function fetchLogsTree(client, path, offset, resolver = null) {
data,
});
- resolvers.forEach(r => resolveCommit(data.commits, path, r));
+ resolvers[path].forEach(r => resolveCommit(data.commits, path, r));
- fetchpromise = null;
+ delete fetchpromises[path];
if (headerLogsOffset) {
fetchLogsTree(client, path, headerLogsOffset);
} else {
- resolvers = [];
+ delete resolvers[path];
}
});
- return fetchpromise;
+ return fetchpromises[path];
}
diff --git a/app/assets/javascripts/repository/utils/icon.js b/app/assets/javascripts/repository/utils/icon.js
deleted file mode 100644
index 47b045c7eaf..00000000000
--- a/app/assets/javascripts/repository/utils/icon.js
+++ /dev/null
@@ -1,98 +0,0 @@
-const entryTypeIcons = {
- tree: 'folder',
- commit: 'archive',
-};
-
-const fileTypeIcons = [
- { extensions: ['pdf'], name: 'file-pdf-o' },
- {
- extensions: [
- 'jpg',
- 'jpeg',
- 'jif',
- 'jfif',
- 'jp2',
- 'jpx',
- 'j2k',
- 'j2c',
- 'png',
- 'gif',
- 'tif',
- 'tiff',
- 'svg',
- 'ico',
- 'bmp',
- ],
- name: 'file-image-o',
- },
- {
- extensions: ['zip', 'zipx', 'tar', 'gz', 'bz', 'bzip', 'xz', 'rar', '7z'],
- name: 'file-archive-o',
- },
- { extensions: ['mp3', 'wma', 'ogg', 'oga', 'wav', 'flac', 'aac'], name: 'file-audio-o' },
- {
- extensions: [
- 'mp4',
- 'm4p',
- 'm4v',
- 'mpg',
- 'mp2',
- 'mpeg',
- 'mpe',
- 'mpv',
- 'm2v',
- 'avi',
- 'mkv',
- 'flv',
- 'ogv',
- 'mov',
- '3gp',
- '3g2',
- ],
- name: 'file-video-o',
- },
- { extensions: ['doc', 'dot', 'docx', 'docm', 'dotx', 'dotm', 'docb'], name: 'file-word-o' },
- {
- extensions: [
- 'xls',
- 'xlt',
- 'xlm',
- 'xlsx',
- 'xlsm',
- 'xltx',
- 'xltm',
- 'xlsb',
- 'xla',
- 'xlam',
- 'xll',
- 'xlw',
- ],
- name: 'file-excel-o',
- },
- {
- extensions: [
- 'ppt',
- 'pot',
- 'pps',
- 'pptx',
- 'pptm',
- 'potx',
- 'potm',
- 'ppam',
- 'ppsx',
- 'ppsm',
- 'sldx',
- 'sldm',
- ],
- name: 'file-powerpoint-o',
- },
-];
-
-export const getIconName = (type, path) => {
- if (entryTypeIcons[type]) return entryTypeIcons[type];
-
- const extension = path.split('.').pop();
- const file = fileTypeIcons.find(t => t.extensions.some(ext => ext === extension));
-
- return file ? file.name : 'file-text-o';
-};
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 8bebd16ace7..87c8aa541d8 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -5,6 +5,7 @@ import Cookies from 'js-cookie';
import { deprecatedCreateFlash as flash } from './flash';
import axios from './lib/utils/axios_utils';
import { sprintf, s__, __ } from './locale';
+import { fixTitle, hide } from '~/tooltips';
function Sidebar() {
this.toggleTodo = this.toggleTodo.bind(this);
@@ -42,13 +43,17 @@ Sidebar.prototype.addEventListeners = function() {
Sidebar.prototype.sidebarToggleClicked = function(e, triggered) {
const $this = $(this);
- const isExpanded = $this.find('i').hasClass('fa-angle-double-right');
+ const $collapseIcon = $('.js-sidebar-collapse');
+ const $expandIcon = $('.js-sidebar-expand');
+ const $toggleContainer = $('.js-sidebar-toggle-container');
+ const isExpanded = $toggleContainer.data('is-expanded');
const tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar');
- const $allGutterToggleIcons = $('.js-sidebar-toggle i');
e.preventDefault();
if (isExpanded) {
- $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left');
+ $toggleContainer.data('is-expanded', false);
+ $collapseIcon.addClass('hidden');
+ $expandIcon.removeClass('hidden');
$('aside.right-sidebar')
.removeClass('right-sidebar-expanded')
.addClass('right-sidebar-collapsed');
@@ -56,7 +61,9 @@ Sidebar.prototype.sidebarToggleClicked = function(e, triggered) {
.removeClass('right-sidebar-expanded')
.addClass('right-sidebar-collapsed');
} else {
- $allGutterToggleIcons.removeClass('fa-angle-double-left').addClass('fa-angle-double-right');
+ $toggleContainer.data('is-expanded', true);
+ $expandIcon.addClass('hidden');
+ $collapseIcon.removeClass('hidden');
$('aside.right-sidebar')
.removeClass('right-sidebar-collapsed')
.addClass('right-sidebar-expanded');
@@ -77,7 +84,7 @@ Sidebar.prototype.toggleTodo = function(e) {
const ajaxType = $this.data('deletePath') ? 'delete' : 'post';
const url = String($this.data('deletePath') || $this.data('createPath'));
- $this.tooltip('hide');
+ hide($this);
$('.js-issuable-todo')
.disable()
@@ -119,7 +126,7 @@ Sidebar.prototype.todoUpdateDone = function(data) {
.data('deletePath', deletePath);
if ($el.hasClass('has-tooltip')) {
- $el.tooltip('_fixTitle');
+ fixTitle($el);
}
if (typeof $el.data('isCollapsed') !== 'undefined') {
diff --git a/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue b/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue
new file mode 100644
index 00000000000..b6e2dd46358
--- /dev/null
+++ b/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue
@@ -0,0 +1,100 @@
+<script>
+import { mapState } from 'vuex';
+import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
+import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
+import { sprintf, s__ } from '~/locale';
+
+export default {
+ name: 'DropdownFilter',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ },
+ props: {
+ filterData: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['query']),
+ scope() {
+ return this.query.scope;
+ },
+ supportedScopes() {
+ return Object.values(this.filterData.scopes);
+ },
+ initialFilter() {
+ return this.query[this.filterData.filterParam];
+ },
+ filter() {
+ return this.initialFilter || this.filterData.filters.ANY.value;
+ },
+ filtersArray() {
+ return this.filterData.filterByScope[this.scope];
+ },
+ selectedFilter: {
+ get() {
+ if (this.filtersArray.some(({ value }) => value === this.filter)) {
+ return this.filter;
+ }
+
+ return this.filterData.filters.ANY.value;
+ },
+ set(filter) {
+ visitUrl(setUrlParams({ [this.filterData.filterParam]: filter }));
+ },
+ },
+ selectedFilterText() {
+ const f = this.filtersArray.find(({ value }) => value === this.selectedFilter);
+ if (!f || f === this.filterData.filters.ANY) {
+ return sprintf(s__('Any %{header}'), { header: this.filterData.header });
+ }
+
+ return f.label;
+ },
+ showDropdown() {
+ return this.supportedScopes.includes(this.scope);
+ },
+ },
+ methods: {
+ dropDownItemClass(filter) {
+ return {
+ 'gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2':
+ filter === this.filterData.filters.ANY,
+ };
+ },
+ isFilterSelected(filter) {
+ return filter === this.selectedFilter;
+ },
+ handleFilterChange(filter) {
+ this.selectedFilter = filter;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ v-if="showDropdown"
+ :text="selectedFilterText"
+ class="col-3 gl-pt-4 gl-pl-0 gl-pr-0 gl-mr-4"
+ menu-class="gl-w-full! gl-pl-0"
+ >
+ <header class="gl-text-center gl-font-weight-bold gl-font-lg">
+ {{ filterData.header }}
+ </header>
+ <gl-dropdown-divider />
+ <gl-dropdown-item
+ v-for="f in filtersArray"
+ :key="f.value"
+ :is-check-item="true"
+ :is-checked="isFilterSelected(f.value)"
+ :class="dropDownItemClass(f)"
+ @click="handleFilterChange(f.value)"
+ >
+ {{ f.label }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js b/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js
new file mode 100644
index 00000000000..b29daca89cb
--- /dev/null
+++ b/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js
@@ -0,0 +1,36 @@
+import { __ } from '~/locale';
+
+const header = __('Confidentiality');
+
+const filters = {
+ ANY: {
+ label: __('Any'),
+ value: null,
+ },
+ CONFIDENTIAL: {
+ label: __('Confidential'),
+ value: 'yes',
+ },
+ NOT_CONFIDENTIAL: {
+ label: __('Not confidential'),
+ value: 'no',
+ },
+};
+
+const scopes = {
+ ISSUES: 'issues',
+};
+
+const filterByScope = {
+ [scopes.ISSUES]: [filters.ANY, filters.CONFIDENTIAL, filters.NOT_CONFIDENTIAL],
+};
+
+const filterParam = 'confidential';
+
+export default {
+ header,
+ filters,
+ scopes,
+ filterByScope,
+ filterParam,
+};
diff --git a/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js b/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js
new file mode 100644
index 00000000000..0b93aa0be29
--- /dev/null
+++ b/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js
@@ -0,0 +1,42 @@
+import { __ } from '~/locale';
+
+const header = __('Status');
+
+const filters = {
+ ANY: {
+ label: __('Any'),
+ value: 'all',
+ },
+ OPEN: {
+ label: __('Open'),
+ value: 'opened',
+ },
+ CLOSED: {
+ label: __('Closed'),
+ value: 'closed',
+ },
+ MERGED: {
+ label: __('Merged'),
+ value: 'merged',
+ },
+};
+
+const scopes = {
+ ISSUES: 'issues',
+ MERGE_REQUESTS: 'merge_requests',
+};
+
+const filterByScope = {
+ [scopes.ISSUES]: [filters.ANY, filters.OPEN, filters.CLOSED],
+ [scopes.MERGE_REQUESTS]: [filters.ANY, filters.OPEN, filters.MERGED, filters.CLOSED],
+};
+
+const filterParam = 'state';
+
+export default {
+ header,
+ filters,
+ scopes,
+ filterByScope,
+ filterParam,
+};
diff --git a/app/assets/javascripts/search/dropdown_filter/index.js b/app/assets/javascripts/search/dropdown_filter/index.js
new file mode 100644
index 00000000000..e5e0745d990
--- /dev/null
+++ b/app/assets/javascripts/search/dropdown_filter/index.js
@@ -0,0 +1,38 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import DropdownFilter from './components/dropdown_filter.vue';
+import stateFilterData from './constants/state_filter_data';
+import confidentialFilterData from './constants/confidential_filter_data';
+
+Vue.use(Translate);
+
+const mountDropdownFilter = (store, { id, filterData }) => {
+ const el = document.getElementById(id);
+
+ if (!el) return false;
+
+ return new Vue({
+ el,
+ store,
+ render(createElement) {
+ return createElement(DropdownFilter, {
+ props: {
+ filterData,
+ },
+ });
+ },
+ });
+};
+
+const dropdownFilters = [
+ {
+ id: 'js-search-filter-by-state',
+ filterData: stateFilterData,
+ },
+ {
+ id: 'js-search-filter-by-confidential',
+ filterData: confidentialFilterData,
+ },
+];
+
+export default store => [...dropdownFilters].map(filter => mountDropdownFilter(store, filter));
diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js
new file mode 100644
index 00000000000..780d3ff0d25
--- /dev/null
+++ b/app/assets/javascripts/search/index.js
@@ -0,0 +1,9 @@
+import { queryToObject } from '~/lib/utils/url_utility';
+import createStore from './store';
+import initDropdownFilters from './dropdown_filter';
+
+export default () => {
+ const store = createStore({ query: queryToObject(window.location.search) });
+
+ initDropdownFilters(store);
+};
diff --git a/app/assets/javascripts/search/state_filter/components/state_filter.vue b/app/assets/javascripts/search/state_filter/components/state_filter.vue
deleted file mode 100644
index f08adaf8c83..00000000000
--- a/app/assets/javascripts/search/state_filter/components/state_filter.vue
+++ /dev/null
@@ -1,94 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
-import {
- FILTER_STATES,
- SCOPES,
- FILTER_STATES_BY_SCOPE,
- FILTER_HEADER,
- FILTER_TEXT,
-} from '../constants';
-import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
-
-const FILTERS_ARRAY = Object.values(FILTER_STATES);
-
-export default {
- name: 'StateFilter',
- components: {
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
- },
- props: {
- scope: {
- type: String,
- required: true,
- },
- state: {
- type: String,
- required: false,
- default: FILTER_STATES.ANY.value,
- validator: v => FILTERS_ARRAY.some(({ value }) => value === v),
- },
- },
- computed: {
- selectedFilterText() {
- const filter = FILTERS_ARRAY.find(({ value }) => value === this.selectedFilter);
- if (!filter || filter === FILTER_STATES.ANY) {
- return FILTER_TEXT;
- }
-
- return filter.label;
- },
- showDropdown() {
- return Object.values(SCOPES).includes(this.scope);
- },
- selectedFilter: {
- get() {
- if (FILTERS_ARRAY.some(({ value }) => value === this.state)) {
- return this.state;
- }
-
- return FILTER_STATES.ANY.value;
- },
- set(state) {
- visitUrl(setUrlParams({ state }));
- },
- },
- },
- methods: {
- dropDownItemClass(filter) {
- return {
- 'gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2':
- filter === FILTER_STATES.ANY,
- };
- },
- isFilterSelected(filter) {
- return filter === this.selectedFilter;
- },
- handleFilterChange(state) {
- this.selectedFilter = state;
- },
- },
- filterStates: FILTER_STATES,
- filterHeader: FILTER_HEADER,
- filtersByScope: FILTER_STATES_BY_SCOPE,
-};
-</script>
-
-<template>
- <gl-dropdown v-if="showDropdown" :text="selectedFilterText" class="col-sm-3 gl-pt-4 gl-pl-0">
- <header class="gl-text-center gl-font-weight-bold gl-font-lg">
- {{ $options.filterHeader }}
- </header>
- <gl-dropdown-divider />
- <gl-dropdown-item
- v-for="filter in $options.filtersByScope[scope]"
- :key="filter.value"
- :is-check-item="true"
- :is-checked="isFilterSelected(filter.value)"
- :class="dropDownItemClass(filter)"
- @click="handleFilterChange(filter.value)"
- >{{ filter.label }}</gl-dropdown-item
- >
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/search/state_filter/constants.js b/app/assets/javascripts/search/state_filter/constants.js
deleted file mode 100644
index 2f11cab9044..00000000000
--- a/app/assets/javascripts/search/state_filter/constants.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import { __ } from '~/locale';
-
-export const FILTER_HEADER = __('Status');
-
-export const FILTER_TEXT = __('Any Status');
-
-export const FILTER_STATES = {
- ANY: {
- label: __('Any'),
- value: 'all',
- },
- OPEN: {
- label: __('Open'),
- value: 'opened',
- },
- CLOSED: {
- label: __('Closed'),
- value: 'closed',
- },
- MERGED: {
- label: __('Merged'),
- value: 'merged',
- },
-};
-
-export const SCOPES = {
- ISSUES: 'issues',
- MERGE_REQUESTS: 'merge_requests',
-};
-
-export const FILTER_STATES_BY_SCOPE = {
- [SCOPES.ISSUES]: [FILTER_STATES.ANY, FILTER_STATES.OPEN, FILTER_STATES.CLOSED],
- [SCOPES.MERGE_REQUESTS]: [
- FILTER_STATES.ANY,
- FILTER_STATES.OPEN,
- FILTER_STATES.MERGED,
- FILTER_STATES.CLOSED,
- ],
-};
diff --git a/app/assets/javascripts/search/state_filter/index.js b/app/assets/javascripts/search/state_filter/index.js
deleted file mode 100644
index 13708574cfb..00000000000
--- a/app/assets/javascripts/search/state_filter/index.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import Vue from 'vue';
-import Translate from '~/vue_shared/translate';
-import StateFilter from './components/state_filter.vue';
-
-Vue.use(Translate);
-
-export default () => {
- const el = document.getElementById('js-search-filter-by-state');
-
- if (!el) return false;
-
- return new Vue({
- el,
- components: {
- StateFilter,
- },
- data() {
- const { dataset } = this.$options.el;
- return {
- scope: dataset.scope,
- state: dataset.state,
- };
- },
-
- render(createElement) {
- return createElement('state-filter', {
- props: {
- scope: this.scope,
- state: this.state,
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js
new file mode 100644
index 00000000000..10cfb647a92
--- /dev/null
+++ b/app/assets/javascripts/search/store/index.js
@@ -0,0 +1,12 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import createState from './state';
+
+Vue.use(Vuex);
+
+export const getStoreConfig = ({ query }) => ({
+ state: createState({ query }),
+});
+
+const createStore = config => new Vuex.Store(getStoreConfig(config));
+export default createStore;
diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js
new file mode 100644
index 00000000000..9115a613767
--- /dev/null
+++ b/app/assets/javascripts/search/store/state.js
@@ -0,0 +1,4 @@
+const createState = ({ query }) => ({
+ query,
+});
+export default createState;
diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
index 1ccf5e9e032..6776a9ebb22 100644
--- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
+++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
@@ -1,7 +1,7 @@
<script>
/* eslint-disable vue/no-v-html */
import Vue from 'vue';
-import { GlFormGroup, GlDeprecatedButton, GlModal, GlToast, GlToggle } from '@gitlab/ui';
+import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { __, s__, sprintf } from '~/locale';
import { visitUrl, getBaseURL } from '~/lib/utils/url_utility';
@@ -11,7 +11,7 @@ Vue.use(GlToast);
export default {
components: {
GlFormGroup,
- GlDeprecatedButton,
+ GlButton,
GlModal,
GlToggle,
},
@@ -123,7 +123,7 @@ export default {
<h4 class="js-section-header">
{{ s__('SelfMonitoring|Self monitoring') }}
</h4>
- <gl-deprecated-button class="js-settings-toggle">{{ __('Expand') }}</gl-deprecated-button>
+ <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
<p class="js-section-sub-header">
{{ s__('SelfMonitoring|Enable or disable instance self monitoring') }}
</p>
@@ -146,6 +146,7 @@ export default {
:ok-title="__('Delete project')"
:cancel-title="__('Cancel')"
ok-variant="danger"
+ category="primary"
@ok="deleteProject"
@cancel="hideSelfMonitorModal"
>
diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js
index bc3b2f16a6a..631d5448d1e 100644
--- a/app/assets/javascripts/sentry/sentry_config.js
+++ b/app/assets/javascripts/sentry/sentry_config.js
@@ -1,5 +1,5 @@
-import * as Sentry from '@sentry/browser';
import $ from 'jquery';
+import * as Sentry from '~/sentry/wrapper';
import { __ } from '~/locale';
const IGNORE_ERRORS = [
diff --git a/app/assets/javascripts/sentry/wrapper.js b/app/assets/javascripts/sentry/wrapper.js
new file mode 100644
index 00000000000..24039e6141c
--- /dev/null
+++ b/app/assets/javascripts/sentry/wrapper.js
@@ -0,0 +1,26 @@
+// Temporarily commented out to investigate performance: https://gitlab.com/gitlab-org/gitlab/-/issues/251179
+// export * from '@sentry/browser';
+
+export function init(...args) {
+ return args;
+}
+
+export function setUser(...args) {
+ return args;
+}
+
+export function captureException(...args) {
+ return args;
+}
+
+export function captureMessage(...args) {
+ return args;
+}
+
+export function withScope(fn) {
+ fn({
+ setTag(...args) {
+ return args;
+ },
+ });
+}
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index e15549f5864..d662cc7b802 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -1,7 +1,6 @@
<script>
-/* eslint-disable vue/no-v-html */
import { mapState, mapActions, mapGetters } from 'vuex';
-import { GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { GlLink, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import EnvironmentRow from './environment_row.vue';
import EmptyState from './empty_state.vue';
@@ -14,6 +13,9 @@ export default {
GlLink,
GlLoadingIcon,
},
+ directives: {
+ SafeHtml,
+ },
computed: {
...mapState(['installed', 'isLoading', 'hasFunctionData', 'helpPath', 'statusPath']),
...mapGetters(['getFunctions']),
@@ -92,9 +94,9 @@ export default {
}}
</p>
<ul>
- <li v-html="noServerlessConfigFile"></li>
- <li v-html="noGitlabYamlConfigured"></li>
- <li v-html="mismatchedServerlessFunctions"></li>
+ <li v-safe-html="noServerlessConfigFile"></li>
+ <li v-safe-html="noGitlabYamlConfigured"></li>
+ <li v-safe-html="mismatchedServerlessFunctions"></li>
<li>{{ s__('Serverless|The deploy job has not finished.') }}</li>
</ul>
diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue
index 0d2c9f5151c..0b83d4b36eb 100644
--- a/app/assets/javascripts/serverless/components/missing_prometheus.vue
+++ b/app/assets/javascripts/serverless/components/missing_prometheus.vue
@@ -1,11 +1,11 @@
<script>
-import { GlDeprecatedButton, GlLink } from '@gitlab/ui';
+import { GlButton, GlLink } from '@gitlab/ui';
import { mapState } from 'vuex';
import { s__ } from '../../locale';
export default {
components: {
- GlDeprecatedButton,
+ GlButton,
GlLink,
},
props: {
@@ -47,9 +47,9 @@ export default {
</p>
<div v-if="!missingData" class="text-left">
- <gl-deprecated-button :href="clustersPath" variant="success">
+ <gl-button :href="clustersPath" variant="success" category="primary">
{{ s__('ServerlessDetails|Install Prometheus') }}
- </gl-deprecated-button>
+ </gl-button>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue
index d6de5e56a5c..79a1f39c7dd 100644
--- a/app/assets/javascripts/serverless/components/url.vue
+++ b/app/assets/javascripts/serverless/components/url.vue
@@ -16,7 +16,9 @@ export default {
<template>
<div class="clipboard-group">
- <div class="url-text-field label label-monospace monospace">{{ uri }}</div>
+ <div class="gl-cursor-text label label-monospace monospace" data-testid="url-text-field">
+ {{ uri }}
+ </div>
<clipboard-button
:text="uri"
:title="s__('ServerlessURL|Copy URL')"
diff --git a/app/assets/javascripts/shared/milestones/form.js b/app/assets/javascripts/shared/milestones/form.js
index 9ee02f923d5..0ff84dc4667 100644
--- a/app/assets/javascripts/shared/milestones/form.js
+++ b/app/assets/javascripts/shared/milestones/form.js
@@ -16,6 +16,5 @@ export default (initGFM = true) => {
milestones: initGFM,
labels: initGFM,
snippets: initGFM,
- vulnerabilities: initGFM,
});
};
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
index 5c67e429383..20dc7cb07e7 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
@@ -1,11 +1,12 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import { n__ } from '~/locale';
+import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
+import { n__, __ } from '~/locale';
export default {
name: 'AssigneeTitle',
components: {
GlLoadingIcon,
+ GlIcon,
},
props: {
loading: {
@@ -26,12 +27,19 @@ export default {
required: false,
default: false,
},
+ changing: {
+ type: Boolean,
+ required: true,
+ },
},
computed: {
assigneeTitle() {
const assignees = this.numberOfAssignees;
return n__('Assignee', `%d Assignees`, assignees);
},
+ titleCopy() {
+ return this.changing ? __('Apply') : __('Edit');
+ },
},
};
</script>
@@ -43,11 +51,12 @@ export default {
v-if="editable"
class="js-sidebar-dropdown-toggle edit-link float-right"
href="#"
+ data-test-id="edit-link"
data-track-event="click_edit_button"
data-track-label="right_sidebar"
data-track-property="assignee"
>
- {{ __('Edit') }}
+ {{ titleCopy }}
</a>
<a
v-if="showToggle"
@@ -56,7 +65,7 @@ export default {
href="#"
role="button"
>
- <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i>
+ <gl-icon aria-hidden="true" data-hidden="true" name="chevron-double-lg-right" :size="12" />
</a>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index 2f714ac3847..b9f268629fb 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -56,6 +56,9 @@ export default {
// Note: Realtime is only available on issues right now, future support for MR wil be built later.
return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue';
},
+ relativeUrlRoot() {
+ return gon.relative_url_root ?? '';
+ },
},
created() {
this.removeAssignee = this.store.removeAssignee.bind(this.store);
@@ -89,6 +92,8 @@ export default {
.saveAssignees(this.field)
.then(() => {
this.loading = false;
+ this.store.resetChanging();
+
refreshUserMergeRequestCounts();
})
.catch(() => {
@@ -113,10 +118,11 @@ export default {
:loading="loading || store.isFetching.assignees"
:editable="store.editable"
:show-toggle="!signedIn"
+ :changing="store.changing"
/>
<assignees
v-if="!store.isFetching.assignees"
- :root-path="store.rootPath"
+ :root-path="relativeUrlRoot"
:users="store.assignees"
:editable="store.editable"
:issuable-type="issuableType"
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
index 86bfacbfb9e..46d51138ccf 100644
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
@@ -1,6 +1,6 @@
<script>
import $ from 'jquery';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { mapActions } from 'vuex';
import { __ } from '~/locale';
import { deprecatedCreateFlash as Flash } from '~/flash';
@@ -8,7 +8,7 @@ import eventHub from '../../event_hub';
export default {
components: {
- GlLoadingIcon,
+ GlButton,
},
props: {
fullPath: {
@@ -64,18 +64,18 @@ export default {
<template>
<div class="sidebar-item-warning-message-actions">
- <button type="button" class="btn btn-default gl-mr-3" @click="closeForm">
+ <gl-button class="gl-mr-3" @click="closeForm">
{{ __('Cancel') }}
- </button>
- <button
- type="button"
- class="btn btn-close"
- data-testid="confidential-toggle"
+ </gl-button>
+ <gl-button
+ category="secondary"
+ variant="warning"
:disabled="isLoading"
+ :loading="isLoading"
+ data-testid="confidential-toggle"
@click.prevent="submitForm"
>
- <gl-loading-icon v-if="isLoading" inline />
{{ toggleButtonText }}
- </button>
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
index d7be8927c29..1af1bc18e3e 100644
--- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
+++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
@@ -1,7 +1,6 @@
<script>
import $ from 'jquery';
import { difference, union } from 'lodash';
-import { mapState, mapActions } from 'vuex';
import flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
@@ -26,47 +25,49 @@ export default {
'projectIssuesPath',
'projectPath',
],
- data: () => ({
- labelsSelectInProgress: false,
- }),
- computed: {
- ...mapState(['selectedLabels']),
- },
- mounted() {
- this.setInitialState({
+ data() {
+ return {
+ isLabelsSelectInProgress: false,
selectedLabels: this.initiallySelectedLabels,
- });
+ };
},
methods: {
- ...mapActions(['setInitialState', 'replaceSelectedLabels']),
handleDropdownClose() {
$(this.$el).trigger('hidden.gl.dropdown');
},
- handleUpdateSelectedLabels(labels) {
+ handleUpdateSelectedLabels(dropdownLabels) {
const currentLabelIds = this.selectedLabels.map(label => label.id);
- const userAddedLabelIds = labels.filter(label => label.set).map(label => label.id);
- const userRemovedLabelIds = labels.filter(label => !label.set).map(label => label.id);
+ const userAddedLabelIds = dropdownLabels.filter(label => label.set).map(label => label.id);
+ const userRemovedLabelIds = dropdownLabels.filter(label => !label.set).map(label => label.id);
- const issuableLabels = difference(
- union(currentLabelIds, userAddedLabelIds),
- userRemovedLabelIds,
- );
+ const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds);
- this.labelsSelectInProgress = true;
+ this.updateSelectedLabels(labelIds);
+ },
+ handleLabelRemove(labelId) {
+ const currentLabelIds = this.selectedLabels.map(label => label.id);
+ const labelIds = difference(currentLabelIds, [labelId]);
+
+ this.updateSelectedLabels(labelIds);
+ },
+ updateSelectedLabels(labelIds) {
+ this.isLabelsSelectInProgress = true;
axios({
data: {
[this.issuableType]: {
- label_ids: issuableLabels,
+ label_ids: labelIds,
},
},
method: 'put',
url: this.labelsUpdatePath,
})
- .then(({ data }) => this.replaceSelectedLabels(data.labels))
+ .then(({ data }) => {
+ this.selectedLabels = data.labels;
+ })
.catch(() => flash(__('An error occurred while updating labels.')))
.finally(() => {
- this.labelsSelectInProgress = false;
+ this.isLabelsSelectInProgress = false;
});
},
},
@@ -76,6 +77,7 @@ export default {
<template>
<labels-select
class="block labels js-labels-block"
+ :allow-label-remove="allowLabelEdit"
:allow-label-create="allowLabelCreate"
:allow-label-edit="allowLabelEdit"
:allow-multiselect="true"
@@ -86,10 +88,12 @@ export default {
:labels-fetch-path="labelsFetchPath"
:labels-filter-base-path="projectIssuesPath"
:labels-manage-path="labelsManagePath"
- :labels-select-in-progress="labelsSelectInProgress"
+ :labels-select-in-progress="isLabelsSelectInProgress"
:selected-labels="selectedLabels"
:variant="$options.sidebar"
+ data-qa-selector="labels_block"
@onDropdownClose="handleDropdownClose"
+ @onLabelRemove="handleLabelRemove"
@updateSelectedLabels="handleUpdateSelectedLabels"
>
{{ __('None') }}
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
index ea7230ae488..26a7c8e4a80 100644
--- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
@@ -1,6 +1,6 @@
<script>
import $ from 'jquery';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { mapActions } from 'vuex';
import { __, sprintf } from '../../../locale';
import { deprecatedCreateFlash as Flash } from '~/flash';
@@ -8,7 +8,7 @@ import eventHub from '../../event_hub';
export default {
components: {
- GlLoadingIcon,
+ GlButton,
},
inject: ['fullPath'],
props: {
@@ -65,19 +65,19 @@ export default {
<template>
<div class="sidebar-item-warning-message-actions">
- <button type="button" class="btn btn-default gl-mr-3" @click="closeForm">
+ <gl-button class="gl-mr-3" @click="closeForm">
{{ __('Cancel') }}
- </button>
+ </gl-button>
- <button
- type="button"
+ <gl-button
data-testid="lock-toggle"
- class="btn btn-close"
+ category="secondary"
+ variant="warning"
:disabled="isLoading"
+ :loading="isLoading"
@click.prevent="submitForm"
>
- <gl-loading-icon v-if="isLoading" inline />
{{ buttonText }}
- </button>
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
index 53ee7f46ad9..b96a2b93712 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -1,8 +1,7 @@
<script>
import { mapGetters } from 'vuex';
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
import eventHub from '~/sidebar/event_hub';
import editForm from './edit_form.vue';
@@ -26,7 +25,7 @@ export default {
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
@@ -79,13 +78,9 @@ export default {
<template>
<div class="block issuable-sidebar-item lock">
<div
- v-tooltip
- :title="tooltipLabel"
+ v-gl-tooltip.left.viewport="{ title: tooltipLabel }"
class="sidebar-collapsed-icon"
data-testid="sidebar-collapse-icon"
- data-container="body"
- data-placement="left"
- data-boundary="viewport"
@click="toggleForm"
>
<gl-icon :name="lockStatus.icon" class="sidebar-item-icon is-active" />
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index e7dbc47aea1..c3a08f760a0 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -1,12 +1,11 @@
<script>
-import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
+import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, n__, sprintf } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
userAvatarImage,
@@ -87,12 +86,9 @@ export default {
<div>
<div
v-if="showParticipantLabel"
- v-tooltip
+ v-gl-tooltip.left.viewport
:title="participantLabel"
class="sidebar-collapsed-icon"
- data-container="body"
- data-placement="left"
- data-boundary="viewport"
@click="onClickCollapsedIcon"
>
<gl-icon name="users" />
diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue
new file mode 100644
index 00000000000..6de926e0ff9
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue
@@ -0,0 +1,24 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import ReviewerAvatar from './reviewer_avatar.vue';
+
+export default {
+ components: {
+ ReviewerAvatar,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <button type="button" class="btn-link">
+ <reviewer-avatar :user="user" :img-size="24" />
+ <span class="author"> {{ user.name }} </span>
+ </button>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
new file mode 100644
index 00000000000..45707c18f7b
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
@@ -0,0 +1,107 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import CollapsedReviewer from './collapsed_reviewer.vue';
+
+const DEFAULT_MAX_COUNTER = 99;
+const DEFAULT_RENDER_COUNT = 5;
+
+export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ CollapsedReviewer,
+ GlIcon,
+ },
+ props: {
+ users: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ hasNoUsers() {
+ return !this.users.length;
+ },
+ hasMoreThanOneReviewer() {
+ return this.users.length > 1;
+ },
+ hasMoreThanTwoReviewers() {
+ return this.users.length > 2;
+ },
+ allReviewersCanMerge() {
+ return this.users.every(user => user.can_merge);
+ },
+ sidebarAvatarCounter() {
+ if (this.users.length > DEFAULT_MAX_COUNTER) {
+ return `${DEFAULT_MAX_COUNTER}+`;
+ }
+
+ return `+${this.users.length - 1}`;
+ },
+ collapsedUsers() {
+ const collapsedLength = this.hasMoreThanTwoReviewers ? 1 : this.users.length;
+
+ return this.users.slice(0, collapsedLength);
+ },
+ tooltipTitleMergeStatus() {
+ const mergeLength = this.users.filter(u => u.can_merge).length;
+
+ if (mergeLength === this.users.length) {
+ return '';
+ } else if (mergeLength > 0) {
+ return sprintf(__('%{mergeLength}/%{usersLength} can merge'), {
+ mergeLength,
+ usersLength: this.users.length,
+ });
+ }
+
+ return this.users.length === 1 ? __('cannot merge') : __('no one can merge');
+ },
+ tooltipTitle() {
+ const maxRender = Math.min(DEFAULT_RENDER_COUNT, this.users.length);
+ const renderUsers = this.users.slice(0, maxRender);
+ const names = renderUsers.map(u => u.name);
+
+ if (!this.users.length) {
+ return __('Reviewer(s)');
+ }
+
+ if (this.users.length > names.length) {
+ names.push(sprintf(__('+ %{amount} more'), { amount: this.users.length - names.length }));
+ }
+
+ const text = names.join(', ');
+
+ return this.tooltipTitleMergeStatus ? `${text} (${this.tooltipTitleMergeStatus})` : text;
+ },
+
+ tooltipOptions() {
+ return { container: 'body', placement: 'left', boundary: 'viewport' };
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-gl-tooltip="tooltipOptions"
+ :class="{ 'multiple-users': hasMoreThanOneReviewer }"
+ :title="tooltipTitle"
+ class="sidebar-collapsed-icon sidebar-collapsed-user"
+ >
+ <gl-icon v-if="hasNoUsers" name="user" :aria-label="__('None')" />
+ <collapsed-reviewer v-for="user in collapsedUsers" :key="user.id" :user="user" />
+ <button v-if="hasMoreThanTwoReviewers" class="btn-link" type="button">
+ <span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span>
+ <i
+ v-if="!allReviewersCanMerge"
+ aria-hidden="true"
+ class="fa fa-exclamation-triangle merge-icon"
+ ></i>
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue
new file mode 100644
index 00000000000..9fa3fa38eac
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue
@@ -0,0 +1,43 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import { __, sprintf } from '~/locale';
+
+export default {
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ imgSize: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ reviewerAlt() {
+ return sprintf(__("%{userName}'s avatar"), { userName: this.user.name });
+ },
+ avatarUrl() {
+ return this.user.avatar || this.user.avatar_url || gon.default_avatar_url;
+ },
+ hasMergeIcon() {
+ return !this.user.can_merge;
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="position-relative">
+ <img
+ :alt="reviewerAlt"
+ :src="avatarUrl"
+ :width="imgSize"
+ :class="`s${imgSize}`"
+ class="avatar avatar-inline m-0"
+ data-qa-selector="avatar_image"
+ />
+ <i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i>
+ </span>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
new file mode 100644
index 00000000000..b1b04564a62
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
@@ -0,0 +1,84 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import ReviewerAvatar from './reviewer_avatar.vue';
+
+export default {
+ components: {
+ ReviewerAvatar,
+ GlLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ tooltipPlacement: {
+ type: String,
+ default: 'bottom',
+ required: false,
+ },
+ tooltipHasName: {
+ type: Boolean,
+ default: true,
+ required: false,
+ },
+ issuableType: {
+ type: String,
+ default: 'issue',
+ required: false,
+ },
+ },
+ computed: {
+ cannotMerge() {
+ return this.issuableType === 'merge_request' && !this.user.can_merge;
+ },
+ tooltipTitle() {
+ if (this.cannotMerge && this.tooltipHasName) {
+ return sprintf(__('%{userName} (cannot merge)'), { userName: this.user.name });
+ } else if (this.cannotMerge) {
+ return __('Cannot merge');
+ } else if (this.tooltipHasName) {
+ return this.user.name;
+ }
+
+ return '';
+ },
+ tooltipOption() {
+ return {
+ container: 'body',
+ placement: this.tooltipPlacement,
+ boundary: 'viewport',
+ };
+ },
+ reviewerUrl() {
+ return this.user.web_url;
+ },
+ },
+};
+</script>
+
+<template>
+ <!-- must be `d-inline-block` or parent flex-basis causes width issues -->
+ <gl-link
+ v-gl-tooltip="tooltipOption"
+ :href="reviewerUrl"
+ :title="tooltipTitle"
+ class="d-inline-block"
+ >
+ <!-- use d-flex so that slot can be appropriately styled -->
+ <span class="d-flex">
+ <reviewer-avatar :user="user" :img-size="32" :issuable-type="issuableType" />
+ <slot :user="user"></slot>
+ </span>
+ </gl-link>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue
new file mode 100644
index 00000000000..4f4f7002dc9
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue
@@ -0,0 +1,65 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
+import { n__ } from '~/locale';
+
+export default {
+ name: 'ReviewerTitle',
+ components: {
+ GlLoadingIcon,
+ GlIcon,
+ },
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ numberOfReviewers: {
+ type: Number,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ showToggle: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ reviewerTitle() {
+ const reviewers = this.numberOfReviewers;
+ return n__('Reviewer', `%d Reviewers`, reviewers);
+ },
+ },
+};
+</script>
+<template>
+ <div class="title hide-collapsed">
+ {{ reviewerTitle }}
+ <gl-loading-icon v-if="loading" inline class="align-bottom" />
+ <a
+ v-if="editable"
+ class="js-sidebar-dropdown-toggle edit-link float-right"
+ href="#"
+ data-track-event="click_edit_button"
+ data-track-label="right_sidebar"
+ data-track-property="reviewer"
+ >
+ {{ __('Edit') }}
+ </a>
+ <a
+ v-if="showToggle"
+ :aria-label="__('Toggle sidebar')"
+ class="gutter-toggle float-right js-sidebar-toggle"
+ href="#"
+ role="button"
+ >
+ <gl-icon aria-hidden="true" data-hidden="true" name="chevron-double-lg-right" :size="12" />
+ </a>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
new file mode 100644
index 00000000000..6a3d88f6385
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
@@ -0,0 +1,72 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import CollapsedReviewerList from './collapsed_reviewer_list.vue';
+import UncollapsedReviewerList from './uncollapsed_reviewer_list.vue';
+
+export default {
+ // name: 'Reviewers' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ name: 'Reviewers',
+ components: {
+ CollapsedReviewerList,
+ UncollapsedReviewerList,
+ },
+ props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ users: {
+ type: Array,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ },
+ computed: {
+ hasNoUsers() {
+ return !this.users.length;
+ },
+ sortedReviewers() {
+ const canMergeUsers = this.users.filter(user => user.can_merge);
+ const canNotMergeUsers = this.users.filter(user => !user.can_merge);
+
+ return [...canMergeUsers, ...canNotMergeUsers];
+ },
+ },
+ methods: {
+ assignSelf() {
+ this.$emit('assign-self');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <collapsed-reviewer-list :users="sortedReviewers" :issuable-type="issuableType" />
+
+ <div class="value hide-collapsed">
+ <template v-if="hasNoUsers">
+ <span class="assign-yourself no-value qa-assign-yourself">
+ {{ __('None') }}
+ </span>
+ </template>
+
+ <uncollapsed-reviewer-list
+ v-else
+ :users="sortedReviewers"
+ :root-path="rootPath"
+ :issuable-type="issuableType"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
new file mode 100644
index 00000000000..aee94a55134
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
@@ -0,0 +1,112 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import { deprecatedCreateFlash as Flash } from '~/flash';
+import eventHub from '~/sidebar/event_hub';
+import Store from '~/sidebar/stores/sidebar_store';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import ReviewerTitle from './reviewer_title.vue';
+import Reviewers from './reviewers.vue';
+import { __ } from '~/locale';
+
+export default {
+ name: 'SidebarReviewers',
+ components: {
+ ReviewerTitle,
+ Reviewers,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ mediator: {
+ type: Object,
+ required: true,
+ },
+ field: {
+ type: String,
+ required: true,
+ },
+ signedIn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ issuableIid: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ store: new Store(),
+ loading: false,
+ };
+ },
+ computed: {
+ relativeUrlRoot() {
+ return gon.relative_url_root ?? '';
+ },
+ },
+ created() {
+ this.removeReviewer = this.store.removeReviewer.bind(this.store);
+ this.addReviewer = this.store.addReviewer.bind(this.store);
+ this.removeAllReviewers = this.store.removeAllReviewers.bind(this.store);
+
+ // Get events from deprecatedJQueryDropdown
+ eventHub.$on('sidebar.removeReviewer', this.removeReviewer);
+ eventHub.$on('sidebar.addReviewer', this.addReviewer);
+ eventHub.$on('sidebar.removeAllReviewers', this.removeAllReviewers);
+ eventHub.$on('sidebar.saveReviewers', this.saveReviewers);
+ },
+ beforeDestroy() {
+ eventHub.$off('sidebar.removeReviewer', this.removeReviewer);
+ eventHub.$off('sidebar.addReviewer', this.addReviewer);
+ eventHub.$off('sidebar.removeAllReviewers', this.removeAllReviewers);
+ eventHub.$off('sidebar.saveReviewers', this.saveReviewers);
+ },
+ methods: {
+ saveReviewers() {
+ this.loading = true;
+
+ this.mediator
+ .saveReviewers(this.field)
+ .then(() => {
+ this.loading = false;
+ // Uncomment once this issue has been addressed > https://gitlab.com/gitlab-org/gitlab/-/issues/237922
+ // refreshUserMergeRequestCounts();
+ })
+ .catch(() => {
+ this.loading = false;
+ return new Flash(__('Error occurred when saving reviewers'));
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <reviewer-title
+ :number-of-reviewers="store.reviewers.length"
+ :loading="loading || store.isFetching.reviewers"
+ :editable="store.editable"
+ :show-toggle="!signedIn"
+ />
+ <reviewers
+ v-if="!store.isFetching.reviewers"
+ :root-path="relativeUrlRoot"
+ :users="store.reviewers"
+ :editable="store.editable"
+ :issuable-type="issuableType"
+ class="value"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
new file mode 100644
index 00000000000..e82a271d007
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -0,0 +1,103 @@
+<script>
+// NOTE! For the first iteration, we are simply copying the implementation of Assignees
+// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import { __, sprintf } from '~/locale';
+import ReviewerAvatarLink from './reviewer_avatar_link.vue';
+
+const DEFAULT_RENDER_COUNT = 5;
+
+export default {
+ components: {
+ ReviewerAvatarLink,
+ },
+ props: {
+ users: {
+ type: Array,
+ required: true,
+ },
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ },
+ data() {
+ return {
+ showLess: true,
+ };
+ },
+ computed: {
+ firstUser() {
+ return this.users[0];
+ },
+ hasOneUser() {
+ return this.users.length === 1;
+ },
+ hiddenReviewersLabel() {
+ const { numberOfHiddenReviewers } = this;
+ return sprintf(__('+ %{numberOfHiddenReviewers} more'), { numberOfHiddenReviewers });
+ },
+ renderShowMoreSection() {
+ return this.users.length > DEFAULT_RENDER_COUNT;
+ },
+ numberOfHiddenReviewers() {
+ return this.users.length - DEFAULT_RENDER_COUNT;
+ },
+ uncollapsedUsers() {
+ const uncollapsedLength = this.showLess
+ ? Math.min(this.users.length, DEFAULT_RENDER_COUNT)
+ : this.users.length;
+ return this.showLess ? this.users.slice(0, uncollapsedLength) : this.users;
+ },
+ username() {
+ return `@${this.firstUser.username}`;
+ },
+ },
+ methods: {
+ toggleShowLess() {
+ this.showLess = !this.showLess;
+ },
+ },
+};
+</script>
+
+<template>
+ <reviewer-avatar-link
+ v-if="hasOneUser"
+ #default="{ user }"
+ tooltip-placement="left"
+ :tooltip-has-name="false"
+ :user="firstUser"
+ :root-path="rootPath"
+ :issuable-type="issuableType"
+ >
+ <div class="gl-ml-3 gl-line-height-normal">
+ <div class="author">{{ user.name }}</div>
+ <div class="username">{{ username }}</div>
+ </div>
+ </reviewer-avatar-link>
+ <div v-else>
+ <div class="user-list">
+ <div v-for="user in uncollapsedUsers" :key="user.id" class="user-item">
+ <reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType" />
+ </div>
+ </div>
+ <div v-if="renderShowMoreSection" class="user-list-more">
+ <button
+ type="button"
+ class="btn-link"
+ data-qa-selector="more_reviewers_link"
+ @click="toggleShowLess"
+ >
+ <template v-if="showLess">
+ {{ hiddenReviewersLabel }}
+ </template>
+ <template v-else>{{ __('- show less') }}</template>
+ </button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
index bc2319c0f36..9d72bf4394e 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
@@ -1,7 +1,6 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
export default {
name: 'TimeTrackingCollapsedState',
@@ -9,7 +8,7 @@ export default {
GlIcon,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
showComparisonState: {
@@ -97,14 +96,7 @@ export default {
</script>
<template>
- <div
- v-tooltip
- :title="tooltipText"
- class="sidebar-collapsed-icon"
- data-container="body"
- data-placement="left"
- data-boundary="viewport"
- >
+ <div v-gl-tooltip:body.viewport.left :title="tooltipText" class="sidebar-collapsed-icon">
<gl-icon name="timer" />
<div class="time-tracking-collapsed-summary">
<div :class="divClass">
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
index 4cb8d9ebd62..d4cc98e3743 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
@@ -1,7 +1,6 @@
<script>
-import { GlProgressBar } from '@gitlab/ui';
+import { GlProgressBar, GlTooltipDirective } from '@gitlab/ui';
import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
-import tooltip from '../../../vue_shared/directives/tooltip';
import { s__, sprintf } from '~/locale';
export default {
@@ -10,7 +9,7 @@ export default {
GlProgressBar,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
timeSpent: {
@@ -73,7 +72,7 @@ export default {
<template>
<div class="time-tracking-comparison-pane">
<div
- v-tooltip
+ v-gl-tooltip
:title="timeRemainingTooltip"
:class="timeRemainingStatusClass"
class="compare-meter"
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
index 05ad7b4ea3e..406677941b7 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
@@ -26,11 +26,14 @@ export default {
methods: {
listenForQuickActions() {
$(document).on('ajax:success', '.gfm-form', this.quickActionListened);
+
eventHub.$on('timeTrackingUpdated', data => {
- this.quickActionListened(null, data);
+ this.quickActionListened({ detail: [data] });
});
},
- quickActionListened(e, data) {
+ quickActionListened(e) {
+ const data = e.detail[0];
+
const subscribedCommands = ['spend_time', 'time_estimate'];
let changedCommands;
if (data !== undefined) {
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index be559b16420..00b4e2de5e5 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -1,10 +1,10 @@
import $ from 'jquery';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import Vuex from 'vuex';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import SidebarLabels from './components/labels/sidebar_labels.vue';
+import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
import SidebarMoveIssue from './lib/sidebar_move_issue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue';
@@ -13,17 +13,15 @@ import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptio
import SidebarSeverity from './components/severity/sidebar_severity.vue';
import Translate from '../vue_shared/translate';
import createDefaultClient from '~/lib/graphql';
-import { store } from '~/notes/stores';
-import { isInIssuePage, parseBoolean } from '~/lib/utils/common_utils';
-import mergeRequestStore from '~/mr_notes/stores';
-import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
+import { isInIssuePage, isInIncidentPage, parseBoolean } from '~/lib/utils/common_utils';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
Vue.use(Translate);
Vue.use(VueApollo);
-Vue.use(Vuex);
-function getSidebarOptions() {
- return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
+function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-options')) {
+ return JSON.parse(sidebarOptEl.innerHTML);
}
function mountAssigneesComponent(mediator) {
@@ -50,6 +48,36 @@ function mountAssigneesComponent(mediator) {
projectPath: fullPath,
field: el.dataset.field,
signedIn: el.hasAttribute('data-signed-in'),
+ issuableType: isInIssuePage() || isInIncidentPage() ? 'issue' : 'merge_request',
+ },
+ }),
+ });
+}
+
+function mountReviewersComponent(mediator) {
+ const el = document.getElementById('js-vue-sidebar-reviewers');
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ if (!el) return;
+
+ const { iid, fullPath } = getSidebarOptions();
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ components: {
+ SidebarReviewers,
+ },
+ render: createElement =>
+ createElement('sidebar-reviewers', {
+ props: {
+ mediator,
+ issuableIid: String(iid),
+ projectPath: fullPath,
+ field: el.dataset.field,
+ signedIn: el.hasAttribute('data-signed-in'),
issuableType: isInIssuePage() ? 'issue' : 'merge_request',
},
}),
@@ -63,8 +91,6 @@ export function mountSidebarLabels() {
return false;
}
- const labelsStore = new Vuex.Store(labelsSelectModule());
-
return new Vue({
el,
provide: {
@@ -74,7 +100,6 @@ export function mountSidebarLabels() {
allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels),
initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels),
},
- store: labelsStore,
render: createElement => createElement(SidebarLabels),
});
}
@@ -89,47 +114,74 @@ function mountConfidentialComponent(mediator) {
const dataNode = document.getElementById('js-confidential-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
- // eslint-disable-next-line no-new
- new Vue({
- el,
- store,
- components: {
- ConfidentialIssueSidebar,
- },
- render: createElement =>
- createElement('confidential-issue-sidebar', {
- props: {
- iid: String(iid),
- fullPath,
- isEditable: initialData.is_editable,
- service: mediator.service,
- },
- }),
- });
+ import(/* webpackChunkName: 'notesStore' */ '~/notes/stores')
+ .then(
+ ({ store }) =>
+ new Vue({
+ el,
+ store,
+ components: {
+ ConfidentialIssueSidebar,
+ },
+ render: createElement =>
+ createElement('confidential-issue-sidebar', {
+ props: {
+ iid: String(iid),
+ fullPath,
+ isEditable: initialData.is_editable,
+ service: mediator.service,
+ },
+ }),
+ }),
+ )
+ .catch(() => {
+ createFlash({ message: __('Failed to load sidebar confidential toggle') });
+ });
}
function mountLockComponent() {
const el = document.getElementById('js-lock-entry-point');
+
+ if (!el) {
+ return;
+ }
+
const { fullPath } = getSidebarOptions();
const dataNode = document.getElementById('js-lock-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
- return el
- ? new Vue({
- el,
- store: isInIssuePage() ? store : mergeRequestStore,
- provide: {
- fullPath,
- },
- render: createElement =>
- createElement(IssuableLockForm, {
- props: {
- isEditable: initialData.is_editable,
- },
- }),
- })
- : undefined;
+ let importStore;
+ if (isInIssuePage() || isInIncidentPage()) {
+ importStore = import(/* webpackChunkName: 'notesStore' */ '~/notes/stores').then(
+ ({ store }) => store,
+ );
+ } else {
+ importStore = import(/* webpackChunkName: 'mrNotesStore' */ '~/mr_notes/stores').then(
+ store => store.default,
+ );
+ }
+
+ importStore
+ .then(
+ store =>
+ new Vue({
+ el,
+ store,
+ provide: {
+ fullPath,
+ },
+ render: createElement =>
+ createElement(IssuableLockForm, {
+ props: {
+ isEditable: initialData.is_editable,
+ },
+ }),
+ }),
+ )
+ .catch(() => {
+ createFlash({ message: __('Failed to load sidebar lock status') });
+ });
}
function mountParticipantsComponent(mediator) {
@@ -218,8 +270,9 @@ function mountSeverityComponent() {
export function mountSidebar(mediator) {
mountAssigneesComponent(mediator);
+ mountReviewersComponent(mediator);
mountConfidentialComponent(mediator);
- mountLockComponent(mediator);
+ mountLockComponent();
mountParticipantsComponent(mediator);
mountSubscriptionsComponent(mediator);
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 8f1f76a2e02..2146fb83b13 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -40,6 +40,17 @@ export default class SidebarMediator {
return this.service.update(field, data);
}
+ saveReviewers(field) {
+ const selected = this.store.reviewers.map(u => u.id);
+
+ // If there are no ids, that means we have to unassign (which is id = 0)
+ // And it only accepts an array, hence [0]
+ const reviewers = selected.length === 0 ? [0] : selected;
+ const data = { reviewer_ids: reviewers };
+
+ return this.service.update(field, data);
+ }
+
setMoveToProjectId(projectId) {
this.store.setMoveToProjectId(projectId);
}
@@ -55,6 +66,7 @@ export default class SidebarMediator {
processFetchedData(data) {
this.store.setAssigneeData(data);
+ this.store.setReviewerData(data);
this.store.setTimeTrackingData(data);
this.store.setParticipantsData(data);
this.store.setSubscriptionsData(data);
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index 095f93b72a9..d53393052eb 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -18,8 +18,10 @@ export default class SidebarStore {
this.humanTimeSpent = '';
this.timeTrackingLimitToHours = timeTrackingLimitToHours;
this.assignees = [];
+ this.reviewers = [];
this.isFetching = {
assignees: true,
+ reviewers: true,
participants: true,
subscriptions: true,
};
@@ -31,17 +33,29 @@ export default class SidebarStore {
this.projectEmailsDisabled = false;
this.subscribeDisabledDescription = '';
this.subscribed = null;
+ this.changing = false;
SidebarStore.singleton = this;
}
- setAssigneeData(data) {
+ setAssigneeData({ assignees }) {
this.isFetching.assignees = false;
- if (data.assignees) {
- this.assignees = data.assignees;
+ if (assignees) {
+ this.assignees = assignees;
}
}
+ setReviewerData({ reviewers }) {
+ this.isFetching.reviewers = false;
+ if (reviewers) {
+ this.reviewers = reviewers;
+ }
+ }
+
+ resetChanging() {
+ this.changing = false;
+ }
+
setTimeTrackingData(data) {
this.timeEstimate = data.time_estimate;
this.totalTimeSpent = data.total_time_spent;
@@ -71,24 +85,47 @@ export default class SidebarStore {
addAssignee(assignee) {
if (!this.findAssignee(assignee)) {
+ this.changing = true;
this.assignees.push(assignee);
}
}
+ addReviewer(reviewer) {
+ if (!this.findReviewer(reviewer)) {
+ this.reviewers.push(reviewer);
+ }
+ }
+
findAssignee(findAssignee) {
- return this.assignees.find(assignee => assignee.id === findAssignee.id);
+ return this.assignees.find(({ id }) => id === findAssignee.id);
}
- removeAssignee(removeAssignee) {
- if (removeAssignee) {
- this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
+ findReviewer(findReviewer) {
+ return this.reviewers.find(({ id }) => id === findReviewer.id);
+ }
+
+ removeAssignee(assignee) {
+ if (assignee) {
+ this.changing = true;
+ this.assignees = this.assignees.filter(({ id }) => id !== assignee.id);
+ }
+ }
+
+ removeReviewer(reviewer) {
+ if (reviewer) {
+ this.reviewers = this.reviewers.filter(({ id }) => id !== reviewer.id);
}
}
removeAllAssignees() {
+ this.changing = true;
this.assignees = [];
}
+ removeAllReviewers() {
+ this.reviewers = [];
+ }
+
setAssigneesFromRealtime(data) {
this.assignees = data;
}
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index 586d1e62c2f..5fa6cef7195 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -57,16 +57,10 @@ export default class SingleFileDiff {
this.content.hide();
this.$toggleIcon.addClass('fa-caret-right').removeClass('fa-caret-down');
this.collapsedContent.show();
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
- }
} else if (this.content) {
this.collapsedContent.hide();
this.content.show();
this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
- }
} else {
this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
return this.getContentHTML(cb);
@@ -90,10 +84,6 @@ export default class SingleFileDiff {
}
this.collapsedContent.after(this.content);
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- gl.diffNotesCompileComponents();
- }
-
const $file = $(this.file);
FilesCommentButton.init($file);
diff --git a/app/assets/javascripts/snippet/snippet_bundle.js b/app/assets/javascripts/snippet/snippet_bundle.js
deleted file mode 100644
index 76a1f6d1458..00000000000
--- a/app/assets/javascripts/snippet/snippet_bundle.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { initEditorLite } from '~/blob/utils';
-import setupCollapsibleInputs from './collapsible_input';
-
-let editor;
-
-const initMonaco = () => {
- const editorEl = document.getElementById('editor');
- const contentEl = document.querySelector('.snippet-file-content');
- const fileNameEl = document.querySelector('.js-snippet-file-name');
- const form = document.querySelector('.snippet-form-holder form');
-
- editor = initEditorLite({
- el: editorEl,
- blobPath: fileNameEl.value,
- blobContent: contentEl.value,
- });
-
- fileNameEl.addEventListener('change', () => {
- editor.updateModelLanguage(fileNameEl.value);
- });
-
- form.addEventListener('submit', () => {
- contentEl.value = editor.getValue();
- });
-};
-
-export default () => {
- initMonaco();
- setupCollapsibleInputs();
-};
diff --git a/app/assets/javascripts/snippet/snippet_edit.js b/app/assets/javascripts/snippet/snippet_edit.js
index 3dc74922a77..88677ddd15f 100644
--- a/app/assets/javascripts/snippet/snippet_edit.js
+++ b/app/assets/javascripts/snippet/snippet_edit.js
@@ -1,33 +1,6 @@
-import $ from 'jquery';
-import initSnippet from '~/snippet/snippet_bundle';
import ZenMode from '~/zen_mode';
-import GLForm from '~/gl_form';
-import { SnippetEditInit } from '~/snippets';
+import SnippetsEdit from '~/snippets/components/edit.vue';
+import SnippetsAppFactory from '~/snippets';
-document.addEventListener('DOMContentLoaded', () => {
- const form = document.querySelector('.snippet-form');
- const personalSnippetOptions = {
- members: false,
- issues: false,
- mergeRequests: false,
- epics: false,
- milestones: false,
- labels: false,
- snippets: false,
- vulnerabilities: false,
- };
- const projectSnippetOptions = {};
-
- const options =
- form.dataset.snippetType === 'project' || form.dataset.projectPath
- ? projectSnippetOptions
- : personalSnippetOptions;
-
- if (gon?.features?.snippetsEditVue) {
- SnippetEditInit();
- } else {
- initSnippet();
- new GLForm($(form), options); // eslint-disable-line no-new
- }
- new ZenMode(); // eslint-disable-line no-new
-});
+SnippetsAppFactory(document.getElementById('js-snippet-edit'), SnippetsEdit);
+new ZenMode(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/snippet/snippet_embed.js b/app/assets/javascripts/snippet/snippet_embed.js
deleted file mode 100644
index 65dd62f6af9..00000000000
--- a/app/assets/javascripts/snippet/snippet_embed.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import { __ } from '~/locale';
-import { parseUrlPathname, parseUrl } from '../lib/utils/common_utils';
-
-function swapActiveState(activateBtn, deactivateBtn) {
- activateBtn.classList.add('is-active');
- deactivateBtn.classList.remove('is-active');
-}
-
-export default () => {
- const shareBtn = document.querySelector('.js-share-btn');
-
- if (shareBtn) {
- const embedBtn = document.querySelector('.js-embed-btn');
- const snippetUrlArea = document.querySelector('.js-snippet-url-area');
- const embedAction = document.querySelector('.js-embed-action');
- const dataUrl = snippetUrlArea.getAttribute('data-url');
-
- snippetUrlArea.addEventListener('click', () => snippetUrlArea.select());
-
- shareBtn.addEventListener('click', () => {
- swapActiveState(shareBtn, embedBtn);
- snippetUrlArea.value = dataUrl;
- embedAction.innerText = __('Share');
- });
-
- embedBtn.addEventListener('click', () => {
- const parser = parseUrl(dataUrl);
- const url = `${parser.origin + parseUrlPathname(dataUrl)}`;
- const params = parser.search;
- const scriptTag = `<script src="${url}.js${params}"></script>`;
-
- swapActiveState(embedBtn, shareBtn);
- snippetUrlArea.value = scriptTag;
- embedAction.innerText = __('Embed');
- });
- }
-};
diff --git a/app/assets/javascripts/snippet/snippet_show.js b/app/assets/javascripts/snippet/snippet_show.js
index bbddfc579c5..caa76fc9988 100644
--- a/app/assets/javascripts/snippet/snippet_show.js
+++ b/app/assets/javascripts/snippet/snippet_show.js
@@ -1,21 +1,13 @@
-import LineHighlighter from '~/line_highlighter';
-import BlobViewer from '~/blob/viewer';
-import ZenMode from '~/zen_mode';
import initNotes from '~/init_notes';
-import snippetEmbed from '~/snippet/snippet_embed';
-import { SnippetShowInit } from '~/snippets';
import loadAwardsHandler from '~/awards_handler';
+import SnippetsShow from '~/snippets/components/show.vue';
+import SnippetsAppFactory from '~/snippets';
+import ZenMode from '~/zen_mode';
+
+SnippetsAppFactory(document.getElementById('js-snippet-view'), SnippetsShow);
+
+initNotes();
+loadAwardsHandler();
-document.addEventListener('DOMContentLoaded', () => {
- if (!gon.features.snippetsVue) {
- new LineHighlighter(); // eslint-disable-line no-new
- new BlobViewer(); // eslint-disable-line no-new
- initNotes();
- new ZenMode(); // eslint-disable-line no-new
- snippetEmbed();
- } else {
- SnippetShowInit();
- initNotes();
- }
- loadAwardsHandler();
-});
+// eslint-disable-next-line no-new
+new ZenMode();
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index 1a539aa0876..dd77d49803f 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -6,7 +6,12 @@ import { __, sprintf } from '~/locale';
import TitleField from '~/vue_shared/components/form/title.vue';
import { redirectTo, joinPaths } from '~/lib/utils/url_utility';
import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
-import { SNIPPET_MARK_EDIT_APP_START } from '~/performance_constants';
+import {
+ SNIPPET_MARK_EDIT_APP_START,
+ SNIPPET_MEASURE_BLOBS_CONTENT,
+} from '~/performance_constants';
+import eventHub from '~/blob/components/eventhub';
+import { performanceMarkAndMeasure } from '~/performance_utils';
import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql';
import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql';
@@ -17,11 +22,14 @@ import {
SNIPPET_VISIBILITY_PRIVATE,
} from '../constants';
import defaultVisibilityQuery from '../queries/snippet_visibility.query.graphql';
+import { markBlobPerformance } from '../utils/blob';
import SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue';
import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
import SnippetDescriptionEdit from './snippet_description_edit.vue';
+eventHub.$on(SNIPPET_MEASURE_BLOBS_CONTENT, markBlobPerformance);
+
export default {
components: {
SnippetDescriptionEdit,
@@ -104,12 +112,6 @@ export default {
}
return this.snippet.webUrl;
},
- titleFieldId() {
- return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_title`;
- },
- descriptionFieldId() {
- return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_description`;
- },
newSnippetSchema() {
return {
title: '',
@@ -119,7 +121,7 @@ export default {
},
},
beforeCreate() {
- performance.mark(SNIPPET_MARK_EDIT_APP_START);
+ performanceMarkAndMeasure({ mark: SNIPPET_MARK_EDIT_APP_START });
},
created() {
window.addEventListener('beforeunload', this.onBeforeUnload);
@@ -151,7 +153,7 @@ export default {
this.newSnippet = false;
},
onSnippetFetch(snippetRes) {
- if (snippetRes.data.snippets.edges.length === 0) {
+ if (snippetRes.data.snippets.nodes.length === 0) {
this.onNewSnippetFetched();
} else {
this.onExistingSnippetFetched();
@@ -220,14 +222,13 @@ export default {
/>
<template v-else>
<title-field
- :id="titleFieldId"
+ id="snippet-title"
v-model="snippet.title"
data-qa-selector="snippet_title_field"
required
:autofocus="true"
/>
<snippet-description-edit
- :id="descriptionFieldId"
v-model="snippet.description"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
diff --git a/app/assets/javascripts/snippets/components/embed_dropdown.vue b/app/assets/javascripts/snippets/components/embed_dropdown.vue
index 589754a8b19..a5d2c337d67 100644
--- a/app/assets/javascripts/snippets/components/embed_dropdown.vue
+++ b/app/assets/javascripts/snippets/components/embed_dropdown.vue
@@ -60,7 +60,7 @@ export default {
class="gl-dropdown-text-py-0 gl-dropdown-text-block"
data-testid="input"
>
- <gl-form-input-group :value="value" readonly select-on-click>
+ <gl-form-input-group :value="value" readonly select-on-click :aria-label="name">
<template #append>
<gl-button
v-gl-tooltip.hover
diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue
index 43be2cb7ed8..4a2f060ff7c 100644
--- a/app/assets/javascripts/snippets/components/show.vue
+++ b/app/assets/javascripts/snippets/components/show.vue
@@ -5,11 +5,18 @@ import SnippetHeader from './snippet_header.vue';
import SnippetTitle from './snippet_title.vue';
import SnippetBlob from './snippet_blob_view.vue';
import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue';
+import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants';
+import {
+ SNIPPET_MARK_VIEW_APP_START,
+ SNIPPET_MEASURE_BLOBS_CONTENT,
+} from '~/performance_constants';
+import { performanceMarkAndMeasure } from '~/performance_utils';
+import eventHub from '~/blob/components/eventhub';
import { getSnippetMixin } from '../mixins/snippets';
-import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants';
+import { markBlobPerformance } from '../utils/blob';
-import { SNIPPET_MARK_VIEW_APP_START } from '~/performance_constants';
+eventHub.$on(SNIPPET_MEASURE_BLOBS_CONTENT, markBlobPerformance);
export default {
components: {
@@ -30,7 +37,7 @@ export default {
},
},
beforeCreate() {
- performance.mark(SNIPPET_MARK_VIEW_APP_START);
+ performanceMarkAndMeasure({ mark: SNIPPET_MARK_VIEW_APP_START });
},
};
</script>
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue
index 55cd13a6930..ab2553265a2 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue
@@ -2,7 +2,6 @@
import { GlButton } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import { s__, sprintf } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SnippetBlobEdit from './snippet_blob_edit.vue';
import { SNIPPET_MAX_BLOBS } from '../constants';
import { createBlob, decorateBlob, diffAll } from '../utils/blob';
@@ -12,7 +11,6 @@ export default {
SnippetBlobEdit,
GlButton,
},
- mixins: [glFeatureFlagsMixin()],
props: {
initBlobs: {
type: Array,
@@ -52,12 +50,6 @@ export default {
canAdd() {
return this.count < SNIPPET_MAX_BLOBS;
},
- hasMultiFilesEnabled() {
- return this.glFeatures.snippetMultipleFiles;
- },
- filesLabel() {
- return this.hasMultiFilesEnabled ? s__('Snippets|Files') : s__('Snippets|File');
- },
firstInputId() {
const blobId = this.blobIds[0];
@@ -131,24 +123,23 @@ export default {
};
</script>
<template>
- <div class="form-group file-editor">
- <label :for="firstInputId">{{ filesLabel }}</label>
+ <div class="form-group">
+ <label :for="firstInputId">{{ s__('Snippets|Files') }}</label>
<snippet-blob-edit
v-for="(blobId, index) in blobIds"
:key="blobId"
:class="{ 'gl-mt-3': index > 0 }"
:blob="blobs[blobId]"
:can-delete="canDelete"
- :show-delete="hasMultiFilesEnabled"
@blob-updated="updateBlob(blobId, $event)"
@delete="deleteBlob(blobId)"
/>
<gl-button
- v-if="hasMultiFilesEnabled"
:disabled="!canAdd"
data-testid="add_button"
class="gl-my-3"
variant="dashed"
+ data-qa-selector="add_file_button"
@click="addBlob"
>{{ addLabel }}</gl-button
>
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
index f3f894ed649..6a10dc38f2c 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue';
-import BlobContentEdit from '~/blob/components/blob_edit_content.vue';
+import EditorLite from '~/vue_shared/components/editor_lite.vue';
import { getBaseURL, joinPaths } from '~/lib/utils/url_utility';
import axios from '~/lib/utils/axios_utils';
import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants';
@@ -11,8 +11,8 @@ import { sprintf } from '~/locale';
export default {
components: {
BlobHeaderEdit,
- BlobContentEdit,
GlLoadingIcon,
+ EditorLite,
},
inheritAttrs: false,
props: {
@@ -28,7 +28,7 @@ export default {
showDelete: {
type: Boolean,
required: false,
- default: false,
+ default: true,
},
},
computed: {
@@ -69,7 +69,7 @@ export default {
};
</script>
<template>
- <div class="file-holder snippet">
+ <div class="file-holder snippet" data-qa-selector="file_holder_container">
<blob-header-edit
:id="inputId"
:value="blob.path"
@@ -85,7 +85,7 @@ export default {
size="lg"
class="loading-animation prepend-top-20 gl-mb-6"
/>
- <blob-content-edit
+ <editor-lite
v-else
:value="blob.content"
:file-global-id="blob.id"
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
index b38be5bb9a4..e88126ea56a 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
@@ -23,6 +23,7 @@ export default {
return {
ids: this.snippet.id,
rich: this.activeViewerType === RICH_BLOB_VIEWER,
+ paths: [this.blob.path],
};
},
update(data) {
@@ -79,8 +80,10 @@ export default {
},
onContentUpdate(data) {
const { path: blobPath } = this.blob;
- const { blobs } = data.snippets.edges[0].node;
- const updatedBlobData = blobs.find(blob => blob.path === blobPath);
+ const {
+ blobs: { nodes: dataBlobs },
+ } = data.snippets.nodes[0];
+ const updatedBlobData = dataBlobs.find(blob => blob.path === blobPath);
return updatedBlobData.richData || updatedBlobData.plainData;
},
},
diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
index 737845d09b8..5e6caf27bdd 100644
--- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
@@ -49,6 +49,7 @@ export default {
:add-spacing-classes="false"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
+ :textarea-value="value"
>
<template #textarea>
<textarea
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
index 0ca69f3161a..30de5a9d0e0 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -18,6 +18,7 @@ import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql';
import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql';
import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql';
import { joinPaths } from '~/lib/utils/url_utility';
+import { fetchPolicies } from '~/lib/graphql';
export default {
components: {
@@ -37,6 +38,7 @@ export default {
},
apollo: {
canCreateSnippet: {
+ fetchPolicy: fetchPolicies.NO_CACHE,
query() {
return this.snippet.project ? CanCreateProjectSnippet : CanCreatePersonalSnippet;
},
diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
index 2cca71708ca..d75b4011d1c 100644
--- a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
+++ b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
@@ -12,18 +12,20 @@ fragment SnippetBase on Snippet {
httpUrlToRepo
sshUrlToRepo
blobs {
- binary
- name
- path
- rawPath
- size
- externalStorage
- renderedAsText
- simpleViewer {
- ...BlobViewer
- }
- richViewer {
- ...BlobViewer
+ nodes {
+ binary
+ name
+ path
+ rawPath
+ size
+ externalStorage
+ renderedAsText
+ simpleViewer {
+ ...BlobViewer
+ }
+ richViewer {
+ ...BlobViewer
+ }
}
}
userPermissions {
diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js
index c70ad9b95f8..b55e1baf41e 100644
--- a/app/assets/javascripts/snippets/index.js
+++ b/app/assets/javascripts/snippets/index.js
@@ -3,20 +3,18 @@ import VueApollo from 'vue-apollo';
import Translate from '~/vue_shared/translate';
import createDefaultClient from '~/lib/graphql';
-import SnippetsShow from './components/show.vue';
-import SnippetsEdit from './components/edit.vue';
import { SNIPPET_LEVELS_MAP, SNIPPET_VISIBILITY_PRIVATE } from '~/snippets/constants';
Vue.use(VueApollo);
Vue.use(Translate);
-function appFactory(el, Component) {
+export default function appFactory(el, Component) {
if (!el) {
return false;
}
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { batchMax: 1 }),
});
const {
@@ -46,11 +44,3 @@ function appFactory(el, Component) {
},
});
}
-
-export const SnippetShowInit = () => {
- appFactory(document.getElementById('js-snippet-view'), SnippetsShow);
-};
-
-export const SnippetEditInit = () => {
- appFactory(document.getElementById('js-snippet-edit'), SnippetsEdit);
-};
diff --git a/app/assets/javascripts/snippets/mixins/snippets.js b/app/assets/javascripts/snippets/mixins/snippets.js
index 15daaa8d84a..d5e69e2a889 100644
--- a/app/assets/javascripts/snippets/mixins/snippets.js
+++ b/app/assets/javascripts/snippets/mixins/snippets.js
@@ -11,9 +11,16 @@ export const getSnippetMixin = {
ids: this.snippetGid,
};
},
- update: data => data.snippets.edges[0]?.node,
+ update: data => {
+ const res = data.snippets.nodes[0];
+ if (res) {
+ res.blobs = res.blobs.nodes;
+ }
+
+ return res;
+ },
result(res) {
- this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault;
+ this.blobs = res.data.snippets.nodes[0]?.blobs || blobsDefault;
if (this.onSnippetFetch) {
this.onSnippetFetch(res);
}
diff --git a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql
index 8f1f16b76c2..0e04ee9b7b8 100644
--- a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql
+++ b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql
@@ -1,9 +1,9 @@
-query SnippetBlobContent($ids: [ID!], $rich: Boolean!) {
+query SnippetBlobContent($ids: [ID!], $rich: Boolean!, $paths: [String!]) {
snippets(ids: $ids) {
- edges {
- node {
- id
- blobs {
+ nodes {
+ id
+ blobs(paths: $paths) {
+ nodes {
path
richData @include(if: $rich)
plainData @skip(if: $rich)
diff --git a/app/assets/javascripts/snippets/queries/snippet.query.graphql b/app/assets/javascripts/snippets/queries/snippet.query.graphql
index b23ab862439..2f385050d89 100644
--- a/app/assets/javascripts/snippets/queries/snippet.query.graphql
+++ b/app/assets/javascripts/snippets/queries/snippet.query.graphql
@@ -4,13 +4,11 @@
query GetSnippetQuery($ids: [ID!]) {
snippets(ids: $ids) {
- edges {
- node {
- ...SnippetBase
- ...SnippetProject
- author {
- ...Author
- }
+ nodes {
+ ...SnippetBase
+ ...SnippetProject
+ author {
+ ...Author
}
}
}
diff --git a/app/assets/javascripts/snippets/utils/blob.js b/app/assets/javascripts/snippets/utils/blob.js
index 21f52671801..c47559b82b8 100644
--- a/app/assets/javascripts/snippets/utils/blob.js
+++ b/app/assets/javascripts/snippets/utils/blob.js
@@ -7,6 +7,8 @@ import {
SNIPPET_LEVELS_MAP,
SNIPPET_VISIBILITY,
} from '../constants';
+import { performanceMarkAndMeasure } from '~/performance_utils';
+import { SNIPPET_MARK_BLOBS_CONTENT, SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance_constants';
const createLocalId = () => uniqueId('blob_local_');
@@ -79,3 +81,16 @@ export const defaultSnippetVisibilityLevels = arr => {
}
return [];
};
+
+export const markBlobPerformance = () => {
+ performanceMarkAndMeasure({
+ mark: SNIPPET_MARK_BLOBS_CONTENT,
+ measures: [
+ {
+ name: SNIPPET_MEASURE_BLOBS_CONTENT,
+ start: undefined,
+ end: SNIPPET_MARK_BLOBS_CONTENT,
+ },
+ ],
+ });
+};
diff --git a/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue b/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue
new file mode 100644
index 00000000000..9f75c65a316
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue
@@ -0,0 +1,104 @@
+<script>
+import { GlForm, GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui';
+import AccessorUtilities from '~/lib/utils/accessor';
+
+export default {
+ components: {
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ description: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ editable: {
+ title: this.title,
+ description: this.description,
+ },
+ };
+ },
+ computed: {
+ editableStorageKey() {
+ return this.getId('local-storage', 'editable');
+ },
+ hasLocalStorage() {
+ return AccessorUtilities.isLocalStorageAccessSafe();
+ },
+ },
+ mounted() {
+ this.initCachedEditable();
+ this.preSelect();
+ },
+ methods: {
+ getId(type, key) {
+ return `sse-merge-request-meta-${type}-${key}`;
+ },
+ initCachedEditable() {
+ if (this.hasLocalStorage) {
+ const cachedEditable = JSON.parse(localStorage.getItem(this.editableStorageKey));
+ if (cachedEditable) {
+ this.editable = cachedEditable;
+ }
+ }
+ },
+ preSelect() {
+ this.$nextTick(() => {
+ this.$refs.title.$el.select();
+ });
+ },
+ resetCachedEditable() {
+ if (this.hasLocalStorage) {
+ window.localStorage.removeItem(this.editableStorageKey);
+ }
+ },
+ onUpdate() {
+ const payload = { ...this.editable };
+ this.$emit('updateSettings', payload);
+
+ if (this.hasLocalStorage) {
+ window.localStorage.setItem(this.editableStorageKey, JSON.stringify(payload));
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form>
+ <gl-form-group
+ key="title"
+ :label="__('Brief title about the change')"
+ :label-for="getId('control', 'title')"
+ >
+ <gl-form-input
+ :id="getId('control', 'title')"
+ ref="title"
+ v-model.lazy="editable.title"
+ type="text"
+ @input="onUpdate"
+ />
+ </gl-form-group>
+
+ <gl-form-group
+ key="description"
+ :label="__('Goal of the changes and what reviewers should be aware of')"
+ :label-for="getId('control', 'description')"
+ >
+ <gl-form-textarea
+ :id="getId('control', 'description')"
+ v-model.lazy="editable.description"
+ @input="onUpdate"
+ />
+ </gl-form-group>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue
new file mode 100644
index 00000000000..4e5245bd892
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue
@@ -0,0 +1,85 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
+
+import EditMetaControls from './edit_meta_controls.vue';
+
+export default {
+ components: {
+ GlModal,
+ EditMetaControls,
+ },
+ props: {
+ sourcePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ mergeRequestMeta: {
+ title: sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), {
+ sourcePath: this.sourcePath,
+ }),
+ description: s__('StaticSiteEditor|Copy update'),
+ },
+ };
+ },
+ computed: {
+ disabled() {
+ return this.mergeRequestMeta.title === '';
+ },
+ primaryProps() {
+ return {
+ text: __('Submit changes'),
+ attributes: [{ variant: 'success' }, { disabled: this.disabled }],
+ };
+ },
+ secondaryProps() {
+ return {
+ text: __('Keep editing'),
+ attributes: [{ variant: 'default' }],
+ };
+ },
+ },
+ methods: {
+ hide() {
+ this.$refs.modal.hide();
+ },
+ show() {
+ this.$refs.modal.show();
+ },
+ onPrimary() {
+ this.$emit('primary', this.mergeRequestMeta);
+ this.$refs.editMetaControls.resetCachedEditable();
+ },
+ onSecondary() {
+ this.hide();
+ },
+ onUpdateSettings(mergeRequestMeta) {
+ this.mergeRequestMeta = { ...mergeRequestMeta };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="modal"
+ modal-id="edit-meta-modal"
+ :title="__('Submit your changes')"
+ :action-primary="primaryProps"
+ :action-secondary="secondaryProps"
+ size="sm"
+ @primary="onPrimary"
+ @secondary="onSecondary"
+ @hide="() => $emit('hide')"
+ >
+ <edit-meta-controls
+ ref="editMetaControls"
+ :title="mergeRequestMeta.title"
+ :description="mergeRequestMeta.description"
+ @updateSettings="onUpdateSettings"
+ />
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
index 2d62964cb3b..3bb5a0b8fd5 100644
--- a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
+++ b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
@@ -41,7 +41,7 @@ export default {
:disabled="savingChanges"
@click="$emit('editSettings')"
>
- {{ __('Settings') }}
+ {{ __('Page settings') }}
</gl-button>
<gl-button
ref="submit"
@@ -50,7 +50,7 @@ export default {
:loading="savingChanges"
@click="$emit('submit')"
>
- {{ __('Submit changes') }}
+ {{ __('Submit changes...') }}
</gl-button>
</div>
</div>
diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js
index 0a5d8c07ad9..cc68bc57bb0 100644
--- a/app/assets/javascripts/static_site_editor/graphql/index.js
+++ b/app/assets/javascripts/static_site_editor/graphql/index.js
@@ -4,6 +4,7 @@ import createDefaultClient from '~/lib/graphql';
import typeDefs from './typedefs.graphql';
import fileResolver from './resolvers/file';
import submitContentChangesResolver from './resolvers/submit_content_changes';
+import hasSubmittedChangesResolver from './resolvers/has_submitted_changes';
Vue.use(VueApollo);
@@ -15,10 +16,12 @@ const createApolloProvider = appData => {
},
Mutation: {
submitContentChanges: submitContentChangesResolver,
+ hasSubmittedChanges: hasSubmittedChangesResolver,
},
},
{
typeDefs,
+ assumeImmutableResults: true,
},
);
diff --git a/app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql b/app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql
new file mode 100644
index 00000000000..1f47929556a
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql
@@ -0,0 +1,5 @@
+mutation hasSubmittedChanges($input: HasSubmittedChangesInput) {
+ hasSubmittedChanges(input: $input) @client {
+ hasSubmittedChanges
+ }
+}
diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
index 946d80efff0..9f4b0afe55f 100644
--- a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
+++ b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
@@ -1,6 +1,7 @@
query appData {
appData @client {
isSupportedContent
+ hasSubmittedChanges
project
sourcePath
username
diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js
new file mode 100644
index 00000000000..ea49b21eb0d
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js
@@ -0,0 +1,25 @@
+import { produce } from 'immer';
+import query from '../queries/app_data.query.graphql';
+
+const hasSubmittedChangesResolver = (_, { input: { hasSubmittedChanges } }, { cache }) => {
+ const oldData = cache.readQuery({ query });
+
+ const data = produce(oldData, draftState => {
+ // punctually modifying draftState as per immer docs upsets our linters
+ return {
+ ...draftState,
+ appData: {
+ __typename: 'AppData',
+ ...draftState.appData,
+ hasSubmittedChanges,
+ },
+ };
+ });
+
+ cache.writeQuery({
+ query,
+ data,
+ });
+};
+
+export default hasSubmittedChangesResolver;
diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
index 0cb26f88785..4137ede49c6 100644
--- a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
+++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
@@ -1,24 +1,34 @@
+import { produce } from 'immer';
import submitContentChanges from '../../services/submit_content_changes';
import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql';
const submitContentChangesResolver = (
_,
- { input: { project: projectId, username, sourcePath, content, images } },
+ { input: { project: projectId, username, sourcePath, content, images, mergeRequestMeta } },
{ cache },
) => {
- return submitContentChanges({ projectId, username, sourcePath, content, images }).then(
- savedContentMeta => {
- cache.writeQuery({
- query: savedContentMetaQuery,
- data: {
- savedContentMeta: {
- __typename: 'SavedContentMeta',
- ...savedContentMeta,
- },
+ return submitContentChanges({
+ projectId,
+ username,
+ sourcePath,
+ content,
+ images,
+ mergeRequestMeta,
+ }).then(savedContentMeta => {
+ const data = produce(savedContentMeta, draftState => {
+ return {
+ savedContentMeta: {
+ __typename: 'SavedContentMeta',
+ ...draftState,
},
- });
- },
- );
+ };
+ });
+
+ cache.writeQuery({
+ query: savedContentMetaQuery,
+ data,
+ });
+ });
};
export default submitContentChangesResolver;
diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
index 78cc1746cdb..0ded1722d26 100644
--- a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
+++ b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
@@ -16,12 +16,17 @@ type SavedContentMeta {
type AppData {
isSupportedContent: Boolean!
+ hasSubmittedChanges: Boolean!
project: String!
returnUrl: String
sourcePath: String!
username: String!
}
+input HasSubmittedChangesInput {
+ hasSubmittedChanges: Boolean!
+}
+
input SubmitContentChangesInput {
project: String!
sourcePath: String!
@@ -40,4 +45,5 @@ extend type Query {
extend type Mutation {
submitContentChanges(input: SubmitContentChangesInput!): SavedContentMeta
+ hasSubmittedChanges(input: HasSubmittedChangesInput!): AppData
}
diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js
index b7e5ea4eee3..fceef8f9084 100644
--- a/app/assets/javascripts/static_site_editor/index.js
+++ b/app/assets/javascripts/static_site_editor/index.js
@@ -12,13 +12,23 @@ const initStaticSiteEditor = el => {
namespace,
project,
mergeRequestsIllustrationPath,
+ // NOTE: The following variables are not yet used, but are supported by the config file,
+ // so we are adding them here as a convenience for future use.
+ // eslint-disable-next-line no-unused-vars
+ staticSiteGenerator,
+ // eslint-disable-next-line no-unused-vars
+ imageUploadPath,
+ mounts,
} = el.dataset;
+ // NOTE that the object in 'mounts' is a JSON string from the data attribute, so it must be parsed into an object.
+ // eslint-disable-next-line no-unused-vars
+ const mountsObject = JSON.parse(mounts);
const { current_username: username } = window.gon;
const returnUrl = el.dataset.returnUrl || null;
-
const router = createRouter(baseUrl);
const apolloProvider = createApolloProvider({
isSupportedContent: parseBoolean(isSupportedContent),
+ hasSubmittedChanges: false,
project: `${namespace}/${project}`,
returnUrl,
sourcePath,
diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue
index eef2bd88f0e..27bd1c99ae2 100644
--- a/app/assets/javascripts/static_site_editor/pages/home.vue
+++ b/app/assets/javascripts/static_site_editor/pages/home.vue
@@ -1,13 +1,16 @@
<script>
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import Tracking from '~/tracking';
+
import SkeletonLoader from '../components/skeleton_loader.vue';
import EditArea from '../components/edit_area.vue';
+import EditMetaModal from '../components/edit_meta_modal.vue';
import InvalidContentMessage from '../components/invalid_content_message.vue';
import SubmitChangesError from '../components/submit_changes_error.vue';
import appDataQuery from '../graphql/queries/app_data.query.graphql';
import sourceContentQuery from '../graphql/queries/source_content.query.graphql';
+import hasSubmittedChangesMutation from '../graphql/mutations/has_submitted_changes.mutation.graphql';
import submitContentChangesMutation from '../graphql/mutations/submit_content_changes.mutation.graphql';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
-import Tracking from '~/tracking';
import { LOAD_CONTENT_ERROR, TRACKING_ACTION_INITIALIZE_EDITOR } from '../constants';
import { SUCCESS_ROUTE } from '../router/constants';
@@ -15,6 +18,7 @@ export default {
components: {
SkeletonLoader,
EditArea,
+ EditMetaModal,
InvalidContentMessage,
SubmitChangesError,
},
@@ -48,6 +52,7 @@ export default {
data() {
return {
content: null,
+ images: null,
submitChangesError: null,
isSavingChanges: false,
};
@@ -64,15 +69,34 @@ export default {
Tracking.event(document.body.dataset.page, TRACKING_ACTION_INITIALIZE_EDITOR);
},
methods: {
+ onHideModal() {
+ this.isSavingChanges = false;
+ this.$refs.editMetaModal.hide();
+ },
onDismissError() {
this.submitChangesError = null;
},
- onSubmit({ content, images }) {
+ onPrepareSubmit({ content, images }) {
this.content = content;
- this.submitChanges(images);
- },
- submitChanges(images) {
+ this.images = images;
+
this.isSavingChanges = true;
+ this.$refs.editMetaModal.show();
+ },
+ onSubmit(mergeRequestMeta) {
+ // eslint-disable-next-line promise/catch-or-return
+ this.$apollo
+ .mutate({
+ mutation: hasSubmittedChangesMutation,
+ variables: {
+ input: {
+ hasSubmittedChanges: true,
+ },
+ },
+ })
+ .finally(() => {
+ this.$router.push(SUCCESS_ROUTE);
+ });
this.$apollo
.mutate({
@@ -83,13 +107,11 @@ export default {
username: this.appData.username,
sourcePath: this.appData.sourcePath,
content: this.content,
- images,
+ images: this.images,
+ mergeRequestMeta,
},
},
})
- .then(() => {
- this.$router.push(SUCCESS_ROUTE);
- })
.catch(e => {
this.submitChangesError = e.message;
})
@@ -107,7 +129,7 @@ export default {
<submit-changes-error
v-if="submitChangesError"
:error="submitChangesError"
- @retry="submitChanges"
+ @retry="onSubmit"
@dismiss="onDismissError"
/>
<edit-area
@@ -116,7 +138,13 @@ export default {
:content="sourceContent.content"
:saving-changes="isSavingChanges"
:return-url="appData.returnUrl"
- @submit="onSubmit"
+ @submit="onPrepareSubmit"
+ />
+ <edit-meta-modal
+ ref="editMetaModal"
+ :source-path="appData.sourcePath"
+ @primary="onSubmit"
+ @hide="onHideModal"
/>
</template>
diff --git a/app/assets/javascripts/static_site_editor/pages/success.vue b/app/assets/javascripts/static_site_editor/pages/success.vue
index f0d597d7c9b..1ee1a3b0edf 100644
--- a/app/assets/javascripts/static_site_editor/pages/success.vue
+++ b/app/assets/javascripts/static_site_editor/pages/success.vue
@@ -1,5 +1,5 @@
<script>
-import { GlEmptyState, GlButton } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
import savedContentMetaQuery from '../graphql/queries/saved_content_meta.query.graphql';
@@ -8,8 +8,9 @@ import { HOME_ROUTE } from '../router/constants';
export default {
components: {
- GlEmptyState,
GlButton,
+ GlEmptyState,
+ GlLoadingIcon,
},
props: {
mergeRequestsIllustrationPath: {
@@ -33,7 +34,7 @@ export default {
},
},
created() {
- if (!this.savedContentMeta) {
+ if (!this.appData.hasSubmittedChanges) {
this.$router.push(HOME_ROUTE);
}
},
@@ -50,40 +51,56 @@ export default {
assignMergeRequestInstruction: s__(
'StaticSiteEditor|3. Assign a person to review and accept the merge request.',
),
+ submittingTitle: s__('StaticSiteEditor|Creating your merge request'),
+ submittingNotePrimary: s__(
+ 'StaticSiteEditor|You can set an assignee to get your changes reviewed and deployed once your merge request is created.',
+ ),
+ submittingNoteSecondary: s__(
+ 'StaticSiteEditor|A link to view the merge request will appear once ready.',
+ ),
};
</script>
<template>
- <div
- v-if="savedContentMeta"
- class="container gl-flex-grow-1 gl-display-flex gl-flex-direction-column"
- >
- <div class="gl-fixed gl-left-0 gl-right-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100">
+ <div>
+ <div class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100">
<div class="container gl-py-4">
- <gl-button
- v-if="appData.returnUrl"
- ref="returnToSiteButton"
- class="gl-mr-5"
- :href="appData.returnUrl"
- >{{ $options.returnToSiteBtnText }}</gl-button
- >
- <strong>
- {{ updatedFileDescription }}
- </strong>
+ <div class="gl-display-flex">
+ <gl-button
+ v-if="appData.returnUrl"
+ ref="returnToSiteButton"
+ class="gl-mr-5 gl-align-self-start"
+ :href="appData.returnUrl"
+ >{{ $options.returnToSiteBtnText }}</gl-button
+ >
+ <strong class="gl-mt-2">
+ {{ updatedFileDescription }}
+ </strong>
+ </div>
</div>
</div>
- <gl-empty-state
- class="gl-my-9"
- :primary-button-text="$options.primaryButtonText"
- :title="$options.title"
- :primary-button-link="savedContentMeta.mergeRequest.url"
- :svg-path="mergeRequestsIllustrationPath"
- >
- <template #description>
- <p>{{ $options.mergeRequestInstructionsHeading }}</p>
- <p>{{ $options.addTitleInstruction }}</p>
- <p>{{ $options.addDescriptionInstruction }}</p>
- <p>{{ $options.assignMergeRequestInstruction }}</p>
- </template>
- </gl-empty-state>
+ <div class="container">
+ <gl-empty-state
+ class="gl-my-7"
+ :title="savedContentMeta ? $options.title : $options.submittingTitle"
+ :primary-button-text="savedContentMeta && $options.primaryButtonText"
+ :primary-button-link="savedContentMeta && savedContentMeta.mergeRequest.url"
+ :svg-path="mergeRequestsIllustrationPath"
+ :svg-height="146"
+ >
+ <template #description>
+ <div v-if="savedContentMeta">
+ <p>{{ $options.mergeRequestInstructionsHeading }}</p>
+ <p>{{ $options.addTitleInstruction }}</p>
+ <p>{{ $options.addDescriptionInstruction }}</p>
+ <p>{{ $options.assignMergeRequestInstruction }}</p>
+ </div>
+ <div v-else>
+ <p>{{ $options.submittingNotePrimary }}</p>
+ <p>{{ $options.submittingNoteSecondary }}</p>
+ <gl-loading-icon size="xl" />
+ </div>
+ </template>
+ </gl-empty-state>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/static_site_editor/services/front_matterify.js b/app/assets/javascripts/static_site_editor/services/front_matterify.js
new file mode 100644
index 00000000000..cbf0fffd515
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/services/front_matterify.js
@@ -0,0 +1,73 @@
+import jsYaml from 'js-yaml';
+
+const NEW_LINE = '\n';
+
+const hasMatter = (firstThreeChars, fourthChar) => {
+ const isYamlDelimiter = firstThreeChars === '---';
+ const isFourthCharNewline = fourthChar === NEW_LINE;
+ return isYamlDelimiter && isFourthCharNewline;
+};
+
+export const frontMatterify = source => {
+ let index = 3;
+ let offset;
+ const delimiter = source.slice(0, index);
+ const type = 'yaml';
+ const NO_FRONTMATTER = {
+ source,
+ matter: null,
+ spacing: null,
+ content: source,
+ delimiter: null,
+ type: null,
+ };
+
+ if (!hasMatter(delimiter, source.charAt(index))) {
+ return NO_FRONTMATTER;
+ }
+
+ offset = source.indexOf(delimiter, index);
+
+ // Finds the end delimiter that starts at a new line
+ while (offset !== -1 && source.charAt(offset - 1) !== NEW_LINE) {
+ index = offset + delimiter.length;
+ offset = source.indexOf(delimiter, index);
+ }
+
+ if (offset === -1) {
+ return NO_FRONTMATTER;
+ }
+
+ const matterStr = source.slice(index, offset);
+ const matter = jsYaml.safeLoad(matterStr);
+
+ let content = source.slice(offset + delimiter.length);
+ let spacing = '';
+ let idx = 0;
+ while (content.charAt(idx).match(/(\s|\n)/)) {
+ spacing += content.charAt(idx);
+ idx += 1;
+ }
+ content = content.replace(spacing, '');
+
+ return {
+ source,
+ matter,
+ spacing,
+ content,
+ delimiter,
+ type,
+ };
+};
+
+export const stringify = ({ matter, spacing, content, delimiter }, newMatter) => {
+ const matterObj = newMatter || matter;
+
+ if (!matterObj) {
+ return content;
+ }
+
+ const header = `${delimiter}${NEW_LINE}${jsYaml.safeDump(matterObj)}${delimiter}`;
+ const body = `${spacing}${content}`;
+ return `${header}${body}`;
+};
diff --git a/app/assets/javascripts/static_site_editor/services/parse_source_file.js b/app/assets/javascripts/static_site_editor/services/parse_source_file.js
index 640186ee1d0..d4fc8b2edb6 100644
--- a/app/assets/javascripts/static_site_editor/services/parse_source_file.js
+++ b/app/assets/javascripts/static_site_editor/services/parse_source_file.js
@@ -1,7 +1,7 @@
-import grayMatter from 'gray-matter';
+import { frontMatterify, stringify } from './front_matterify';
const parseSourceFile = raw => {
- const remake = source => grayMatter(source, {});
+ const remake = source => frontMatterify(source);
let editable = remake(raw);
@@ -13,20 +13,17 @@ const parseSourceFile = raw => {
}
};
- const trimmedEditable = () => grayMatter.stringify(editable).trim();
+ const content = (isBody = false) => (isBody ? editable.content : stringify(editable));
- const content = (isBody = false) => (isBody ? editable.content.trim() : trimmedEditable()); // gray-matter internally adds an eof newline so we trim to bypass, open issue: https://github.com/jonschlinkert/gray-matter/issues/96
-
- const matter = () => editable.data;
+ const matter = () => editable.matter;
const syncMatter = settings => {
- const source = grayMatter.stringify(editable.content, settings);
- syncContent(source);
+ editable.matter = settings;
};
- const isModified = () => trimmedEditable() !== raw;
+ const isModified = () => stringify(editable) !== raw;
- const hasMatter = () => editable.matter.length > 0;
+ const hasMatter = () => Boolean(editable.matter);
return {
matter,
diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
index da62d3fa4fc..8623a671a7d 100644
--- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
+++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
@@ -1,6 +1,5 @@
import Api from '~/api';
import Tracking from '~/tracking';
-import { s__, sprintf } from '~/locale';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import generateBranchName from '~/static_site_editor/services/generate_branch_name';
@@ -71,6 +70,7 @@ const commitContent = (projectId, message, branch, sourcePath, content, images)
const createMergeRequest = (
projectId,
title,
+ description,
sourceBranch,
targetBranch = DEFAULT_TARGET_BRANCH,
) => {
@@ -80,6 +80,7 @@ const createMergeRequest = (
projectId,
convertObjectPropsToSnakeCase({
title,
+ description,
sourceBranch,
targetBranch,
}),
@@ -88,11 +89,16 @@ const createMergeRequest = (
});
};
-const submitContentChanges = ({ username, projectId, sourcePath, content, images }) => {
+const submitContentChanges = ({
+ username,
+ projectId,
+ sourcePath,
+ content,
+ images,
+ mergeRequestMeta,
+}) => {
const branch = generateBranchName(username);
- const mergeRequestTitle = sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), {
- sourcePath,
- });
+ const { title: mergeRequestTitle, description: mergeRequestDescription } = mergeRequestMeta;
const meta = {};
return createBranch(projectId, branch)
@@ -104,7 +110,7 @@ const submitContentChanges = ({ username, projectId, sourcePath, content, images
.then(({ data: { short_id: label, web_url: url } }) => {
Object.assign(meta, { commit: { label, url } });
- return createMergeRequest(projectId, mergeRequestTitle, branch);
+ return createMergeRequest(projectId, mergeRequestTitle, mergeRequestDescription, branch);
})
.then(({ data: { iid: label, web_url: url } }) => {
Object.assign(meta, { mergeRequest: { label: label.toString(), url } });
diff --git a/app/assets/javascripts/static_site_editor/services/templater.js b/app/assets/javascripts/static_site_editor/services/templater.js
index a1c1bb6b8d6..d302aea78a3 100644
--- a/app/assets/javascripts/static_site_editor/services/templater.js
+++ b/app/assets/javascripts/static_site_editor/services/templater.js
@@ -15,7 +15,7 @@ const markPrefix = `${marker}-${Date.now()}`;
const reHelpers = {
template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`,
- openTag: '<[a-zA-Z]+.*?>',
+ openTag: '<(?!figure|iframe)[a-zA-Z]+.*?>',
closeTag: '</.+>',
};
const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm');
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index f2b05946a08..b51951674d5 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -31,6 +31,15 @@ export default class TaskList {
init() {
this.disable(); // Prevent duplicate event bindings
+ const taskListFields = document.querySelectorAll(
+ `${this.taskListContainerSelector} .js-task-list-field[data-value]`,
+ );
+
+ taskListFields.forEach(taskListField => {
+ // eslint-disable-next-line no-param-reassign
+ taskListField.value = taskListField.dataset.value;
+ });
+
$(this.taskListContainerSelector).taskList('enable');
$(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler);
}
diff --git a/app/assets/javascripts/test_utils/simulate_drag.js b/app/assets/javascripts/test_utils/simulate_drag.js
index f4090de3f1e..321315d531b 100644
--- a/app/assets/javascripts/test_utils/simulate_drag.js
+++ b/app/assets/javascripts/test_utils/simulate_drag.js
@@ -143,7 +143,7 @@ export default function simulateDrag(options) {
const dragInterval = setInterval(() => {
const progress = (new Date().getTime() - startTime) / duration;
const x = fromRect.cx + (toRect.cx - fromRect.cx) * progress;
- const y = fromRect.cy + (toRect.cy - fromRect.cy) * progress;
+ const y = fromRect.cy + (toRect.cy - fromRect.cy + options.extraHeight) * progress;
const overEl = fromEl.ownerDocument.elementFromPoint(x, y);
simulateEvent(overEl, 'pointermove', {
diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js
index cfbd88d6c40..9f5dce4183c 100644
--- a/app/assets/javascripts/tooltips/index.js
+++ b/app/assets/javascripts/tooltips/index.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import jQuery from 'jquery';
-import { toArray, isFunction } from 'lodash';
+import { toArray, isFunction, isElement } from 'lodash';
import Tooltips from './components/tooltips.vue';
let app;
@@ -54,10 +54,16 @@ const handleTooltipEvent = (rootTarget, e, selector, config = {}) => {
}
};
-const applyToElements = (elements, handler) => toArray(elements).forEach(handler);
+const applyToElements = (elements, handler) => {
+ const iterable = isElement(elements) ? [elements] : toArray(elements);
+
+ toArray(iterable).forEach(handler);
+};
const invokeBootstrapApi = (elements, method) => {
if (isFunction(elements.tooltip)) {
+ elements.tooltip(method);
+ } else {
jQuery(elements).tooltip(method);
}
};
diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js
index 37ebe6b6c4d..c1521882682 100644
--- a/app/assets/javascripts/tracking.js
+++ b/app/assets/javascripts/tracking.js
@@ -9,7 +9,7 @@ const DEFAULT_SNOWPLOW_OPTIONS = {
respectDoNotTrack: true,
forceSecureTracker: true,
eventMethod: 'post',
- contexts: { webPage: true },
+ contexts: { webPage: true, performanceTiming: true },
formTracking: false,
linkClickTracking: false,
};
diff --git a/app/assets/javascripts/user_lists/components/add_user_modal.vue b/app/assets/javascripts/user_lists/components/add_user_modal.vue
new file mode 100644
index 00000000000..a8dde1f681e
--- /dev/null
+++ b/app/assets/javascripts/user_lists/components/add_user_modal.vue
@@ -0,0 +1,72 @@
+<script>
+import { GlModal, GlFormGroup, GlFormTextarea } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { ADD_USER_MODAL_ID } from '../constants/show';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormTextarea,
+ GlModal,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ modalOptions: {
+ actionPrimary: {
+ text: s__('UserLists|Add'),
+ attributes: [{ 'data-testid': 'confirm-add-user-ids' }],
+ },
+ actionCancel: {
+ text: s__('UserLists|Cancel'),
+ attributes: [{ 'data-testid': 'cancel-add-user-ids' }],
+ },
+ modalId: ADD_USER_MODAL_ID,
+ static: true,
+ },
+ translations: {
+ title: s__('UserLists|Add users'),
+ description: s__(
+ 'UserLists|Enter a comma separated list of user IDs. These IDs should be the users of the system in which the feature flag is set, not GitLab IDs',
+ ),
+ userIdsLabel: s__('UserLists|User IDs'),
+ },
+ data() {
+ return {
+ userIds: '',
+ };
+ },
+ methods: {
+ submitUsers() {
+ this.$emit('addUsers', this.userIds);
+ this.clearInput();
+ },
+ clearInput() {
+ this.userIds = '';
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ v-bind="$options.modalOptions"
+ :visible="visible"
+ data-testid="add-users-modal"
+ @primary="submitUsers"
+ @canceled="clearInput"
+ >
+ <template #modal-title>
+ {{ $options.translations.title }}
+ </template>
+ <template #default>
+ <p data-testid="add-userids-description">{{ $options.translations.description }}</p>
+ <gl-form-group label-for="add-user-ids" :label="$options.translations.userIdsLabel">
+ <gl-form-textarea id="add-user-ids" v-model="userIds" />
+ </gl-form-group>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/user_lists/components/edit_user_list.vue b/app/assets/javascripts/user_lists/components/edit_user_list.vue
new file mode 100644
index 00000000000..d56c3d61027
--- /dev/null
+++ b/app/assets/javascripts/user_lists/components/edit_user_list.vue
@@ -0,0 +1,74 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import statuses from '../constants/edit';
+import UserListForm from './user_list_form.vue';
+
+export default {
+ components: {
+ GlAlert,
+ GlLoadingIcon,
+ UserListForm,
+ },
+ inject: ['userListsDocsPath'],
+ translations: {
+ saveButtonLabel: s__('UserLists|Save'),
+ },
+ computed: {
+ ...mapState(['userList', 'status', 'errorMessage']),
+ title() {
+ return sprintf(s__('UserLists|Edit %{name}'), { name: this.userList?.name });
+ },
+ isLoading() {
+ return this.status === statuses.LOADING;
+ },
+ isError() {
+ return this.status === statuses.ERROR;
+ },
+ hasUserList() {
+ return Boolean(this.userList);
+ },
+ },
+ mounted() {
+ this.fetchUserList();
+ },
+ methods: {
+ ...mapActions(['fetchUserList', 'updateUserList', 'dismissErrorAlert']),
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert
+ v-if="isError"
+ :dismissible="hasUserList"
+ variant="danger"
+ @dismiss="dismissErrorAlert"
+ >
+ <ul class="gl-mb-0">
+ <li v-for="(message, index) in errorMessage" :key="index">
+ {{ message }}
+ </li>
+ </ul>
+ </gl-alert>
+
+ <gl-loading-icon v-if="isLoading" size="xl" />
+
+ <template v-else-if="hasUserList">
+ <h3
+ data-testid="user-list-title"
+ class="gl-font-weight-bold gl-pb-5 gl-border-b-solid gl-border-gray-100 gl-border-1"
+ >
+ {{ title }}
+ </h3>
+ <user-list-form
+ :cancel-path="userList.path"
+ :save-button-label="$options.translations.saveButtonLabel"
+ :user-lists-docs-path="userListsDocsPath"
+ :user-list="userList"
+ @submit="updateUserList"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/user_lists/components/new_user_list.vue b/app/assets/javascripts/user_lists/components/new_user_list.vue
new file mode 100644
index 00000000000..522e077fb25
--- /dev/null
+++ b/app/assets/javascripts/user_lists/components/new_user_list.vue
@@ -0,0 +1,50 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { GlAlert } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import UserListForm from './user_list_form.vue';
+
+export default {
+ components: {
+ GlAlert,
+ UserListForm,
+ },
+ inject: ['userListsDocsPath', 'featureFlagsPath'],
+ translations: {
+ pageTitle: s__('UserLists|New list'),
+ createButtonLabel: s__('UserLists|Create'),
+ },
+ computed: {
+ ...mapState(['userList', 'errorMessage']),
+ isError() {
+ return Array.isArray(this.errorMessage) && this.errorMessage.length > 0;
+ },
+ },
+ methods: {
+ ...mapActions(['createUserList', 'dismissErrorAlert']),
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert v-if="isError" variant="danger" @dismiss="dismissErrorAlert">
+ <ul class="gl-mb-0">
+ <li v-for="(message, index) in errorMessage" :key="index">
+ {{ message }}
+ </li>
+ </ul>
+ </gl-alert>
+
+ <h3 class="gl-font-weight-bold gl-pb-5 gl-border-b-solid gl-border-gray-100 gl-border-1">
+ {{ $options.translations.pageTitle }}
+ </h3>
+
+ <user-list-form
+ :cancel-path="featureFlagsPath"
+ :save-button-label="$options.translations.createButtonLabel"
+ :user-lists-docs-path="userListsDocsPath"
+ :user-list="userList"
+ @submit="createUserList"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/user_lists/components/user_list.vue b/app/assets/javascripts/user_lists/components/user_list.vue
new file mode 100644
index 00000000000..0e2b72c1423
--- /dev/null
+++ b/app/assets/javascripts/user_lists/components/user_list.vue
@@ -0,0 +1,142 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import {
+ GlAlert,
+ GlButton,
+ GlEmptyState,
+ GlLoadingIcon,
+ GlModalDirective as GlModal,
+} from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import { states, ADD_USER_MODAL_ID } from '../constants/show';
+import AddUserModal from './add_user_modal.vue';
+
+const commonTableClasses = ['gl-py-5', 'gl-border-b-1', 'gl-border-b-solid', 'gl-border-gray-100'];
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlEmptyState,
+ GlLoadingIcon,
+ AddUserModal,
+ },
+ directives: {
+ GlModal,
+ },
+ props: {
+ emptyStatePath: {
+ required: true,
+ type: String,
+ },
+ },
+ translations: {
+ addUserButtonLabel: s__('UserLists|Add Users'),
+ emptyStateTitle: s__('UserLists|There are no users'),
+ emptyStateDescription: s__(
+ 'UserLists|Define a set of users to be used within feature flag strategies',
+ ),
+ userIdLabel: s__('UserLists|User IDs'),
+ userIdColumnHeader: s__('UserLists|User ID'),
+ errorMessage: __('Something went wrong on our end. Please try again!'),
+ editButtonLabel: s__('UserLists|Edit'),
+ },
+ classes: {
+ headerClasses: [
+ 'gl-display-flex',
+ 'gl-justify-content-space-between',
+ 'gl-pb-5',
+ 'gl-border-b-1',
+ 'gl-border-b-solid',
+ 'gl-border-gray-100',
+ ].join(' '),
+ tableHeaderClasses: commonTableClasses.join(' '),
+ tableRowClasses: [
+ ...commonTableClasses,
+ 'gl-display-flex',
+ 'gl-justify-content-space-between',
+ 'gl-align-items-center',
+ ].join(' '),
+ },
+ ADD_USER_MODAL_ID,
+ computed: {
+ ...mapState(['userList', 'userIds', 'state']),
+ name() {
+ return this.userList?.name ?? '';
+ },
+ hasUserIds() {
+ return this.userIds.length > 0;
+ },
+ isLoading() {
+ return this.state === states.LOADING;
+ },
+ hasError() {
+ return this.state === states.ERROR;
+ },
+ editPath() {
+ return this.userList?.edit_path;
+ },
+ },
+ mounted() {
+ this.fetchUserList();
+ },
+ methods: {
+ ...mapActions(['fetchUserList', 'dismissErrorAlert', 'removeUserId', 'addUserIds']),
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert v-if="hasError" variant="danger" @dismiss="dismissErrorAlert">
+ {{ $options.translations.errorMessage }}
+ </gl-alert>
+ <gl-loading-icon v-if="isLoading" size="xl" class="gl-mt-6" />
+ <div v-else>
+ <add-user-modal @addUsers="addUserIds" />
+ <div :class="$options.classes.headerClasses">
+ <div>
+ <h3>{{ name }}</h3>
+ <h4 class="gl-text-gray-500">{{ $options.translations.userIdLabel }}</h4>
+ </div>
+ <div class="gl-mt-6">
+ <gl-button v-if="editPath" :href="editPath" data-testid="edit-user-list" class="gl-mr-3">
+ {{ $options.translations.editButtonLabel }}
+ </gl-button>
+ <gl-button
+ v-gl-modal="$options.ADD_USER_MODAL_ID"
+ data-testid="add-users"
+ variant="success"
+ >
+ {{ $options.translations.addUserButtonLabel }}
+ </gl-button>
+ </div>
+ </div>
+ <div v-if="hasUserIds">
+ <div :class="$options.classes.tableHeaderClasses">
+ {{ $options.translations.userIdColumnHeader }}
+ </div>
+ <div
+ v-for="id in userIds"
+ :key="id"
+ data-testid="user-id-row"
+ :class="$options.classes.tableRowClasses"
+ >
+ <span data-testid="user-id">{{ id }}</span>
+ <gl-button
+ category="secondary"
+ variant="danger"
+ icon="remove"
+ data-testid="delete-user-id"
+ @click="removeUserId(id)"
+ />
+ </div>
+ </div>
+ <gl-empty-state
+ v-else
+ :title="$options.translations.emptyStateTitle"
+ :description="$options.translations.emptyStateDescription"
+ :svg-path="emptyStatePath"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/user_lists/components/user_list_form.vue b/app/assets/javascripts/user_lists/components/user_list_form.vue
new file mode 100644
index 00000000000..657acb51fee
--- /dev/null
+++ b/app/assets/javascripts/user_lists/components/user_list_form.vue
@@ -0,0 +1,97 @@
+<script>
+import { GlButton, GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ cancelPath: {
+ type: String,
+ required: true,
+ },
+ saveButtonLabel: {
+ type: String,
+ required: true,
+ },
+ userListsDocsPath: {
+ type: String,
+ required: true,
+ },
+ userList: {
+ type: Object,
+ required: true,
+ },
+ },
+ classes: {
+ actionContainer: [
+ 'gl-py-5',
+ 'gl-display-flex',
+ 'gl-justify-content-space-between',
+ 'gl-px-4',
+ 'gl-border-t-solid',
+ 'gl-border-gray-100',
+ 'gl-border-1',
+ 'gl-bg-gray-10',
+ ],
+ },
+ translations: {
+ formLabel: s__('UserLists|Feature flag list'),
+ formSubtitle: s__(
+ 'UserLists|Lists allow you to define a set of users to be used with feature flags. %{linkStart}Read more about feature flag lists.%{linkEnd}',
+ ),
+ nameLabel: s__('UserLists|Name'),
+ cancelButtonLabel: s__('UserLists|Cancel'),
+ },
+ data() {
+ return {
+ name: this.userList.name,
+ };
+ },
+ methods: {
+ submit() {
+ this.$emit('submit', { ...this.userList, name: this.name });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="gl-display-flex gl-mt-7">
+ <div class="gl-flex-basis-0 gl-mr-7">
+ <h4 class="gl-min-width-fit-content gl-white-space-nowrap">
+ {{ $options.translations.formLabel }}
+ </h4>
+ <gl-sprintf :message="$options.translations.formSubtitle" class="gl-text-gray-500">
+ <template #link="{ content }">
+ <gl-link :href="userListsDocsPath" data-testid="user-list-docs-link">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-flex-fill-1 gl-ml-7">
+ <gl-form-group
+ label-for="user-list-name"
+ :label="$options.translations.nameLabel"
+ class="gl-mb-7"
+ >
+ <gl-form-input id="user-list-name" v-model="name" data-testid="user-list-name" required />
+ </gl-form-group>
+ <div :class="$options.classes.actionContainer">
+ <gl-button variant="success" data-testid="save-user-list" @click="submit">
+ {{ saveButtonLabel }}
+ </gl-button>
+ <gl-button :href="cancelPath" data-testid="user-list-cancel">
+ {{ $options.translations.cancelButtonLabel }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/user_lists/constants/edit.js b/app/assets/javascripts/user_lists/constants/edit.js
new file mode 100644
index 00000000000..33378f0d39f
--- /dev/null
+++ b/app/assets/javascripts/user_lists/constants/edit.js
@@ -0,0 +1,6 @@
+export default Object.freeze({
+ LOADING: 'LOADING',
+ SUCCESS: 'SUCCESS',
+ ERROR: 'ERROR',
+ UNSYNCED: 'UNSYNCED',
+});
diff --git a/app/assets/javascripts/user_lists/constants/show.js b/app/assets/javascripts/user_lists/constants/show.js
new file mode 100644
index 00000000000..045375d5900
--- /dev/null
+++ b/app/assets/javascripts/user_lists/constants/show.js
@@ -0,0 +1,8 @@
+export const states = Object.freeze({
+ LOADING: 'LOADING',
+ SUCCESS: 'SUCCESS',
+ ERROR: 'ERROR',
+ ERROR_DISMISSED: 'ERROR_DISMISSED',
+});
+
+export const ADD_USER_MODAL_ID = 'add-userids-modal';
diff --git a/app/assets/javascripts/user_lists/store/edit/actions.js b/app/assets/javascripts/user_lists/store/edit/actions.js
new file mode 100644
index 00000000000..8f0a2bafec7
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/edit/actions.js
@@ -0,0 +1,22 @@
+import Api from '~/api';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { getErrorMessages } from '../utils';
+import * as types from './mutation_types';
+
+export const fetchUserList = ({ commit, state }) => {
+ commit(types.REQUEST_USER_LIST);
+ return Api.fetchFeatureFlagUserList(state.projectId, state.userListIid)
+ .then(({ data }) => commit(types.RECEIVE_USER_LIST_SUCCESS, data))
+ .catch(response => commit(types.RECEIVE_USER_LIST_ERROR, getErrorMessages(response)));
+};
+
+export const dismissErrorAlert = ({ commit }) => commit(types.DISMISS_ERROR_ALERT);
+
+export const updateUserList = ({ commit, state }, userList) => {
+ return Api.updateFeatureFlagUserList(state.projectId, {
+ iid: userList.iid,
+ name: userList.name,
+ })
+ .then(({ data }) => redirectTo(data.path))
+ .catch(response => commit(types.RECEIVE_USER_LIST_ERROR, getErrorMessages(response)));
+};
diff --git a/app/assets/javascripts/user_lists/store/edit/index.js b/app/assets/javascripts/user_lists/store/edit/index.js
new file mode 100644
index 00000000000..b30b0b04b9e
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/edit/index.js
@@ -0,0 +1,11 @@
+import Vuex from 'vuex';
+import createState from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default initialState =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: createState(initialState),
+ });
diff --git a/app/assets/javascripts/user_lists/store/edit/mutation_types.js b/app/assets/javascripts/user_lists/store/edit/mutation_types.js
new file mode 100644
index 00000000000..8b572e36839
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/edit/mutation_types.js
@@ -0,0 +1,5 @@
+export const REQUEST_USER_LIST = 'REQUEST_USER_LIST';
+export const RECEIVE_USER_LIST_SUCCESS = 'RECEIVE_USER_LIST_SUCCESS';
+export const RECEIVE_USER_LIST_ERROR = 'RECEIVE_USER_LIST_ERROR';
+
+export const DISMISS_ERROR_ALERT = 'DISMISS_ERROR_ALERT';
diff --git a/app/assets/javascripts/user_lists/store/edit/mutations.js b/app/assets/javascripts/user_lists/store/edit/mutations.js
new file mode 100644
index 00000000000..8a202885069
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/edit/mutations.js
@@ -0,0 +1,19 @@
+import statuses from '../../constants/edit';
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_USER_LIST](state) {
+ state.status = statuses.LOADING;
+ },
+ [types.RECEIVE_USER_LIST_SUCCESS](state, userList) {
+ state.status = statuses.SUCCESS;
+ state.userList = userList;
+ },
+ [types.RECEIVE_USER_LIST_ERROR](state, error) {
+ state.status = statuses.ERROR;
+ state.errorMessage = error;
+ },
+ [types.DISMISS_ERROR_ALERT](state) {
+ state.status = statuses.UNSYNCED;
+ },
+};
diff --git a/app/assets/javascripts/user_lists/store/edit/state.js b/app/assets/javascripts/user_lists/store/edit/state.js
new file mode 100644
index 00000000000..66fbe3c2ba9
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/edit/state.js
@@ -0,0 +1,9 @@
+import statuses from '../../constants/edit';
+
+export default ({ projectId = '', userListIid = '' }) => ({
+ status: statuses.LOADING,
+ projectId,
+ userListIid,
+ userList: null,
+ errorMessage: [],
+});
diff --git a/app/assets/javascripts/user_lists/store/new/actions.js b/app/assets/javascripts/user_lists/store/new/actions.js
new file mode 100644
index 00000000000..185508bcfbc
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/new/actions.js
@@ -0,0 +1,15 @@
+import Api from '~/api';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { getErrorMessages } from '../utils';
+import * as types from './mutation_types';
+
+export const dismissErrorAlert = ({ commit }) => commit(types.DISMISS_ERROR_ALERT);
+
+export const createUserList = ({ commit, state }, userList) => {
+ return Api.createFeatureFlagUserList(state.projectId, {
+ ...state.userList,
+ ...userList,
+ })
+ .then(({ data }) => redirectTo(data.path))
+ .catch(response => commit(types.RECEIVE_CREATE_USER_LIST_ERROR, getErrorMessages(response)));
+};
diff --git a/app/assets/javascripts/user_lists/store/new/index.js b/app/assets/javascripts/user_lists/store/new/index.js
new file mode 100644
index 00000000000..b30b0b04b9e
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/new/index.js
@@ -0,0 +1,11 @@
+import Vuex from 'vuex';
+import createState from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default initialState =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: createState(initialState),
+ });
diff --git a/app/assets/javascripts/user_lists/store/new/mutation_types.js b/app/assets/javascripts/user_lists/store/new/mutation_types.js
new file mode 100644
index 00000000000..9a5ce6e99f5
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/new/mutation_types.js
@@ -0,0 +1,3 @@
+export const RECEIVE_CREATE_USER_LIST_ERROR = 'RECEIVE_CREATE_USER_LIST_ERROR';
+
+export const DISMISS_ERROR_ALERT = 'DISMISS_ERROR_ALERT';
diff --git a/app/assets/javascripts/user_lists/store/new/mutations.js b/app/assets/javascripts/user_lists/store/new/mutations.js
new file mode 100644
index 00000000000..d7c1276bd72
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/new/mutations.js
@@ -0,0 +1,10 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.RECEIVE_CREATE_USER_LIST_ERROR](state, error) {
+ state.errorMessage = error;
+ },
+ [types.DISMISS_ERROR_ALERT](state) {
+ state.errorMessage = '';
+ },
+};
diff --git a/app/assets/javascripts/user_lists/store/new/state.js b/app/assets/javascripts/user_lists/store/new/state.js
new file mode 100644
index 00000000000..0fa73b4ffc1
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/new/state.js
@@ -0,0 +1,5 @@
+export default ({ projectId = '' }) => ({
+ projectId,
+ userList: { name: '', user_xids: '' },
+ errorMessage: [],
+});
diff --git a/app/assets/javascripts/user_lists/store/show/actions.js b/app/assets/javascripts/user_lists/store/show/actions.js
new file mode 100644
index 00000000000..15b971aa5e8
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/show/actions.js
@@ -0,0 +1,32 @@
+import Api from '~/api';
+import { stringifyUserIds } from '../utils';
+import * as types from './mutation_types';
+
+export const fetchUserList = ({ commit, state }) => {
+ commit(types.REQUEST_USER_LIST);
+ return Api.fetchFeatureFlagUserList(state.projectId, state.userListIid)
+ .then(response => commit(types.RECEIVE_USER_LIST_SUCCESS, response.data))
+ .catch(() => commit(types.RECEIVE_USER_LIST_ERROR));
+};
+
+export const dismissErrorAlert = ({ commit }) => commit(types.DISMISS_ERROR_ALERT);
+export const addUserIds = ({ dispatch, commit }, userIds) => {
+ commit(types.ADD_USER_IDS, userIds);
+ return dispatch('updateUserList');
+};
+
+export const removeUserId = ({ commit, dispatch }, userId) => {
+ commit(types.REMOVE_USER_ID, userId);
+ return dispatch('updateUserList');
+};
+
+export const updateUserList = ({ commit, state }) => {
+ commit(types.REQUEST_USER_LIST);
+
+ return Api.updateFeatureFlagUserList(state.projectId, {
+ ...state.userList,
+ user_xids: stringifyUserIds(state.userIds),
+ })
+ .then(response => commit(types.RECEIVE_USER_LIST_SUCCESS, response.data))
+ .catch(() => commit(types.RECEIVE_USER_LIST_ERROR));
+};
diff --git a/app/assets/javascripts/user_lists/store/show/index.js b/app/assets/javascripts/user_lists/store/show/index.js
new file mode 100644
index 00000000000..b30b0b04b9e
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/show/index.js
@@ -0,0 +1,11 @@
+import Vuex from 'vuex';
+import createState from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default initialState =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: createState(initialState),
+ });
diff --git a/app/assets/javascripts/user_lists/store/show/mutation_types.js b/app/assets/javascripts/user_lists/store/show/mutation_types.js
new file mode 100644
index 00000000000..fb967f06beb
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/show/mutation_types.js
@@ -0,0 +1,8 @@
+export const REQUEST_USER_LIST = 'REQUEST_USER_LIST';
+export const RECEIVE_USER_LIST_SUCCESS = 'RECEIVE_USER_LIST_SUCCESS';
+export const RECEIVE_USER_LIST_ERROR = 'RECEIVE_USER_LIST_ERROR';
+
+export const DISMISS_ERROR_ALERT = 'DISMISS_ERROR_ALERT';
+
+export const ADD_USER_IDS = 'ADD_USER_IDS';
+export const REMOVE_USER_ID = 'REMOVE_USER_ID';
diff --git a/app/assets/javascripts/user_lists/store/show/mutations.js b/app/assets/javascripts/user_lists/store/show/mutations.js
new file mode 100644
index 00000000000..c3e766465a7
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/show/mutations.js
@@ -0,0 +1,29 @@
+import { states } from '../../constants/show';
+import * as types from './mutation_types';
+import { parseUserIds } from '../utils';
+
+export default {
+ [types.REQUEST_USER_LIST](state) {
+ state.state = states.LOADING;
+ },
+ [types.RECEIVE_USER_LIST_SUCCESS](state, userList) {
+ state.state = states.SUCCESS;
+ state.userIds = userList.user_xids?.length > 0 ? parseUserIds(userList.user_xids) : [];
+ state.userList = userList;
+ },
+ [types.RECEIVE_USER_LIST_ERROR](state) {
+ state.state = states.ERROR;
+ },
+ [types.DISMISS_ERROR_ALERT](state) {
+ state.state = states.ERROR_DISMISSED;
+ },
+ [types.ADD_USER_IDS](state, ids) {
+ state.userIds = [
+ ...state.userIds,
+ ...parseUserIds(ids).filter(id => id && !state.userIds.includes(id)),
+ ];
+ },
+ [types.REMOVE_USER_ID](state, id) {
+ state.userIds = state.userIds.filter(uid => uid !== id);
+ },
+};
diff --git a/app/assets/javascripts/user_lists/store/show/state.js b/app/assets/javascripts/user_lists/store/show/state.js
new file mode 100644
index 00000000000..a5780893ccb
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/show/state.js
@@ -0,0 +1,9 @@
+import { states } from '../../constants/show';
+
+export default ({ projectId = '', userListIid = '' }) => ({
+ state: states.LOADING,
+ projectId,
+ userListIid,
+ userIds: [],
+ userList: null,
+});
diff --git a/app/assets/javascripts/user_lists/store/utils.js b/app/assets/javascripts/user_lists/store/utils.js
new file mode 100644
index 00000000000..f4e46947759
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/utils.js
@@ -0,0 +1,5 @@
+export const parseUserIds = userIds => userIds.split(/\s*,\s*/g);
+
+export const stringifyUserIds = userIds => userIds.join(',');
+
+export const getErrorMessages = error => [].concat(error?.response?.data?.message ?? error.message);
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index c8f95dac48e..3521c1a105f 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
-import { sanitize } from 'dompurify';
+import { sanitize } from '~/lib/dompurify';
import UsersCache from './lib/utils/users_cache';
import UserPopover from './vue_shared/components/user_popover/user_popover.vue';
@@ -42,6 +42,7 @@ const populateUserInfo = user => {
bio: userData.bio,
bioHtml: sanitize(userData.bio_html),
workInformation: userData.work_information,
+ websiteUrl: userData.website_url,
loaded: true,
});
}
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index 5f4260f26ff..20d1a3c1fcd 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -19,6 +19,7 @@ import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
function UsersSelect(currentUser, els, options = {}) {
+ const elsClassName = els?.toString().match('.(.+$)')[1];
const $els = $(els || '.js-user-search');
this.users = this.users.bind(this);
this.user = this.user.bind(this);
@@ -127,9 +128,16 @@ function UsersSelect(currentUser, els, options = {}) {
.find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`);
firstSelected.remove();
- emitSidebarEvent('sidebar.removeAssignee', {
- id: firstSelectedId,
- });
+
+ if ($dropdown.hasClass(elsClassName)) {
+ emitSidebarEvent('sidebar.removeReviewer', {
+ id: firstSelectedId,
+ });
+ } else {
+ emitSidebarEvent('sidebar.removeAssignee', {
+ id: firstSelectedId,
+ });
+ }
}
}
};
@@ -392,7 +400,11 @@ function UsersSelect(currentUser, els, options = {}) {
defaultLabel,
hidden() {
if ($dropdown.hasClass('js-multiselect')) {
- emitSidebarEvent('sidebar.saveAssignees');
+ if ($dropdown.hasClass(elsClassName)) {
+ emitSidebarEvent('sidebar.saveReviewers');
+ } else {
+ emitSidebarEvent('sidebar.saveAssignees');
+ }
}
if (!$dropdown.data('alwaysShowSelectbox')) {
@@ -428,10 +440,18 @@ function UsersSelect(currentUser, els, options = {}) {
previouslySelected.each((index, element) => {
element.remove();
});
- emitSidebarEvent('sidebar.removeAllAssignees');
+ if ($dropdown.hasClass(elsClassName)) {
+ emitSidebarEvent('sidebar.removeAllReviewers');
+ } else {
+ emitSidebarEvent('sidebar.removeAllAssignees');
+ }
} else if (isActive) {
// user selected
- emitSidebarEvent('sidebar.addAssignee', user);
+ if ($dropdown.hasClass(elsClassName)) {
+ emitSidebarEvent('sidebar.addReviewer', user);
+ } else {
+ emitSidebarEvent('sidebar.addAssignee', user);
+ }
// Remove unassigned selection (if it was previously selected)
const unassignedSelected = $dropdown
@@ -448,7 +468,11 @@ function UsersSelect(currentUser, els, options = {}) {
}
// User unselected
- emitSidebarEvent('sidebar.removeAssignee', user);
+ if ($dropdown.hasClass(elsClassName)) {
+ emitSidebarEvent('sidebar.removeReviewer', user);
+ } else {
+ emitSidebarEvent('sidebar.removeAssignee', user);
+ }
}
if (getSelected().find(u => u === gon.current_user_id)) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
index 208df03b6a4..b90cbfd1a1a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
@@ -74,9 +74,6 @@ export default {
canBeManuallyRedeployed() {
return this.computedDeploymentStatus === FAILED && Boolean(this.redeployPath);
},
- shouldShowManualButtons() {
- return this.glFeatures.deployFromFooter;
- },
hasExternalUrls() {
return Boolean(this.deployment.external_url && this.deployment.external_url_formatted);
},
@@ -154,7 +151,7 @@ export default {
<template>
<div>
<deployment-action-button
- v-if="shouldShowManualButtons && canBeManuallyDeployed"
+ v-if="canBeManuallyDeployed"
:action-in-progress="actionInProgress"
:actions-configuration="$options.actionsConfiguration[constants.DEPLOYING]"
:computed-deployment-status="computedDeploymentStatus"
@@ -165,7 +162,7 @@ export default {
<span>{{ $options.actionsConfiguration[constants.DEPLOYING].buttonText }}</span>
</deployment-action-button>
<deployment-action-button
- v-if="shouldShowManualButtons && canBeManuallyRedeployed"
+ v-if="canBeManuallyRedeployed"
:action-in-progress="actionInProgress"
:actions-configuration="$options.actionsConfiguration[constants.REDEPLOYING]"
:computed-deployment-status="computedDeploymentStatus"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
index 157d6d60290..e3c0b7935d7 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
@@ -72,12 +72,7 @@ export default {
css-class="deploy-link js-deploy-url inline"
/>
<gl-dropdown size="small" class="js-mr-wigdet-deployment-dropdown">
- <gl-search-box-by-type
- v-model.trim="searchTerm"
- v-autofocusonshow
- autofocus
- class="gl-m-3"
- />
+ <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus />
<gl-dropdown-item
v-for="change in filteredChanges"
:key="change.path"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue
index 6b3007fce51..c762922d890 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue
@@ -1,5 +1,5 @@
<script>
-import tooltip from '~/vue_shared/directives/tooltip';
+import { GlTooltipDirective } from '@gitlab/ui';
import MrWidgetAuthor from './mr_widget_author.vue';
export default {
@@ -8,7 +8,7 @@ export default {
MrWidgetAuthor,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
actionText: {
@@ -34,6 +34,7 @@ export default {
<h4 class="js-mr-widget-author">
{{ actionText }}
<mr-widget-author :author="author" />
- <time v-tooltip :title="dateTitle" data-container="body"> {{ dateReadable }} </time>
+ <span class="sr-only">{{ dateReadable }} ({{ dateTitle }})</span>
+ <time v-gl-tooltip.hover aria-hidden :title="dateTitle"> {{ dateReadable }} </time>
</h4>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
index 814d4e8341e..eb8989adb2a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -111,9 +111,10 @@ export default {
v-html="mr.sourceBranchLink"
/><clipboard-button
ref="copyBranchNameButton"
+ data-testid="mr-widget-copy-clipboard"
:text="branchNameClipboardData"
:title="__('Copy branch name')"
- css-class="btn-default btn-transparent btn-clipboard"
+ category="tertiary"
/>
{{ s__('mrWidget|into') }}
<tooltip-on-truncate
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
index 859f2c57598..c917b69953f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
@@ -1,6 +1,5 @@
<script>
-import { GlIcon, GlSprintf } from '@gitlab/ui';
-import tooltip from '../../vue_shared/directives/tooltip';
+import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '../../locale';
export default {
@@ -13,7 +12,7 @@ export default {
GlSprintf,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
};
</script>
@@ -28,7 +27,7 @@ export default {
</gl-sprintf>
</span>
<gl-icon
- v-tooltip
+ v-gl-tooltip.hover
:title="$options.i18n.tooltipTitle"
:aria-label="$options.i18n.tooltipTitle"
name="question-o"
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 7ddcdd49df5..29c26f4fb3e 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
@@ -1,9 +1,11 @@
<script>
+import { GlButton } from '@gitlab/ui';
import statusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'MRWidgetArchived',
components: {
+ GlButton,
statusIcon,
},
};
@@ -12,9 +14,9 @@ export default {
<div class="mr-widget-body media">
<div class="space-children">
<status-icon status="warning" />
- <button type="button" class="btn btn-success btn-sm" disabled="true">
+ <gl-button category="secondary" variant="success" :disabled="true">
{{ s__('mrWidget|Merge') }}
- </button>
+ </gl-button>
</div>
<div class="media-body">
<span class="bold">
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 83e7d6db9fa..30da9947859 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
@@ -1,5 +1,5 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import eventHub from '../../event_hub';
import statusIcon from '../mr_widget_status_icon.vue';
@@ -8,6 +8,7 @@ export default {
components: {
statusIcon,
GlLoadingIcon,
+ GlButton,
},
props: {
mr: {
@@ -33,20 +34,21 @@ export default {
<template>
<div class="mr-widget-body media">
<status-icon status="warning" />
- <div class="media-body space-children">
+ <div class="media-body space-children gl-display-flex gl-flex-wrap gl-align-items-center">
<span class="bold">
<template v-if="mr.mergeError">{{ mr.mergeError }}</template>
{{ s__('mrWidget|This merge request failed to be merged automatically') }}
</span>
- <button
+ <gl-button
:disabled="isRefreshing"
- type="button"
- class="btn btn-sm btn-default"
+ category="secondary"
+ variant="default"
+ size="small"
@click="refreshWidget"
>
<gl-loading-icon v-if="isRefreshing" :inline="true" />
{{ s__('mrWidget|Refresh') }}
- </button>
+ </gl-button>
</div>
</div>
</template>
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 58839251edc..17cd740ddd9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -1,8 +1,7 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
-import { GlLoadingIcon, GlButton } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { deprecatedCreateFlash as Flash } from '~/flash';
-import tooltip from '~/vue_shared/directives/tooltip';
import { s__, __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import MrWidgetAuthorTime from '../mr_widget_author_time.vue';
@@ -12,7 +11,7 @@ import eventHub from '../../event_hub';
export default {
name: 'MRWidgetMerged',
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
MrWidgetAuthorTime,
@@ -115,7 +114,7 @@ export default {
/>
<gl-button
v-if="mr.canRevertInCurrentMR"
- v-tooltip
+ v-gl-tooltip.hover
:title="revertTitle"
size="small"
category="secondary"
@@ -128,7 +127,7 @@ export default {
</gl-button>
<gl-button
v-else-if="mr.revertInForkPath"
- v-tooltip
+ v-gl-tooltip.hover
:href="mr.revertInForkPath"
:title="revertTitle"
size="small"
@@ -140,7 +139,7 @@ export default {
</gl-button>
<gl-button
v-if="mr.canCherryPickInCurrentMR"
- v-tooltip
+ v-gl-tooltip.hover
:title="cherryPickTitle"
size="small"
href="#modal-cherry-pick-commit"
@@ -151,7 +150,7 @@ export default {
</gl-button>
<gl-button
v-else-if="mr.cherryPickInForkPath"
- v-tooltip
+ v-gl-tooltip.hover
:href="mr.cherryPickInForkPath"
:title="cherryPickTitle"
size="small"
@@ -177,7 +176,9 @@ export default {
<clipboard-button
:title="__('Copy commit SHA')"
:text="mr.mergeCommitSha"
- css-class="btn-default btn-transparent btn-clipboard js-mr-merged-copy-sha"
+ css-class="js-mr-merged-copy-sha"
+ category="tertiary"
+ size="small"
/>
</template>
</p>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
index 83783528cc1..6489569cf68 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
@@ -1,13 +1,12 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
import statusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'MRWidgetMissingBranch',
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
@@ -52,7 +51,7 @@ export default {
<span class="bold js-branch-text">
<span class="capitalize"> {{ missingBranchName }} </span>
{{ s__('mrWidget|branch does not exist.') }} {{ missingBranchNameMessage }}
- <gl-icon v-tooltip :title="message" :aria-label="message" name="question-o" />
+ <gl-icon v-gl-tooltip :title="message" :aria-label="message" name="question-o" />
</span>
</div>
</div>
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 ec0934c5b4b..14c2e9fa828 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
@@ -1,6 +1,6 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { escape } from 'lodash';
import simplePoll from '../../../lib/utils/simple_poll';
import eventHub from '../../event_hub';
@@ -12,7 +12,7 @@ export default {
name: 'MRWidgetRebase',
components: {
statusIcon,
- GlLoadingIcon,
+ GlButton,
},
props: {
mr: {
@@ -109,29 +109,29 @@ export default {
<div class="rebase-state-find-class-convention media media-body space-children">
<template v-if="mr.rebaseInProgress || isMakingRequest">
- <span class="bold">{{ __('Rebase in progress') }}</span>
+ <span class="bold" data-testid="rebase-message">{{ __('Rebase in progress') }}</span>
</template>
<template v-if="!mr.rebaseInProgress && !mr.canPushToSourceBranch">
- <span class="bold" v-html="fastForwardMergeText"></span>
+ <span class="bold" data-testid="rebase-message" v-html="fastForwardMergeText"></span>
</template>
<template v-if="!mr.rebaseInProgress && mr.canPushToSourceBranch && !isMakingRequest">
<div
class="accept-merge-holder clearfix js-toggle-container accept-action media space-children"
>
- <button
- :disabled="isMakingRequest"
- type="button"
- class="btn btn-sm btn-reopen btn-success qa-mr-rebase-button"
+ <gl-button
+ :loading="isMakingRequest"
+ variant="success"
+ class="qa-mr-rebase-button"
@click="rebase"
>
- <gl-loading-icon v-if="isMakingRequest" />{{ __('Rebase') }}
- </button>
- <span v-if="!rebasingError" class="bold">{{
+ {{ __('Rebase') }}
+ </gl-button>
+ <span v-if="!rebasingError" class="bold" data-testid="rebase-message">{{
__(
'Fast-forward merge is not possible. Rebase the source branch onto the target branch.',
)
}}</span>
- <span v-else class="bold danger">{{ rebasingError }}</span>
+ <span v-else class="bold danger" data-testid="rebase-message">{{ rebasingError }}</span>
</div>
</template>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 240bab58297..835f7b9e9a9 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
@@ -1,12 +1,9 @@
<script>
-/* eslint-disable vue/no-v-html */
import { isEmpty } from 'lodash';
import { GlIcon, GlButton, GlSprintf, GlLink } from '@gitlab/ui';
-import successSvg from 'icons/_icon_status_success.svg';
-import warningSvg from 'icons/_icon_status_warning.svg';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import simplePoll from '~/lib/utils/simple_poll';
-import { __, sprintf } from '~/locale';
+import { __ } from '~/locale';
import MergeRequest from '../../../merge_request';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { deprecatedCreateFlash as Flash } from '../../../flash';
@@ -59,8 +56,6 @@ export default {
commitMessage: this.mr.commitMessage,
squashBeforeMerge: this.mr.squashIsSelected,
isSquashReadOnly: this.mr.squashIsReadonly,
- successSvg,
- warningSvg,
squashCommitMessage: this.mr.squashCommitMessage,
};
},
@@ -147,16 +142,7 @@ export default {
return !this.mr.ffOnlyEnabled;
},
shaMismatchLink() {
- const href = this.mr.mergeRequestDiffsPath;
-
- return sprintf(
- __('New changes were added. %{linkStart}Reload the page to review them%{linkEnd}'),
- {
- linkStart: `<a href="${href}">`,
- linkEnd: '</a>',
- },
- false,
- );
+ return this.mr.mergeRequestDiffsPath;
},
},
methods: {
@@ -331,7 +317,7 @@ export default {
@click.prevent="handleMergeButtonClick(true)"
>
<span class="media">
- <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span>
+ <gl-icon name="status_success" class="merge-opt-icon" aria-hidden="true" />
<span class="media-body merge-opt-title">{{ autoMergeText }}</span>
</span>
</a>
@@ -349,7 +335,7 @@ export default {
@click.prevent="handleMergeImmediatelyButtonClick"
>
<span class="media">
- <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span>
+ <gl-icon name="status_warning" class="merge-opt-icon" aria-hidden="true" />
<span class="media-body merge-opt-title">{{ __('Merge immediately') }}</span>
</span>
</a>
@@ -400,7 +386,17 @@ export default {
</div>
<div v-if="mr.isSHAMismatch" class="d-flex align-items-center mt-2 js-sha-mismatch">
<gl-icon name="warning-solid" class="text-warning mr-1" />
- <span class="text-warning" v-html="shaMismatchLink"></span>
+ <span class="text-warning">
+ <gl-sprintf
+ :message="
+ __('New changes were added. %{linkStart}Reload the page to review them%{linkEnd}')
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="mr.mergeRequestDiffsPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
index 6608381f348..ff0d065c71d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
@@ -1,14 +1,16 @@
<script>
-import { GlIcon } from '@gitlab/ui';
-import tooltip from '~/vue_shared/directives/tooltip';
-import { __ } from '~/locale';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { SQUASH_BEFORE_MERGE } from '../../i18n';
export default {
components: {
GlIcon,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
+ },
+ i18n: {
+ ...SQUASH_BEFORE_MERGE,
},
props: {
value: {
@@ -28,7 +30,10 @@ export default {
},
computed: {
tooltipTitle() {
- return this.isDisabled ? __('Required in this project.') : false;
+ return this.isDisabled ? this.$options.i18n.tooltipTitle : null;
+ },
+ tooltipFocusable() {
+ return this.isDisabled ? '0' : null;
},
},
};
@@ -37,10 +42,11 @@ export default {
<template>
<div class="inline">
<label
- v-tooltip
+ v-gl-tooltip
:class="{ 'gl-text-gray-400': isDisabled }"
+ :tabindex="tooltipFocusable"
data-testid="squashLabel"
- :data-title="tooltipTitle"
+ :title="tooltipTitle"
>
<input
:checked="value"
@@ -50,19 +56,20 @@ export default {
class="qa-squash-checkbox js-squash-checkbox"
@change="$emit('input', $event.target.checked)"
/>
- {{ __('Squash commits') }}
+ {{ $options.i18n.checkboxLabel }}
</label>
<a
v-if="helpPath"
- v-tooltip
+ v-gl-tooltip
:href="helpPath"
- data-title="About this feature"
- data-placement="bottom"
+ :title="$options.i18n.helpLabel"
target="_blank"
rel="noopener noreferrer nofollow"
- data-container="body"
>
<gl-icon name="question" />
+ <span class="sr-only">
+ {{ $options.i18n.helpLabel }}
+ </span>
</a>
</div>
</template>
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 61cc950f058..eba3d50fdc9 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
@@ -3,13 +3,13 @@ import $ from 'jquery';
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import { deprecatedCreateFlash as createFlash } from '~/flash';
+import MergeRequest from '~/merge_request';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import getStateQuery from '../../queries/get_state.query.graphql';
import workInProgressQuery from '../../queries/states/work_in_progress.query.graphql';
import removeWipMutation from '../../queries/toggle_wip.mutation.graphql';
import StatusIcon from '../mr_widget_status_icon.vue';
-import tooltip from '../../../vue_shared/directives/tooltip';
import eventHub from '../../event_hub';
export default {
@@ -18,9 +18,6 @@ export default {
StatusIcon,
GlButton,
},
- directives: {
- tooltip,
- },
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
apollo: {
userPermissions: {
@@ -128,8 +125,7 @@ export default {
.then(res => res.data)
.then(data => {
eventHub.$emit('UpdateWidgetData', data);
- createFlash(__('The merge request can now be merged.'), 'notice');
- $('.merge-request .detail-page-description .title').text(this.mr.title);
+ MergeRequest.toggleDraftStatus(this.mr.title, true);
})
.catch(() => {
this.isMakingRequest = false;
diff --git a/app/assets/javascripts/vue_merge_request_widget/i18n.js b/app/assets/javascripts/vue_merge_request_widget/i18n.js
new file mode 100644
index 00000000000..e8e522a01e9
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/i18n.js
@@ -0,0 +1,7 @@
+import { __ } from '~/locale';
+
+export const SQUASH_BEFORE_MERGE = {
+ tooltipTitle: __('Required in this project.'),
+ checkboxLabel: __('Squash commits'),
+ helpLabel: __('What is squashing?'),
+};
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 43ce748b41d..46749fc5e87 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
@@ -4,6 +4,7 @@ import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue';
import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
+import { GlSafeHtmlDirective } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import Project from '~/pages/projects/project';
import SmartInterval from '~/smart_interval';
@@ -45,12 +46,16 @@ import GroupedTestReportsApp from '../reports/components/grouped_test_reports_ap
import { setFaviconOverlay } from '../lib/utils/common_utils';
import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue';
import getStateQuery from './queries/get_state.query.graphql';
+import { isExperimentEnabled } from '~/lib/utils/experimentation';
export default {
el: '#js-vue-mr-widget',
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'MRWidget',
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
components: {
Loading,
'mr-widget-header': WidgetHeader,
@@ -85,6 +90,7 @@ export default {
TerraformPlan,
GroupedAccessibilityReportsApp,
MrWidgetApprovals,
+ SecurityReportsApp: () => import('~/vue_shared/security_reports/security_reports_app.vue'),
},
apollo: {
state: {
@@ -148,7 +154,7 @@ export default {
},
shouldSuggestPipelines() {
return (
- gon.features?.suggestPipeline &&
+ isExperimentEnabled('suggestPipeline') &&
!this.mr.hasCI &&
this.mr.mergeRequestAddCiConfigPath &&
!this.mr.isDismissedSuggestPipeline
@@ -178,6 +184,9 @@ export default {
this.mr.mergePipelinesEnabled && this.mr.sourceProjectId !== this.mr.targetProjectId,
);
},
+ shouldRenderSecurityReport() {
+ return Boolean(window.gon?.features?.coreSecurityMrWidget && this.mr.pipeline.id);
+ },
mergeError() {
let { mergeError } = this.mr;
@@ -455,6 +464,13 @@ export default {
:codequality-help-path="mr.codequalityHelpPath"
/>
+ <security-reports-app
+ v-if="shouldRenderSecurityReport"
+ :pipeline-id="mr.pipeline.id"
+ :project-id="mr.targetProjectId"
+ :security-reports-docs-path="mr.securityReportsDocsPath"
+ />
+
<grouped-test-reports-app
v-if="mr.testResultsPath"
class="js-reports-container"
@@ -498,7 +514,7 @@ export default {
</mr-widget-alert-message>
<mr-widget-alert-message v-if="mr.mergeError" type="danger">
- {{ mergeError }}
+ <span v-safe-html="mergeError"></span>
</mr-widget-alert-message>
<source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" />
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 846b1c453a1..8b235b20ad4 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
@@ -232,6 +232,7 @@ export default class MergeRequestStore {
this.userCalloutsPath = data.user_callouts_path;
this.suggestPipelineFeatureId = data.suggest_pipeline_feature_id;
this.isDismissedSuggestPipeline = data.is_dismissed_suggest_pipeline;
+ this.securityReportsDocsPath = data.security_reports_docs_path;
// codeclimate
const blobPath = data.blob_path || {};
diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue
index f333ab49ead..9b21de19185 100644
--- a/app/assets/javascripts/vue_shared/components/actions_button.vue
+++ b/app/assets/javascripts/vue_shared/components/actions_button.vue
@@ -3,7 +3,7 @@ import {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
- GlLink,
+ GlButton,
GlTooltipDirective,
} from '@gitlab/ui';
@@ -12,7 +12,7 @@ export default {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
- GlLink,
+ GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -27,6 +27,16 @@ export default {
required: false,
default: '',
},
+ category: {
+ type: String,
+ required: false,
+ default: 'secondary',
+ },
+ variant: {
+ type: String,
+ required: false,
+ default: 'default',
+ },
},
computed: {
hasMultipleActions() {
@@ -54,6 +64,8 @@ export default {
class="gl-button-deprecated-adapter"
:text="selectedAction.text"
:split-href="selectedAction.href"
+ :variant="variant"
+ :category="category"
split
@click="handleClick(selectedAction, $event)"
>
@@ -77,14 +89,15 @@ export default {
<gl-dropdown-divider v-if="index != actions.length - 1" :key="action.key + '_divider'" />
</template>
</gl-dropdown>
- <gl-link
+ <gl-button
v-else-if="selectedAction"
v-gl-tooltip="selectedAction.tooltip"
v-bind="selectedAction.attrs"
- class="btn"
+ :variant="variant"
+ :category="category"
:href="selectedAction.href"
@click="handleClick(selectedAction, $event)"
>
{{ selectedAction.text }}
- </gl-link>
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/alert_details_table.vue b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
index c94e784c01e..34f6d384f7b 100644
--- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue
+++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
@@ -1,20 +1,38 @@
<script>
import { GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { reduce } from 'lodash';
import { s__ } from '~/locale';
import {
capitalizeFirstCharacter,
convertToSentenceCase,
splitCamelCase,
} from '~/lib/utils/text_utility';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const thClass = 'gl-bg-transparent! gl-border-1! gl-border-b-solid! gl-border-gray-200!';
const tdClass = 'gl-border-gray-100! gl-p-5!';
+const allowedFields = [
+ 'iid',
+ 'title',
+ 'severity',
+ 'status',
+ 'startedAt',
+ 'eventCount',
+ 'monitoringTool',
+ 'service',
+ 'description',
+ 'endedAt',
+ 'details',
+ 'hosts',
+];
+
export default {
components: {
GlLoadingIcon,
GlTable,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
alert: {
type: Object,
@@ -42,14 +60,37 @@ export default {
},
],
computed: {
+ flaggedAllowedFields() {
+ return this.shouldDisplayEnvironment ? [...allowedFields, 'environment'] : allowedFields;
+ },
items() {
if (!this.alert) {
return [];
}
- return Object.entries(this.alert).map(([fieldName, value]) => ({
- fieldName,
- value,
- }));
+ return reduce(
+ this.alert,
+ (allowedItems, fieldValue, fieldName) => {
+ if (this.isAllowed(fieldName)) {
+ let value;
+ if (fieldName === 'environment') {
+ value = fieldValue?.name;
+ } else {
+ value = fieldValue;
+ }
+ return [...allowedItems, { fieldName, value }];
+ }
+ return allowedItems;
+ },
+ [],
+ );
+ },
+ shouldDisplayEnvironment() {
+ return this.glFeatures.exposeEnvironmentPathInAlertDetails;
+ },
+ },
+ methods: {
+ isAllowed(fieldName) {
+ return this.flaggedAllowedFields.includes(fieldName);
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index e1f54b62223..2e4b9b9a135 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -1,7 +1,7 @@
<script>
/* eslint-disable vue/no-v-html */
import { groupBy } from 'lodash';
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
import tooltip from '~/vue_shared/directives/tooltip';
import { glEmojiTag } from '../../emoji';
import { __, sprintf } from '~/locale';
@@ -12,6 +12,7 @@ const NO_USER_ID = -1;
export default {
components: {
GlIcon,
+ GlLoadingIcon,
},
directives: {
tooltip,
@@ -184,10 +185,7 @@ export default {
<span class="award-control-icon award-control-icon-super-positive">
<gl-icon aria-hidden="true" name="smiley" />
</span>
- <i
- aria-hidden="true"
- class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"
- ></i>
+ <gl-loading-icon size="md" color="dark" class="award-control-icon-loading" />
</button>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
index 9e2b3097499..7a76888c916 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
@@ -1,9 +1,5 @@
-import {
- SNIPPET_MARK_VIEW_APP_START,
- SNIPPET_MARK_BLOBS_CONTENT,
- SNIPPET_MEASURE_BLOBS_CONTENT,
- SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP,
-} from '~/performance_constants';
+import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance_constants';
+import eventHub from '~/blob/components/eventhub';
export default {
props: {
@@ -17,12 +13,6 @@ export default {
},
},
mounted() {
- window.requestAnimationFrame(() => {
- if (!performance.getEntriesByName(SNIPPET_MARK_BLOBS_CONTENT).length) {
- performance.mark(SNIPPET_MARK_BLOBS_CONTENT);
- performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT);
- performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP, SNIPPET_MARK_VIEW_APP_START);
- }
- });
+ eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT);
},
};
diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
index d7af3b3298e..1b7e51b7d02 100644
--- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -7,7 +7,7 @@ import CiIcon from './ci_icon.vue';
*
* Receives status object containing:
* status: {
- * details_path: "/gitlab-org/gitlab-foss/pipelines/8150156" // url
+ * details_path or detailsPath: "/gitlab-org/gitlab-foss/pipelines/8150156" // url
* group:"running" // used for CSS class
* icon: "icon_status_running" // used to render the icon
* label:"running" // used for potential tooltip
@@ -46,6 +46,13 @@ export default {
},
},
computed: {
+ title() {
+ return !this.showText ? this.status?.text : '';
+ },
+ detailsPath() {
+ // For now, this can either come from graphQL with camelCase or REST API in snake_case
+ return this.status.detailsPath || this.status.details_path;
+ },
cssClass() {
const className = this.status.group;
return className ? `ci-status ci-${className} qa-status-badge` : 'ci-status qa-status-badge';
@@ -54,12 +61,7 @@ export default {
};
</script>
<template>
- <a
- v-gl-tooltip
- :href="status.details_path"
- :class="cssClass"
- :title="!showText ? status.text : ''"
- >
+ <a v-gl-tooltip :href="detailsPath" :class="cssClass" :title="title">
<ci-icon :status="status" :css-classes="iconClasses" />
<template v-if="showText">
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index 0234b6bf848..960551fae91 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -12,7 +12,7 @@
* css-class="btn-transparent"
* />
*/
-import { GlDeprecatedButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
export default {
name: 'ClipboardButton',
@@ -20,8 +20,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: {
- GlDeprecatedButton,
- GlIcon,
+ GlButton,
},
props: {
text: {
@@ -50,7 +49,17 @@ export default {
cssClass: {
type: String,
required: false,
- default: 'btn-default',
+ default: null,
+ },
+ category: {
+ type: String,
+ required: false,
+ default: 'secondary',
+ },
+ size: {
+ type: String,
+ required: false,
+ default: 'medium',
},
},
computed: {
@@ -65,13 +74,15 @@ export default {
</script>
<template>
- <gl-deprecated-button
+ <gl-button
v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }"
v-gl-tooltip.hover.blur
:class="cssClass"
:title="title"
:data-clipboard-text="clipboardText"
- >
- <gl-icon name="copy-to-clipboard" />
- </gl-deprecated-button>
+ :category="category"
+ :size="size"
+ icon="copy-to-clipboard"
+ :aria-label="__('Copy this value')"
+ />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
index c1c8fb3a6e2..e01a651806d 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -139,7 +139,7 @@ export default {
<template>
<div class="branch-commit cgray">
<template v-if="shouldShowRefInfo">
- <div class="icon-container">
+ <div class="icon-container gl-display-inline-block">
<gl-icon v-if="tag" name="tag" />
<gl-icon v-else-if="mergeRequestRef" name="git-merge" />
<gl-icon v-else name="branch" />
diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
index e7f6cc1abc0..a42a606d446 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
@@ -12,6 +12,11 @@ export default {
type: String,
required: true,
},
+ handleSubmit: {
+ type: Function,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -41,7 +46,11 @@ export default {
this.$refs.modal.hide();
},
submitModal() {
- this.$refs.form.submit();
+ if (this.handleSubmit) {
+ this.handleSubmit(this.path);
+ } else {
+ this.$refs.form.submit();
+ }
},
},
csrf,
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
index fe488ab6cfa..5ac30424f98 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
@@ -4,6 +4,11 @@ import ImageViewer from './viewers/image_viewer.vue';
import DownloadViewer from './viewers/download_viewer.vue';
export default {
+ components: {
+ MarkdownViewer,
+ ImageViewer,
+ DownloadViewer,
+ },
props: {
content: {
type: String,
@@ -45,35 +50,25 @@ export default {
default: () => ({}),
},
},
- computed: {
- viewer() {
- if (!this.path) return null;
- if (!this.type) return DownloadViewer;
-
- switch (this.type) {
- case 'markdown':
- return MarkdownViewer;
- case 'image':
- return ImageViewer;
- default:
- return DownloadViewer;
- }
- },
- },
};
</script>
<template>
<div class="preview-container">
- <component
- :is="viewer"
- :path="path"
+ <image-viewer v-if="type === 'image'" :path="path" :file-size="fileSize" />
+ <markdown-viewer
+ v-if="type === 'markdown'"
+ :content="content"
+ :commit-sha="commitSha"
:file-path="filePath"
- :file-size="fileSize"
:project-path="projectPath"
- :content="content"
:images="images"
- :commit-sha="commitSha"
+ />
+ <download-viewer
+ v-if="!type && path"
+ :path="path"
+ :file-path="filePath"
+ :file-size="fileSize"
/>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
index f9d3d76e7f5..8d55701f499 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
@@ -1,10 +1,9 @@
<script>
-import { GlLink, GlIcon } from '@gitlab/ui';
+import { GlIcon } from '@gitlab/ui';
import { numberToHumanSize } from '../../../../lib/utils/number_utils';
export default {
components: {
- GlLink,
GlIcon,
},
props: {
@@ -44,16 +43,10 @@ export default {
({{ fileSizeReadable }})
</template>
</p>
- <gl-link
- :href="path"
- class="btn btn-default"
- rel="nofollow"
- :download="fileName"
- target="_blank"
- >
+ <a :href="path" class="btn btn-default" rel="nofollow" :download="fileName" target="_blank">
<gl-icon :size="16" name="download" class="float-left gl-mr-3" />
{{ __('Download') }}
- </gl-link>
+ </a>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue
index 543547b37fe..c4bce860ae4 100644
--- a/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue
+++ b/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue
@@ -1,5 +1,6 @@
<script>
import $ from 'jquery';
+import { GlButton } from '@gitlab/ui';
const buttonVariants = ['danger', 'primary', 'success', 'warning'];
const sizeVariants = ['sm', 'md', 'lg', 'xl'];
@@ -7,6 +8,9 @@ const sizeVariants = ['sm', 'md', 'lg', 'xl'];
export default {
name: 'DeprecatedModal2', // use GlModal instead
+ components: {
+ GlButton,
+ },
props: {
id: {
type: String,
@@ -72,20 +76,21 @@ export default {
<div :id="id" class="modal fade" tabindex="-1" role="dialog">
<div :class="modalSizeClass" class="modal-dialog" role="document">
<div class="modal-content">
- <div class="modal-header">
+ <div class="modal-header gl-pr-4">
<slot name="header">
<h4 class="modal-title">
<slot name="title"> {{ headerTitleText }} </slot>
</h4>
- <button
+ <gl-button
:aria-label="s__('Modal|Close')"
- type="button"
- class="close js-modal-close-action"
+ variant="default"
+ category="tertiary"
+ size="small"
+ icon="close"
+ class="js-modal-close-action"
data-dismiss="modal"
@click="emitCancel($event)"
- >
- <span aria-hidden="true">&times;</span>
- </button>
+ />
</slot>
</div>
@@ -93,23 +98,21 @@ export default {
<div class="modal-footer">
<slot name="footer">
- <button
- type="button"
- class="btn js-modal-cancel-action qa-modal-cancel-button"
+ <gl-button
+ class="js-modal-cancel-action qa-modal-cancel-button"
data-dismiss="modal"
@click="emitCancel($event)"
>
{{ s__('Modal|Cancel') }}
- </button>
- <button
+ </gl-button>
+ <gl-button
:class="`btn-${footerPrimaryButtonVariant}`"
- type="button"
- class="btn js-modal-primary-action qa-modal-primary-button"
+ class="js-modal-primary-action qa-modal-primary-button"
data-dismiss="modal"
@click="emitSubmit($event)"
>
{{ footerPrimaryButtonText }}
- </button>
+ </gl-button>
</slot>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
index c7d7c3a1d24..2a28b13e7bf 100644
--- a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
+++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
@@ -22,7 +22,7 @@ export default {
},
data() {
return {
- isDismissed: 'false',
+ isDismissed: false,
};
},
computed: {
@@ -30,12 +30,12 @@ export default {
return `${slugifyWithUnderscore(this.featureName)}_feedback_dismissed`;
},
showAlert() {
- return this.isDismissed === 'false';
+ return !this.isDismissed;
},
},
methods: {
dismissFeedbackAlert() {
- this.isDismissed = 'true';
+ this.isDismissed = true;
},
},
};
@@ -43,16 +43,12 @@ export default {
<template>
<div v-show="showAlert">
- <local-storage-sync
- :value="isDismissed"
- :storage-key="storageKey"
- @input="dismissFeedbackAlert"
- />
+ <local-storage-sync v-model="isDismissed" :storage-key="storageKey" as-json />
<gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissFeedbackAlert">
<gl-sprintf
:message="
__(
- 'We’ve been making changes to %{featureName} and we’d love your feedback %{linkStart}in this issue%{linkEnd} to help us improve the experience.',
+ 'Please share your feedback about %{featureName} %{linkStart}in this issue%{linkEnd} to help us improve the experience.',
)
"
>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue
index 7157337f8f3..48b94fdc181 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue
@@ -1,7 +1,11 @@
<script>
+import { GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
+ components: {
+ GlIcon,
+ },
props: {
placeholderText: {
type: String,
@@ -40,6 +44,6 @@ export default {
type="search"
autocomplete="off"
/>
- <i class="fa fa-search dropdown-input-search" aria-hidden="true" data-hidden="true"> </i>
+ <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" data-hidden="true" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue
deleted file mode 100644
index 4d85726065b..00000000000
--- a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue
+++ /dev/null
@@ -1,92 +0,0 @@
-<script>
-import { GlDeprecatedButton, GlIcon } from '@gitlab/ui';
-
-export default {
- components: {
- GlIcon,
- GlDeprecatedButton,
- },
- props: {
- size: {
- type: String,
- required: false,
- default: '',
- },
- primaryButtonClass: {
- type: String,
- required: false,
- default: '',
- },
- dropdownClass: {
- type: String,
- required: false,
- default: '',
- },
- actions: {
- type: Array,
- required: true,
- },
- defaultAction: {
- type: Number,
- required: true,
- },
- },
- data() {
- return {
- selectedAction: this.defaultAction,
- };
- },
- computed: {
- selectedActionTitle() {
- return this.actions[this.selectedAction].title;
- },
- buttonSizeClass() {
- return `btn-${this.size}`;
- },
- },
- methods: {
- handlePrimaryActionClick() {
- this.$emit('onActionClick', this.actions[this.selectedAction]);
- },
- handleActionClick(selectedAction) {
- this.selectedAction = selectedAction;
- this.$emit('onActionSelect', selectedAction);
- },
- },
-};
-</script>
-
-<template>
- <div class="btn-group droplab-dropdown comment-type-dropdown">
- <gl-deprecated-button
- :class="primaryButtonClass"
- :size="size"
- @click.prevent="handlePrimaryActionClick"
- >
- {{ selectedActionTitle }}
- </gl-deprecated-button>
- <button
- :class="buttonSizeClass"
- type="button"
- class="btn dropdown-toggle pl-2 pr-2"
- data-display="static"
- data-toggle="dropdown"
- >
- <gl-icon name="chevron-down" :aria-label="__('toggle dropdown')" />
- </button>
- <ul :class="dropdownClass" class="dropdown-menu dropdown-open-top">
- <template v-for="(action, index) in actions">
- <li :key="index" :class="{ 'droplab-item-selected': selectedAction === index }">
- <gl-deprecated-button class="btn-transparent" @click.prevent="handleActionClick(index)">
- <i aria-hidden="true" class="fa fa-check icon"> </i>
- <div class="description">
- <strong>{{ action.title }}</strong>
- <p>{{ action.description }}</p>
- </div>
- </gl-deprecated-button>
- </li>
- <li v-if="index === 0" :key="`${index}-separator`" class="divider droplab-item-ignore"></li>
- </template>
- </ul>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/editor_lite.vue b/app/assets/javascripts/vue_shared/components/editor_lite.vue
new file mode 100644
index 00000000000..cfe3ce0a11c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/editor_lite.vue
@@ -0,0 +1,99 @@
+<script>
+import { debounce } from 'lodash';
+import Editor from '~/editor/editor_lite';
+import { CONTENT_UPDATE_DEBOUNCE } from '~/editor/constants';
+
+function initEditorLite({ el, ...args }) {
+ const editor = new Editor({
+ scrollbar: {
+ alwaysConsumeMouseWheel: false,
+ },
+ });
+
+ return editor.createInstance({
+ el,
+ ...args,
+ });
+}
+
+export default {
+ inheritAttrs: false,
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ fileName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ // This is used to help uniquely create a monaco model
+ // even if two blob's share a file path.
+ fileGlobalId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ extensions: {
+ type: [String, Array],
+ required: false,
+ default: () => null,
+ },
+ editorOptions: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ loading: true,
+ editor: null,
+ };
+ },
+ watch: {
+ fileName(newVal) {
+ this.editor.updateModelLanguage(newVal);
+ },
+ value(newVal) {
+ if (this.editor.getValue() !== newVal) {
+ this.editor.setValue(newVal);
+ }
+ },
+ },
+ mounted() {
+ this.editor = initEditorLite({
+ el: this.$refs.editor,
+ blobPath: this.fileName,
+ blobContent: this.value,
+ blobGlobalId: this.fileGlobalId,
+ extensions: this.extensions,
+ ...this.editorOptions,
+ });
+
+ this.editor.onDidChangeModelContent(
+ debounce(this.onFileChange.bind(this), CONTENT_UPDATE_DEBOUNCE),
+ );
+ },
+ beforeDestroy() {
+ this.editor.dispose();
+ },
+ methods: {
+ onFileChange() {
+ this.$emit('input', this.editor.getValue());
+ },
+ },
+};
+</script>
+<template>
+ <div
+ :id="`editor-lite-${fileGlobalId}`"
+ ref="editor"
+ data-editor-loading
+ @editor-ready="$emit('editor-ready')"
+ >
+ <pre class="editor-loading-content">{{ value }}</pre>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index 012aca8105a..386df617d47 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -230,13 +230,12 @@ export default {
@keydown="onKeydown($event)"
@keyup="onKeyup($event)"
/>
- <i
- :class="{
- hidden: showClearInputButton,
- }"
+ <gl-icon
+ name="search"
+ class="dropdown-input-search"
+ :class="{ hidden: showClearInputButton }"
aria-hidden="true"
- class="fa fa-search dropdown-input-search"
- ></i>
+ />
<gl-icon
name="close"
class="dropdown-input-clear"
diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
index b70f093e930..91a0ac3aa92 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
+++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
@@ -9,6 +9,12 @@ const fileExtensionIcons = {
'md.rendered': 'markdown',
markdown: 'markdown',
'markdown.rendered': 'markdown',
+ mdown: 'markdown',
+ 'mdown.rendered': 'markdown',
+ mkd: 'markdown',
+ 'mkd.rendered': 'markdown',
+ mkdn: 'markdown',
+ 'mkdn.rendered': 'markdown',
rst: 'markdown',
blink: 'blink',
css: 'css',
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
new file mode 100644
index 00000000000..443cb28cf10
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
@@ -0,0 +1,121 @@
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import Api from '~/api';
+import * as types from './mutation_types';
+
+export const setEndpoints = ({ commit }, params) => {
+ const { milestonesEndpoint, labelsEndpoint, groupEndpoint, projectEndpoint } = params;
+ commit(types.SET_MILESTONES_ENDPOINT, milestonesEndpoint);
+ commit(types.SET_LABELS_ENDPOINT, labelsEndpoint);
+ commit(types.SET_GROUP_ENDPOINT, groupEndpoint);
+ commit(types.SET_PROJECT_ENDPOINT, projectEndpoint);
+};
+
+export function fetchBranches({ commit, state }, search = '') {
+ const { projectEndpoint } = state;
+ commit(types.REQUEST_BRANCHES);
+
+ return Api.branches(projectEndpoint, search)
+ .then(response => {
+ commit(types.RECEIVE_BRANCHES_SUCCESS, response.data);
+ return response;
+ })
+ .catch(({ response }) => {
+ const { status } = response;
+ commit(types.RECEIVE_BRANCHES_ERROR, status);
+ createFlash(__('Failed to load branches. Please try again.'));
+ });
+}
+
+export const fetchMilestones = ({ commit, state }, search_title = '') => {
+ commit(types.REQUEST_MILESTONES);
+ const { milestonesEndpoint } = state;
+
+ return axios
+ .get(milestonesEndpoint, { params: { search_title } })
+ .then(response => {
+ commit(types.RECEIVE_MILESTONES_SUCCESS, response.data);
+ return response;
+ })
+ .catch(({ response }) => {
+ const { status } = response;
+ commit(types.RECEIVE_MILESTONES_ERROR, status);
+ createFlash(__('Failed to load milestones. Please try again.'));
+ });
+};
+
+export const fetchLabels = ({ commit, state }, search = '') => {
+ commit(types.REQUEST_LABELS);
+
+ return axios
+ .get(state.labelsEndpoint, { params: { search } })
+ .then(response => {
+ commit(types.RECEIVE_LABELS_SUCCESS, response.data);
+ return response;
+ })
+ .catch(({ response }) => {
+ const { status } = response;
+ commit(types.RECEIVE_LABELS_ERROR, status);
+ createFlash(__('Failed to load labels. Please try again.'));
+ });
+};
+
+function fetchUser(options = {}) {
+ const { commit, projectEndpoint, groupEndpoint, query, action, errorMessage } = options;
+ commit(`REQUEST_${action}`);
+
+ let fetchUserPromise;
+ if (projectEndpoint) {
+ fetchUserPromise = Api.projectUsers(projectEndpoint, query).then(data => ({ data }));
+ } else {
+ fetchUserPromise = Api.groupMembers(groupEndpoint, { query });
+ }
+
+ return fetchUserPromise
+ .then(response => {
+ commit(`RECEIVE_${action}_SUCCESS`, response.data);
+ return response;
+ })
+ .catch(({ response }) => {
+ const { status } = response;
+ commit(`RECEIVE_${action}_ERROR`, status);
+ createFlash(errorMessage);
+ });
+}
+
+export const fetchAuthors = ({ commit, state }, query = '') => {
+ const { projectEndpoint, groupEndpoint } = state;
+
+ return fetchUser({
+ commit,
+ query,
+ projectEndpoint,
+ groupEndpoint,
+ action: 'AUTHORS',
+ errorMessage: __('Failed to load authors. Please try again.'),
+ });
+};
+
+export const fetchAssignees = ({ commit, state }, query = '') => {
+ const { projectEndpoint, groupEndpoint } = state;
+
+ return fetchUser({
+ commit,
+ query,
+ projectEndpoint,
+ groupEndpoint,
+ action: 'ASSIGNEES',
+ errorMessage: __('Failed to load assignees. Please try again.'),
+ });
+};
+
+export const setFilters = ({ commit, dispatch }, filters) => {
+ commit(types.SET_SELECTED_FILTERS, filters);
+
+ return dispatch('setFilters', filters, { root: true });
+};
+
+export const initialize = ({ commit }, initialFilters) => {
+ commit(types.SET_SELECTED_FILTERS, initialFilters);
+};
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/index.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/index.js
new file mode 100644
index 00000000000..665bb29a17e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default {
+ namespaced: true,
+ actions,
+ mutations,
+ state: state(),
+};
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types.js
new file mode 100644
index 00000000000..07163550524
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types.js
@@ -0,0 +1,26 @@
+export const SET_MILESTONES_ENDPOINT = 'SET_MILESTONES_ENDPOINT';
+export const SET_LABELS_ENDPOINT = 'SET_LABELS_ENDPOINT';
+export const SET_GROUP_ENDPOINT = 'SET_GROUP_ENDPOINT';
+export const SET_PROJECT_ENDPOINT = 'SET_PROJECT_ENDPOINT';
+
+export const REQUEST_BRANCHES = 'REQUEST_BRANCHES';
+export const RECEIVE_BRANCHES_SUCCESS = 'RECEIVE_BRANCHES_SUCCESS';
+export const RECEIVE_BRANCHES_ERROR = 'RECEIVE_BRANCHES_ERROR';
+
+export const REQUEST_MILESTONES = 'REQUEST_MILESTONES';
+export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
+export const RECEIVE_MILESTONES_ERROR = 'RECEIVE_MILESTONES_ERROR';
+
+export const REQUEST_LABELS = 'REQUEST_LABELS';
+export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS';
+export const RECEIVE_LABELS_ERROR = 'RECEIVE_LABELS_ERROR';
+
+export const REQUEST_AUTHORS = 'REQUEST_AUTHORS';
+export const RECEIVE_AUTHORS_SUCCESS = 'RECEIVE_AUTHORS_SUCCESS';
+export const RECEIVE_AUTHORS_ERROR = 'RECEIVE_AUTHORS_ERROR';
+
+export const REQUEST_ASSIGNEES = 'REQUEST_ASSIGNEES';
+export const RECEIVE_ASSIGNEES_SUCCESS = 'RECEIVE_ASSIGNEES_SUCCESS';
+export const RECEIVE_ASSIGNEES_ERROR = 'RECEIVE_ASSIGNEES_ERROR';
+
+export const SET_SELECTED_FILTERS = 'SET_SELECTED_FILTERS';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutations.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutations.js
new file mode 100644
index 00000000000..056b1c6310f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/mutations.js
@@ -0,0 +1,109 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_SELECTED_FILTERS](state, params) {
+ const {
+ selectedSourceBranch = null,
+ selectedSourceBranchList = [],
+ selectedTargetBranch = null,
+ selectedTargetBranchList = [],
+ selectedAuthor = null,
+ selectedAuthorList = [],
+ selectedMilestone = null,
+ selectedMilestoneList = [],
+ selectedAssignee = null,
+ selectedAssigneeList = [],
+ selectedLabel = null,
+ selectedLabelList = [],
+ } = params;
+ state.branches.source.selected = selectedSourceBranch;
+ state.branches.source.selectedList = selectedSourceBranchList;
+ state.branches.target.selected = selectedTargetBranch;
+ state.branches.target.selectedList = selectedTargetBranchList;
+ state.authors.selected = selectedAuthor;
+ state.authors.selectedList = selectedAuthorList;
+ state.assignees.selected = selectedAssignee;
+ state.assignees.selectedList = selectedAssigneeList;
+ state.milestones.selected = selectedMilestone;
+ state.milestones.selectedList = selectedMilestoneList;
+ state.labels.selected = selectedLabel;
+ state.labels.selectedList = selectedLabelList;
+ },
+ [types.SET_MILESTONES_ENDPOINT](state, milestonesEndpoint) {
+ state.milestonesEndpoint = milestonesEndpoint;
+ },
+ [types.SET_LABELS_ENDPOINT](state, labelsEndpoint) {
+ state.labelsEndpoint = labelsEndpoint;
+ },
+ [types.SET_GROUP_ENDPOINT](state, groupEndpoint) {
+ state.groupEndpoint = groupEndpoint;
+ },
+ [types.SET_PROJECT_ENDPOINT](state, projectEndpoint) {
+ state.projectEndpoint = projectEndpoint;
+ },
+ [types.REQUEST_MILESTONES](state) {
+ state.milestones.isLoading = true;
+ },
+ [types.RECEIVE_MILESTONES_SUCCESS](state, data) {
+ state.milestones.isLoading = false;
+ state.milestones.data = data;
+ state.milestones.errorCode = null;
+ },
+ [types.RECEIVE_MILESTONES_ERROR](state, errorCode) {
+ state.milestones.isLoading = false;
+ state.milestones.errorCode = errorCode;
+ state.milestones.data = [];
+ },
+ [types.REQUEST_LABELS](state) {
+ state.labels.isLoading = true;
+ },
+ [types.RECEIVE_LABELS_SUCCESS](state, data) {
+ state.labels.isLoading = false;
+ state.labels.data = data;
+ state.labels.errorCode = null;
+ },
+ [types.RECEIVE_LABELS_ERROR](state, errorCode) {
+ state.labels.isLoading = false;
+ state.labels.errorCode = errorCode;
+ state.labels.data = [];
+ },
+ [types.REQUEST_AUTHORS](state) {
+ state.authors.isLoading = true;
+ },
+ [types.RECEIVE_AUTHORS_SUCCESS](state, data) {
+ state.authors.isLoading = false;
+ state.authors.data = data;
+ state.authors.errorCode = null;
+ },
+ [types.RECEIVE_AUTHORS_ERROR](state, errorCode) {
+ state.authors.isLoading = false;
+ state.authors.errorCode = errorCode;
+ state.authors.data = [];
+ },
+ [types.REQUEST_ASSIGNEES](state) {
+ state.assignees.isLoading = true;
+ },
+ [types.RECEIVE_ASSIGNEES_SUCCESS](state, data) {
+ state.assignees.isLoading = false;
+ state.assignees.data = data;
+ state.assignees.errorCode = null;
+ },
+ [types.RECEIVE_ASSIGNEES_ERROR](state, errorCode) {
+ state.assignees.isLoading = false;
+ state.assignees.errorCode = errorCode;
+ state.assignees.data = [];
+ },
+ [types.REQUEST_BRANCHES](state) {
+ state.branches.isLoading = true;
+ },
+ [types.RECEIVE_BRANCHES_SUCCESS](state, data) {
+ state.branches.isLoading = false;
+ state.branches.data = data;
+ state.branches.errorCode = null;
+ },
+ [types.RECEIVE_BRANCHES_ERROR](state, errorCode) {
+ state.branches.isLoading = false;
+ state.branches.errorCode = errorCode;
+ state.branches.data = [];
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/state.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/state.js
new file mode 100644
index 00000000000..f89f5efc341
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/state.js
@@ -0,0 +1,47 @@
+export default () => ({
+ milestonesEndpoint: '',
+ labelsEndpoint: '',
+ groupEndpoint: '',
+ projectEndpoint: '',
+ branches: {
+ isLoading: false,
+ errorCode: null,
+ data: [],
+ source: {
+ selected: null,
+ selectedList: [],
+ },
+ target: {
+ selected: null,
+ selectedList: [],
+ },
+ },
+ milestones: {
+ isLoading: false,
+ errorCode: null,
+ data: [],
+ selected: null,
+ selectedList: [],
+ },
+ labels: {
+ isLoading: false,
+ errorCode: null,
+ data: [],
+ selected: null,
+ selectedList: [],
+ },
+ authors: {
+ isLoading: false,
+ errorCode: null,
+ data: [],
+ selected: null,
+ selectedList: [],
+ },
+ assignees: {
+ isLoading: false,
+ errorCode: null,
+ data: [],
+ selected: null,
+ selectedList: [],
+ },
+});
diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
index da4b0aedef5..e895a7a52ab 100644
--- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
@@ -1,5 +1,5 @@
<script>
-import { escape } from 'lodash';
+import { escape, last } from 'lodash';
import Tribute from 'tributejs';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
@@ -12,6 +12,8 @@ const AutoComplete = {
MergeRequests: 'mergeRequests',
};
+const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings
+
function doesCurrentLineStartWith(searchString, fullText, selectionStart) {
const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length;
const currentLine = fullText.split('\n')[currentLineNumber - 1];
@@ -74,30 +76,40 @@ const autoCompleteMap = {
return this.members;
},
menuItemTemplate({ original }) {
- const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : '';
-
- const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass}
- gl-display-inline-flex! gl-align-items-center gl-justify-content-center`;
-
- const avatarTag = original.avatar_url
- ? `<img
- src="${original.avatar_url}"
- alt="${original.username}'s avatar"
- class="${avatarClasses}"/>`
- : `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`;
-
- const name = escape(original.name);
+ const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0';
+ const noAvatarClasses = `${commonClasses} gl-rounded-small
+ gl-display-flex gl-align-items-center gl-justify-content-center`;
+
+ const avatar = original.avatar_url
+ ? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />`
+ : `<div class="${noAvatarClasses}" aria-hidden="true">
+ ${original.username.charAt(0).toUpperCase()}</div>`;
+
+ let displayName = original.name;
+ let parentGroupOrUsername = `@${original.username}`;
+
+ if (original.type === groupType) {
+ const splitName = original.name.split(' / ');
+ displayName = splitName.pop();
+ parentGroupOrUsername = splitName.pop();
+ }
const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
- const icon = original.mentionsDisabled
- ? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3')
+ const disabledMentionsIcon = original.mentionsDisabled
+ ? spriteIcon('notifications-off', 's16 gl-ml-3')
: '';
- return `${avatarTag}
- ${original.username}
- <small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small>
- ${icon}`;
+ return `
+ <div class="gl-display-flex gl-align-items-center">
+ ${avatar}
+ <div class="gl-font-sm gl-line-height-normal gl-ml-3">
+ <div>${escape(displayName)}${count}</div>
+ <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div>
+ </div>
+ ${disabledMentionsIcon}
+ </div>
+ `;
},
},
[AutoComplete.MergeRequests]: {
@@ -134,7 +146,8 @@ export default {
{
trigger: '@',
fillAttr: 'username',
- lookup: value => value.name + value.username,
+ lookup: value =>
+ value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username,
menuItemTemplate: autoCompleteMap[AutoComplete.Members].menuItemTemplate,
values: this.getValues(AutoComplete.Members),
},
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index 6ff6f10f786..79d9ba6df57 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -1,10 +1,11 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlTooltipDirective, GlLink, GlDeprecatedButton } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
+import { GlTooltipDirective, GlLink, GlButton, GlTooltip } from '@gitlab/ui';
import CiIconBadge from './ci_badge_link.vue';
import TimeagoTooltip from './time_ago_tooltip.vue';
import UserAvatarImage from './user_avatar/user_avatar_image.vue';
+import { glEmojiTag } from '../../emoji';
+import { __, sprintf } from '../../locale';
/**
* Renders header component for job and pipeline page based on UI mockups
@@ -19,11 +20,13 @@ export default {
TimeagoTooltip,
UserAvatarImage,
GlLink,
- GlDeprecatedButton,
+ GlButton,
+ GlTooltip,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ EMOJI_REF: 'EMOJI_REF',
props: {
status: {
type: Object,
@@ -62,6 +65,27 @@ export default {
userAvatarAltText() {
return sprintf(__(`%{username}'s avatar`), { username: this.user.name });
},
+ userPath() {
+ // GraphQL returns `webPath` and Rest `path`
+ return this.user?.webPath || this.user?.path;
+ },
+ avatarUrl() {
+ // GraphQL returns `avatarUrl` and Rest `avatar_url`
+ return this.user?.avatarUrl || this.user?.avatar_url;
+ },
+ statusTooltipHTML() {
+ // Rest `status_tooltip_html` which is a ready to work
+ // html for the emoji and the status text inside a tooltip.
+ // GraphQL returns `status.emoji` and `status.message` which
+ // needs to be combined to make the html we want.
+ const { emoji } = this.user?.status || {};
+ const emojiHtml = emoji ? glEmojiTag(emoji) : '';
+
+ return emojiHtml || this.user?.status_tooltip_html;
+ },
+ message() {
+ return this.user?.status?.message;
+ },
},
methods: {
@@ -73,7 +97,11 @@ export default {
</script>
<template>
- <header class="page-content-header ci-header-container">
+ <header
+ class="page-content-header gl-display-flex gl-min-h-7"
+ data-qa-selector="pipeline_header"
+ data-testid="ci-header-content"
+ >
<section class="header-main-content">
<ci-icon-badge :status="status" />
@@ -89,12 +117,12 @@ export default {
<template v-if="user">
<gl-link
v-gl-tooltip
- :href="user.path"
+ :href="userPath"
:title="user.email"
class="js-user-link commit-committer-link"
>
<user-avatar-image
- :img-src="user.avatar_url"
+ :img-src="avatarUrl"
:img-alt="userAvatarAltText"
:tooltip-text="user.name"
:img-size="24"
@@ -102,21 +130,27 @@ export default {
{{ user.name }}
</gl-link>
- <span v-if="user.status_tooltip_html" v-html="user.status_tooltip_html"></span>
+ <gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]">
+ {{ message }}
+ </gl-tooltip>
+ <span
+ v-if="statusTooltipHTML"
+ :ref="$options.EMOJI_REF"
+ :data-testid="message"
+ v-html="statusTooltipHTML"
+ ></span>
</template>
</section>
<section v-if="$slots.default" data-testid="headerButtons" class="gl-display-flex">
<slot></slot>
</section>
- <gl-deprecated-button
+ <gl-button
v-if="hasSidebarButton"
- id="toggleSidebar"
- class="d-block d-sm-none
-sidebar-toggle-btn js-sidebar-build-toggle js-sidebar-build-toggle-header"
+ class="d-sm-none js-sidebar-build-toggle gl-ml-auto"
+ icon="angle-double-left"
+ :aria-label="__('Toggle sidebar')"
@click="onClickSidebarButton"
- >
- <i class="fa fa-angle-double-left" aria-hidden="true" aria-labelledby="toggleSidebar"> </i>
- </gl-deprecated-button>
+ />
</header>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
index b5d6b872547..80c03342f11 100644
--- a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
+++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
@@ -1,4 +1,6 @@
<script>
+import { isEqual } from 'lodash';
+
export default {
props: {
storageKey: {
@@ -6,31 +8,65 @@ export default {
required: true,
},
value: {
- type: String,
+ type: [String, Number, Boolean, Array, Object],
required: false,
default: '',
},
+ asJson: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ persist: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
watch: {
value(newVal) {
- this.saveValue(newVal);
+ this.saveValue(this.serialize(newVal));
},
},
mounted() {
// On mount, trigger update if we actually have a localStorageValue
- const value = this.getValue();
+ const { exists, value } = this.getStorageValue();
- if (value && this.value !== value) {
+ if (exists && !isEqual(value, this.value)) {
this.$emit('input', value);
}
},
methods: {
- getValue() {
- return localStorage.getItem(this.storageKey);
+ getStorageValue() {
+ const value = localStorage.getItem(this.storageKey);
+
+ if (value === null) {
+ return { exists: false };
+ }
+
+ try {
+ return { exists: true, value: this.deserialize(value) };
+ } catch {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[gitlab] Failed to deserialize value from localStorage (key=${this.storageKey})`,
+ value,
+ );
+ // default to "don't use localStorage value"
+ return { exists: false };
+ }
},
saveValue(val) {
+ if (!this.persist) return;
+
localStorage.setItem(this.storageKey, val);
},
+ serialize(val) {
+ return this.asJson ? JSON.stringify(val) : val;
+ },
+ deserialize(val) {
+ return this.asJson ? JSON.parse(val) : val;
+ },
},
render() {
return this.$slots.default;
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index a48c279d0e3..65116ed8ca3 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -25,6 +25,18 @@ export default {
},
mixins: [glFeatureFlagsMixin()],
props: {
+ /**
+ * This prop should be bound to the value of the `<textarea>` element
+ * that is rendered as a child of this component (in the `textarea` slot)
+ */
+ textareaValue: {
+ type: String,
+ required: true,
+ },
+ markdownDocsPath: {
+ type: String,
+ required: true,
+ },
isSubmitting: {
type: Boolean,
required: false,
@@ -35,10 +47,6 @@ export default {
required: false,
default: '',
},
- markdownDocsPath: {
- type: String,
- required: true,
- },
addSpacingClasses: {
type: Boolean,
required: false,
@@ -84,12 +92,6 @@ export default {
required: false,
default: false,
},
- // This prop is used as a fallback in case if textarea.elm is undefined
- textareaValue: {
- type: String,
- required: false,
- default: '',
- },
},
data() {
return {
@@ -165,17 +167,20 @@ export default {
},
mounted() {
// GLForm class handles all the toolbar buttons
- return new GLForm($(this.$refs['gl-form']), {
- emojis: this.enableAutocomplete,
- members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- epics: this.enableAutocomplete,
- milestones: this.enableAutocomplete,
- labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- snippets: this.enableAutocomplete,
- vulnerabilities: this.enableAutocomplete,
- });
+ return new GLForm(
+ $(this.$refs['gl-form']),
+ {
+ emojis: this.enableAutocomplete,
+ members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
+ issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
+ mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
+ epics: this.enableAutocomplete,
+ milestones: this.enableAutocomplete,
+ labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
+ snippets: this.enableAutocomplete,
+ },
+ true,
+ );
},
beforeDestroy() {
const glForm = $(this.$refs['gl-form']).data('glForm');
@@ -189,17 +194,11 @@ export default {
this.previewMarkdown = true;
- /*
- Can't use `$refs` as the component is technically in the parent component
- so we access the VNode & then get the element
- */
- const text = this.$slots.textarea[0]?.elm?.value || this.textareaValue;
-
- if (text) {
+ if (this.textareaValue) {
this.markdownPreviewLoading = true;
this.markdownPreview = __('Loading…');
axios
- .post(this.markdownPreviewPath, { text })
+ .post(this.markdownPreviewPath, { text: this.textareaValue })
.then(response => this.renderMarkdown(response.data))
.catch(() => new Flash(__('Error loading markdown preview')));
} else {
@@ -234,7 +233,7 @@ export default {
<div
ref="gl-form"
:class="{ 'gl-mt-3 gl-mb-3': addSpacingClasses }"
- class="js-vue-markdown-field md-area position-relative"
+ class="js-vue-markdown-field md-area position-relative gfm-form"
>
<markdown-header
:preview-markdown="previewMarkdown"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
index 13c42d35b04..13ec7a6ada9 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -27,6 +27,11 @@ export default {
type: String,
required: true,
},
+ suggestionsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
computed: {
batchSuggestionsCount() {
@@ -62,6 +67,7 @@ export default {
<div class="md-suggestion">
<suggestion-diff-header
class="qa-suggestion-diff-header js-suggestion-diff-header"
+ :suggestions-count="suggestionsCount"
:can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled"
:is-applied="suggestion.applied"
:is-batched="isBatched"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index 1fc54d2f52e..fb9636ba734 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -42,6 +42,11 @@ export default {
required: false,
default: null,
},
+ suggestionsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
@@ -127,7 +132,7 @@ export default {
</div>
<div v-else class="d-flex align-items-center">
<gl-button
- v-if="canBeBatched && !isDisableButton"
+ v-if="suggestionsCount > 1 && canBeBatched && !isDisableButton"
class="btn-inverted js-add-to-batch-btn btn-grouped"
:disabled="isDisableButton"
@click="addSuggestionToBatch"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 083f581af05..927a93487e6 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -38,6 +38,11 @@ export default {
type: String,
required: true,
},
+ suggestionsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
@@ -77,12 +82,12 @@ export default {
this.isRendered = true;
},
generateDiff(suggestionIndex) {
- const { suggestions, disabled, batchSuggestionsInfo, helpPagePath } = this;
+ const { suggestions, disabled, batchSuggestionsInfo, helpPagePath, suggestionsCount } = this;
const suggestion =
suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
const SuggestionDiffComponent = Vue.extend(SuggestionDiff);
const suggestionDiff = new SuggestionDiffComponent({
- propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath },
+ propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath, suggestionsCount },
});
suggestionDiff.$on('apply', ({ suggestionId, callback }) => {
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue
new file mode 100644
index 00000000000..10078d5cd64
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue
@@ -0,0 +1,59 @@
+<script>
+import ActionButtonGroup from './action_button_group.vue';
+import RemoveMemberButton from './remove_member_button.vue';
+import ApproveAccessRequestButton from './approve_access_request_button.vue';
+import { s__, sprintf } from '~/locale';
+
+export default {
+ name: 'AccessRequestActionButtons',
+ components: { ActionButtonGroup, RemoveMemberButton, ApproveAccessRequestButton },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ permissions: {
+ type: Object,
+ required: true,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ message() {
+ const { user, source } = this.member;
+
+ if (this.isCurrentUser) {
+ return sprintf(
+ s__('Members|Are you sure you want to withdraw your access request for "%{source}"'),
+ { source: source.name },
+ );
+ }
+
+ return sprintf(
+ s__('Members|Are you sure you want to deny %{usersName}\'s request to join "%{source}"'),
+ { usersName: user.name, source: source.name },
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <action-button-group>
+ <div v-if="permissions.canUpdate" class="gl-px-1">
+ <approve-access-request-button :member-id="member.id" />
+ </div>
+ <div v-if="permissions.canRemove" class="gl-px-1">
+ <remove-member-button
+ :member-id="member.id"
+ :message="message"
+ :title="s__('Member|Deny access')"
+ :is-access-request="true"
+ icon="close"
+ />
+ </div>
+ </action-button-group>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue
new file mode 100644
index 00000000000..8356fdb60b1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue
@@ -0,0 +1,11 @@
+<script>
+export default {
+ name: 'ActionButtonGroup',
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-align-items-center gl-justify-content-end gl-mx-n1">
+ <slot></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue
new file mode 100644
index 00000000000..e8a53ff173d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue
@@ -0,0 +1,42 @@
+<script>
+import { mapState } from 'vuex';
+import { GlButton, GlForm, GlTooltipDirective } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import { __ } from '~/locale';
+
+export default {
+ name: 'ApproveAccessRequestButton',
+ csrf,
+ title: __('Grant access'),
+ components: { GlButton, GlForm },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ memberId: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['memberPath']),
+ approvePath() {
+ return this.memberPath.replace(/:id$/, `${this.memberId}/approve_access_request`);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form :action="approvePath" method="post">
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <gl-button
+ v-gl-tooltip.hover
+ :title="$options.title"
+ :aria-label="$options.title"
+ icon="check"
+ variant="success"
+ type="submit"
+ />
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue
new file mode 100644
index 00000000000..2aebfe80db5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue
@@ -0,0 +1,27 @@
+<script>
+import ActionButtonGroup from './action_button_group.vue';
+import RemoveGroupLinkButton from './remove_group_link_button.vue';
+
+export default {
+ name: 'GroupActionButtons',
+ components: { ActionButtonGroup, RemoveGroupLinkButton },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ permissions: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <action-button-group>
+ <div v-if="permissions.canRemove" class="gl-px-1">
+ <remove-group-link-button :group-link="member" />
+ </div>
+ </action-button-group>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue
new file mode 100644
index 00000000000..2b0a75640e2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue
@@ -0,0 +1,48 @@
+<script>
+import ActionButtonGroup from './action_button_group.vue';
+import RemoveMemberButton from './remove_member_button.vue';
+import ResendInviteButton from './resend_invite_button.vue';
+import { s__, sprintf } from '~/locale';
+
+export default {
+ name: 'InviteActionButtons',
+ components: { ActionButtonGroup, RemoveMemberButton, ResendInviteButton },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ permissions: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ message() {
+ const { invite, source } = this.member;
+
+ return sprintf(
+ s__(
+ 'Members|Are you sure you want to revoke the invitation for %{inviteEmail} to join "%{source}"',
+ ),
+ { inviteEmail: invite.email, source: source.name },
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <action-button-group>
+ <div v-if="permissions.canResend" class="gl-px-1">
+ <resend-invite-button :member-id="member.id" />
+ </div>
+ <div v-if="permissions.canRemove" class="gl-px-1">
+ <remove-member-button
+ :member-id="member.id"
+ :message="message"
+ :title="s__('Member|Revoke invite')"
+ />
+ </div>
+ </action-button-group>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue
new file mode 100644
index 00000000000..d9976e7181c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue
@@ -0,0 +1,40 @@
+<script>
+import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import LeaveModal from '../modals/leave_modal.vue';
+import { LEAVE_MODAL_ID } from '../constants';
+
+export default {
+ name: 'LeaveButton',
+ title: __('Leave'),
+ modalId: LEAVE_MODAL_ID,
+ components: {
+ GlButton,
+ LeaveModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-button
+ v-gl-tooltip.hover
+ v-gl-modal="$options.modalId"
+ :title="$options.title"
+ :aria-label="$options.title"
+ icon="leave"
+ variant="danger"
+ />
+ <leave-modal :member="member" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue
new file mode 100644
index 00000000000..9d89cb40676
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue
@@ -0,0 +1,36 @@
+<script>
+import { mapActions } from 'vuex';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'RemoveGroupLinkButton',
+ i18n: {
+ buttonTitle: s__('Members|Remove group'),
+ },
+ components: { GlButton },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ groupLink: {
+ type: Object,
+ required: true,
+ },
+ },
+ methods: {
+ ...mapActions(['showRemoveGroupLinkModal']),
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip.hover
+ variant="danger"
+ :title="$options.i18n.buttonTitle"
+ :aria-label="$options.i18n.buttonTitle"
+ icon="remove"
+ @click="showRemoveGroupLinkModal(groupLink)"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue
new file mode 100644
index 00000000000..b0b7ff4ce9a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue
@@ -0,0 +1,57 @@
+<script>
+import { mapState } from 'vuex';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+
+export default {
+ name: 'RemoveMemberButton',
+ components: { GlButton },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ memberId: {
+ type: Number,
+ required: true,
+ },
+ message: {
+ type: String,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ icon: {
+ type: String,
+ required: false,
+ default: 'remove',
+ },
+ isAccessRequest: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapState(['memberPath']),
+ computedMemberPath() {
+ return this.memberPath.replace(':id', this.memberId);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip.hover
+ class="js-remove-member-button"
+ variant="danger"
+ :title="title"
+ :aria-label="title"
+ :icon="icon"
+ :data-member-path="computedMemberPath"
+ :data-is-access-request="isAccessRequest"
+ :data-message="message"
+ data-qa-selector="delete_member_button"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue
new file mode 100644
index 00000000000..1cc3fd17e98
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue
@@ -0,0 +1,41 @@
+<script>
+import { mapState } from 'vuex';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import { __ } from '~/locale';
+
+export default {
+ name: 'ResendInviteButton',
+ csrf,
+ title: __('Resend invite'),
+ components: { GlButton },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ memberId: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['memberPath']),
+ resendPath() {
+ return this.memberPath.replace(/:id$/, `${this.memberId}/resend_invite`);
+ },
+ },
+};
+</script>
+
+<template>
+ <form :action="resendPath" method="post">
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <gl-button
+ v-gl-tooltip.hover
+ :title="$options.title"
+ :aria-label="$options.title"
+ icon="paper-airplane"
+ type="submit"
+ />
+ </form>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue
new file mode 100644
index 00000000000..8fa3d439fc1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue
@@ -0,0 +1,61 @@
+<script>
+import ActionButtonGroup from './action_button_group.vue';
+import RemoveMemberButton from './remove_member_button.vue';
+import LeaveButton from './leave_button.vue';
+import { s__, sprintf } from '~/locale';
+
+export default {
+ name: 'UserActionButtons',
+ components: { ActionButtonGroup, RemoveMemberButton, LeaveButton },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: true,
+ },
+ permissions: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ message() {
+ const { user, source } = this.member;
+
+ if (user) {
+ return sprintf(
+ s__('Members|Are you sure you want to remove %{usersName} from "%{source}"'),
+ {
+ usersName: user.name,
+ source: source.name,
+ },
+ );
+ }
+
+ return sprintf(
+ s__('Members|Are you sure you want to remove this orphaned member from "%{source}"'),
+ {
+ source: source.name,
+ },
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <action-button-group>
+ <div v-if="permissions.canRemove" class="gl-px-1">
+ <leave-button v-if="isCurrentUser" :member="member" />
+ <remove-member-button
+ v-else
+ :member-id="member.id"
+ :message="message"
+ :title="s__('Member|Remove member')"
+ />
+ </div>
+ </action-button-group>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue
new file mode 100644
index 00000000000..12b748f9ab6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui';
+import { AVATAR_SIZE } from '../constants';
+
+export default {
+ name: 'GroupAvatar',
+ avatarSize: AVATAR_SIZE,
+ components: { GlAvatarLink, GlAvatarLabeled },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ group() {
+ return this.member.sharedWithGroup;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-avatar-link :href="group.webUrl">
+ <gl-avatar-labeled
+ :label="group.fullName"
+ :src="group.avatarUrl"
+ :alt="group.fullName"
+ :size="$options.avatarSize"
+ :entity-name="group.name"
+ :entity-id="group.id"
+ />
+ </gl-avatar-link>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue
new file mode 100644
index 00000000000..28654a60860
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue
@@ -0,0 +1,32 @@
+<script>
+import { GlAvatarLabeled } from '@gitlab/ui';
+import { AVATAR_SIZE } from '../constants';
+
+export default {
+ name: 'InviteAvatar',
+ avatarSize: AVATAR_SIZE,
+ components: { GlAvatarLabeled },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ invite() {
+ return this.member.invite;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-avatar-labeled
+ :label="invite.email"
+ :src="invite.avatarUrl"
+ :alt="invite.email"
+ :size="$options.avatarSize"
+ :entity-name="invite.email"
+ :entity-id="member.id"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue
new file mode 100644
index 00000000000..e5e7cdf149c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue
@@ -0,0 +1,91 @@
+<script>
+import {
+ GlAvatarLink,
+ GlAvatarLabeled,
+ GlBadge,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
+import { generateBadges } from 'ee_else_ce/vue_shared/components/members/utils';
+import { __ } from '~/locale';
+import { AVATAR_SIZE } from '../constants';
+import { glEmojiTag } from '~/emoji';
+
+export default {
+ name: 'UserAvatar',
+ avatarSize: AVATAR_SIZE,
+ orphanedUserLabel: __('Orphaned member'),
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
+ components: {
+ GlAvatarLink,
+ GlAvatarLabeled,
+ GlBadge,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ user() {
+ return this.member.user;
+ },
+ badges() {
+ return generateBadges(this.member, this.isCurrentUser).filter(badge => badge.show);
+ },
+ statusEmoji() {
+ return this.user?.status?.emoji;
+ },
+ },
+ methods: {
+ glEmojiTag,
+ },
+};
+</script>
+
+<template>
+ <gl-avatar-link
+ v-if="user"
+ class="js-user-link"
+ :href="user.webUrl"
+ :data-user-id="user.id"
+ :data-username="user.username"
+ >
+ <gl-avatar-labeled
+ :label="user.name"
+ :sub-label="`@${user.username}`"
+ :src="user.avatarUrl"
+ :alt="user.name"
+ :size="$options.avatarSize"
+ :entity-name="user.name"
+ :entity-id="user.id"
+ >
+ <template #meta>
+ <div v-if="statusEmoji" class="gl-p-1">
+ <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag(statusEmoji)"></span>
+ </div>
+ <div v-for="badge in badges" :key="badge.text" class="gl-p-1">
+ <gl-badge size="sm" :variant="badge.variant">
+ {{ badge.text }}
+ </gl-badge>
+ </div>
+ </template>
+ </gl-avatar-labeled>
+ </gl-avatar-link>
+
+ <gl-avatar-labeled
+ v-else
+ :label="$options.orphanedUserLabel"
+ :alt="$options.orphanedUserLabel"
+ :size="$options.avatarSize"
+ :entity-name="$options.orphanedUserLabel"
+ :entity-id="member.id"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/constants.js b/app/assets/javascripts/vue_shared/components/members/constants.js
new file mode 100644
index 00000000000..6509779053e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/constants.js
@@ -0,0 +1,70 @@
+import { __ } from '~/locale';
+
+export const FIELDS = [
+ {
+ key: 'account',
+ label: __('Account'),
+ },
+ {
+ key: 'source',
+ label: __('Source'),
+ thClass: 'col-meta',
+ tdClass: 'col-meta',
+ },
+ {
+ key: 'granted',
+ label: __('Access granted'),
+ thClass: 'col-meta',
+ tdClass: 'col-meta',
+ },
+ {
+ key: 'invited',
+ label: __('Invited'),
+ thClass: 'col-meta',
+ tdClass: 'col-meta',
+ },
+ {
+ key: 'requested',
+ label: __('Requested'),
+ thClass: 'col-meta',
+ tdClass: 'col-meta',
+ },
+ {
+ key: 'expires',
+ label: __('Access expires'),
+ thClass: 'col-meta',
+ tdClass: 'col-meta',
+ },
+ {
+ key: 'maxRole',
+ label: __('Max role'),
+ thClass: 'col-max-role',
+ tdClass: 'col-max-role',
+ },
+ {
+ key: 'expiration',
+ label: __('Expiration'),
+ thClass: 'col-expiration',
+ tdClass: 'col-expiration',
+ },
+ {
+ key: 'actions',
+ thClass: 'col-actions',
+ tdClass: 'col-actions',
+ },
+];
+
+export const AVATAR_SIZE = 48;
+
+export const MEMBER_TYPES = {
+ user: 'user',
+ group: 'group',
+ invite: 'invite',
+ accessRequest: 'accessRequest',
+};
+
+export const DAYS_TO_EXPIRE_SOON = 7;
+
+export const LEAVE_MODAL_ID = 'member-leave-modal';
+
+export const REMOVE_GROUP_LINK_MODAL_ID = 'remove-group-link-modal-id';
diff --git a/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue b/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue
new file mode 100644
index 00000000000..9a2ce0d4931
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue
@@ -0,0 +1,70 @@
+<script>
+import { mapState } from 'vuex';
+import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import { __, s__, sprintf } from '~/locale';
+import { LEAVE_MODAL_ID } from '../constants';
+
+export default {
+ name: 'LeaveModal',
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ actionPrimary: {
+ text: __('Leave'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ csrf,
+ modalId: LEAVE_MODAL_ID,
+ modalContent: s__('Members|Are you sure you want to leave "%{source}"?'),
+ components: { GlModal, GlForm, GlSprintf },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['memberPath']),
+ leavePath() {
+ return this.memberPath.replace(/:id$/, 'leave');
+ },
+ modalTitle() {
+ return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.name });
+ },
+ },
+ methods: {
+ handlePrimary() {
+ this.$refs.form.$el.submit();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ v-bind="$attrs"
+ :modal-id="$options.modalId"
+ :title="modalTitle"
+ :action-primary="$options.actionPrimary"
+ :action-cancel="$options.actionCancel"
+ size="sm"
+ @primary="handlePrimary"
+ >
+ <gl-form ref="form" :action="leavePath" method="post">
+ <p>
+ <gl-sprintf :message="$options.modalContent">
+ <template #source>{{ member.source.name }}</template>
+ </gl-sprintf>
+ </p>
+
+ <input type="hidden" name="_method" value="delete" />
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ </gl-form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue b/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue
new file mode 100644
index 00000000000..e8890717724
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue
@@ -0,0 +1,69 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { GlModal, GlSprintf, GlForm } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import { __, s__, sprintf } from '~/locale';
+import { REMOVE_GROUP_LINK_MODAL_ID } from '../constants';
+
+export default {
+ name: 'RemoveGroupLinkModal',
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ actionPrimary: {
+ text: s__('Members|Remove group'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ csrf,
+ i18n: {
+ modalBody: s__('Members|Are you sure you want to remove "%{groupName}"?'),
+ },
+ modalId: REMOVE_GROUP_LINK_MODAL_ID,
+ components: { GlModal, GlSprintf, GlForm },
+ computed: {
+ ...mapState(['memberPath', 'groupLinkToRemove', 'removeGroupLinkModalVisible']),
+ groupLinkPath() {
+ return this.memberPath.replace(/:id$/, this.groupLinkToRemove?.id);
+ },
+ groupName() {
+ return this.groupLinkToRemove?.sharedWithGroup.fullName;
+ },
+ modalTitle() {
+ return sprintf(s__('Members|Remove "%{groupName}"'), { groupName: this.groupName });
+ },
+ },
+ methods: {
+ ...mapActions(['hideRemoveGroupLinkModal']),
+ handlePrimary() {
+ this.$refs.form.$el.submit();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ v-bind="$attrs"
+ :modal-id="$options.modalId"
+ :visible="removeGroupLinkModalVisible"
+ :title="modalTitle"
+ :action-primary="$options.actionPrimary"
+ :action-cancel="$options.actionCancel"
+ size="sm"
+ @primary="handlePrimary"
+ @hide="hideRemoveGroupLinkModal"
+ >
+ <gl-form ref="form" :action="groupLinkPath" method="post">
+ <p>
+ <gl-sprintf :message="$options.i18n.modalBody">
+ <template #groupName>{{ groupName }}</template>
+ </gl-sprintf>
+ </p>
+
+ <input type="hidden" name="_method" value="delete" />
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ </gl-form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/table/created_at.vue b/app/assets/javascripts/vue_shared/components/members/table/created_at.vue
new file mode 100644
index 00000000000..0bad70894f9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/created_at.vue
@@ -0,0 +1,40 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ name: 'CreatedAt',
+ components: { GlSprintf, TimeAgoTooltip },
+ props: {
+ date: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ createdBy: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ showCreatedBy() {
+ return this.createdBy?.name && this.createdBy?.webUrl;
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-sprintf v-if="showCreatedBy" :message="s__('Members|%{time} by %{user}')">
+ <template #time>
+ <time-ago-tooltip :time="date" />
+ </template>
+ <template #user>
+ <a :href="createdBy.webUrl">{{ createdBy.name }}</a>
+ </template>
+ </gl-sprintf>
+ <time-ago-tooltip v-else :time="date" />
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue b/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue
new file mode 100644
index 00000000000..de65e3fb10f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+import {
+ approximateDuration,
+ differenceInSeconds,
+ formatDate,
+ getDayDifference,
+} from '~/lib/utils/datetime_utility';
+import { DAYS_TO_EXPIRE_SOON } from '../constants';
+
+export default {
+ name: 'ExpiresAt',
+ components: { GlSprintf },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ date: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ noExpirationSet() {
+ return this.date === null;
+ },
+ parsed() {
+ return new Date(this.date);
+ },
+ differenceInSeconds() {
+ return differenceInSeconds(new Date(), this.parsed);
+ },
+ isExpired() {
+ return this.differenceInSeconds <= 0;
+ },
+ inWords() {
+ return approximateDuration(this.differenceInSeconds);
+ },
+ formatted() {
+ return formatDate(this.parsed);
+ },
+ expiresSoon() {
+ return getDayDifference(new Date(), this.parsed) < DAYS_TO_EXPIRE_SOON;
+ },
+ cssClass() {
+ return {
+ 'gl-text-red-500': this.isExpired,
+ 'gl-text-orange-500': this.expiresSoon,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <span v-if="noExpirationSet">{{ s__('Members|No expiration set') }}</span>
+ <span v-else v-gl-tooltip.hover :title="formatted" :class="cssClass">
+ <template v-if="isExpired">{{ s__('Members|Expired') }}</template>
+ <gl-sprintf v-else :message="s__('Members|in %{time}')">
+ <template #time>
+ {{ inWords }}
+ </template>
+ </gl-sprintf>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue
new file mode 100644
index 00000000000..320d8c99223
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue
@@ -0,0 +1,57 @@
+<script>
+import UserActionButtons from '../action_buttons/user_action_buttons.vue';
+import GroupActionButtons from '../action_buttons/group_action_buttons.vue';
+import InviteActionButtons from '../action_buttons/invite_action_buttons.vue';
+import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue';
+import { MEMBER_TYPES } from '../constants';
+
+export default {
+ name: 'MemberActionButtons',
+ components: {
+ UserActionButtons,
+ GroupActionButtons,
+ InviteActionButtons,
+ AccessRequestActionButtons,
+ },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ memberType: {
+ type: String,
+ required: true,
+ },
+ permissions: {
+ type: Object,
+ required: true,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ actionButtonComponent() {
+ const dictionary = {
+ [MEMBER_TYPES.user]: 'user-action-buttons',
+ [MEMBER_TYPES.group]: 'group-action-buttons',
+ [MEMBER_TYPES.invite]: 'invite-action-buttons',
+ [MEMBER_TYPES.accessRequest]: 'access-request-action-buttons',
+ };
+
+ return dictionary[this.memberType];
+ },
+ },
+};
+</script>
+
+<template>
+ <component
+ :is="actionButtonComponent"
+ v-if="actionButtonComponent"
+ :member="member"
+ :permissions="permissions"
+ :is-current-user="isCurrentUser"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue b/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue
new file mode 100644
index 00000000000..a1f98d4008a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue
@@ -0,0 +1,35 @@
+<script>
+import { kebabCase } from 'lodash';
+import UserAvatar from '../avatars/user_avatar.vue';
+import InviteAvatar from '../avatars/invite_avatar.vue';
+import GroupAvatar from '../avatars/group_avatar.vue';
+
+export default {
+ name: 'MemberAvatar',
+ components: { UserAvatar, InviteAvatar, GroupAvatar, AccessRequestAvatar: UserAvatar },
+ props: {
+ memberType: {
+ type: String,
+ required: true,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: true,
+ },
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ avatarComponent() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `${kebabCase(this.memberType)}-avatar`;
+ },
+ },
+};
+</script>
+
+<template>
+ <component :is="avatarComponent" :member="member" :is-current-user="isCurrentUser" />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_source.vue b/app/assets/javascripts/vue_shared/components/members/table/member_source.vue
new file mode 100644
index 00000000000..030d72c3420
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/member_source.vue
@@ -0,0 +1,27 @@
+<script>
+import { GlTooltipDirective } from '@gitlab/ui';
+
+export default {
+ name: 'MemberSource',
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ memberSource: {
+ type: Object,
+ required: true,
+ },
+ isDirectMember: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span v-if="isDirectMember">{{ __('Direct member') }}</span>
+ <a v-else v-gl-tooltip.hover :title="__('Inherited')" :href="memberSource.webUrl">{{
+ memberSource.name
+ }}</a>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue
new file mode 100644
index 00000000000..c1a80a85dbe
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue
@@ -0,0 +1,110 @@
+<script>
+import { mapState } from 'vuex';
+import { GlTable, GlBadge } from '@gitlab/ui';
+import { FIELDS } from '../constants';
+import initUserPopovers from '~/user_popovers';
+import MemberAvatar from './member_avatar.vue';
+import MemberSource from './member_source.vue';
+import CreatedAt from './created_at.vue';
+import ExpiresAt from './expires_at.vue';
+import MemberActionButtons from './member_action_buttons.vue';
+import MembersTableCell from './members_table_cell.vue';
+import RoleDropdown from './role_dropdown.vue';
+import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
+
+export default {
+ name: 'MembersTable',
+ components: {
+ GlTable,
+ GlBadge,
+ MemberAvatar,
+ CreatedAt,
+ ExpiresAt,
+ MembersTableCell,
+ MemberSource,
+ MemberActionButtons,
+ RoleDropdown,
+ RemoveGroupLinkModal,
+ },
+ computed: {
+ ...mapState(['members', 'tableFields']),
+ filteredFields() {
+ return FIELDS.filter(field => this.tableFields.includes(field.key));
+ },
+ },
+ mounted() {
+ initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-table
+ class="members-table"
+ head-variant="white"
+ stacked="lg"
+ :fields="filteredFields"
+ :items="members"
+ primary-key="id"
+ thead-class="border-bottom"
+ :empty-text="__('No members found')"
+ show-empty
+ >
+ <template #cell(account)="{ item: member }">
+ <members-table-cell #default="{ memberType, isCurrentUser }" :member="member">
+ <member-avatar
+ :member-type="memberType"
+ :is-current-user="isCurrentUser"
+ :member="member"
+ />
+ </members-table-cell>
+ </template>
+
+ <template #cell(source)="{ item: member }">
+ <members-table-cell #default="{ isDirectMember }" :member="member">
+ <member-source :is-direct-member="isDirectMember" :member-source="member.source" />
+ </members-table-cell>
+ </template>
+
+ <template #cell(granted)="{ item: { createdAt, createdBy } }">
+ <created-at :date="createdAt" :created-by="createdBy" />
+ </template>
+
+ <template #cell(invited)="{ item: { createdAt, createdBy } }">
+ <created-at :date="createdAt" :created-by="createdBy" />
+ </template>
+
+ <template #cell(requested)="{ item: { createdAt } }">
+ <created-at :date="createdAt" />
+ </template>
+
+ <template #cell(expires)="{ item: { expiresAt } }">
+ <expires-at :date="expiresAt" />
+ </template>
+
+ <template #cell(maxRole)="{ item: member }">
+ <members-table-cell #default="{ permissions }" :member="member">
+ <role-dropdown v-if="permissions.canUpdate" :member="member" />
+ <gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge>
+ </members-table-cell>
+ </template>
+
+ <template #cell(actions)="{ item: member }">
+ <members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member">
+ <member-action-buttons
+ :member-type="memberType"
+ :is-current-user="isCurrentUser"
+ :permissions="permissions"
+ :member="member"
+ />
+ </members-table-cell>
+ </template>
+
+ <template #head(actions)="{ label }">
+ <span data-testid="col-actions" class="gl-sr-only">{{ label }}</span>
+ </template>
+ </gl-table>
+ <remove-group-link-modal />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue
new file mode 100644
index 00000000000..5602978bb6c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue
@@ -0,0 +1,64 @@
+<script>
+import { mapState } from 'vuex';
+import { MEMBER_TYPES } from '../constants';
+
+export default {
+ name: 'MembersTableCell',
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['sourceId', 'currentUserId']),
+ isGroup() {
+ return Boolean(this.member.sharedWithGroup);
+ },
+ isInvite() {
+ return Boolean(this.member.invite);
+ },
+ isAccessRequest() {
+ return Boolean(this.member.requestedAt);
+ },
+ memberType() {
+ if (this.isGroup) {
+ return MEMBER_TYPES.group;
+ } else if (this.isInvite) {
+ return MEMBER_TYPES.invite;
+ } else if (this.isAccessRequest) {
+ return MEMBER_TYPES.accessRequest;
+ }
+
+ return MEMBER_TYPES.user;
+ },
+ isDirectMember() {
+ return this.isGroup || this.member.source?.id === this.sourceId;
+ },
+ isCurrentUser() {
+ return this.member.user?.id === this.currentUserId;
+ },
+ canRemove() {
+ return this.isDirectMember && this.member.canRemove;
+ },
+ canResend() {
+ return Boolean(this.member.invite?.canResend);
+ },
+ canUpdate() {
+ return !this.isCurrentUser && this.isDirectMember && this.member.canUpdate;
+ },
+ },
+ render() {
+ return this.$scopedSlots.default({
+ memberType: this.memberType,
+ isDirectMember: this.isDirectMember,
+ isCurrentUser: this.isCurrentUser,
+ permissions: {
+ canRemove: this.canRemove,
+ canResend: this.canResend,
+ canUpdate: this.canUpdate,
+ },
+ });
+ },
+};
+</script>
diff --git a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue
new file mode 100644
index 00000000000..2b40ccc3a9d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import { mapActions } from 'vuex';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'RoleDropdown',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isDesktop: false,
+ busy: false,
+ };
+ },
+ mounted() {
+ this.isDesktop = bp.isDesktop();
+ },
+ methods: {
+ ...mapActions(['updateMemberRole']),
+ handleSelect(value, name) {
+ if (value === this.member.accessLevel.integerValue) {
+ return;
+ }
+
+ this.busy = true;
+
+ this.updateMemberRole({
+ memberId: this.member.id,
+ accessLevel: { integerValue: value, stringValue: name },
+ })
+ .then(() => {
+ this.$toast.show(s__('Members|Role updated successfully.'));
+ this.busy = false;
+ })
+ .catch(() => {
+ this.busy = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ :right="!isDesktop"
+ :text="member.accessLevel.stringValue"
+ :header-text="__('Change permissions')"
+ :disabled="busy"
+ >
+ <gl-dropdown-item
+ v-for="(value, name) in member.validRoles"
+ :key="value"
+ is-check-item
+ :is-checked="value === member.accessLevel.integerValue"
+ @click="handleSelect(value, name)"
+ >
+ {{ name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/utils.js b/app/assets/javascripts/vue_shared/components/members/utils.js
new file mode 100644
index 00000000000..782a0b7f96b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/utils.js
@@ -0,0 +1,19 @@
+import { __ } from '~/locale';
+
+export const generateBadges = (member, isCurrentUser) => [
+ {
+ show: isCurrentUser,
+ text: __("It's you"),
+ variant: 'success',
+ },
+ {
+ show: member.user?.blocked,
+ text: __('Blocked'),
+ variant: 'danger',
+ },
+ {
+ show: member.user?.twoFactorEnabled,
+ text: __('2FA'),
+ variant: 'info',
+ },
+];
diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
index 35ba7c665d5..cad4439ecea 100644
--- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
+++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
@@ -1,19 +1,16 @@
<script>
import $ from 'jquery';
-import { GlDeprecatedButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import Clipboard from 'clipboard';
import { __ } from '~/locale';
export default {
components: {
- GlDeprecatedButton,
- GlIcon,
+ GlButton,
},
-
directives: {
GlTooltip: GlTooltipDirective,
},
-
props: {
text: {
type: String,
@@ -55,15 +52,12 @@ export default {
default: null,
},
},
-
copySuccessText: __('Copied'),
-
computed: {
modalDomId() {
return this.modalId ? `#${this.modalId}` : '';
},
},
-
mounted() {
this.$nextTick(() => {
this.clipboard = new Clipboard(this.$el, {
@@ -83,13 +77,11 @@ export default {
.on('error', e => this.$emit('error', e));
});
},
-
destroyed() {
if (this.clipboard) {
this.clipboard.destroy();
}
},
-
methods: {
updateTooltip(target) {
const $target = $(target);
@@ -112,15 +104,12 @@ export default {
};
</script>
<template>
- <gl-deprecated-button
+ <gl-button
v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }"
:class="cssClasses"
:data-clipboard-target="target"
:data-clipboard-text="text"
:title="title"
- >
- <slot>
- <gl-icon name="copy-to-clipboard" />
- </slot>
- </gl-deprecated-button>
+ icon="copy-to-clipboard"
+ />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
index f8983a3d29a..3749888ee36 100644
--- a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
+++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
@@ -58,7 +58,12 @@ export default {
active: tab.isActive,
}"
>
- <a :class="`js-${scope}-tab-${tab.scope}`" role="button" @click="onTabClick(tab)">
+ <a
+ :class="`js-${scope}-tab-${tab.scope}`"
+ :data-testid="`${scope}-tab-${tab.scope}`"
+ role="button"
+ @click="onTabClick(tab)"
+ >
{{ tab.name }}
<span v-if="shouldRenderBadge(tab.count)" class="badge badge-pill"> {{ tab.count }} </span>
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 53dbae39608..3aca068c074 100644
--- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
@@ -12,7 +12,7 @@ export default {
</script>
<template>
- <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note">
+ <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note_placeholder">
<div class="timeline-icon"></div>
<div class="timeline-content">
<div class="note-header"></div>
diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js
new file mode 100644
index 00000000000..b7768cfa5b9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js
@@ -0,0 +1,21 @@
+import { __ } from '~/locale';
+
+export const tdClass =
+ 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap';
+export const thClass = 'gl-hover-bg-blue-50';
+export const bodyTrClass =
+ 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-blue-50 gl-hover-border-b-solid gl-hover-border-blue-200';
+
+export const defaultPageSize = 20;
+
+export const initialPaginationState = {
+ page: 1,
+ prevPageCursor: '',
+ nextPageCursor: '',
+ firstPageSize: defaultPageSize,
+ lastPageSize: null,
+};
+
+export const defaultI18n = {
+ searchPlaceholder: __('Search or filter results…'),
+};
diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
new file mode 100644
index 00000000000..8e85d93e6d1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
@@ -0,0 +1,313 @@
+<script>
+import { GlAlert, GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui';
+import Api from '~/api';
+import Tracking from '~/tracking';
+import { __ } from '~/locale';
+import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
+import { initialPaginationState, defaultI18n, defaultPageSize } from './constants';
+import { isAny } from './utils';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
+
+export default {
+ defaultI18n,
+ components: {
+ GlAlert,
+ GlBadge,
+ GlPagination,
+ GlTabs,
+ GlTab,
+ FilteredSearchBar,
+ },
+ inject: {
+ projectPath: {
+ default: '',
+ },
+ textQuery: {
+ default: '',
+ },
+ assigneeUsernameQuery: {
+ default: '',
+ },
+ authorUsernameQuery: {
+ default: '',
+ },
+ },
+ props: {
+ items: {
+ type: Array,
+ required: true,
+ },
+ itemsCount: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ pageInfo: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ statusTabs: {
+ type: Array,
+ required: true,
+ },
+ showItems: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ showErrorMsg: {
+ type: Boolean,
+ required: true,
+ },
+ trackViewsOptions: {
+ type: Object,
+ required: true,
+ },
+ i18n: {
+ type: Object,
+ required: true,
+ },
+ serverErrorMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ filterSearchKey: {
+ type: String,
+ required: true,
+ },
+ filterSearchTokens: {
+ type: Array,
+ required: false,
+ default: () => ['author_username', 'assignee_username'],
+ },
+ },
+ data() {
+ return {
+ searchTerm: this.textQuery,
+ authorUsername: this.authorUsernameQuery,
+ assigneeUsername: this.assigneeUsernameQuery,
+ filterParams: {},
+ pagination: initialPaginationState,
+ filteredByStatus: '',
+ statusFilter: '',
+ };
+ },
+ computed: {
+ defaultTokens() {
+ return [
+ {
+ type: 'author_username',
+ icon: 'user',
+ title: __('Author'),
+ unique: true,
+ symbol: '@',
+ token: AuthorToken,
+ operators: [{ value: '=', description: __('is'), default: 'true' }],
+ fetchPath: this.projectPath,
+ fetchAuthors: Api.projectUsers.bind(Api),
+ },
+ {
+ type: 'assignee_username',
+ icon: 'user',
+ title: __('Assignee'),
+ unique: true,
+ symbol: '@',
+ token: AuthorToken,
+ operators: [{ value: '=', description: __('is'), default: 'true' }],
+ fetchPath: this.projectPath,
+ fetchAuthors: Api.projectUsers.bind(Api),
+ },
+ ];
+ },
+ filteredSearchTokens() {
+ return this.defaultTokens.filter(({ type }) => this.filterSearchTokens.includes(type));
+ },
+ filteredSearchValue() {
+ const value = [];
+
+ if (this.authorUsername) {
+ value.push({
+ type: 'author_username',
+ value: { data: this.authorUsername },
+ });
+ }
+
+ if (this.assigneeUsername) {
+ value.push({
+ type: 'assignee_username',
+ value: { data: this.assigneeUsername },
+ });
+ }
+
+ if (this.searchTerm) {
+ value.push(this.searchTerm);
+ }
+
+ return value;
+ },
+ itemsForCurrentTab() {
+ return this.itemsCount?.[this.filteredByStatus.toLowerCase()] ?? 0;
+ },
+ showPaginationControls() {
+ return Boolean(this.pageInfo?.hasNextPage || this.pageInfo?.hasPreviousPage);
+ },
+ previousPage() {
+ return Math.max(this.pagination.page - 1, 0);
+ },
+ nextPage() {
+ const nextPage = this.pagination.page + 1;
+ return nextPage > Math.ceil(this.itemsForCurrentTab / defaultPageSize) ? null : nextPage;
+ },
+ },
+ mounted() {
+ this.trackPageViews();
+ },
+ methods: {
+ filterItemsByStatus(tabIndex) {
+ this.resetPagination();
+ const { filters, status } = this.statusTabs[tabIndex];
+ this.statusFilter = filters;
+ this.filteredByStatus = status;
+
+ this.$emit('tabs-changed', { filters, status });
+ },
+ handlePageChange(page) {
+ const { startCursor, endCursor } = this.pageInfo;
+
+ if (page > this.pagination.page) {
+ this.pagination = {
+ ...initialPaginationState,
+ nextPageCursor: endCursor,
+ page,
+ };
+ } else {
+ this.pagination = {
+ lastPageSize: defaultPageSize,
+ firstPageSize: null,
+ prevPageCursor: startCursor,
+ nextPageCursor: '',
+ page,
+ };
+ }
+
+ this.$emit('page-changed', this.pagination);
+ },
+ resetPagination() {
+ this.pagination = initialPaginationState;
+ this.$emit('page-changed', this.pagination);
+ },
+ handleFilterItems(filters) {
+ this.resetPagination();
+ const filterParams = { authorUsername: '', assigneeUsername: '', search: '' };
+
+ filters.forEach(filter => {
+ if (typeof filter === 'object') {
+ switch (filter.type) {
+ case 'author_username':
+ filterParams.authorUsername = isAny(filter.value.data);
+ break;
+ case 'assignee_username':
+ filterParams.assigneeUsername = isAny(filter.value.data);
+ break;
+ case 'filtered-search-term':
+ if (filter.value.data !== '') filterParams.search = filter.value.data;
+ break;
+ default:
+ break;
+ }
+ }
+ });
+
+ this.filterParams = filterParams;
+ this.updateUrl();
+ this.searchTerm = filterParams?.search;
+ this.authorUsername = filterParams?.authorUsername;
+ this.assigneeUsername = filterParams?.assigneeUsername;
+
+ this.$emit('filters-changed', {
+ searchTerm: this.searchTerm,
+ authorUsername: this.authorUsername,
+ assigneeUsername: this.assigneeUsername,
+ });
+ },
+ updateUrl() {
+ const { authorUsername, assigneeUsername, search } = this.filterParams || {};
+
+ const params = {
+ ...(authorUsername !== '' && { author_username: authorUsername }),
+ ...(assigneeUsername !== '' && { assignee_username: assigneeUsername }),
+ ...(search !== '' && { search }),
+ };
+
+ updateHistory({
+ url: setUrlParams(params, window.location.href, true),
+ title: document.title,
+ replace: true,
+ });
+ },
+ trackPageViews() {
+ const { category, action } = this.trackViewsOptions;
+ Tracking.event(category, action);
+ },
+ },
+};
+</script>
+<template>
+ <div class="incident-management-list">
+ <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="$emit('error-alert-dismissed')">
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <p v-html="serverErrorMessage || i18n.errorMsg"></p>
+ </gl-alert>
+
+ <div
+ class="list-header gl-display-flex gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-gray-100"
+ >
+ <gl-tabs content-class="gl-p-0" @input="filterItemsByStatus">
+ <gl-tab v-for="tab in statusTabs" :key="tab.status" :data-testid="tab.status">
+ <template #title>
+ <span>{{ tab.title }}</span>
+ <gl-badge v-if="itemsCount" pill size="sm" class="gl-tab-counter-badge">
+ {{ itemsCount[tab.status.toLowerCase()] }}
+ </gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+
+ <slot name="header-actions"></slot>
+ </div>
+
+ <div class="filtered-search-wrapper">
+ <filtered-search-bar
+ :namespace="projectPath"
+ :search-input-placeholder="$options.defaultI18n.searchPlaceholder"
+ :tokens="filteredSearchTokens"
+ :initial-filter-value="filteredSearchValue"
+ initial-sortby="created_desc"
+ :recent-searches-storage-key="filterSearchKey"
+ class="row-content-block"
+ @onFilter="handleFilterItems"
+ />
+ </div>
+
+ <h4 class="gl-display-block d-md-none my-3">
+ <slot name="title"></slot>
+ </h4>
+
+ <slot v-if="showItems" name="table"></slot>
+
+ <gl-pagination
+ v-if="showPaginationControls"
+ :value="pagination.page"
+ :prev-page="previousPage"
+ :next-page="nextPage"
+ align="center"
+ class="gl-pagination gl-mt-3"
+ @input="handlePageChange"
+ />
+
+ <slot v-if="!showItems" name="emtpy-state"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/utils.js b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/utils.js
new file mode 100644
index 00000000000..7de4263acbb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/utils.js
@@ -0,0 +1,11 @@
+import { __ } from '~/locale';
+
+/**
+ * Return a empty string when passed a value of 'Any'
+ *
+ * @param {String} value
+ * @returns {String}
+ */
+export const isAny = value => {
+ return value === __('Any') ? '' : value;
+};
diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
index 50a19dc2156..7046ac5be03 100644
--- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
@@ -39,7 +39,7 @@ export default {
},
},
mounted() {
- this.detailsSlots = Object.keys(this.$slots).filter(k => k.startsWith('details_'));
+ this.detailsSlots = Object.keys(this.$slots).filter(k => k.startsWith('details-'));
},
methods: {
toggleDetails() {
diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
index cc33b8f85cd..06b4309ad42 100644
--- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
@@ -1,10 +1,12 @@
<script>
-import { GlAvatar } from '@gitlab/ui';
+import { GlAvatar, GlSprintf, GlLink } from '@gitlab/ui';
export default {
name: 'TitleArea',
components: {
GlAvatar,
+ GlSprintf,
+ GlLink,
},
props: {
avatar: {
@@ -17,6 +19,11 @@ export default {
default: null,
required: false,
},
+ infoMessages: {
+ type: Array,
+ default: () => [],
+ required: false,
+ },
},
data() {
return {
@@ -24,43 +31,64 @@ export default {
};
},
mounted() {
- this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith('metadata_'));
+ this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith('metadata-'));
},
};
</script>
<template>
- <div class="gl-display-flex gl-justify-content-space-between gl-py-3">
- <div class="gl-flex-direction-column">
- <div class="gl-display-flex">
- <gl-avatar v-if="avatar" :src="avatar" shape="rect" class="gl-align-self-center gl-mr-4" />
+ <div class="gl-display-flex gl-flex-direction-column">
+ <div class="gl-display-flex gl-justify-content-space-between gl-py-3">
+ <div class="gl-flex-direction-column">
+ <div class="gl-display-flex">
+ <gl-avatar
+ v-if="avatar"
+ :src="avatar"
+ shape="rect"
+ class="gl-align-self-center gl-mr-4"
+ />
- <div class="gl-display-flex gl-flex-direction-column">
- <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-2" data-testid="title">
- <slot name="title">{{ title }}</slot>
- </h1>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-2" data-testid="title">
+ <slot name="title">{{ title }}</slot>
+ </h1>
+
+ <div
+ v-if="$slots['sub-header']"
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ >
+ <slot name="sub-header"></slot>
+ </div>
+ </div>
+ </div>
+ <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3">
<div
- v-if="$slots['sub-header']"
- class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1"
+ v-for="(row, metadataIndex) in metadataSlots"
+ :key="metadataIndex"
+ class="gl-display-flex gl-align-items-center gl-mr-5"
>
- <slot name="sub-header"></slot>
+ <slot :name="row"></slot>
</div>
</div>
</div>
-
- <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3">
- <div
- v-for="(row, metadataIndex) in metadataSlots"
- :key="metadataIndex"
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <slot :name="row"></slot>
- </div>
+ <div v-if="$slots['right-actions']" class="gl-mt-3">
+ <slot name="right-actions"></slot>
</div>
</div>
- <div v-if="$slots['right-actions']" class="gl-mt-3">
- <slot name="right-actions"></slot>
- </div>
+ <p>
+ <span
+ v-for="(message, index) in infoMessages"
+ :key="index"
+ class="gl-mr-2"
+ data-testid="info-message"
+ >
+ <gl-sprintf :message="message.text">
+ <template #docLink="{content}">
+ <gl-link :href="message.link" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </p>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
index c08659919fa..cbb30baa488 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
@@ -2,8 +2,15 @@ import { __ } from '~/locale';
export const CUSTOM_EVENTS = {
openAddImageModal: 'gl_openAddImageModal',
+ openInsertVideoModal: 'gl_openInsertVideoModal',
};
+export const YOUTUBE_URL = 'https://www.youtube.com';
+
+export const YOUTUBE_EMBED_URL = `${YOUTUBE_URL}/embed`;
+
+export const ALLOWED_VIDEO_ORIGINS = [YOUTUBE_URL];
+
/* eslint-disable @gitlab/require-i18n-strings */
export const TOOLBAR_ITEM_CONFIGS = [
{ icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') },
@@ -23,6 +30,7 @@ export const TOOLBAR_ITEM_CONFIGS = [
{ icon: 'dash', command: 'HR', tooltip: __('Add a line') },
{ icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') },
{ icon: 'doc-image', event: CUSTOM_EVENTS.openAddImageModal, tooltip: __('Insert an image') },
+ { icon: 'live-preview', event: CUSTOM_EVENTS.openInsertVideoModal, tooltip: __('Insert video') },
{ isDivider: true },
{ icon: 'code', command: 'Code', tooltip: __('Insert inline code') },
{ icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') },
@@ -40,3 +48,10 @@ export const EDITOR_PREVIEW_STYLE = 'horizontal';
export const IMAGE_TABS = { UPLOAD_TAB: 0, URL_TAB: 1 };
export const MAX_FILE_SIZE = 2097152; // 2Mb
+
+export const VIDEO_ATTRIBUTES = {
+ width: '560',
+ height: '315',
+ frameBorder: '0',
+ allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
+};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue
index 429a4e04110..e1652f54982 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue
@@ -32,8 +32,8 @@ export default {
uploadImageTab: null,
};
},
- modalTitle: __('Image Details'),
- okTitle: __('Insert'),
+ modalTitle: __('Image details'),
+ okTitle: __('Insert image'),
urlTabTitle: __('By URL'),
urlLabel: __('Image URL'),
descriptionLabel: __('Description'),
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue
new file mode 100644
index 00000000000..99bb2080610
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlModal, GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui';
+import { isSafeURL } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import { YOUTUBE_URL, YOUTUBE_EMBED_URL } from '../constants';
+
+export default {
+ components: {
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlSprintf,
+ },
+ data() {
+ return {
+ url: null,
+ urlError: null,
+ description: __(
+ 'If the YouTube URL is https://www.youtube.com/watch?v=0t1DgySidms then the video ID is %{id}',
+ ),
+ };
+ },
+ modalTitle: __('Insert a video'),
+ okTitle: __('Insert video'),
+ label: __('YouTube URL or ID'),
+ methods: {
+ show() {
+ this.urlError = null;
+ this.url = null;
+
+ this.$refs.modal.show();
+ },
+ onPrimary(event) {
+ this.submitURL(event);
+ },
+ submitURL(event) {
+ const url = this.generateUrl();
+
+ if (!url) {
+ event.preventDefault();
+ return;
+ }
+
+ this.$emit('insertVideo', url);
+ },
+ generateUrl() {
+ let { url } = this;
+ const reYouTubeId = /^[A-z0-9]*$/;
+ const reYouTubeUrl = RegExp(`${YOUTUBE_URL}/(embed/|watch\\?v=)([A-z0-9]+)`);
+
+ if (reYouTubeId.test(url)) {
+ url = `${YOUTUBE_EMBED_URL}/${url}`;
+ } else if (reYouTubeUrl.test(url)) {
+ url = `${YOUTUBE_EMBED_URL}/${reYouTubeUrl.exec(url)[2]}`;
+ }
+
+ if (!isSafeURL(url) || !reYouTubeUrl.test(url)) {
+ this.urlError = __('Please provide a valid YouTube URL or ID');
+ this.$refs.urlInput.$el.focus();
+ return null;
+ }
+
+ return url;
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ size="sm"
+ modal-id="insert-video-modal"
+ :title="$options.modalTitle"
+ :ok-title="$options.okTitle"
+ @primary="onPrimary"
+ >
+ <gl-form-group
+ :label="$options.label"
+ label-for="video-modal-url-input"
+ :state="!Boolean(urlError)"
+ :invalid-feedback="urlError"
+ >
+ <gl-form-input id="video-modal-url-input" ref="urlInput" v-model="url" />
+ <gl-sprintf slot="description" :message="description" class="text-gl-muted">
+ <template #id>
+ <strong>{{ __('0t1DgySidms') }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-form-group>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
index d96fe46522e..c2518441506 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
@@ -3,6 +3,7 @@ import 'codemirror/lib/codemirror.css';
import '@toast-ui/editor/dist/toastui-editor.css';
import AddImageModal from './modals/add_image/add_image_modal.vue';
+import InsertVideoModal from './modals/insert_video_modal.vue';
import { EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, CUSTOM_EVENTS } from './constants';
import {
@@ -12,6 +13,7 @@ import {
removeCustomEventListener,
addImage,
getMarkdown,
+ insertVideo,
} from './services/editor_service';
export default {
@@ -21,6 +23,7 @@ export default {
toast => toast.Editor,
),
AddImageModal,
+ InsertVideoModal,
},
props: {
content: {
@@ -63,6 +66,12 @@ export default {
editorInstance() {
return this.$refs.editor;
},
+ customEventListeners() {
+ return [
+ { event: CUSTOM_EVENTS.openAddImageModal, listener: this.onOpenAddImageModal },
+ { event: CUSTOM_EVENTS.openInsertVideoModal, listener: this.onOpenInsertVideoModal },
+ ];
+ },
},
created() {
this.editorOptions = getEditorOptions(this.options);
@@ -72,16 +81,16 @@ export default {
},
methods: {
addListeners(editorApi) {
- addCustomEventListener(editorApi, CUSTOM_EVENTS.openAddImageModal, this.onOpenAddImageModal);
+ this.customEventListeners.forEach(({ event, listener }) => {
+ addCustomEventListener(editorApi, event, listener);
+ });
editorApi.eventManager.listen('changeMode', this.onChangeMode);
},
removeListeners() {
- removeCustomEventListener(
- this.editorApi,
- CUSTOM_EVENTS.openAddImageModal,
- this.onOpenAddImageModal,
- );
+ this.customEventListeners.forEach(({ event, listener }) => {
+ removeCustomEventListener(this.editorApi, event, listener);
+ });
this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode);
},
@@ -111,6 +120,12 @@ export default {
addImage(this.editorInstance, image);
},
+ onOpenInsertVideoModal() {
+ this.$refs.insertVideoModal.show();
+ },
+ onInsertVideo(url) {
+ insertVideo(this.editorInstance, url);
+ },
onChangeMode(newMode) {
this.$emit('modeChange', newMode);
},
@@ -130,5 +145,6 @@ export default {
@load="onLoad"
/>
<add-image-modal ref="addImageModal" :image-root="imageRoot" @addImage="onAddImage" />
+ <insert-video-modal ref="insertVideoModal" @insertVideo="onInsertVideo" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
index 51ba033dff0..8b3fbcabcfa 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
@@ -3,7 +3,8 @@ import { defaults } from 'lodash';
import ToolbarItem from '../toolbar_item.vue';
import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer';
import buildCustomHTMLRenderer from './build_custom_renderer';
-import { TOOLBAR_ITEM_CONFIGS } from '../constants';
+import { TOOLBAR_ITEM_CONFIGS, VIDEO_ATTRIBUTES } from '../constants';
+import sanitizeHTML from './sanitize_html';
const buildWrapper = propsData => {
const instance = new Vue({
@@ -16,6 +17,23 @@ const buildWrapper = propsData => {
return instance.$el;
};
+const buildVideoIframe = src => {
+ const wrapper = document.createElement('figure');
+ const iframe = document.createElement('iframe');
+ const videoAttributes = { ...VIDEO_ATTRIBUTES, src };
+ const wrapperClasses = ['gl-relative', 'gl-h-0', 'video_container'];
+ const iframeClasses = ['gl-absolute', 'gl-top-0', 'gl-left-0', 'gl-w-full', 'gl-h-full'];
+
+ wrapper.setAttribute('contenteditable', 'false');
+ wrapper.classList.add(...wrapperClasses);
+ iframe.classList.add(...iframeClasses);
+ Object.assign(iframe, videoAttributes);
+
+ wrapper.appendChild(iframe);
+
+ return wrapper;
+};
+
export const generateToolbarItem = config => {
const { icon, classes, event, command, tooltip, isDivider } = config;
@@ -43,6 +61,16 @@ export const removeCustomEventListener = (editorApi, event, handler) =>
export const addImage = ({ editor }, image) => editor.exec('AddImage', image);
+export const insertVideo = ({ editor }, url) => {
+ const videoIframe = buildVideoIframe(url);
+
+ if (editor.isWysiwygMode()) {
+ editor.getSquire().insertElement(videoIframe);
+ } else {
+ editor.insertText(videoIframe.outerHTML);
+ }
+};
+
export const getMarkdown = editorInstance => editorInstance.invoke('getMarkdown');
/**
@@ -62,5 +90,6 @@ export const getEditorOptions = externalOptions => {
return defaults({
customHTMLRenderer: buildCustomHTMLRenderer(externalOptions?.customRenderers),
toolbarItems: TOOLBAR_ITEM_CONFIGS.map(toolbarItem => generateToolbarItem(toolbarItem)),
+ customHTMLSanitizer: html => sanitizeHTML(html),
});
};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js
index b179ca61dba..18bd17d43d9 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js
@@ -1,7 +1,21 @@
import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token';
+import { ALLOWED_VIDEO_ORIGINS } from '../../constants';
+import { getURLOrigin } from '~/lib/utils/url_utility';
-const canRender = ({ type }) => {
- return type === 'htmlBlock';
+const isVideoFrame = html => {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+ const {
+ children: { length },
+ } = doc;
+ const iframe = doc.querySelector('iframe');
+ const origin = iframe && getURLOrigin(iframe.getAttribute('src'));
+
+ return length === 1 && ALLOWED_VIDEO_ORIGINS.includes(origin);
+};
+
+const canRender = ({ type, literal }) => {
+ return type === 'htmlBlock' && !isVideoFrame(literal);
};
const render = node => buildUneditableHtmlAsTextTokens(node);
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js
new file mode 100644
index 00000000000..eae2e0335c1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js
@@ -0,0 +1,22 @@
+import createSanitizer from 'dompurify';
+import { ALLOWED_VIDEO_ORIGINS } from '../constants';
+import { getURLOrigin } from '~/lib/utils/url_utility';
+
+const sanitizer = createSanitizer(window);
+const ADD_TAGS = ['iframe'];
+
+sanitizer.addHook('uponSanitizeElement', node => {
+ if (node.tagName !== 'IFRAME') {
+ return;
+ }
+
+ const origin = getURLOrigin(node.getAttribute('src'));
+
+ if (!ALLOWED_VIDEO_ORIGINS.includes(origin)) {
+ node.remove();
+ }
+});
+
+const sanitize = content => sanitizer.sanitize(content, { ADD_TAGS });
+
+export default sanitize;
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
index 0ed5a050fe4..6511c8d8c31 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_calendar_icon.vue
@@ -1,11 +1,10 @@
<script>
-import { GlIcon } from '@gitlab/ui';
-import tooltip from '~/vue_shared/directives/tooltip';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
name: 'CollapsedCalendarIcon',
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
@@ -41,16 +40,7 @@ export default {
</script>
<template>
- <div
- v-tooltip
- :class="containerClass"
- :title="tooltipText"
- data-container="body"
- data-placement="left"
- data-html="true"
- data-boundary="viewport"
- @click="click"
- >
+ <div v-gl-tooltip.left.viewport :class="containerClass" :title="tooltipText" @click="click">
<gl-icon v-if="showIcon" name="calendar" />
<slot>
<span> {{ text }} </span>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
index 6839354fb3a..267c3be5f50 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
@@ -38,6 +38,7 @@ export default {
<template>
<div
class="labels-select-dropdown-contents w-100 mt-1 mb-3 py-2 rounded-top rounded-bottom position-absolute"
+ data-qa-selector="labels_dropdown_content"
:style="directionStyle"
>
<component :is="dropdownContentsView" />
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
index 0b763aa4b72..c8dee81d746 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
@@ -1,6 +1,7 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon, GlButton, GlSearchBoxByType, GlLink } from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
@@ -39,9 +40,9 @@ export default {
...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
visibleLabels() {
if (this.searchKey) {
- return this.labels.filter(label =>
- label.title.toLowerCase().includes(this.searchKey.toLowerCase()),
- );
+ return fuzzaldrinPlus.filter(this.labels, this.searchKey, {
+ key: ['title'],
+ });
}
return this.labels;
},
@@ -112,6 +113,7 @@ export default {
this.currentHighlightItem += 1;
} else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) {
this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]);
+ this.searchKey = '';
} else if (e.keyCode === ESC_KEY_CODE) {
this.toggleDropdownContents();
}
@@ -155,7 +157,11 @@ export default {
/>
</div>
<div class="dropdown-input" @click.stop="() => {}">
- <gl-search-box-by-type v-model="searchKey" :autofocus="true" />
+ <gl-search-box-by-type
+ v-model="searchKey"
+ :autofocus="true"
+ data-qa-selector="dropdown_input_field"
+ />
</div>
<div
v-show="showListContainer"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
index 12ad2acf308..a6f99289df4 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
@@ -8,8 +8,20 @@ export default {
components: {
GlLabel,
},
+ props: {
+ disableLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
computed: {
- ...mapState(['selectedLabels', 'allowScopedLabels', 'labelsFilterBasePath']),
+ ...mapState([
+ 'selectedLabels',
+ 'allowLabelRemove',
+ 'allowScopedLabels',
+ 'labelsFilterBasePath',
+ ]),
},
methods: {
labelFilterUrl(label) {
@@ -35,12 +47,17 @@ export default {
<template v-for="label in selectedLabels" v-else>
<gl-label
:key="label.id"
+ data-qa-selector="selected_label_content"
+ :data-qa-label-name="label.title"
:title="label.title"
:description="label.description"
:background-color="label.color"
:target="labelFilterUrl(label)"
:scoped="scopedLabel(label)"
+ :show-close-button="allowLabelRemove"
+ :disabled="disableLabels"
tooltip-placement="top"
+ @close="$emit('onLabelRemove', label.id)"
/>
</template>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
index 34f5517ef99..c651013c5f5 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
@@ -28,6 +28,11 @@ export default {
DropdownValueCollapsed,
},
props: {
+ allowLabelRemove: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
allowLabelEdit: {
type: Boolean,
required: true,
@@ -130,6 +135,7 @@ export default {
mounted() {
this.setInitialState({
variant: this.variant,
+ allowLabelRemove: this.allowLabelRemove,
allowLabelEdit: this.allowLabelEdit,
allowLabelCreate: this.allowLabelCreate,
allowMultiselect: this.allowMultiselect,
@@ -252,7 +258,10 @@ export default {
:allow-label-edit="allowLabelEdit"
:labels-select-in-progress="labelsSelectInProgress"
/>
- <dropdown-value>
+ <dropdown-value
+ :disable-labels="labelsSelectInProgress"
+ @onLabelRemove="$emit('onLabelRemove', $event)"
+ >
<slot></slot>
</dropdown-value>
<dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" />
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js
index 2d236566b3d..e624bd1eaee 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js
@@ -54,8 +54,5 @@ export const createLabel = ({ state, dispatch }, label) => {
});
};
-export const replaceSelectedLabels = ({ commit }, selectedLabels) =>
- commit(types.REPLACE_SELECTED_LABELS, selectedLabels);
-
export const updateSelectedLabels = ({ commit }, labels) =>
commit(types.UPDATE_SELECTED_LABELS, { labels });
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js
index af92665d4eb..2e044dc3b3c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js
@@ -15,7 +15,6 @@ export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE';
export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY';
export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS';
-export const REPLACE_SELECTED_LABELS = 'REPLACE_SELECTED_LABELS';
export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS';
export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW';
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
index 7edd290a819..54f8c78b4e1 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
@@ -57,10 +57,6 @@ export default {
state.labelCreateInProgress = false;
},
- [types.REPLACE_SELECTED_LABELS](state, selectedLabels = []) {
- state.selectedLabels = selectedLabels;
- },
-
[types.UPDATE_SELECTED_LABELS](state, { labels }) {
// Find the label to update from all the labels
// and change `set` prop value to represent their current state.
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
index 3f3358d4805..d66cfed4163 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
@@ -15,6 +15,7 @@ export default () => ({
// UI Flags
variant: '',
+ allowLabelRemove: false,
allowLabelCreate: false,
allowLabelEdit: false,
allowScopedLabels: false,
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
index 040a15406e0..6dacf4e10d3 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
@@ -1,11 +1,14 @@
<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
export default {
name: 'ToggleSidebar',
+ components: {
+ GlButton,
+ },
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
collapsed: {
@@ -22,6 +25,12 @@ export default {
tooltipLabel() {
return this.collapsed ? __('Expand sidebar') : __('Collapse sidebar');
},
+ buttonIcon() {
+ return this.collapsed ? 'chevron-double-lg-left' : 'chevron-double-lg-right';
+ },
+ allCssClasses() {
+ return [this.cssClasses, { 'js-sidebar-collapsed': this.collapsed }];
+ },
},
methods: {
toggle() {
@@ -32,25 +41,15 @@ export default {
</script>
<template>
- <button
- v-tooltip
+ <gl-button
+ v-gl-tooltip:body.viewport.left
:title="tooltipLabel"
- :class="cssClasses"
- type="button"
- class="btn btn-blank gutter-toggle btn-sidebar-action js-sidebar-vue-toggle"
- data-container="body"
- data-placement="left"
- data-boundary="viewport"
+ :class="allCssClasses"
+ class="gutter-toggle btn-sidebar-action js-sidebar-vue-toggle"
+ :icon="buttonIcon"
+ category="tertiary"
+ size="small"
+ :aria-label="__('toggle collapse')"
@click="toggle"
- >
- <i
- :class="{
- 'fa-angle-double-right': !collapsed,
- 'fa-angle-double-left': collapsed,
- }"
- :aria-label="__('toggle collapse')"
- class="fa"
- >
- </i>
- </button>
+ />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue
index e9b99c6ea78..11049028ff6 100644
--- a/app/assets/javascripts/vue_shared/components/split_button.vue
+++ b/app/assets/javascripts/vue_shared/components/split_button.vue
@@ -1,19 +1,15 @@
<script>
import { isString } from 'lodash';
-import {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownItem,
-} from '@gitlab/ui';
+import { GlDropdown, GlDropdownDivider, GlDropdownItem } from '@gitlab/ui';
const isValidItem = item =>
isString(item.eventName) && isString(item.title) && isString(item.description);
export default {
components: {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
},
props: {
@@ -32,7 +28,7 @@ export default {
variant: {
type: String,
required: false,
- default: 'secondary',
+ default: 'default',
},
},
@@ -61,8 +57,8 @@ export default {
</script>
<template>
- <gl-deprecated-dropdown
- :menu-class="`dropdown-menu-selectable ${menuClass}`"
+ <gl-dropdown
+ :menu-class="menuClass"
split
:text="dropdownToggleText"
:variant="variant"
@@ -70,20 +66,20 @@ export default {
@click="triggerEvent"
>
<template v-for="(item, itemIndex) in actionItems">
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
:key="item.eventName"
- :active="selectedItem === item"
- active-class="is-active"
+ :is-check-item="true"
+ :is-checked="selectedItem === item"
@click="changeSelectedItem(item)"
>
<strong>{{ item.title }}</strong>
<div>{{ item.description }}</div>
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
- <gl-deprecated-dropdown-divider
+ <gl-dropdown-divider
v-if="itemIndex < actionItems.length - 1"
:key="`${item.eventName}-divider`"
/>
</template>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
index a0c161a335a..f2e9c4a4fbb 100644
--- a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
+++ b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
@@ -1,11 +1,11 @@
<script>
+import { GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import { roundOffFloat } from '~/lib/utils/common_utils';
-import tooltip from '~/vue_shared/directives/tooltip';
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
cssClass: {
@@ -112,7 +112,7 @@ export default {
<span v-if="!totalCount" class="status-unavailable">{{ unavailableLabel }}</span>
<span
v-if="successPercent"
- v-tooltip
+ v-gl-tooltip
:title="successTooltip"
:style="successBarStyle"
class="status-green"
@@ -122,7 +122,7 @@ export default {
</span>
<span
v-if="neutralPercent"
- v-tooltip
+ v-gl-tooltip
:title="neutralTooltip"
:style="neutralBarStyle"
class="status-neutral"
@@ -132,7 +132,7 @@ export default {
</span>
<span
v-if="failurePercent"
- v-tooltip
+ v-gl-tooltip
:title="failureTooltip"
:style="failureBarStyle"
class="status-red"
diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
index 135b9842cbf..f6721f5a27b 100644
--- a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDeprecatedDropdownItem, GlSearchBoxByType, GlIcon } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import { __ } from '~/locale';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
@@ -7,9 +7,8 @@ export default {
name: 'TimezoneDropdown',
components: {
GlDropdown,
- GlDeprecatedDropdownItem,
+ GlDropdownItem,
GlSearchBoxByType,
- GlIcon,
},
directives: {
autofocusonshow,
@@ -74,29 +73,23 @@ export default {
};
</script>
<template>
- <gl-dropdown :text="value" block lazy menu-class="gl-w-full!">
- <template #button-content>
- <span class="gl-flex-grow-1" :class="{ 'gl-text-gray-300': !value }">
- {{ selectedTimezoneLabel }}
- </span>
- <gl-icon name="chevron-down" />
- </template>
-
- <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus class="gl-m-3" />
- <gl-deprecated-dropdown-item
+ <gl-dropdown :text="selectedTimezoneLabel" block lazy menu-class="gl-w-full!">
+ <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus />
+ <gl-dropdown-item
v-for="timezone in filteredResults"
:key="timezone.formattedTimezone"
+ :is-checked="isSelected(timezone)"
+ :is-check-item="true"
@click="selectTimezone(timezone)"
>
- <gl-icon
- :class="{ invisible: !isSelected(timezone) }"
- name="mobile-issue-close"
- class="gl-vertical-align-middle"
- />
{{ timezone.formattedTimezone }}
- </gl-deprecated-dropdown-item>
- <gl-deprecated-dropdown-item v-if="!filteredResults.length" data-testid="noMatchingResults">
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="!filteredResults.length"
+ class="gl-pointer-events-none"
+ data-testid="noMatchingResults"
+ >
{{ $options.tranlations.noResultsText }}
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/todo_button.vue b/app/assets/javascripts/vue_shared/components/todo_button.vue
index debf19ccca6..a9d4f8403fa 100644
--- a/app/assets/javascripts/vue_shared/components/todo_button.vue
+++ b/app/assets/javascripts/vue_shared/components/todo_button.vue
@@ -15,7 +15,7 @@ export default {
},
computed: {
buttonLabel() {
- return this.isTodo ? __('Mark as done') : __('Add a To-Do');
+ return this.isTodo ? __('Mark as done') : __('Add a To Do');
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue
index 29d4516bece..861661d3519 100644
--- a/app/assets/javascripts/vue_shared/components/toggle_button.vue
+++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue
@@ -59,7 +59,7 @@ export default {
</script>
<template>
- <label class="toggle-wrapper">
+ <label class="gl-mt-2">
<input v-if="name" :name="name" :value="value" type="hidden" />
<button
type="button"
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 6aaff000845..3f5738b2b93 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,16 +1,27 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlPopover, GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlIcon } from '@gitlab/ui';
+import {
+ GlPopover,
+ GlLink,
+ GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+ GlIcon,
+} from '@gitlab/ui';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
import { glEmojiTag } from '../../../emoji';
const MAX_SKELETON_LINES = 4;
+const SECURITY_BOT_USER_DATA = {
+ username: 'GitLab-Security-Bot',
+ name: 'GitLab Security Bot',
+};
+
export default {
name: 'UserPopover',
maxSkeletonLines: MAX_SKELETON_LINES,
components: {
GlIcon,
+ GlLink,
GlPopover,
GlSkeletonLoading,
UserAvatarImage,
@@ -43,6 +54,15 @@ export default {
userIsLoading() {
return !this.user?.loaded;
},
+ isSecurityBot() {
+ const { username, name, websiteUrl = '' } = this.user;
+ return (
+ gon.features?.securityAutoFix &&
+ username === SECURITY_BOT_USER_DATA.username &&
+ name === SECURITY_BOT_USER_DATA.name &&
+ websiteUrl.length
+ );
+ },
},
};
</script>
@@ -89,6 +109,12 @@ export default {
<div v-if="statusHtml" class="js-user-status gl-mt-3">
<span v-html="statusHtml"></span>
</div>
+ <div v-if="isSecurityBot" class="gl-text-blue-500">
+ <gl-icon name="question" />
+ <gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
+ {{ sprintf(__('Learn more about %{username}'), { username: user.name }) }}
+ </gl-link>
+ </div>
</template>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index 8307c6d3b55..877414519f7 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -4,6 +4,7 @@ import { __ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
+const KEY_EDIT = 'edit';
const KEY_WEB_IDE = 'webide';
const KEY_GITPOD = 'gitpod';
@@ -13,15 +14,31 @@ export default {
LocalStorageSync,
},
props: {
- webIdeUrl: {
- type: String,
- required: true,
+ isFork: {
+ type: Boolean,
+ required: false,
+ default: false,
},
needsToFork: {
type: Boolean,
required: false,
default: false,
},
+ gitpodEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isBlob: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showEditButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
showWebIdeButton: {
type: Boolean,
required: false,
@@ -32,15 +49,20 @@ export default {
required: false,
default: false,
},
- gitpodUrl: {
+ editUrl: {
type: String,
required: false,
default: '',
},
- gitpodEnabled: {
- type: Boolean,
+ webIdeUrl: {
+ type: String,
required: false,
- default: false,
+ default: '',
+ },
+ gitpodUrl: {
+ type: String,
+ required: false,
+ default: '',
},
},
data() {
@@ -50,7 +72,33 @@ export default {
},
computed: {
actions() {
- return [this.webIdeAction, this.gitpodAction].filter(x => x);
+ return [this.webIdeAction, this.editAction, this.gitpodAction].filter(action => action);
+ },
+ editAction() {
+ if (!this.showEditButton) {
+ return null;
+ }
+
+ const handleOptions = this.needsToFork
+ ? {
+ href: '#modal-confirm-fork-edit',
+ handle: () => this.showModal('#modal-confirm-fork-edit'),
+ }
+ : { href: this.editUrl };
+
+ return {
+ key: KEY_EDIT,
+ text: __('Edit'),
+ secondaryText: __('Edit this file only.'),
+ tooltip: '',
+ attrs: {
+ 'data-qa-selector': 'edit_button',
+ 'data-track-event': 'click_edit',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ 'data-track-label': 'Edit',
+ },
+ ...handleOptions,
+ };
},
webIdeAction() {
if (!this.showWebIdeButton) {
@@ -58,16 +106,30 @@ export default {
}
const handleOptions = this.needsToFork
- ? { href: '#modal-confirm-fork', handle: () => this.showModal('#modal-confirm-fork') }
+ ? {
+ href: '#modal-confirm-fork-webide',
+ handle: () => this.showModal('#modal-confirm-fork-webide'),
+ }
: { href: this.webIdeUrl };
+ let text = __('Web IDE');
+
+ if (this.isBlob) {
+ text = __('Edit in Web IDE');
+ } else if (this.isFork) {
+ text = __('Edit fork in Web IDE');
+ }
+
return {
key: KEY_WEB_IDE,
- text: __('Web IDE'),
+ text,
secondaryText: __('Quickly and easily edit multiple files in your project.'),
tooltip: '',
attrs: {
'data-qa-selector': 'web_ide_button',
+ 'data-track-event': 'click_edit_ide',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ 'data-track-label': 'Web IDE',
},
...handleOptions,
};
@@ -107,8 +169,14 @@ export default {
</script>
<template>
- <div>
- <actions-button :actions="actions" :selected-key="selection" @select="select" />
+ <div class="d-inline-block gl-ml-3">
+ <actions-button
+ :actions="actions"
+ :selected-key="selection"
+ :variant="isBlob ? 'info' : 'default'"
+ :category="isBlob ? 'primary' : 'secondary'"
+ @select="select"
+ />
<local-storage-sync
storage-key="gl-web-ide-button-selected"
:value="selection"
diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js
index 73e92728cb9..0eb505bfce8 100644
--- a/app/assets/javascripts/vue_shared/directives/tooltip.js
+++ b/app/assets/javascripts/vue_shared/directives/tooltip.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import '~/commons/bootstrap';
+import { parseBoolean } from '~/lib/utils/common_utils';
export default {
bind(el) {
@@ -9,6 +10,10 @@ export default {
$(el).tooltip({
trigger: 'hover',
delay,
+ // By default, sanitize is run even if there is no `html` or `template` present
+ // so let's optimize to only run this when necessary.
+ // https://github.com/twbs/bootstrap/blob/c5966de27395a407f9a3d20d0eb2ff8e8fb7b564/js/src/tooltip.js#L716
+ sanitize: parseBoolean(el.dataset.html) || Boolean(el.dataset.template),
});
},
diff --git a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
index a740a3fa6b9..cdbde55901d 100644
--- a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
@@ -10,6 +10,10 @@ import { validateParams } from '~/pipelines/utils';
export default {
methods: {
onChangeTab(scope) {
+ if (this.scope === scope) {
+ return;
+ }
+
let params = {
scope,
page: '1',
diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
index be5f55a5220..c0fc055a01b 100644
--- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
@@ -111,7 +111,7 @@ const mixins = {
return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length;
},
isOpen() {
- return this.state === 'opened';
+ return this.state === 'opened' || this.state === 'reopened';
},
isClosed() {
return this.state === 'closed';
diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
new file mode 100644
index 00000000000..d5696e3c8cf
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
@@ -0,0 +1,107 @@
+<script>
+import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import ReportSection from '~/reports/components/report_section.vue';
+import { status } from '~/reports/constants';
+import { s__ } from '~/locale';
+import Flash from '~/flash';
+import Api from '~/api';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ ReportSection,
+ },
+ props: {
+ pipelineId: {
+ type: Number,
+ required: true,
+ },
+ projectId: {
+ type: Number,
+ required: true,
+ },
+ securityReportsDocsPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ hasSecurityReports: false,
+
+ // Error state is shown even when successfully loaded, since success
+ // state suggests that the security scans detected no security problems,
+ // which is not necessarily the case. A future iteration will actually
+ // check whether problems were found and display the appropriate status.
+ status: status.ERROR,
+ };
+ },
+ created() {
+ this.checkHasSecurityReports(this.$options.reportTypes)
+ .then(hasSecurityReports => {
+ this.hasSecurityReports = hasSecurityReports;
+ })
+ .catch(error => {
+ Flash({
+ message: this.$options.i18n.apiError,
+ captureError: true,
+ error,
+ });
+ });
+ },
+ methods: {
+ checkHasSecurityReports(reportTypes) {
+ return Api.pipelineJobs(this.projectId, this.pipelineId).then(({ data: jobs }) =>
+ jobs.some(({ artifacts = [] }) =>
+ artifacts.some(({ file_type }) => reportTypes.includes(file_type)),
+ ),
+ );
+ },
+ activatePipelinesTab() {
+ if (window.mrTabs) {
+ window.mrTabs.tabShown('pipelines');
+ }
+ },
+ },
+ reportTypes: ['sast', 'secret_detection'],
+ i18n: {
+ apiError: s__(
+ 'SecurityReports|Failed to get security report information. Please reload the page or try again later.',
+ ),
+ scansHaveRun: s__(
+ 'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports',
+ ),
+ securityReportsHelp: s__('SecurityReports|Security reports help page link'),
+ },
+};
+</script>
+<template>
+ <report-section
+ v-if="hasSecurityReports"
+ :status="status"
+ :has-issues="false"
+ class="mr-widget-border-top mr-report"
+ data-testid="security-mr-widget"
+ >
+ <template #error>
+ <gl-sprintf :message="$options.i18n.scansHaveRun">
+ <template #link="{ content }">
+ <gl-link data-testid="show-pipelines" @click="activatePipelinesTab">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+
+ <gl-link
+ target="_blank"
+ data-testid="help"
+ :href="securityReportsDocsPath"
+ :aria-label="$options.i18n.securityReportsHelp"
+ >
+ <gl-icon name="question" />
+ </gl-link>
+ </template>
+ </report-section>
+</template>
diff --git a/app/assets/javascripts/vuex_shared/modules/members/actions.js b/app/assets/javascripts/vuex_shared/modules/members/actions.js
new file mode 100644
index 00000000000..f7fdddfd070
--- /dev/null
+++ b/app/assets/javascripts/vuex_shared/modules/members/actions.js
@@ -0,0 +1,25 @@
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+
+export const updateMemberRole = async ({ state, commit }, { memberId, accessLevel }) => {
+ try {
+ await axios.put(
+ state.memberPath.replace(/:id$/, memberId),
+ state.requestFormatter({ accessLevel: accessLevel.integerValue }),
+ );
+
+ commit(types.RECEIVE_MEMBER_ROLE_SUCCESS, { memberId, accessLevel });
+ } catch (error) {
+ commit(types.RECEIVE_MEMBER_ROLE_ERROR);
+
+ throw error;
+ }
+};
+
+export const showRemoveGroupLinkModal = ({ commit }, groupLink) => {
+ commit(types.SHOW_REMOVE_GROUP_LINK_MODAL, groupLink);
+};
+
+export const hideRemoveGroupLinkModal = ({ commit }) => {
+ commit(types.HIDE_REMOVE_GROUP_LINK_MODAL);
+};
diff --git a/app/assets/javascripts/vuex_shared/modules/members/index.js b/app/assets/javascripts/vuex_shared/modules/members/index.js
index ec6a94178f3..682e85298ad 100644
--- a/app/assets/javascripts/vuex_shared/modules/members/index.js
+++ b/app/assets/javascripts/vuex_shared/modules/members/index.js
@@ -1,6 +1,10 @@
-import createState from './state';
+import createState from 'ee_else_ce/vuex_shared/modules/members/state';
+import * as actions from './actions';
+import mutations from './mutations';
export default initialState => ({
namespaced: true,
state: createState(initialState),
+ actions,
+ mutations,
});
diff --git a/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js b/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js
new file mode 100644
index 00000000000..00f4c910669
--- /dev/null
+++ b/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js
@@ -0,0 +1,7 @@
+export const RECEIVE_MEMBER_ROLE_SUCCESS = 'RECEIVE_MEMBER_ROLE_SUCCESS';
+export const RECEIVE_MEMBER_ROLE_ERROR = 'RECEIVE_MEMBER_ROLE_ERROR';
+
+export const HIDE_ERROR = 'HIDE_ERROR';
+
+export const SHOW_REMOVE_GROUP_LINK_MODAL = 'SHOW_REMOVE_GROUP_LINK_MODAL';
+export const HIDE_REMOVE_GROUP_LINK_MODAL = 'HIDE_REMOVE_GROUP_LINK_MODAL';
diff --git a/app/assets/javascripts/vuex_shared/modules/members/mutations.js b/app/assets/javascripts/vuex_shared/modules/members/mutations.js
new file mode 100644
index 00000000000..281c947e68f
--- /dev/null
+++ b/app/assets/javascripts/vuex_shared/modules/members/mutations.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import { s__ } from '~/locale';
+import * as types from './mutation_types';
+import { findMember } from './utils';
+
+export default {
+ [types.RECEIVE_MEMBER_ROLE_SUCCESS](state, { memberId, accessLevel }) {
+ const member = findMember(state, memberId);
+
+ if (!member) {
+ return;
+ }
+
+ Vue.set(member, 'accessLevel', accessLevel);
+ },
+ [types.RECEIVE_MEMBER_ROLE_ERROR](state) {
+ state.errorMessage = s__(
+ "Members|An error occurred while updating the member's role, please try again.",
+ );
+ state.showError = true;
+ },
+ [types.HIDE_ERROR](state) {
+ state.showError = false;
+ state.errorMessage = '';
+ },
+ [types.SHOW_REMOVE_GROUP_LINK_MODAL](state, groupLink) {
+ state.removeGroupLinkModalVisible = true;
+ state.groupLinkToRemove = groupLink;
+ },
+ [types.HIDE_REMOVE_GROUP_LINK_MODAL](state) {
+ state.removeGroupLinkModalVisible = false;
+ },
+};
diff --git a/app/assets/javascripts/vuex_shared/modules/members/state.js b/app/assets/javascripts/vuex_shared/modules/members/state.js
index 1511961245c..e4867819e17 100644
--- a/app/assets/javascripts/vuex_shared/modules/members/state.js
+++ b/app/assets/javascripts/vuex_shared/modules/members/state.js
@@ -1,5 +1,19 @@
-export default ({ members, sourceId, currentUserId }) => ({
+export default ({
members,
sourceId,
currentUserId,
+ tableFields,
+ memberPath,
+ requestFormatter,
+}) => ({
+ members,
+ sourceId,
+ currentUserId,
+ tableFields,
+ memberPath,
+ requestFormatter,
+ showError: false,
+ errorMessage: '',
+ removeGroupLinkModalVisible: false,
+ groupLinkToRemove: null,
});
diff --git a/app/assets/javascripts/vuex_shared/modules/members/utils.js b/app/assets/javascripts/vuex_shared/modules/members/utils.js
new file mode 100644
index 00000000000..7dcd33111e8
--- /dev/null
+++ b/app/assets/javascripts/vuex_shared/modules/members/utils.js
@@ -0,0 +1 @@
+export const findMember = (state, memberId) => state.members.find(member => member.id === memberId);
diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue
index a00661c214d..9400dacedc2 100644
--- a/app/assets/javascripts/whats_new/components/app.vue
+++ b/app/assets/javascripts/whats_new/components/app.vue
@@ -1,6 +1,10 @@
<script>
import { mapState, mapActions } from 'vuex';
import { GlDrawer, GlBadge, GlIcon, GlLink } from '@gitlab/ui';
+import SkeletonLoader from './skeleton_loader.vue';
+import Tracking from '~/tracking';
+
+const trackingMixin = Tracking.mixin();
export default {
components: {
@@ -8,66 +12,90 @@ export default {
GlBadge,
GlIcon,
GlLink,
+ SkeletonLoader,
},
+ mixins: [trackingMixin],
props: {
- features: {
+ storageKey: {
type: String,
- required: false,
+ required: true,
default: null,
},
},
computed: {
- ...mapState(['open']),
- parsedFeatures() {
- let features;
-
- try {
- features = JSON.parse(this.$props.features) || [];
- } catch (err) {
- features = [];
- }
-
- return features;
- },
+ ...mapState(['open', 'features']),
},
mounted() {
- this.openDrawer();
+ this.openDrawer(this.storageKey);
+ this.fetchItems();
+
+ const body = document.querySelector('body');
+ const namespaceId = body.getAttribute('data-namespace-id');
+
+ this.track('click_whats_new_drawer', { label: 'namespace_id', value: namespaceId });
},
methods: {
- ...mapActions(['openDrawer', 'closeDrawer']),
+ ...mapActions(['openDrawer', 'closeDrawer', 'fetchItems']),
},
};
</script>
<template>
<div>
- <gl-drawer class="mt-6" :open="open" @close="closeDrawer">
+ <gl-drawer class="whats-new-drawer" :open="open" @close="closeDrawer">
<template #header>
<h4 class="page-title my-2">{{ __("What's new at GitLab") }}</h4>
</template>
<div class="pb-6">
- <div v-for="feature in parsedFeatures" :key="feature.title" class="mb-6">
- <gl-link :href="feature.url" target="_blank">
- <h5 class="gl-font-base">{{ feature.title }}</h5>
- </gl-link>
- <div class="mb-2">
- <template v-for="package_name in feature.packages">
- <gl-badge :key="package_name" size="sm" class="whats-new-item-badge mr-1">
- <gl-icon name="license" />{{ package_name }}
- </gl-badge>
- </template>
+ <template v-if="features">
+ <div v-for="feature in features" :key="feature.title" class="mb-6">
+ <gl-link
+ :href="feature.url"
+ target="_blank"
+ data-testid="whats-new-title-link"
+ data-track-event="click_whats_new_item"
+ :data-track-label="feature.title"
+ :data-track-property="feature.url"
+ >
+ <h5 class="gl-font-base">{{ feature.title }}</h5>
+ </gl-link>
+ <div v-if="feature.packages" class="gl-mb-3">
+ <template v-for="package_name in feature.packages">
+ <gl-badge :key="package_name" size="sm" class="whats-new-item-badge gl-mr-2">
+ <gl-icon name="license" />{{ package_name }}
+ </gl-badge>
+ </template>
+ </div>
+ <gl-link
+ :href="feature.url"
+ target="_blank"
+ data-track-event="click_whats_new_item"
+ :data-track-label="feature.title"
+ :data-track-property="feature.url"
+ >
+ <img
+ :alt="feature.title"
+ :src="feature.image_url"
+ class="img-thumbnail px-6 gl-py-3 whats-new-item-image"
+ />
+ </gl-link>
+ <p class="gl-pt-3">{{ feature.body }}</p>
+ <gl-link
+ :href="feature.url"
+ target="_blank"
+ data-track-event="click_whats_new_item"
+ :data-track-label="feature.title"
+ :data-track-property="feature.url"
+ >{{ __('Learn more') }}</gl-link
+ >
</div>
- <gl-link :href="feature.url" target="_blank">
- <img
- :alt="feature.title"
- :src="feature.image_url"
- class="img-thumbnail px-6 py-2 whats-new-item-image"
- />
- </gl-link>
- <p class="pt-2">{{ feature.body }}</p>
- <gl-link :href="feature.url" target="_blank">{{ __('Learn more') }}</gl-link>
+ </template>
+ <div v-else class="gl-mt-5">
+ <skeleton-loader />
+ <skeleton-loader />
</div>
</div>
</gl-drawer>
+ <div v-if="open" class="whats-new-modal-backdrop modal-backdrop"></div>
</div>
</template>
diff --git a/app/assets/javascripts/whats_new/components/skeleton_loader.vue b/app/assets/javascripts/whats_new/components/skeleton_loader.vue
new file mode 100644
index 00000000000..41e7790f300
--- /dev/null
+++ b/app/assets/javascripts/whats_new/components/skeleton_loader.vue
@@ -0,0 +1,25 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+};
+</script>
+
+<template>
+ <gl-skeleton-loader :width="350" :height="420">
+ <rect width="350" height="16" />
+ <rect y="25" width="110" height="16" rx="8" />
+ <rect x="115" y="25" width="110" height="16" rx="8" />
+ <rect x="230" y="25" width="110" height="16" rx="8" />
+ <rect y="50" width="350" height="165" rx="12" />
+ <rect y="230" width="480" height="8" />
+ <rect y="254" width="560" height="8" />
+ <rect y="278" width="320" height="8" />
+ <rect y="302" width="480" height="8" />
+ <rect y="326" width="560" height="8" />
+ <rect y="365" width="80" height="8" />
+ </gl-skeleton-loader>
+</template>
diff --git a/app/assets/javascripts/whats_new/index.js b/app/assets/javascripts/whats_new/index.js
index 19cdb590ae2..a57c9718156 100644
--- a/app/assets/javascripts/whats_new/index.js
+++ b/app/assets/javascripts/whats_new/index.js
@@ -19,7 +19,7 @@ export default () => {
render(createElement) {
return createElement('app', {
props: {
- features: whatsNewElm.getAttribute('data-features'),
+ storageKey: whatsNewElm.getAttribute('data-storage-key'),
},
});
},
diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js
index 53488413d9e..a84dfb399d8 100644
--- a/app/assets/javascripts/whats_new/store/actions.js
+++ b/app/assets/javascripts/whats_new/store/actions.js
@@ -1,10 +1,20 @@
import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
export default {
closeDrawer({ commit }) {
commit(types.CLOSE_DRAWER);
},
- openDrawer({ commit }) {
+ openDrawer({ commit }, storageKey) {
commit(types.OPEN_DRAWER);
+
+ if (storageKey) {
+ localStorage.setItem(storageKey, JSON.stringify(false));
+ }
+ },
+ fetchItems({ commit }) {
+ return axios.get('/-/whats_new').then(({ data }) => {
+ commit(types.SET_FEATURES, data);
+ });
},
};
diff --git a/app/assets/javascripts/whats_new/store/mutation_types.js b/app/assets/javascripts/whats_new/store/mutation_types.js
index daa65230101..124d33a88b1 100644
--- a/app/assets/javascripts/whats_new/store/mutation_types.js
+++ b/app/assets/javascripts/whats_new/store/mutation_types.js
@@ -1,2 +1,3 @@
export const CLOSE_DRAWER = 'CLOSE_DRAWER';
export const OPEN_DRAWER = 'OPEN_DRAWER';
+export const SET_FEATURES = 'SET_FEATURES';
diff --git a/app/assets/javascripts/whats_new/store/mutations.js b/app/assets/javascripts/whats_new/store/mutations.js
index f7e84ee81a9..4fb7b17244e 100644
--- a/app/assets/javascripts/whats_new/store/mutations.js
+++ b/app/assets/javascripts/whats_new/store/mutations.js
@@ -7,4 +7,7 @@ export default {
[types.OPEN_DRAWER](state) {
state.open = true;
},
+ [types.SET_FEATURES](state, data) {
+ state.features = data;
+ },
};
diff --git a/app/assets/javascripts/whats_new/store/state.js b/app/assets/javascripts/whats_new/store/state.js
index 97089a095f1..4c76284b865 100644
--- a/app/assets/javascripts/whats_new/store/state.js
+++ b/app/assets/javascripts/whats_new/store/state.js
@@ -1,3 +1,4 @@
export default {
open: false,
+ features: null,
};
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index f706b615e7e..a31cb0b0485 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -1,41 +1,27 @@
@import './pages/admin';
@import './pages/alert_management/details';
@import './pages/alert_management/severity-icons';
-@import './pages/boards';
@import './pages/branches';
@import './pages/builds';
@import './pages/ci_projects';
@import './pages/clusters';
@import './pages/commits';
-@import './pages/cycle_analytics';
@import './pages/deploy_keys';
@import './pages/detail_page';
-@import './pages/dev_ops_report';
-@import './pages/diff';
@import './pages/editor';
@import './pages/environment_logs';
-@import './pages/environments';
-@import './pages/error_details';
-@import './pages/error_list';
-@import './pages/error_tracking_list';
@import './pages/events';
-@import './pages/experience_level';
-@import './pages/experimental_separate_sign_up';
-@import './pages/graph';
@import './pages/groups';
@import './pages/help';
@import './pages/import';
@import './pages/incident_management_list';
@import './pages/issuable';
@import './pages/issues/issue_count_badge';
-@import './pages/issues/issues_list';
@import './pages/issues';
@import './pages/labels';
@import './pages/login';
@import './pages/members';
-@import './pages/merge_conflicts';
@import './pages/merge_requests';
-@import './pages/milestone';
@import './pages/monitor';
@import './pages/note_form';
@import './pages/notes';
@@ -47,19 +33,14 @@
@import './pages/profiles/preferences';
@import './pages/projects';
@import './pages/prometheus';
-@import './pages/reports';
@import './pages/runners';
@import './pages/search';
-@import './pages/serverless';
@import './pages/service_desk';
@import './pages/settings';
@import './pages/settings_ci_cd';
@import './pages/sherlock';
@import './pages/status';
@import './pages/storage_quota';
-@import './pages/tags';
@import './pages/tree';
@import './pages/trials';
-@import './pages/ui_dev_kit';
@import './pages/users';
-@import './pages/wiki';
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 8acd338fff8..4b1139d2354 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -1,11 +1,3 @@
-/*
- * This is a manifest file that'll automatically include all the stylesheets available in this directory
- * and any sub-directories. You're free to add application-wide styles to this file and they'll appear at
- * the top of the compiled file, but it's generally better to create a new file per style scope.
- *= require_self
- *= require cropper.css
-*/
-
// Welcome to GitLab css!
// If you need to add or modify UI component that is common for many pages
// like a table or typography then make changes in the framework/ directory.
@@ -36,17 +28,6 @@
// EE-only stylesheets
@import 'application_ee';
-// CSS util classes
-/**
- These are deprecated in favor of the Gitlab UI utilities imported below.
- Please check https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss
- to see the available utility classes.
-**/
-@import 'utilities';
-
-// Gitlab UI util classes
-@import '@gitlab/ui/src/scss/utilities';
-
/* print styles */
@media print {
@import 'print';
diff --git a/app/assets/stylesheets/application_utilities.scss b/app/assets/stylesheets/application_utilities.scss
new file mode 100644
index 00000000000..817e983a0ec
--- /dev/null
+++ b/app/assets/stylesheets/application_utilities.scss
@@ -0,0 +1,12 @@
+@import 'page_bundles/mixins_and_variables_and_functions';
+
+// CSS util classes
+/**
+ These are deprecated in favor of the Gitlab UI utilities imported below.
+ Please check https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss
+ to see the available utility classes.
+**/
+@import 'utilities';
+
+// Gitlab UI util classes
+@import '@gitlab/ui/src/scss/utilities';
diff --git a/app/assets/stylesheets/application_utilities_dark.scss b/app/assets/stylesheets/application_utilities_dark.scss
new file mode 100644
index 00000000000..eb32cdfc444
--- /dev/null
+++ b/app/assets/stylesheets/application_utilities_dark.scss
@@ -0,0 +1,3 @@
+@import './themes/dark';
+
+@import 'application_utilities';
diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss
index 21133316291..81f2091e915 100644
--- a/app/assets/stylesheets/components/design_management/design.scss
+++ b/app/assets/stylesheets/components/design_management/design.scss
@@ -1,3 +1,6 @@
+$design-pin-diameter: 28px;
+$t-gray-a-16-design-pin: rgba($black, 0.16);
+
.layout-page.design-detail-layout {
max-height: 100vh;
}
@@ -9,34 +12,61 @@
top: 35px;
}
- .design-pin {
- transition: opacity $gl-transition-duration-medium $general-hover-transition-curve;
-
- &.inactive {
- @include gl-opacity-5;
-
- &:hover {
- @include gl-opacity-10;
- }
- }
- }
-
.badge.badge-pill {
display: flex;
- height: 28px;
- width: 28px;
- background-color: $blue-400;
+ height: $design-pin-diameter;
+ width: $design-pin-diameter;
+ box-sizing: content-box;
+ background-color: $purple-500;
color: $white;
- border: $white 1px solid;
+ font-weight: $gl-font-weight-bold;
border-radius: 50%;
+ z-index: 1;
+ padding: 0;
&.resolved {
background-color: $gray-500;
}
}
- .design-detail-overlay-add-comment {
- cursor: crosshair;
+ .comment-indicator {
+ border-radius: 50%;
+ }
+
+ .comment-indicator,
+ .frame .badge.badge-pill {
+ &:active {
+ cursor: grabbing;
+ }
+ }
+
+ /**
+ * Design pin that overlays the design
+ */
+ .frame .badge.badge-pill {
+ box-shadow: 0 2px 4px $t-gray-a-08, 0 0 1px $t-gray-a-24;
+ border: $white 2px solid;
+ will-change: transform, box-shadow, opacity;
+ // NOTE: verbose transition property required for Safari
+ transition: transform $general-hover-transition-duration linear, box-shadow $general-hover-transition-duration linear, opacity $general-hover-transition-duration linear;
+ transform-origin: 0 0;
+ transform: translate(-50%, -50%);
+
+ &:hover {
+ transform: scale(1.2) translate(-50%, -50%);
+ }
+
+ &:active {
+ box-shadow: 0 0 4px $t-gray-a-16-design-pin, 0 4px 12px $t-gray-a-16-design-pin;
+ }
+
+ &.inactive {
+ @include gl-opacity-5;
+
+ &:hover {
+ @include gl-opacity-10;
+ }
+ }
}
}
@@ -105,8 +135,8 @@
border-left: 1px solid $gray-100;
position: absolute;
left: 28px;
- top: -18px;
- height: 18px;
+ top: -17px;
+ height: 17px;
}
.design-note {
@@ -152,6 +182,10 @@
}
}
+.design-card-header {
+ background: transparent;
+}
+
.design-dropzone-border {
border: 2px dashed $gray-100;
}
diff --git a/app/assets/stylesheets/components/rich_content_editor.scss b/app/assets/stylesheets/components/rich_content_editor.scss
index b1189137d59..d97a9bc227d 100644
--- a/app/assets/stylesheets/components/rich_content_editor.scss
+++ b/app/assets/stylesheets/components/rich_content_editor.scss
@@ -44,3 +44,11 @@
@include gl-line-height-20;
}
}
+
+/**
+* Styling below ensures that YouTube videos are displayed in the editor the same as they would in about.gitlab.com
+* https://gitlab.com/gitlab-com/www-gitlab-com/-/blob/master/source/stylesheets/_base.scss#L977
+*/
+.video_container {
+ padding-bottom: 56.25%;
+}
diff --git a/app/assets/stylesheets/components/whats_new.scss b/app/assets/stylesheets/components/whats_new.scss
index 4fff900f5a5..6c58346b750 100644
--- a/app/assets/stylesheets/components/whats_new.scss
+++ b/app/assets/stylesheets/components/whats_new.scss
@@ -1,9 +1,32 @@
+.whats-new-drawer {
+ margin-top: $header-height;
+ @include gl-shadow-none;
+}
+
+.with-performance-bar .whats-new-drawer {
+ margin-top: calc(#{$performance-bar-height} + #{$header-height});
+}
+
.gl-badge.whats-new-item-badge {
background-color: $purple-light;
color: $purple;
- font-weight: bold;
+ @include gl-font-weight-bold;
}
.whats-new-item-image {
border-color: $gray-50;
}
+
+.whats-new-modal-backdrop {
+ z-index: 9;
+}
+
+.whats-new-notification-count {
+ @include gl-bg-gray-900;
+ @include gl-font-sm;
+ @include gl-line-height-normal;
+ @include gl-text-white;
+ @include gl-vertical-align-top;
+ border-radius: 20px;
+ padding: 3px 10px;
+}
diff --git a/app/assets/stylesheets/fontawesome_custom.scss b/app/assets/stylesheets/fontawesome_custom.scss
index 46e5e5a28ea..a3338ff13b5 100644
--- a/app/assets/stylesheets/fontawesome_custom.scss
+++ b/app/assets/stylesheets/fontawesome_custom.scss
@@ -88,11 +88,6 @@
content: '\f078';
}
-.fa-remove::before,
-.fa-times::before {
- content: '\f00d';
-}
-
.fa-caret-down::before {
content: '\f0d7';
}
@@ -101,10 +96,6 @@
content: '\f00c';
}
-.fa-search::before {
- content: '\f002';
-}
-
.fa-warning::before,
.fa-exclamation-triangle::before {
content: '\f071';
@@ -118,18 +109,6 @@
content: '\f110';
}
-.fa-calendar::before {
- content: '\f073';
-}
-
-.fa-angle-double-right::before {
- content: '\f101';
-}
-
-.fa-angle-double-left::before {
- content: '\f100';
-}
-
.fa-trash-o::before {
content: '\f014';
}
@@ -146,14 +125,6 @@
content: '\f077';
}
-.fa-file-text-o::before {
- content: '\f0f6';
-}
-
-.fa-github::before {
- content: '\f09b';
-}
-
.fa-paperclip::before {
content: '\f0c6';
}
@@ -162,10 +133,6 @@
content: '\f188';
}
-.fa-google::before {
- content: '\f1a0';
-}
-
.fa-exclamation-circle::before {
content: '\f06a';
}
@@ -174,10 +141,6 @@
content: '\f0f3';
}
-.fa-bitbucket-square::before {
- content: '\f172';
-}
-
.fa-file-o::before {
content: '\f016';
}
@@ -190,22 +153,10 @@
content: '\f111';
}
-.fa-bitbucket::before {
- content: '\f171';
-}
-
.fa-git::before {
content: '\f1d3';
}
-.fa-folder::before {
- content: '\f07b';
-}
-
-.fa-archive::before {
- content: '\f187';
-}
-
.fa-thumb-tack::before {
content: '\f08d';
}
@@ -214,10 +165,6 @@
content: '\f06d';
}
-.fa-globe::before {
- content: '\f0ac';
-}
-
.fa-pause::before {
content: '\f04c';
}
@@ -226,14 +173,6 @@
content: '\f04b';
}
-.fa-search-plus::before {
- content: '\f00e';
-}
-
-.fa-search-minus::before {
- content: '\f010';
-}
-
.fa-share::before {
content: '\f064';
}
@@ -258,10 +197,6 @@
content: '\f081';
}
-.fa-unlink::before {
- content: '\f127';
-}
-
.fa-file-pdf-o::before {
content: '\f1c1';
}
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index f875420b9c9..e40b95cdce6 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -70,3 +70,4 @@
@import 'framework/spinner';
@import 'framework/card';
@import 'framework/editor-lite';
+@import 'framework/diffs';
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index 136ff82e0f8..196fb3a7088 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -112,8 +112,7 @@ a {
}
.dropdown-menu a,
-.dropdown-menu button,
-.dropdown-menu-nav a {
+.dropdown-menu button {
transition: none;
}
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index a9c1652d00d..a8cc685d880 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -417,12 +417,6 @@
}
}
-@include media-breakpoint-down(xs) {
- .btn-wide-on-xs {
- width: 100%;
- }
-}
-
.btn-blank {
padding: 0;
background: transparent;
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 714ef8b2175..8dbed9c03f2 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -324,15 +324,8 @@ img.emoji {
}
.project-item-select-holder {
- display: inline-block;
- position: relative;
-
.project-item-select {
- position: absolute;
- top: 0;
- right: 0;
min-width: 250px;
- visibility: hidden;
}
}
@@ -428,7 +421,6 @@ img.emoji {
/** COMMON SIZING CLASSES **/
.w-0 { width: 0; }
.w-8em { width: 8em; }
-.w-3rem { width: 3rem; }
.w-15p { width: 15%; }
.w-30p { width: 30%; }
.w-60p { width: 60%; }
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index 7004bcc121d..48252762546 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -363,20 +363,30 @@
// Collapsed nav
.toggle-sidebar-button,
-.close-nav-button {
- width: $contextual-sidebar-width - 1px;
+.close-nav-button,
+.toggle-right-sidebar-button {
transition: width $sidebar-transition-duration;
- position: fixed;
height: $toggle-sidebar-height;
- bottom: 0;
padding: 0 $gl-padding;
background-color: $gray-light;
border: 0;
- border-top: 1px solid $border-color;
color: $gl-text-color-secondary;
display: flex;
align-items: center;
+ &:hover {
+ background-color: $border-color;
+ color: $gl-text-color;
+ }
+}
+
+.toggle-sidebar-button,
+.close-nav-button {
+ position: fixed;
+ bottom: 0;
+ width: $contextual-sidebar-width - 1px;
+ border-top: 1px solid $border-color;
+
svg {
margin-right: 8px;
}
@@ -384,11 +394,10 @@
.icon-chevron-double-lg-right {
display: none;
}
+}
- &:hover {
- background-color: $border-color;
- color: $gl-text-color;
- }
+.toggle-right-sidebar-button {
+ border-bottom: 1px solid $border-color;
}
.collapse-text {
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/framework/diffs.scss
index 62af7103b39..c0a2350d080 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -13,6 +13,21 @@
box-shadow: 0 -2px 0 0 var(--white);
cursor: pointer;
+ .dropdown-menu {
+ cursor: auto;
+ }
+
+ @media (max-width: map-get($grid-breakpoints, sm)-1) {
+ .file-header-content {
+ width: 0;
+ flex: 1;
+ }
+
+ .file-actions {
+ margin-left: $gl-spacing-scale-2;
+ }
+ }
+
@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
@@ -23,6 +38,14 @@
top: $mr-file-header-top;
z-index: 120;
+ .with-system-header & {
+ top: $mr-file-header-top + $system-header-height;
+ }
+
+ .with-system-header.with-performance-bar & {
+ top: $mr-file-header-top + $system-header-height + $performance-bar-height;
+ }
+
&::before {
content: '';
position: absolute;
@@ -55,10 +78,6 @@
}
}
- a:hover {
- text-decoration: none;
- }
-
&:hover {
background-color: $gray-normal;
}
@@ -248,6 +267,7 @@
}
}
}
+
//.view.swipe
.view.onion-skin {
.onion-skin-frame {
@@ -316,6 +336,7 @@
}
}
}
+
//.view.onion-skin
}
@@ -942,15 +963,13 @@ table.code {
.frame.click-to-comment,
.btn-transparent.image-diff-overlay-add-comment {
position: relative;
- cursor: image-url('illustrations/image_comment_light_cursor.svg')
- $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
+ cursor: image-url('illustrations/image_comment_light_cursor.svg') $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
// Retina cursor
// scss-lint:disable DuplicateProperty
cursor: image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x,
- image-url('illustrations/image_comment_light_cursor@2x.svg') 2x)
- $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
+ image-url('illustrations/image_comment_light_cursor@2x.svg') 2x) $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
.comment-indicator {
@@ -1059,77 +1078,6 @@ table.code {
position: relative;
}
-.diff-tree-list {
- position: -webkit-sticky;
- position: sticky;
- $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 15px;
- top: $top-pos;
- max-height: calc(100vh - #{$top-pos});
- z-index: 202;
-
- .with-performance-bar & {
- $performance-bar-top-pos: $performance-bar-height + $top-pos;
- top: $performance-bar-top-pos;
- max-height: calc(100vh - #{$performance-bar-top-pos});
- }
-
- .drag-handle {
- bottom: 16px;
- transform: translateX(10px);
- }
-}
-
-.diff-files-holder {
- flex: 1;
- min-width: 0;
- z-index: 201;
-}
-
-.compare-versions-container {
- min-width: 0;
-}
-
-.tree-list-holder {
- height: 100%;
-
- .file-row {
- margin-left: 0;
- margin-right: 0;
- }
-}
-
-.tree-list-scroll {
- max-height: 100%;
- padding-bottom: $grid-size;
- overflow-y: scroll;
- overflow-x: auto;
-}
-
-.tree-list-search {
- flex: 0 0 34px;
-
- .form-control {
- padding-left: 30px;
- }
-}
-
-.tree-list-icon {
- top: 50%;
- left: 10px;
- transform: translateY(-50%);
-
- &,
- svg {
- fill: $gl-text-color-tertiary;
- }
-}
-
-.tree-list-clear-icon {
- right: 10px;
- left: auto;
- line-height: 0;
-}
-
.discussion-collapsible {
margin: 0 $gl-padding $gl-padding 71px;
@@ -1145,30 +1093,6 @@ table.code {
}
}
-@media (max-width: map-get($grid-breakpoints, md)-1) {
- .diffs .files {
- @include fixed-width-container;
- flex-direction: column;
-
- .diff-tree-list {
- position: relative;
- top: 0;
- // !important is required to override inline styles of resizable sidebar
- width: 100% !important;
- }
-
- .tree-list-holder {
- max-height: calc(50px + 50vh);
- padding-right: 0;
- }
- }
-
- .discussion-collapsible {
- margin: $gl-padding;
- margin-top: 0;
- }
-}
-
.image-diff-overlay,
.image-diff-overlay-add-comment {
top: 0;
@@ -1191,3 +1115,15 @@ table.code {
display: none;
}
}
+
+@media (max-width: map-get($grid-breakpoints, md)-1) {
+ .diffs .files {
+ @include fixed-width-container;
+ flex-direction: column;
+ }
+
+ .discussion-collapsible {
+ margin: $gl-padding;
+ margin-top: 0;
+ }
+}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index ad5864ef6d9..e8d37fcf40b 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -33,8 +33,7 @@
}
.show.dropdown {
- .dropdown-menu,
- .dropdown-menu-nav {
+ .dropdown-menu {
@include set-visible;
min-height: $dropdown-min-height;
max-height: $dropdown-max-height;
@@ -190,15 +189,6 @@
background-color: $gray-darker;
color: $gl-text-color;
outline: 0;
-
- // make sure the text color is not overridden
- &.text-danger {
- color: $brand-danger;
- }
-
- .avatar {
- border-color: $white;
- }
}
@mixin dropdown-link {
@@ -217,11 +207,6 @@
text-align: left;
width: 100%;
- // make sure the text color is not overridden
- &.text-danger {
- color: $brand-danger;
- }
-
&.disable-hover {
text-decoration: none;
}
@@ -233,10 +218,6 @@
@include dropdown-item-hover;
text-decoration: none;
-
- .badge.badge-pill {
- background-color: darken($blue-50, 5%);
- }
}
&.dropdown-menu-user-link {
@@ -258,8 +239,7 @@
}
}
-.dropdown-menu,
-.dropdown-menu-nav {
+.dropdown-menu {
display: none;
position: absolute;
width: auto;
@@ -393,49 +373,56 @@
pointer-events: none;
}
- .dropdown-menu li {
- cursor: pointer;
+ .dropdown-menu {
+ display: none;
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
- &.droplab-item-active button {
- @include dropdown-item-hover;
- }
+ li {
+ cursor: pointer;
- > a,
- > button {
- display: flex;
- margin: 0;
- text-overflow: inherit;
- text-align: left;
+ &.droplab-item-active button {
+ @include dropdown-item-hover;
+ }
- &.btn .fa:not(:last-child) {
- margin-left: 5px;
+ > a,
+ > button {
+ display: flex;
+ margin: 0;
+ text-overflow: inherit;
+ text-align: left;
+
+ &.btn .fa:not(:last-child) {
+ margin-left: 5px;
+ }
}
- }
- > button.dropdown-epic-button {
- flex-direction: column;
+ > button.dropdown-epic-button {
+ flex-direction: column;
- .reference {
- color: $gray-300;
- margin-top: $gl-padding-4;
+ .reference {
+ color: $gray-300;
+ margin-top: $gl-padding-4;
+ }
}
- }
- &.droplab-item-selected i {
- visibility: visible;
- }
+ &.droplab-item-selected i {
+ visibility: visible;
+ }
- .icon {
- visibility: hidden;
- }
+ .icon {
+ visibility: hidden;
+ }
- .description {
- display: inline-block;
- white-space: normal;
- margin-left: 5px;
+ .description {
+ display: inline-block;
+ white-space: normal;
+ margin-left: 5px;
- p {
- margin-bottom: 0;
+ p {
+ margin-bottom: 0;
+ }
}
}
}
@@ -447,21 +434,12 @@
}
}
-.droplab-dropdown .dropdown-menu,
-.droplab-dropdown .dropdown-menu-nav {
- display: none;
- opacity: 1;
- visibility: visible;
- transform: translateY(0);
-}
-
.comment-type-dropdown.show .dropdown-menu {
display: block;
}
.filtered-search-box-input-container {
- .dropdown-menu,
- .dropdown-menu-nav {
+ .dropdown-menu {
max-width: 280px;
}
}
@@ -850,8 +828,7 @@
}
header.navbar-gitlab .dropdown {
- .dropdown-menu,
- .dropdown-menu-nav {
+ .dropdown-menu {
width: 100%;
min-width: 100%;
}
diff --git a/app/assets/stylesheets/framework/editor-lite.scss b/app/assets/stylesheets/framework/editor-lite.scss
index 75d511d7f66..20fea7a82ca 100644
--- a/app/assets/stylesheets/framework/editor-lite.scss
+++ b/app/assets/stylesheets/framework/editor-lite.scss
@@ -1,5 +1,3 @@
-.monaco-editor.gl-editor-lite {
- .line-numbers {
- @include gl-pt-0;
- }
+[id^='editor-lite-'] {
+ height: 500px;
}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 76c6e03377c..f8710cc1346 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -50,7 +50,7 @@
right: 15px;
margin-left: auto;
- .btn {
+ .btn:not(.btn-icon) {
padding: 0 10px;
font-size: 13px;
line-height: 28px;
@@ -372,7 +372,7 @@ span.idiff {
color: $gl-text-color;
}
- .file-actions .btn {
+ .file-actions .btn:not(.btn-icon) {
padding: 0 10px;
font-size: 13px;
line-height: 28px;
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 1a394ad124b..5f56fa3be86 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -355,28 +355,45 @@
background-color: $white;
}
- .boards-switcher {
- margin: 0 0 10px;
+ .filtered-search-block .boards-switcher {
+ @include gl-mr-0;
+ margin-bottom: $gl-input-padding;
.boards-selector-wrapper,
.dropdown {
- display: block;
+ @include gl-display-block;
}
}
.filter-dropdown-container {
> div {
- margin: 0;
+ @include gl-m-0;
> .btn {
- margin: 0 0 10px;
- width: 100%;
+ margin: 0 0 $gl-input-padding;
+ @include gl-w-full;
}
}
.board-labels-toggle-wrapper {
margin-bottom: $gl-input-padding;
}
+
+ .board-swimlanes-toggle-wrapper {
+ @include gl-h-auto;
+ margin-bottom: $gl-input-padding;
+
+ > span,
+ > .dropdown,
+ .gl-dropdown-toggle {
+ @include gl-w-full;
+ @include gl-m-0;
+ }
+
+ > .dropdown {
+ @include gl-mt-2;
+ }
+ }
}
.boards-add-list > .btn {
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index cf21c23cb17..52319d9658b 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -203,18 +203,6 @@
margin-right: 0;
}
}
-
- &:hover,
- &:focus {
- text-decoration: none;
- outline: 0;
- opacity: 1;
- color: $white;
-
- &.header-user-dropdown-toggle .header-user-avatar {
- border-color: $white;
- }
- }
}
.header-new-dropdown-toggle {
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index ec0755b1614..5623d38d66e 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -9,6 +9,7 @@
}
}
+.ci-status-icon-error,
.ci-status-icon-failed {
svg {
fill: $red-500;
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index 61e8c0d4718..63be2bdef8e 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -215,7 +215,7 @@
}
&.build-trace-rounded {
- border-radius: $border-radius-base;
+ border-radius: $gl-border-radius-base;
}
}
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 292d57f132c..7ebc972ac37 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -28,10 +28,6 @@
text-decoration: none;
color: $black;
border-bottom: 2px solid $gray-darkest;
-
- .badge.badge-pill {
- color: $black;
- }
}
}
@@ -380,33 +376,11 @@
}
.project-item-select-holder.btn-group {
- display: flex;
- overflow: hidden;
- float: right;
-
- .new-project-item-link {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
.new-project-item-select-button {
- width: 32px;
+ max-width: 44px;
}
}
.empty-state .project-item-select-holder.btn-group {
- float: none;
- justify-content: center;
-
- .btn {
- // overrides styles applied to plain `.empty-state .btn`
- margin: 10px 0;
- max-width: 300px;
- width: auto;
-
- @include media-breakpoint-down(xs) {
- max-width: 250px;
- }
- }
+ max-width: 320px;
}
diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss
index 4c1c9d15121..47184804353 100644
--- a/app/assets/stylesheets/framework/snippets.scss
+++ b/app/assets/stylesheets/framework/snippets.scss
@@ -18,18 +18,6 @@
}
}
-.snippet-form-holder .file-holder .file-title {
- padding: 2px;
-}
-
-.markdown-snippet-copy {
- position: fixed;
- top: -10px;
- left: -10px;
- max-height: 0;
- max-width: 0;
-}
-
.snippet-file-content {
border-radius: 3px;
@@ -49,21 +37,6 @@
min-height: $header-height;
}
-.snippet-actions {
- @include media-breakpoint-up(sm) {
- float: right;
- }
-}
-
.snippet-scope-menu .btn-success {
margin-top: 15px;
}
-
-.embed-snippet {
- padding-right: 0;
- padding-top: $gl-padding;
-
- .embed-toggle-list li button {
- padding: 8px 40px;
- }
-}
diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss
index 59e83608d9d..39d9e9a77f9 100644
--- a/app/assets/stylesheets/framework/tables.scss
+++ b/app/assets/stylesheets/framework/tables.scss
@@ -9,11 +9,15 @@ table {
* This is a temporary workaround until we fix the neutral
* color palette in https://gitlab.com/gitlab-org/gitlab/-/issues/213570
*
- * The overwrites here affected the security dashboard tables, when removing
- * this code, table-th-transparent and original-text-color classes should
- * be removed there.
+ * The overwrites here affected the following areas:
+ * - The security dashboard tables. When removing
+ * this code, table-th-transparent and original-text-color classes should
+ * be removed there.
+ * - The subscription seats table. When removing this code, the .seats-table
+ * <th> and margin overrides should be removed there.
*
* Remove this code as soon as this happens
+ *
*/
&.gl-table {
@include gl-text-gray-500;
@@ -186,6 +190,7 @@ table {
.checkbox {
padding-left: $gl-spacing-scale-4;
padding-right: 0;
+ width: 1px;
+ td,
+ th {
@@ -205,12 +210,20 @@ table {
width: 10%;
}
+ .description {
+ max-width: 0;
+ }
+
.identifier {
width: 16%;
}
.scanner {
- width: 15%;
+ width: 10%;
+ }
+
+ .activity {
+ width: 5%;
}
}
}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 8b5fa6c1b6c..c15d46d43b2 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -439,10 +439,6 @@
content: '\f0c6';
}
- &:hover::before {
- text-decoration: none;
- }
-
&.no-attachment-icon {
&::before {
display: none;
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 8cebfc430e0..f0b1e859139 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -780,7 +780,6 @@ $login-brand-holder-color: #888;
* Projects
*/
$project-option-descr-color: #54565b;
-$project-network-controls-color: #888;
/*
* Monitor Charts
@@ -819,7 +818,6 @@ $pipeline-dropdown-line-height: 20px;
$pipeline-dropdown-status-icon-size: 18px;
$ci-action-dropdown-button-size: 24px;
$ci-action-dropdown-svg-size: 12px;
-$pipelines-table-header-height: 40px;
/*
CI variable lists
@@ -868,9 +866,6 @@ $add-to-slack-popup-max-width: 400px;
$add-to-slack-gif-max-width: 850px;
$add-to-slack-well-max-width: 750px;
$add-to-slack-logo-size: 100px;
-$double-headed-arrow-width: 100px;
-$double-headed-arrow-height: 25px;
-$right-arrow-size: 16px;
/*
Popup
diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss
index 55996a074c6..d550a1faa18 100644
--- a/app/assets/stylesheets/framework/wells.scss
+++ b/app/assets/stylesheets/framework/wells.scss
@@ -3,7 +3,6 @@
color: $gl-text-color;
border: 1px solid $border-color;
border-radius: $border-radius-default;
- margin-bottom: $gl-padding-8;
.card.card-body-segment {
padding: $gl-padding;
@@ -29,11 +28,6 @@
.ref-name {
font-size: 12px;
-
- &:hover {
- text-decoration: underline;
- color: $gl-text-color;
- }
}
}
diff --git a/app/assets/stylesheets/lazy_bundles/cropper.css b/app/assets/stylesheets/lazy_bundles/cropper.css
new file mode 100644
index 00000000000..9c7fdded117
--- /dev/null
+++ b/app/assets/stylesheets/lazy_bundles/cropper.css
@@ -0,0 +1,378 @@
+/*!
+ * Cropper v2.3.0
+ * https://github.com/fengyuanchen/cropper
+ *
+ * Copyright (c) 2014-2016 Fengyuan Chen and contributors
+ * Released under the MIT license
+ *
+ * Date: 2016-02-22T02:13:13.332Z
+ */
+.cropper-container {
+ font-size: 0;
+ line-height: 0;
+
+ position: relative;
+
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+
+ direction: ltr !important;
+ touch-action: none;
+ -webkit-tap-highlight-color: transparent;
+ -webkit-touch-callout: none;
+}
+
+.cropper-container img {
+ display: block;
+
+ width: 100%;
+ min-width: 0 !important;
+ max-width: none !important;
+ height: 100%;
+ min-height: 0 !important;
+ max-height: none !important;
+
+ image-orientation: 0deg !important;
+}
+
+.cropper-wrap-box,
+.cropper-canvas,
+.cropper-drag-box,
+.cropper-crop-box,
+.cropper-modal {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+}
+
+.cropper-wrap-box {
+ overflow: hidden;
+}
+
+.cropper-drag-box {
+ opacity: 0;
+ background-color: #fff;
+
+ filter: alpha(opacity=0);
+}
+
+.cropper-modal {
+ opacity: 0.5;
+ background-color: #000;
+
+ filter: alpha(opacity=50);
+}
+
+.cropper-view-box {
+ display: block;
+ overflow: hidden;
+
+ width: 100%;
+ height: 100%;
+
+ outline: 1px solid #39f;
+ outline-color: rgba(51, 153, 255, 0.75);
+}
+
+.cropper-dashed {
+ position: absolute;
+
+ display: block;
+
+ opacity: 0.5;
+ border: 0 dashed #eee;
+
+ filter: alpha(opacity=50);
+}
+
+.cropper-dashed.dashed-h {
+ top: 33.33333%;
+ left: 0;
+
+ width: 100%;
+ height: 33.33333%;
+
+ border-top-width: 1px;
+ border-bottom-width: 1px;
+}
+
+.cropper-dashed.dashed-v {
+ top: 0;
+ left: 33.33333%;
+
+ width: 33.33333%;
+ height: 100%;
+
+ border-right-width: 1px;
+ border-left-width: 1px;
+}
+
+.cropper-center {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+
+ display: block;
+
+ width: 0;
+ height: 0;
+
+ opacity: 0.75;
+
+ filter: alpha(opacity=75);
+}
+
+.cropper-center::before,
+.cropper-center::after {
+ position: absolute;
+
+ display: block;
+
+ content: ' ';
+
+ background-color: #eee;
+}
+
+.cropper-center::before {
+ top: 0;
+ left: -3px;
+
+ width: 7px;
+ height: 1px;
+}
+
+.cropper-center::after {
+ top: -3px;
+ left: 0;
+
+ width: 1px;
+ height: 7px;
+}
+
+.cropper-face,
+.cropper-line,
+.cropper-point {
+ position: absolute;
+
+ display: block;
+
+ width: 100%;
+ height: 100%;
+
+ opacity: 0.1;
+
+ filter: alpha(opacity=10);
+}
+
+.cropper-face {
+ top: 0;
+ left: 0;
+
+ background-color: #fff;
+}
+
+.cropper-line {
+ background-color: #39f;
+}
+
+.cropper-line.line-e {
+ top: 0;
+ right: -3px;
+
+ width: 5px;
+
+ cursor: e-resize;
+}
+
+.cropper-line.line-n {
+ top: -3px;
+ left: 0;
+
+ height: 5px;
+
+ cursor: n-resize;
+}
+
+.cropper-line.line-w {
+ top: 0;
+ left: -3px;
+
+ width: 5px;
+
+ cursor: w-resize;
+}
+
+.cropper-line.line-s {
+ bottom: -3px;
+ left: 0;
+
+ height: 5px;
+
+ cursor: s-resize;
+}
+
+.cropper-point {
+ width: 5px;
+ height: 5px;
+
+ opacity: 0.75;
+ background-color: #39f;
+
+ filter: alpha(opacity=75);
+}
+
+.cropper-point.point-e {
+ top: 50%;
+ right: -3px;
+
+ margin-top: -3px;
+
+ cursor: e-resize;
+}
+
+.cropper-point.point-n {
+ top: -3px;
+ left: 50%;
+
+ margin-left: -3px;
+
+ cursor: n-resize;
+}
+
+.cropper-point.point-w {
+ top: 50%;
+ left: -3px;
+
+ margin-top: -3px;
+
+ cursor: w-resize;
+}
+
+.cropper-point.point-s {
+ bottom: -3px;
+ left: 50%;
+
+ margin-left: -3px;
+
+ cursor: s-resize;
+}
+
+.cropper-point.point-ne {
+ top: -3px;
+ right: -3px;
+
+ cursor: ne-resize;
+}
+
+.cropper-point.point-nw {
+ top: -3px;
+ left: -3px;
+
+ cursor: nw-resize;
+}
+
+.cropper-point.point-sw {
+ bottom: -3px;
+ left: -3px;
+
+ cursor: sw-resize;
+}
+
+.cropper-point.point-se {
+ right: -3px;
+ bottom: -3px;
+
+ width: 20px;
+ height: 20px;
+
+ cursor: se-resize;
+
+ opacity: 1;
+
+ filter: alpha(opacity=100);
+}
+
+.cropper-point.point-se::before {
+ position: absolute;
+ right: -50%;
+ bottom: -50%;
+
+ display: block;
+
+ width: 200%;
+ height: 200%;
+
+ content: ' ';
+
+ opacity: 0;
+ background-color: #39f;
+
+ filter: alpha(opacity=0);
+}
+
+@media (min-width: 768px) {
+ .cropper-point.point-se {
+ width: 15px;
+ height: 15px;
+ }
+}
+
+@media (min-width: 992px) {
+ .cropper-point.point-se {
+ width: 10px;
+ height: 10px;
+ }
+}
+
+@media (min-width: 1200px) {
+ .cropper-point.point-se {
+ width: 5px;
+ height: 5px;
+
+ opacity: 0.75;
+
+ filter: alpha(opacity=75);
+ }
+}
+
+.cropper-invisible {
+ opacity: 0;
+
+ filter: alpha(opacity=0);
+}
+
+.cropper-bg {
+ background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');
+}
+
+.cropper-hide {
+ position: absolute;
+
+ display: block;
+
+ width: 0;
+ height: 0;
+}
+
+.cropper-hidden {
+ display: none !important;
+}
+
+.cropper-move {
+ cursor: move;
+}
+
+.cropper-crop {
+ cursor: crosshair;
+}
+
+.cropper-disabled .cropper-drag-box,
+.cropper-disabled .cropper-face,
+.cropper-disabled .cropper-line,
+.cropper-disabled .cropper-point {
+ cursor: not-allowed;
+}
diff --git a/app/assets/stylesheets/mailer.scss b/app/assets/stylesheets/mailer.scss
index a5fc92237df..b2050c0e73f 100644
--- a/app/assets/stylesheets/mailer.scss
+++ b/app/assets/stylesheets/mailer.scss
@@ -52,6 +52,10 @@ a {
margin-top: 0;
}
+.invite-body {
+ width: 360px;
+}
+
.invite-actions {
margin-top: 24px;
}
@@ -64,6 +68,15 @@ a {
color: $white;
}
+.invite-btn-decline {
+ border-radius: $border-radius-default;
+ border: 1px solid $purple;
+ padding: $gl-btn-vert-padding $gl-btn-horz-padding;
+ cursor: pointer;
+ color: $purple;
+ margin-left: 4px;
+}
+
tr td {
font-family: $mailer-font;
}
diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
index 1e239877428..d1f7c2e9865 100644
--- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
+++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
@@ -6,9 +6,10 @@
$bs-input-focus-box-shadow: rgba(0, 123, 255, 0.25);
a:not(.btn),
- .btn-link:hover,
- .btn-link:focus,
- .btn-link:active {
+ .gl-button.btn-link,
+ .gl-button.btn-link:hover,
+ .gl-button.btn-link:focus,
+ .gl-button.btn-link:active {
color: var(--ide-link-color, $blue-600);
}
@@ -225,20 +226,20 @@
@return calc(#{$original-padding + $original-border} - var(--ide-btn-hover-border-width, #{$original-border}));
}
- .btn:not(.btn-link):not([disabled]):hover {
+ .btn:not(.gl-button):not(.btn-link):not([disabled]):hover {
border-width: var(--ide-btn-hover-border-width, 1px);
padding: calc-btn-hover-padding(6px) calc-btn-hover-padding(10px);
}
- .btn:not([disabled]).btn-sm:hover {
+ .btn:not(.gl-button):not([disabled]).btn-sm:hover {
padding: calc-btn-hover-padding(4px) calc-btn-hover-padding(10px);
}
- .btn:not([disabled]).btn-block:hover {
+ .btn:not(.gl-button):not([disabled]).btn-block:hover {
padding: calc-btn-hover-padding(6px) 0;
}
- .btn-default,
+ .btn-default:not(.gl-button),
.dropdown,
.dropdown-menu-toggle {
background-color: var(--ide-input-background, $white) !important;
@@ -257,7 +258,7 @@
}
// In IDE, the only inverted buttons are `.btn-remove`
- .btn-inverted.btn-remove {
+ .btn-inverted.btn-remove:not(.gl-button) {
color: var(--ide-input-color, $red-500) !important;
background-color: var(--ide-input-background, $white) !important;
border-color: var(--ide-btn-default-border, $red-500);
@@ -276,17 +277,21 @@
}
}
- .btn-default {
+ // todo: remove this block after all default buttons have been migrated to gl-button
+ .btn-default:not(.gl-button) {
+ background-color: var(--ide-btn-default-background, $white) !important;
+ border-color: var(--ide-btn-default-border, $border-color);
+
&:hover,
&:focus {
border-color: var(--ide-btn-default-hover-border, $border-white-normal) !important;
- background-color: var(--ide-input-background, $white-normal) !important;
+ background-color: var(--ide-btn-default-background, $white-normal) !important;
}
&:active,
.active {
border-color: var(--ide-btn-default-hover-border, $border-white-normal) !important;
- background-color: var(--ide-input-background, $white-dark) !important;
+ background-color: var(--ide-btn-default-background, $white-dark) !important;
}
}
@@ -320,8 +325,9 @@
border-color: var(--ide-dropdown-hover-background, $gray-100) !important;
}
- .btn-primary,
- .btn-info {
+ // todo: remove this block after all primary/info buttons have been migrated to gl-button
+ .btn-primary:not(.gl-button),
+ .btn-info:not(.gl-button) {
background-color: var(--ide-btn-primary-background, $blue-500);
border-color: var(--ide-btn-primary-border, $blue-600) !important;
@@ -338,7 +344,8 @@
}
}
- .btn-success {
+ // todo: remove this block after all success buttons have been migrated to gl-button
+ .btn-success:not(.gl-button) {
background-color: var(--ide-btn-success-background, $green-500);
border-color: var(--ide-btn-success-border, $green-600) !important;
@@ -355,12 +362,70 @@
}
}
- .btn[disabled] {
+ // todo: remove this block after all disabled buttons have been migrated to gl-button
+ .btn[disabled]:not(.gl-button) {
background-color: var(--ide-btn-default-background, $gray-light) !important;
border: 1px solid var(--ide-btn-disabled-border, $gray-100) !important;
color: var(--ide-btn-disabled-color, $gl-text-color-disabled) !important;
}
+ @function ide-btn-var($btn-type, $var-type, $value) {
+ @return var(--ide-btn-#{$btn-type}-#{$var-type}, $value);
+ }
+
+ @mixin ide-gl-button($btn-type, $bg-normal, $bg-hover, $bg-active, $border-normal, $border-hover, $border-focus, $border-active, $border-width-hover: 2px, $box-shadow-hover: $t-gray-a-08, $box-shadow-focus: 0 0 0 4px rgba($blue-500, 0.25)) {
+ background-color: ide-btn-var($btn-type, background, $bg-normal);
+ box-shadow: inset 0 0 0 1px ide-btn-var($btn-type, border, $border-normal);
+
+ &:hover,
+ &:focus {
+ background-color: ide-btn-var($btn-type, background, $bg-hover);
+ }
+
+ &:hover {
+ box-shadow: inset 0 0 0 ide-btn-var($btn-type, hover-border-width, $border-width-hover) ide-btn-var($btn-type, hover-border, $border-hover),
+ 0 2px 2px 0 $box-shadow-hover;
+ }
+
+ &:focus {
+ box-shadow: inset 0 0 0 ide-btn-var($btn-type, hover-border-width, $border-width-hover) ide-btn-var($btn-type, hover-border, $border-focus),
+ ide-btn-var($btn-type, focus-box-shadow, $box-shadow-focus);
+ }
+
+ &:active:focus {
+ background-color: ide-btn-var($btn-type, background, $bg-active);
+ box-shadow: inset 0 0 0 ide-btn-var($btn-type, hover-border-width, $border-width-hover) ide-btn-var($btn-type, hover-border, $border-active),
+ ide-btn-var($btn-type, focus-box-shadow, $box-shadow-focus);
+ }
+ }
+
+ .btn-default.gl-button.gl-button {
+ color: var(--ide-input-color, $gl-text-color);
+
+ @include ide-gl-button(default, $white, $gray-50, $gray-100, $gray-200, $gray-300, $gray-300, $gray-400);
+ }
+
+ .btn-success.gl-button.gl-button {
+ @include ide-gl-button(success, $green-500, $green-600, $green-800, $green-600, $green-800, $green-800, $green-900);
+ }
+
+ .btn-info.gl-button.gl-button,
+ .btn-primary.gl-button.gl-button {
+ @include ide-gl-button(primary, $blue-500, $blue-600, $blue-800, $blue-600, $blue-800, $blue-800, $blue-900);
+ }
+
+ .btn-danger.btn-danger-secondary.gl-button.gl-button {
+ color: var(--ide-input-color, $red-500);
+
+ @include ide-gl-button(danger-secondary, $white, $red-50, $red-100, $red-500, $red-600, $red-600, $red-700);
+ }
+
+ .btn[disabled].gl-button.gl-button {
+ color: var(--ide-btn-disabled-color, $gl-text-color-disabled);
+
+ @include ide-gl-button(disabled, $gray-10, $gray-10, $gray-10, $gray-100, $gray-100, $gray-100, $gray-100, 1px, transparent, transparent);
+ }
+
.md table:not(.code) tbody {
background-color: var(--ide-border-color, $white);
}
diff --git a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
new file mode 100644
index 00000000000..499394ad960
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
@@ -0,0 +1,233 @@
+@mixin flat-connector-before($length: 44px) {
+ &::before {
+ content: '';
+ position: absolute;
+ top: 48%;
+ left: -$length;
+ border-top: 2px solid $border-color;
+ width: $length;
+ height: 1px;
+ }
+}
+
+@mixin build-content($border-radius: 30px) {
+ display: inline-block;
+ padding: 8px 10px 9px;
+ width: 100%;
+ border: 1px solid $border-color;
+ border-radius: $border-radius;
+ background-color: $white;
+
+ &:hover {
+ background-color: $gray-darker;
+ border: 1px solid $dropdown-toggle-active-border-color;
+ color: $gl-text-color;
+ }
+}
+
+@mixin mini-pipeline-graph-color(
+ $color-background-default,
+ $color-background-hover-focus,
+ $color-background-active,
+ $color-foreground-default,
+ $color-foreground-hover-focus,
+ $color-foreground-active
+) {
+ background-color: $color-background-default;
+ border-color: $color-foreground-default;
+
+ svg {
+ fill: $color-foreground-default;
+ }
+
+ &:hover,
+ &:focus {
+ background-color: $color-background-hover-focus;
+ border-color: $color-foreground-hover-focus;
+
+ svg {
+ fill: $color-foreground-hover-focus;
+ }
+ }
+
+ &:active {
+ background-color: $color-background-active;
+ border-color: $color-foreground-active;
+
+ svg {
+ fill: $color-foreground-active;
+ }
+ }
+
+ &:focus {
+ box-shadow: 0 0 4px 1px $blue-300;
+ }
+}
+
+@mixin mini-pipeline-item() {
+ border-radius: 100px;
+ background-color: $white;
+ border-width: 1px;
+ border-style: solid;
+ width: $ci-action-icon-size;
+ height: $ci-action-icon-size;
+ margin: 0;
+ padding: 0;
+ position: relative;
+ vertical-align: middle;
+
+ &:hover,
+ &:active,
+ &:focus {
+ outline: none;
+ border-width: 2px;
+ }
+
+ // Dropdown button animation in mini pipeline graph
+ &.ci-status-icon-success {
+ @include mini-pipeline-graph-color($white, $green-100, $green-200, $green-500, $green-600, $green-700);
+ }
+
+ &.ci-status-icon-failed {
+ @include mini-pipeline-graph-color($white, $red-100, $red-200, $red-500, $red-600, $red-700);
+ }
+
+ &.ci-status-icon-pending,
+ &.ci-status-icon-waiting-for-resource,
+ &.ci-status-icon-success-with-warnings {
+ @include mini-pipeline-graph-color($white, $orange-50, $orange-100, $orange-500, $orange-600, $orange-700);
+ }
+
+ &.ci-status-icon-preparing,
+ &.ci-status-icon-running {
+ @include mini-pipeline-graph-color($white, $blue-100, $blue-200, $blue-500, $blue-600, $blue-700);
+ }
+
+ &.ci-status-icon-canceled,
+ &.ci-status-icon-scheduled,
+ &.ci-status-icon-disabled,
+ &.ci-status-icon-not-found,
+ &.ci-status-icon-manual {
+ @include mini-pipeline-graph-color($white, $gray-500, $gray-700, $gray-900, $gray-950, $black);
+ }
+
+ &.ci-status-icon-created,
+ &.ci-status-icon-skipped {
+ @include mini-pipeline-graph-color($white, $gray-100, $gray-200, $gray-300, $gray-400, $gray-500);
+ }
+}
+
+/**
+ Action icons inside dropdowns:
+ - mini graph in pipelines table
+ - dropdown in big graph
+ - mini graph in MR widget pipeline
+ - mini graph in Commit widget pipeline
+*/
+@mixin pipeline-graph-dropdown-menu() {
+ width: 240px;
+ max-width: 240px;
+
+ // override dropdown.scss
+ &.dropdown-menu li button,
+ &.dropdown-menu li a.ci-action-icon-container {
+ padding: 0;
+ text-align: center;
+ }
+
+ .ci-action-icon-container {
+ position: absolute;
+ right: 8px;
+ top: 8px;
+
+ &.ci-action-icon-wrapper {
+ height: $ci-action-dropdown-button-size;
+ width: $ci-action-dropdown-button-size;
+ border-radius: 50%;
+ display: block;
+
+ &:hover {
+ box-shadow: inset 0 0 0 0.0625rem $dropdown-toggle-active-border-color;
+ background-color: $gray-darker;
+
+ svg {
+ fill: $gl-text-color;
+ }
+ }
+
+ .spinner,
+ svg {
+ width: $ci-action-dropdown-svg-size;
+ height: $ci-action-dropdown-svg-size;
+ fill: $gl-text-color-secondary;
+ position: relative;
+ top: 1px;
+ vertical-align: initial;
+ }
+ }
+ }
+
+ // SVGs in the commit widget and mr widget
+ a.ci-action-icon-container.ci-action-icon-wrapper svg {
+ top: 5px;
+ }
+
+ .scrollable-menu {
+ padding: 0;
+ max-height: 245px;
+ overflow: auto;
+ }
+
+ li {
+ position: relative;
+
+ // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered
+ &:hover > .mini-pipeline-graph-dropdown-item,
+ &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item {
+ @extend .mini-pipeline-graph-dropdown-item:hover;
+ }
+
+ // link to the build
+ .mini-pipeline-graph-dropdown-item {
+ align-items: center;
+ clear: both;
+ display: flex;
+ font-weight: normal;
+ line-height: $line-height-base;
+ white-space: nowrap;
+
+ // Match dropdown.scss for all `a` tags
+ &.non-details-job-component {
+ padding: $gl-padding-8 $gl-btn-horz-padding;
+ }
+
+ .ci-job-name-component {
+ align-items: center;
+ display: flex;
+ flex: 1;
+ }
+
+ .ci-status-icon {
+ @include gl-mr-3;
+
+ position: relative;
+
+ > svg {
+ width: $pipeline-dropdown-status-icon-size;
+ height: $pipeline-dropdown-status-icon-size;
+ margin: 3px 0;
+ position: relative;
+ overflow: visible;
+ display: block;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ outline: none;
+ text-decoration: none;
+ background-color: $gray-darker;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index c4852974a4d..e908e3622ed 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -1,3 +1,5 @@
+@import 'mixins_and_variables_and_functions';
+
.user-can-drag {
cursor: grab;
}
@@ -25,7 +27,7 @@
margin: 0;
padding: $gl-padding-4 $gl-padding $gl-padding;
border-bottom: 0;
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
}
.issue-boards-page {
@@ -100,7 +102,7 @@
}
&:hover {
- background-color: $gray-50;
+ background-color: var(--gray-50, $gray-50);
transition: background-color 0.1s linear;
}
}
@@ -165,8 +167,8 @@
.board-inner {
font-size: $issue-boards-font-size;
- background: $gray-light;
- border: 1px solid $gray-100;
+ background: var(--gray-10, $gray-10);
+ border: 1px solid var(--gray-100, $gray-100);
}
.board-header {
@@ -197,7 +199,7 @@
.board-title {
align-items: center;
font-size: 1em;
- border-bottom: 1px solid $gray-100;
+ border-bottom: 1px solid var(--gray-100, $gray-100);
padding: 0 $gl-spacing-scale-3;
height: 3rem;
@@ -215,14 +217,14 @@
outline: 0;
&:hover {
- color: $blue-600;
+ color: var(--blue-600, $blue-600);
box-shadow: none;
}
}
.board-blank-state,
.board-promotion-state {
- background-color: $white;
+ background-color: var(--white, $white);
flex: 1;
overflow-y: auto;
overflow-x: hidden;
@@ -256,9 +258,9 @@
}
.board-card {
- background: $white;
- border: 1px solid $gray-100;
- box-shadow: 0 1px 2px $issue-boards-card-shadow;
+ background: var(--white, $white);
+ border: 1px solid var(--gray-100, $gray-100);
+ box-shadow: 0 1px 2px rgba(var(--black, $black), 0.1);
line-height: $gl-padding;
list-style: none;
position: relative;
@@ -269,12 +271,12 @@
&.is-active,
&.is-active .board-card-assignee:hover a {
- background-color: $blue-50;
+ background-color: var(--blue-50, $blue-50);
}
&.multi-select {
- border-color: $blue-200;
- background-color: $blue-50;
+ border-color: var(--blue-200, $blue-200);
+ background-color: var(--blue-50, $blue-50);
}
.gl-label {
@@ -283,12 +285,12 @@
}
.confidential-icon {
- color: $orange-500;
+ color: var(--orange-500, $orange-500);
cursor: help;
}
.issue-blocked-icon {
- color: $red-500;
+ color: var(--red-500, $red-500);
}
@include media-breakpoint-down(md) {
@@ -301,7 +303,7 @@
font-size: 1em;
a {
- color: $gl-text-color;
+ color: var(--gray-900, $gray-900);
}
@include media-breakpoint-down(md) {
@@ -323,7 +325,7 @@
min-width: $gl-padding-24;
height: $gl-padding-24;
border-radius: $gl-padding-24;
- background-color: $gl-text-color-tertiary;
+ background-color: var(--gray-400, $gray-400);
font-size: $gl-font-size-xs;
cursor: help;
font-weight: $gl-font-weight-bold;
@@ -356,8 +358,6 @@
}
.avatar {
- margin: 0;
-
@include media-breakpoint-down(md) {
width: $gl-padding;
height: $gl-padding;
@@ -372,7 +372,7 @@
.board-card-number {
font-size: $gl-font-size-xs;
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
@include media-breakpoint-up(md) {
font-size: $label-font-size;
@@ -381,7 +381,7 @@
.board-list-count {
padding: 10px 0;
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
font-size: 13px;
}
@@ -437,8 +437,8 @@
max-width: 1100px;
min-height: 500px;
padding: 25px 15px 0;
- background-color: $white;
- box-shadow: 0 2px 12px rgba($black, 0.5);
+ background-color: var(--white, $white);
+ box-shadow: 0 2px 12px rgba(var(--black, $black), 0.5);
.empty-state {
&.add-issues-empty-state-filter {
@@ -486,8 +486,8 @@
}
.board-card {
- border: 1px solid $border-white-normal;
- box-shadow: 0 1px 2px rgba($issue-boards-card-shadow, 0.3);
+ border: 1px solid var(--gray-900, $gray-900);
+ box-shadow: 0 1px 2px rgba(var(--black, $black), 0.4);
cursor: pointer;
}
}
@@ -511,16 +511,16 @@
right: -3px;
top: -3px;
width: 17px;
- background-color: $blue-500;
+ background-color: var(--blue-500, $blue-500);
color: $white;
- border: 1px solid $blue-600;
+ border: 1px solid var(--blue-600, $blue-600);
font-size: 9px;
line-height: 15px;
border-radius: 50%;
}
.board-card-info {
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
white-space: nowrap;
margin-right: $gl-padding-8;
@@ -529,7 +529,7 @@
}
&.board-card-weight {
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
cursor: pointer;
&:hover {
@@ -539,7 +539,7 @@
}
.board-card-info-icon {
- color: $gray-500;
+ color: var(--gray-500, $gray-500);
margin-right: $gl-padding-4;
vertical-align: text-top;
}
@@ -568,7 +568,7 @@
height: 100%;
top: 0;
left: 0;
- background: $white;
+ background: var(--white, $white);
z-index: 9000;
@include media-breakpoint-down(sm) {
@@ -591,7 +591,7 @@
}
.board-header-collapsed-info-icon:hover {
- color: $gray-900;
+ color: var(--gray-900, $gray-900);
}
$epic-icons-spacing: 40px;
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/page_bundles/cycle_analytics.scss
index c509bf121bc..3a5e2e4159d 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/page_bundles/cycle_analytics.scss
@@ -1,3 +1,5 @@
+@import 'mixins_and_variables_and_functions';
+
#cycle-analytics,
.cycle-analytics {
margin: 24px auto 0;
@@ -84,7 +86,7 @@
}
.text {
- color: $layout-link-gray;
+ color: var(--gray-500, $gray-500);
margin: 0;
}
@@ -127,14 +129,14 @@
line-height: 65px;
&.active {
- background: $blue-50;
- border-color: $blue-300;
- box-shadow: inset 4px 0 0 0 $blue-500;
+ background: var(--blue-50, $blue-50);
+ border-color: var(--blue-300, $blue-300);
+ box-shadow: inset 4px 0 0 0 var(--blue-500, $blue-500);
}
&:hover:not(.active) {
- background-color: $gray-lightest;
- box-shadow: inset 2px 0 0 0 $border-color;
+ background-color: var(--gray-10, $gray-10);
+ box-shadow: inset 2px 0 0 0 var(--border-color, $border-color);
cursor: pointer;
}
@@ -148,7 +150,7 @@
.stage-empty,
.not-available {
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
}
}
}
@@ -172,7 +174,7 @@
}
.events-info {
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
}
}
@@ -191,7 +193,7 @@
list-style-type: none;
padding: 0 0 $gl-padding;
margin: 0 $gl-padding $gl-padding;
- border-bottom: 1px solid $gray-darker;
+ border-bottom: 1px solid var(--gray-50, $gray-50);
&:last-child {
border-bottom: 0;
@@ -220,7 +222,7 @@
display: block;
a {
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
}
}
}
@@ -232,24 +234,24 @@
.total-time {
font-size: $cycle-analytics-big-font;
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
span {
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
font-size: $gl-font-size;
}
}
.issue-date,
.build-date {
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
}
.mr-link,
.issue-link,
.commit-author-link,
.issue-author-link {
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
}
// Custom CSS for components
@@ -287,16 +289,16 @@
}
.item-build-name {
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
}
.pipeline-id {
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
padding: 0 3px 0 0;
}
.ref-name {
- color: $black;
+ color: var(--gray-900, $gray-900);
display: inline-block;
max-width: 180px;
text-overflow: ellipsis;
@@ -307,14 +309,14 @@
}
.commit-sha {
- color: $blue-600;
+ color: var(--blue-600, $blue-600);
line-height: 1.3;
vertical-align: top;
font-weight: $gl-font-weight-normal;
}
.fa {
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
font-size: $code-font-size;
}
}
@@ -326,10 +328,10 @@
width: 75%;
margin: 0 auto;
padding-top: 130px;
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
h4 {
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
}
}
diff --git a/app/assets/stylesheets/pages/dev_ops_report.scss b/app/assets/stylesheets/page_bundles/dev_ops_report.scss
index 871cd9c4f02..5c6019efce6 100644
--- a/app/assets/stylesheets/pages/dev_ops_report.scss
+++ b/app/assets/stylesheets/page_bundles/dev_ops_report.scss
@@ -1,3 +1,5 @@
+@import 'mixins_and_variables_and_functions';
+
$space-between-cards: 8px;
.devops-empty svg {
@@ -21,7 +23,7 @@ $space-between-cards: 8px;
.devops-header-subtitle {
font-size: 22px;
line-height: 1;
- color: $gl-text-color-secondary;
+ color: var(--gl-text-color-secondary, $gl-text-color-secondary);
margin-left: 8px;
font-weight: $gl-font-weight-normal;
@@ -31,10 +33,10 @@ $space-between-cards: 8px;
a {
font-size: 18px;
- color: $gl-text-color-secondary;
+ color: var(--gl-text-color-secondary, $gl-text-color-secondary);
&:hover {
- color: $blue-500;
+ color: var(--blue-500, $blue-500);
}
}
}
@@ -52,7 +54,7 @@ $space-between-cards: 8px;
align-items: stretch;
text-align: center;
width: 50%;
- border-color: $border-color;
+ border-color: var(--border-color, $border-color);
margin: 0 0 32px;
padding: $space-between-cards / 2;
position: relative;
@@ -75,7 +77,7 @@ $space-between-cards: 8px;
}
.devops-card {
- border: solid 1px $border-color;
+ border: solid 1px var(--border-color, $border-color);
border-radius: 3px;
border-top-width: 3px;
display: flex;
@@ -84,26 +86,26 @@ $space-between-cards: 8px;
}
.devops-card-low {
- border-top-color: $red-400;
+ border-top-color: var(--red-400, $red-400);
.board-card-score-big {
- background-color: $red-50;
+ background-color: var(--red-50, $red-50);
}
}
.devops-card-average {
- border-top-color: $orange-200;
+ border-top-color: var(--orange-200, $orange-200);
.board-card-score-big {
- background-color: $orange-50;
+ background-color: var(--orange-50, $orange-50);
}
}
.devops-card-high {
- border-top-color: $green-400;
+ border-top-color: var(--green-400, $green-400);
.board-card-score-big {
- background-color: $green-50;
+ background-color: var(--green-50, $green-50);
}
}
@@ -119,7 +121,7 @@ $space-between-cards: 8px;
.light-text {
font-size: 13px;
line-height: 1.25;
- color: $gl-text-color-secondary;
+ color: var(--gl-text-color-secondary, $gl-text-color-secondary);
}
}
@@ -132,7 +134,7 @@ $space-between-cards: 8px;
}
.board-card-score {
- color: $gl-text-color-secondary;
+ color: var(--gl-text-color-secondary, $gl-text-color-secondary);
.board-card-score-name {
font-size: 13px;
@@ -142,13 +144,13 @@ $space-between-cards: 8px;
.board-card-score-value {
font-size: 16px;
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
font-weight: $gl-font-weight-normal;
}
.board-card-score-big {
- border-top: 2px solid $border-color;
- border-bottom: 1px solid $border-color;
+ border-top: 2px solid var(--border-color, $border-color);
+ border-bottom: 1px solid var(--border-color, $border-color);
font-size: 22px;
padding: 10px 0;
font-weight: $gl-font-weight-normal;
@@ -159,17 +161,17 @@ $space-between-cards: 8px;
> * {
font-size: 16px;
- color: $gl-text-color-secondary;
+ color: var(--gl-text-color-secondary, $gl-text-color-secondary);
padding: 10px;
flex-grow: 1;
&:hover {
- background-color: $border-color;
- color: $gl-text-color;
+ background-color: var(--border-color, $border-color);
+ color: var(--border-color, $border-color);
}
+ * {
- border-left: solid 1px $border-color;
+ border-left: solid 1px var(--border-color, $border-color);
}
}
}
@@ -180,7 +182,7 @@ $space-between-cards: 8px;
min-width: 100%;
justify-content: space-around;
position: relative;
- background: $border-color;
+ background: var(--border-color, $border-color);
}
.devops-step {
@@ -202,12 +204,12 @@ $space-between-cards: 8px;
display: flex;
flex-direction: column;
align-items: center;
- border: solid 1px $border-color;
- background: $white;
+ border: solid 1px var(--border-color, $border-color);
+ background: var(--white, $white);
transform: translate(-50%, -50%);
- color: $gl-text-color-secondary;
- fill: $gl-text-color-secondary;
- box-shadow: 0 2px 4px $dropdown-shadow-color;
+ color: var(--gl-text-color-secondary, $gl-text-color-secondary);
+ fill: var(--gl-text-color-secondary, $gl-text-color-secondary);
+ box-shadow: 0 2px 4px var(--dropdown-shadow-color, $dropdown-shadow-color);
&:hover {
padding: 8px 10px;
@@ -247,13 +249,13 @@ $space-between-cards: 8px;
}
.devops-high-score {
- color: $green-400;
+ color: var(--green-400, $green-400);
}
.devops-average-score {
- color: $orange-500;
+ color: var(--orange-500, $orange-500);
}
.devops-low-score {
- color: $red-400;
+ color: var(--red-400, $red-400);
}
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/page_bundles/environments.scss
index 5ce500dad1d..871f118ea9d 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/page_bundles/environments.scss
@@ -1,13 +1,4 @@
-@include media-breakpoint-down(md) {
- .deployments-container {
- width: 100%;
- overflow: auto;
- }
-}
-
-.environments-folder-name {
- font-weight: $gl-font-weight-normal;
-}
+@import 'page_bundles/mixins_and_variables_and_functions';
.environments-container {
.ci-table {
@@ -17,24 +8,24 @@
.external-url,
.dropdown-new {
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
}
.build-link,
.ref-name {
- color: $gl-text-color;
+ color: var(--gray-900, $gray-900);
}
.folder-icon {
margin-right: 3px;
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
display: inline-block;
vertical-align: text-top;
}
.folder-name {
cursor: pointer;
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
display: inline-block;
}
@@ -83,17 +74,17 @@
.x-axis path,
.y-axis path {
- stroke: $gray-300;
+ stroke: var(--gray-300, $gray-300);
}
.label-x-axis-line,
.label-y-axis-line {
- stroke: $border-color;
+ stroke: var(--gray-100, $gray-100);
}
.y-axis {
line {
- stroke: $gray-300;
+ stroke: var(--gray-300, $gray-300);
stroke-width: 1;
}
}
@@ -103,13 +94,13 @@
}
.rect-text-metric {
- fill: $white;
+ fill: var(--white, $white);
stroke-width: 1;
- stroke: $gray-darkest;
+ stroke: var(--gray-600, $gray-600);
}
.rect-axis-text {
- fill: $white;
+ fill: var(--white, $white);
}
.text-metric {
@@ -117,18 +108,18 @@
}
.selected-metric-line {
- stroke: $gray-900;
+ stroke: var(--gray-900, $gray-900);
stroke-width: 1;
}
.deployment-line {
- stroke: $black;
+ stroke: var(--white, $white);
stroke-width: 1;
}
.divider-line {
stroke-width: 1;
- stroke: $gray-darkest;
+ stroke: var(--gray-600, $gray-600);
}
.environments-actions {
diff --git a/app/assets/stylesheets/pages/error_details.scss b/app/assets/stylesheets/page_bundles/error_tracking_details.scss
index 78cac12d6be..a47c5cc9b3e 100644
--- a/app/assets/stylesheets/pages/error_details.scss
+++ b/app/assets/stylesheets/page_bundles/error_tracking_details.scss
@@ -1,15 +1,17 @@
+@import 'page_bundles/mixins_and_variables_and_functions';
+
.error-details {
li {
@include gl-line-height-32;
}
.btn-outline-info {
- color: $blue-500;
- border-color: $blue-500;
+ color: var(--blue-500, $blue-500);
+ border-color: var(--blue-500, $blue-500);
}
.error-details-header {
- border-bottom: 1px solid $border-color;
+ border-bottom: 1px solid var(--border-color, $border-color);
@include media-breakpoint-down(xs) {
flex-flow: column;
diff --git a/app/assets/stylesheets/pages/error_list.scss b/app/assets/stylesheets/page_bundles/error_tracking_index.scss
index 3ec3e4f6b43..65bddfb7890 100644
--- a/app/assets/stylesheets/pages/error_list.scss
+++ b/app/assets/stylesheets/page_bundles/error_tracking_index.scss
@@ -1,4 +1,10 @@
+@import 'page_bundles/mixins_and_variables_and_functions';
+
.error-list {
+ .dropdown {
+ min-width: auto;
+ }
+
.sort-control {
.btn {
padding-right: 2rem;
@@ -17,7 +23,7 @@
min-height: 68px;
&:last-child {
- background-color: $gray-10;
+ background-color: var(--gray-10, $gray-10);
&::before {
content: none !important;
diff --git a/app/assets/stylesheets/page_bundles/experimental_separate_sign_up.scss b/app/assets/stylesheets/page_bundles/experimental_separate_sign_up.scss
new file mode 100644
index 00000000000..337b5b001fe
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/experimental_separate_sign_up.scss
@@ -0,0 +1,96 @@
+@import 'mixins_and_variables_and_functions';
+
+.signup-page {
+ .page-wrap {
+ background-color: var(--gray-10, $gray-10);
+ }
+
+ .signup-box-container {
+ max-width: 960px;
+ }
+
+ .signup-box {
+ background-color: var(--white, $white);
+ box-shadow: 0 0 0 1px var(--border-color, $border-color);
+ border-radius: $border-radius;
+ }
+
+ .form-control {
+ &:active,
+ &:focus {
+ background-color: var(--white, $white);
+ }
+ }
+
+ .devise-errors {
+ h2 {
+ font-size: $gl-font-size;
+ color: var(--red-700, $red-700);
+ }
+ }
+
+ .omniauth-divider {
+ &::before,
+ &::after {
+ content: '';
+ flex: 1;
+ border-bottom: 1px solid var(--gray-100, $gray-100);
+ margin: $gl-padding-24 0;
+ }
+
+ &::before {
+ margin-right: $gl-padding;
+ }
+
+ &::after {
+ margin-left: $gl-padding;
+ }
+ }
+
+ .omniauth-btn {
+ width: 48%;
+
+ @include media-breakpoint-down(md) {
+ width: 100%;
+ }
+
+ img {
+ width: $default-icon-size;
+ height: $default-icon-size;
+ }
+ }
+
+ .decline-page {
+ width: 350px;
+ }
+}
+
+.signup-page[data-page^='registrations:experience_levels'] {
+ $card-shadow-color: rgba(var(--black, $black), 0.2);
+
+ .page-wrap {
+ background-color: var(--white, $white);
+ }
+
+ .card-deck {
+ max-width: 828px;
+ }
+
+ .card {
+ transition: box-shadow 0.3s ease-in-out;
+ }
+
+ .card:hover {
+ box-shadow: 0 $gl-spacing-scale-3 $gl-spacing-scale-5 $card-shadow-color;
+ }
+
+ @media (min-width: $breakpoint-sm) {
+ .card-deck .card {
+ margin: 0 $gl-spacing-scale-3;
+ }
+ }
+
+ .stretched-link:hover {
+ text-decoration: none;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss b/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss
index 37e6be9849b..41f9a8e6db7 100644
--- a/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss
+++ b/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss
@@ -20,20 +20,28 @@
--ide-btn-default-background: transparent;
--ide-btn-default-border: #bfbfbf;
--ide-btn-default-hover-border: #d8d8d8;
+ --ide-btn-default-hover-border-width: 2px;
+ --ide-btn-default-focus-box-shadow: 0 0 0 1px #bfbfbf;
--ide-btn-primary-background: #1068bf;
--ide-btn-primary-border: #428fdc;
--ide-btn-primary-hover-border: #63a6e9;
+ --ide-btn-primary-hover-border-width: 2px;
+ --ide-btn-primary-focus-box-shadow: 0 0 0 1px #63a6e9;
--ide-btn-success-background: #217645;
--ide-btn-success-border: #108548;
--ide-btn-success-hover-border: #2da160;
+ --ide-btn-success-hover-border-width: 2px;
+ --ide-btn-success-focus-box-shadow: 0 0 0 1px #2da160;
+ --ide-btn-disabled-background: transparent;
--ide-btn-disabled-border: rgba(223, 223, 223, 0.24);
+ --ide-btn-disabled-hover-border: rgba(223, 223, 223, 0.24);
+ --ide-btn-disabled-hover-border-width: 1px;
+ --ide-btn-disabled-focus-box-shadow: 0 0 0 0 transparent;
--ide-btn-disabled-color: rgba(145, 145, 145, 0.48);
- --ide-btn-hover-border-width: 2px;
-
--ide-dropdown-background: #404040;
--ide-dropdown-hover-background: #525252;
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss
index 0ef0834d8db..ccb6f7a333b 100644
--- a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss
+++ b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss
@@ -18,22 +18,30 @@
--ide-input-color: #fff;
--ide-btn-default-background: transparent;
- --ide-btn-default-border: var(--ide-input-border);
+ --ide-btn-default-border: #d8d8d8;
--ide-btn-default-hover-border: #d8d8d8;
+ --ide-btn-default-hover-border-width: 2px;
+ --ide-btn-default-focus-box-shadow: 0 0 0 1px #d8d8d8;
--ide-btn-primary-background: #1068bf;
--ide-btn-primary-border: #428fdc;
--ide-btn-primary-hover-border: #63a6e9;
+ --ide-btn-primary-hover-border-width: 2px;
+ --ide-btn-primary-focus-box-shadow: 0 0 0 1px #63a6e9;
--ide-btn-success-background: #217645;
--ide-btn-success-border: #108548;
--ide-btn-success-hover-border: #2da160;
+ --ide-btn-success-hover-border-width: 2px;
+ --ide-btn-success-focus-box-shadow: 0 0 0 1px #2da160;
+ --ide-btn-disabled-background: transparent;
--ide-btn-disabled-border: rgba(223, 223, 223, 0.24);
+ --ide-btn-disabled-hover-border: rgba(223, 223, 223, 0.24);
+ --ide-btn-disabled-hover-border-width: 1px;
+ --ide-btn-disabled-focus-box-shadow: transparent;
--ide-btn-disabled-color: rgba(145, 145, 145, 0.48);
- --ide-btn-hover-border-width: 2px;
-
--ide-dropdown-background: #004c61;
--ide-dropdown-hover-background: #00617a;
diff --git a/app/assets/stylesheets/page_bundles/issues_list.scss b/app/assets/stylesheets/page_bundles/issues_list.scss
new file mode 100644
index 00000000000..8a958bdf0c5
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/issues_list.scss
@@ -0,0 +1,45 @@
+@import 'mixins_and_variables_and_functions';
+
+.issues-list {
+ &.manual-ordering {
+ background-color: var(--gray-10, $gray-10);
+ border-radius: $border-radius-default;
+ padding: $gl-padding-8;
+
+ .issue {
+ background-color: var(--white, $white);
+ margin-bottom: $gl-padding-8;
+ border-radius: $border-radius-default;
+ border: 1px solid var(--border-color, $border-color);
+ box-shadow: 0 1px 2px $issue-boards-card-shadow;
+ }
+ }
+
+ .issue {
+ padding: 10px $gl-padding;
+ position: relative;
+
+ .title {
+ margin-bottom: 2px;
+ }
+
+ .issue-labels,
+ .author-link {
+ display: inline-block;
+ }
+
+ .icon-merge-request-unmerged {
+ height: 13px;
+ margin-bottom: 3px;
+ }
+ }
+}
+
+.user-can-drag {
+ cursor: grab;
+}
+
+.is-ghost {
+ opacity: 0.3;
+ pointer-events: none;
+}
diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss
index 83d16f29d49..b8cdd120e04 100644
--- a/app/assets/stylesheets/page_bundles/jira_connect.scss
+++ b/app/assets/stylesheets/page_bundles/jira_connect.scss
@@ -13,6 +13,7 @@ $atlaskit-border-color: #dfe1e6;
padding-top: $gl-padding-4;
.ak-button {
+ align-items: center;
height: auto;
margin-left: $btn-margin-5;
}
@@ -20,6 +21,74 @@ $atlaskit-border-color: #dfe1e6;
}
}
+$header-height: 40px;
+
+.jira-connect-header {
+ border-bottom: 1px solid $gray-100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: $header-height;
+ padding-left: 16px;
+ padding-right: 16px;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+}
+
+.jira-connect-user {
+ float: right;
+ position: relative;
+ top: -30px;
+}
+
+.jira-connect-app {
+ margin-top: $header-height;
+ max-width: 600px;
+ padding-top: 48px;
+ padding-left: 16px;
+ padding-right: 16px;
+ margin-left: auto;
+ margin-right: auto;
+ text-align: center;
+}
+
+.gl-mt-5 {
+ margin-top: 16px;
+}
+
+.heading-with-border {
+ border-bottom: 1px solid $gray-100;
+ display: inline-block;
+ padding-bottom: 16px;
+}
+
+svg {
+ fill: currentColor;
+
+ &.s16 {
+ height: 16px;
+ width: 16px;
+ }
+}
+
+.ak-field-group label {
+ text-align: left;
+}
+
+.ak-button__appearance-primary {
+ &:hover {
+ color: $white;
+ text-decoration: none;
+ }
+
+ svg {
+ align-self: center;
+ margin-left: 4px;
+ }
+}
+
.subscriptions {
tbody {
tr {
@@ -31,3 +100,11 @@ $atlaskit-border-color: #dfe1e6;
}
}
}
+
+.empty-subscriptions {
+ color: $gray-900;
+}
+
+.browser-limitations-notice {
+ margin-top: 32px;
+}
diff --git a/app/assets/stylesheets/page_bundles/jira_connect_users.scss b/app/assets/stylesheets/page_bundles/jira_connect_users.scss
new file mode 100644
index 00000000000..6725bf8f1a1
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/jira_connect_users.scss
@@ -0,0 +1,13 @@
+@import 'mixins_and_variables_and_functions';
+
+.jira-connect-users-container {
+ margin-left: auto;
+ margin-right: auto;
+ width: px-to-rem(350px);
+}
+
+.devise-layout-html body .navless-container {
+ @include media-breakpoint-down(xs) {
+ padding-top: 65px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/merge_conflicts.scss b/app/assets/stylesheets/page_bundles/merge_conflicts.scss
index 9d583dcaa52..b0655408edf 100644
--- a/app/assets/stylesheets/pages/merge_conflicts.scss
+++ b/app/assets/stylesheets/page_bundles/merge_conflicts.scss
@@ -1,3 +1,4 @@
+@import 'mixins_and_variables_and_functions';
// Disabled to use the color map for creating color schemes
// scss-lint:disable ColorVariable
$colors: (
@@ -225,6 +226,14 @@ $colors: (
.solarized-dark {
@include color-scheme('solarized-dark'); }
+ .none {
+ .line_content.header {
+ button {
+ color: $gray-900;
+ }
+ }
+ }
+
.diff-wrap-lines .line_content {
white-space: normal;
min-height: 19px;
@@ -240,14 +249,14 @@ $colors: (
right: 10px;
padding: 0;
outline: none;
- color: $white;
+ color: var(--white, $white);
width: 75px; // static width to make 2 buttons have same width
height: 19px;
}
}
.btn-success .fa-spinner {
- color: $white;
+ color: var(--white, $white);
}
.editor-wrap {
@@ -263,7 +272,7 @@ $colors: (
&.saved {
.editor {
- border-top: solid 2px $green-300;
+ border-top: solid 2px var(--green-300, $green-300);
}
}
@@ -282,10 +291,10 @@ $colors: (
}
.discard-changes-alert {
- background-color: $gray-light;
+ background-color: var(--gray-10, $gray-10);
text-align: right;
padding: $gl-padding-top $gl-padding;
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
.discard-actions {
display: inline-block;
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
new file mode 100644
index 00000000000..5553dffac05
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -0,0 +1,96 @@
+@import 'mixins_and_variables_and_functions';
+
+.compare-versions-container {
+ min-width: 0;
+}
+
+.diff-files-holder {
+ flex: 1;
+ min-width: 0;
+ z-index: 201;
+}
+
+.diff-tree-list {
+ position: -webkit-sticky;
+ position: sticky;
+ $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 15px;
+ top: $top-pos;
+ max-height: calc(100vh - #{$top-pos});
+ z-index: 202;
+
+ .with-system-header & {
+ top: $top-pos + $system-header-height;
+ }
+
+ .with-system-header.with-performance-bar & {
+ top: $top-pos + $system-header-height + $performance-bar-height;
+ }
+
+ .with-performance-bar & {
+ $performance-bar-top-pos: $performance-bar-height + $top-pos;
+ top: $performance-bar-top-pos;
+ max-height: calc(100vh - #{$performance-bar-top-pos});
+ }
+
+ .drag-handle {
+ bottom: 16px;
+ transform: translateX(10px);
+ }
+}
+
+.tree-list-holder {
+ height: 100%;
+
+ .file-row {
+ margin-left: 0;
+ margin-right: 0;
+ }
+}
+
+.tree-list-scroll {
+ max-height: 100%;
+ padding-bottom: $grid-size;
+ overflow-y: scroll;
+ overflow-x: auto;
+}
+
+.tree-list-search {
+ flex: 0 0 34px;
+
+ .form-control {
+ padding-left: 30px;
+ }
+}
+
+.tree-list-icon {
+ top: 50%;
+ left: 10px;
+ transform: translateY(-50%);
+
+ &,
+ svg {
+ fill: var(--gray-400, $gray-400);
+ }
+}
+
+.tree-list-clear-icon {
+ right: 10px;
+ left: auto;
+ line-height: 0;
+}
+
+@media (max-width: map-get($grid-breakpoints, md)-1) {
+ .diffs .files {
+ .diff-tree-list {
+ position: relative;
+ top: 0;
+ // !important is required to override inline styles of resizable sidebar
+ width: 100% !important;
+ }
+
+ .tree-list-holder {
+ max-height: calc(50px + 50vh);
+ padding-right: 0;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss
index e9eb79b071c..858e13fc558 100644
--- a/app/assets/stylesheets/pages/milestone.scss
+++ b/app/assets/stylesheets/page_bundles/milestone.scss
@@ -1,3 +1,5 @@
+@import 'mixins_and_variables_and_functions';
+
$status-box-line-height: 26px;
.issues-sortable-list .str-truncated {
@@ -8,13 +10,13 @@ $status-box-line-height: 26px;
padding: $gl-padding-8;
margin-top: $gl-padding-8;
border-radius: $border-radius-default;
- background-color: $gray-100;
+ background-color: var(--gray-100, $gray-100);
.milestone {
border: 0;
padding: $gl-padding-top $gl-padding;
border-radius: $border-radius-default;
- background-color: $white;
+ background-color: var(--white, $white);
&:not(:last-child) {
margin-bottom: $gl-padding-4;
@@ -33,7 +35,7 @@ $status-box-line-height: 26px;
.milestone-progress,
.milestone-release-links {
a {
- color: $blue-600;
+ color: var(--blue-600, $blue-600);
}
}
@@ -61,7 +63,7 @@ $status-box-line-height: 26px;
.issuable-row {
span {
a {
- color: $gl-text-color;
+ color: var(--gray-900, $gray-900);
word-wrap: break-word;
}
@@ -162,7 +164,7 @@ $status-box-line-height: 26px;
}
.issuable-number {
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
margin-right: 5px;
}
@@ -177,7 +179,7 @@ $status-box-line-height: 26px;
}
.milestone-detail {
- border-bottom: 1px solid $border-color;
+ border-bottom: 1px solid var(--border-color, $border-color);
}
@include media-breakpoint-down(xs) {
@@ -233,7 +235,7 @@ $status-box-line-height: 26px;
}
.issuable-row {
- background-color: $white;
+ background-color: var(--white, $white);
}
.milestone-popover-instructions-list {
diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss
new file mode 100644
index 00000000000..8e7be629481
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/pipeline.scss
@@ -0,0 +1,484 @@
+@import 'mixins_and_variables_and_functions';
+@import './pipeline_mixins';
+
+/**
+ * Pipeline Page Bundle
+ *
+ * Styles used to render a single pipeline page.
+ *
+ * Includes its tabs:
+ *
+ * - [data-page='projects:pipelines:show']
+ * - [data-page='projects:pipelines:dag']
+ * - [data-page='projects:pipelines:builds']
+ * - [data-page='projects:pipelines:failures']
+ * - [data-page='projects:pipelines:tests']
+ * - ...
+ */
+
+.tab-pane {
+ .ci-table {
+ thead th {
+ border-top: 0;
+ }
+ }
+}
+
+.build-failures {
+ .build-state {
+ padding: 20px 2px;
+
+ .build-name {
+ font-weight: $gl-font-weight-normal;
+ }
+
+ .stage {
+ color: $gl-text-color-secondary;
+ font-weight: $gl-font-weight-normal;
+ vertical-align: middle;
+ }
+ }
+
+ .build-log {
+ border: 0;
+ line-height: initial;
+ }
+
+ .build-trace-row td {
+ border-top: 0;
+ border-bottom-width: 1px;
+ border-bottom-style: solid;
+ padding-top: 0;
+ }
+
+ .build-trace {
+ width: 100%;
+ text-align: left;
+ margin-top: $gl-padding;
+ }
+
+ .build-name {
+ width: 196px;
+
+ a {
+ font-weight: $gl-font-weight-bold;
+ color: $gl-text-color;
+ text-decoration: none;
+
+ &:focus,
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ .build-actions {
+ width: 70px;
+ text-align: right;
+ }
+
+ .build-stage {
+ width: 140px;
+ }
+
+ .ci-status-icon-failed {
+ padding: 10px 0 10px 12px;
+ width: 12px + 24px; // padding-left + svg width
+ }
+
+ .build-icon svg {
+ width: 24px;
+ height: 24px;
+ vertical-align: middle;
+ }
+
+ .build-state,
+ .build-trace-row {
+ > td:last-child {
+ padding-right: 0;
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ td:empty {
+ display: none;
+ }
+
+ .ci-table {
+ margin-top: 2 * $gl-padding;
+ }
+
+ .build-trace-container {
+ padding-top: $gl-padding;
+ padding-bottom: $gl-padding;
+ }
+
+ .build-trace {
+ margin-bottom: 0;
+ margin-top: 0;
+ }
+ }
+}
+
+.pipeline-tab-content {
+ display: flex;
+ width: 100%;
+ min-height: $dropdown-max-height-lg;
+ background-color: $gray-light;
+ padding: $gl-padding 0;
+ overflow: auto;
+}
+
+// Pipeline graph, used at
+// app/assets/javascripts/pipelines/components/graph/graph_component.vue
+.pipeline-graph {
+ white-space: nowrap;
+ transition: max-height 0.3s, padding 0.3s;
+
+ .stage-column-list,
+ .builds-container > ul {
+ padding: 0;
+ }
+
+ a {
+ text-decoration: none;
+ color: $gl-text-color;
+ }
+
+ svg {
+ vertical-align: middle;
+ }
+
+ .stage-column {
+ display: inline-block;
+ vertical-align: top;
+
+ &.left-margin {
+ &:not(:first-child) {
+ margin-left: 44px;
+
+ .left-connector {
+ @include flat-connector-before;
+ }
+ }
+ }
+
+ &.no-margin {
+ margin: 0;
+ }
+
+ li {
+ list-style: none;
+ }
+
+ // when downstream pipelines are present, the last stage isn't the last column
+ &:last-child:not(.has-downstream) {
+ .build {
+ // Remove right connecting horizontal line from first build in last stage
+ &:first-child::after {
+ border: 0;
+ }
+ // Remove right curved connectors from all builds in last stage
+ &:not(:first-child)::after {
+ border: 0;
+ }
+ // Remove opposite curve
+ .curve::before {
+ display: none;
+ }
+ }
+ }
+
+ // when upstream pipelines are present, the first stage isn't the first column
+ &:first-child:not(.has-upstream) {
+ .build {
+ // Remove left curved connectors from all builds in first stage
+ &:not(:first-child)::before {
+ border: 0;
+ }
+ // Remove opposite curve
+ .curve::after {
+ display: none;
+ }
+ }
+ }
+
+ // Curve first child connecting lines in opposite direction
+ .curve {
+ display: none;
+
+ &::before,
+ &::after {
+ content: '';
+ width: 21px;
+ height: 25px;
+ position: absolute;
+ top: -31px;
+ border-top: 2px solid $border-color;
+ }
+
+ &::after {
+ left: -44px;
+ border-right: 2px solid $border-color;
+ border-radius: 0 20px;
+ }
+
+ &::before {
+ right: -44px;
+ border-left: 2px solid $border-color;
+ border-radius: 20px 0 0;
+ }
+ }
+ }
+
+ .stage-name {
+ margin: 0 0 15px 10px;
+ font-weight: $gl-font-weight-bold;
+ width: 176px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 2.2em;
+ }
+
+ .build {
+ position: relative;
+ width: 186px;
+ margin-bottom: 10px;
+ white-space: normal;
+
+ .ci-job-dropdown-container {
+ // override dropdown.scss
+ .dropdown-menu li button {
+ padding: 0;
+ text-align: center;
+ }
+ }
+
+ // ensure .build-content has hover style when action-icon is hovered
+ .ci-job-dropdown-container:hover .build-content {
+ @extend .build-content:hover;
+ }
+
+ .ci-status-icon svg {
+ height: 24px;
+ width: 24px;
+ }
+
+ .dropdown-menu-toggle {
+ background-color: transparent;
+ border: 0;
+ padding: 0;
+
+ &:focus {
+ outline: none;
+ }
+ }
+
+ .build-content {
+ @include build-content();
+ }
+
+ a.build-content:hover,
+ button.build-content:hover {
+ background-color: $gray-darker;
+ border: 1px solid $dropdown-toggle-active-border-color;
+ }
+
+ // Connect first build in each stage with right horizontal line
+ &:first-child {
+ &::after {
+ content: '';
+ position: absolute;
+ top: 48%;
+ right: -48px;
+ border-top: 2px solid $border-color;
+ width: 48px;
+ height: 1px;
+ }
+ }
+
+ // Connect each build (except for first) with curved lines
+ &:not(:first-child) {
+ &::after,
+ &::before {
+ content: '';
+ top: -49px;
+ position: absolute;
+ border-bottom: 2px solid $border-color;
+ width: 25px;
+ height: 69px;
+ }
+
+ // Right connecting curves
+ &::after {
+ right: -25px;
+ border-right: 2px solid $border-color;
+ border-radius: 0 0 20px;
+ }
+
+ // Left connecting curves
+ &::before {
+ left: -25px;
+ border-left: 2px solid $border-color;
+ border-radius: 0 0 0 20px;
+ }
+ }
+
+ // Connect second build to first build with smaller curved line
+ &:nth-child(2) {
+ &::after,
+ &::before {
+ height: 29px;
+ top: -9px;
+ }
+
+ .curve {
+ display: block;
+ }
+ }
+ }
+
+ .ci-action-icon-container {
+ position: absolute;
+ right: 5px;
+ top: 50%;
+ transform: translateY(-50%);
+
+ // Action Icons in big pipeline-graph nodes
+ &.ci-action-icon-wrapper {
+ height: 30px;
+ width: 30px;
+ border-radius: 100%;
+ display: block;
+ padding: 0;
+ line-height: 0;
+
+ svg {
+ fill: $gl-text-color-secondary;
+ }
+
+ .spinner {
+ top: 2px;
+ }
+
+ &.play {
+ svg {
+ left: 1px;
+ top: 1px;
+ }
+ }
+ }
+ }
+
+ .stage-action svg {
+ left: 1px;
+ top: -2px;
+ }
+}
+
+// Triggers the dropdown in the big pipeline graph
+.dropdown-counter-badge {
+ font-weight: 100;
+ font-size: 15px;
+ position: absolute;
+ right: 13px;
+ top: 8px;
+}
+
+.split-report-section {
+ border-bottom: 1px solid var(--gray-50, $gray-50);
+
+ .report-block-container {
+ max-height: 500px;
+ overflow: auto;
+ }
+
+ .space-children,
+ .space-children > span {
+ display: flex;
+ align-self: center;
+ }
+
+ .media {
+ align-items: center;
+ padding: 10px;
+ line-height: 20px;
+
+ /*
+ This fixes the wrapping div of the icon in the report header.
+ Apparently the borderless status icons are half the size of the status icons with border.
+ This means we have to double the size of the wrapping div for borderless icons.
+ */
+ .space-children:first-child {
+ width: 32px;
+ height: 32px;
+ align-items: center;
+ justify-content: center;
+ margin-right: 5px;
+ margin-left: 1px;
+ }
+ }
+
+ .code-text {
+ width: 100%;
+ flex: 1;
+ }
+}
+
+.big-pipeline-graph-dropdown-menu {
+ @include pipeline-graph-dropdown-menu();
+ width: 195px;
+ min-width: 195px;
+ left: 100%;
+ top: -10px;
+ box-shadow: 0 1px 5px $black-transparent;
+
+ /**
+ * Top arrow in the dropdown in the big pipeline graph
+ */
+ &::before,
+ &::after {
+ content: '';
+ display: inline-block;
+ position: absolute;
+ width: 0;
+ height: 0;
+ border-color: transparent;
+ border-style: solid;
+ top: 18px;
+ }
+
+ &::before {
+ left: -6px;
+ margin-top: 3px;
+ border-width: 7px 5px 7px 0;
+ border-right-color: $border-color;
+ }
+
+ &::after {
+ left: -5px;
+ border-width: 10px 7px 10px 0;
+ border-right-color: $white;
+ }
+}
+
+.codequality-report {
+ .media {
+ padding: $gl-padding;
+ }
+
+ .media-body {
+ flex-direction: row;
+ }
+
+ .report-block-container {
+ height: auto !important;
+ }
+}
+
+.test-reports-table {
+ .build-trace {
+ @include build-trace();
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss
new file mode 100644
index 00000000000..6ff07017d2e
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/pipelines.scss
@@ -0,0 +1,195 @@
+@import 'mixins_and_variables_and_functions';
+@import './pipeline_mixins';
+
+/**
+ * Pipelines Bundle: Pipeline lists and Mini Pipelines
+ */
+
+// Pipelines list
+// Should affect pipelines table components rendered by:
+// - app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+
+.pipelines {
+ .badge {
+ margin-bottom: 3px;
+ }
+
+ .pipeline-actions {
+ min-width: 170px; //Guarantees buttons don't break in several lines.
+
+ .btn-default {
+ color: $gl-text-color-secondary;
+ }
+
+ .btn.btn-retry:hover,
+ .btn.btn-retry:focus {
+ border-color: $dropdown-toggle-active-border-color;
+ background-color: $white-normal;
+ }
+
+ svg path {
+ fill: $gl-text-color-secondary;
+ }
+
+ .dropdown-menu {
+ max-height: $dropdown-max-height;
+ overflow-y: auto;
+ }
+
+ .dropdown-toggle,
+ .dropdown-menu {
+ color: $gl-text-color-secondary;
+
+ .fa {
+ color: $gl-text-color-secondary;
+ font-size: 14px;
+ }
+ }
+
+ .btn-group.open .btn-default {
+ background-color: $white-normal;
+ border-color: $border-white-normal;
+ }
+
+ .btn .text-center {
+ display: inline;
+ }
+
+ .tooltip {
+ white-space: nowrap;
+ }
+ }
+
+ .pipeline-tags .label-container {
+ white-space: normal;
+ }
+}
+
+// Mini Pipelines
+
+.stage-cell {
+ .mini-pipeline-graph-dropdown-toggle {
+ svg {
+ height: $ci-action-icon-size;
+ width: $ci-action-icon-size;
+ position: absolute;
+ top: -1px;
+ left: -1px;
+ z-index: 2;
+ overflow: visible;
+ }
+
+ &:hover,
+ &:active,
+ &:focus {
+ svg {
+ top: -2px;
+ left: -2px;
+ }
+ }
+ }
+
+ .stage-container {
+ display: inline-block;
+ position: relative;
+ vertical-align: middle;
+ height: $ci-action-icon-size;
+ margin: 3px 0;
+
+ + .stage-container {
+ margin-left: 6px;
+ }
+
+ // Hack to show a button tooltip inline
+ button.has-tooltip + .tooltip {
+ min-width: 105px;
+ }
+
+ // Bootstrap way of showing the content inline for anchors.
+ a.has-tooltip {
+ white-space: nowrap;
+ }
+
+ &:not(:last-child) {
+ &::after {
+ content: '';
+ width: 7px;
+ position: absolute;
+ right: -7px;
+ top: 11px;
+ border-bottom: 2px solid $border-color;
+ }
+ }
+
+ //delete when all pipelines are updated to new size
+ &.mr-widget-pipeline-stages {
+ + .stage-container {
+ margin-left: 4px;
+ }
+
+ &:not(:last-child) {
+ &::after {
+ width: 4px;
+ right: -4px;
+ top: 11px;
+ }
+ }
+ }
+ }
+}
+
+// Dropdown button in mini pipeline graph
+button.mini-pipeline-graph-dropdown-toggle {
+ @include mini-pipeline-item();
+}
+
+// Action icons inside dropdowns:
+// mini graph in pipelines table
+// mini graph in MR widget pipeline
+// mini graph in Commit widget pipeline
+.mini-pipeline-graph-dropdown-menu {
+ @include pipeline-graph-dropdown-menu();
+
+ &::before,
+ &::after {
+ content: '';
+ display: inline-block;
+ position: absolute;
+ width: 0;
+ height: 0;
+ border-color: transparent;
+ border-style: solid;
+ top: -6px;
+ left: 50%;
+ transform: translate(-50%, 0);
+ border-width: 0 5px 6px;
+
+ @include media-breakpoint-down(sm) {
+ left: 100%;
+ margin-left: -12px;
+ }
+ }
+
+ &::before {
+ border-width: 0 5px 5px;
+ border-bottom-color: $border-color;
+ }
+
+ &::after {
+ margin-top: 1px;
+ border-bottom-color: $white;
+ }
+
+ /**
+ * Center dropdown menu in mini graph
+ */
+ .dropdown &.dropdown-menu {
+ transform: translate(-80%, 0);
+
+ @media (min-width: map-get($grid-breakpoints, md)) {
+ transform: translate(-50%, 0);
+ right: auto;
+ left: 50%;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/reports.scss b/app/assets/stylesheets/page_bundles/reports.scss
index 0c0605b0b3d..5a9dd454970 100644
--- a/app/assets/stylesheets/pages/reports.scss
+++ b/app/assets/stylesheets/page_bundles/reports.scss
@@ -1,42 +1,4 @@
-.split-report-section {
- border-bottom: 1px solid $gray-darker;
-
- .report-block-container {
- max-height: 500px;
- overflow: auto;
- }
-
- .space-children,
- .space-children > span {
- display: flex;
- align-self: center;
- }
-
- .media {
- align-items: center;
- padding: 10px;
- line-height: 20px;
-
- /*
- This fixes the wrapping div of the icon in the report header.
- Apparently the borderless status icons are half the size of the status icons with border.
- This means we have to double the size of the wrapping div for borderless icons.
- */
- .space-children:first-child {
- width: 32px;
- height: 32px;
- align-items: center;
- justify-content: center;
- margin-right: 5px;
- margin-left: 1px;
- }
- }
-
- .code-text {
- width: 100%;
- flex: 1;
- }
-}
+@import 'mixins_and_variables_and_functions';
.mr-widget-grouped-section {
.report-block-container {
@@ -69,15 +31,15 @@
display: flex;
&.failed svg {
- color: $red-500;
+ color: var(--red-500, $red-500);
}
&.success svg {
- color: $green-500;
+ color: var(--green-500, $green-500);
}
&.neutral svg {
- color: $gray-500;
+ color: var(--gray-500, $gray-500);
}
.ci-status-icon {
diff --git a/app/assets/stylesheets/page_bundles/terminal.scss b/app/assets/stylesheets/page_bundles/terminal.scss
new file mode 100644
index 00000000000..627baf96d6f
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/terminal.scss
@@ -0,0 +1,3 @@
+#terminal > div {
+ min-height: 450px;
+}
diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss
index ccf11058b5b..eb34e7f3876 100644
--- a/app/assets/stylesheets/pages/wiki.scss
+++ b/app/assets/stylesheets/page_bundles/wiki.scss
@@ -1,3 +1,5 @@
+@import 'mixins_and_variables_and_functions';
+
.title .edit-wiki-header {
width: 780px;
margin-left: auto;
@@ -9,7 +11,7 @@
position: relative;
.wiki-breadcrumb {
- border-bottom: 1px solid $white-normal;
+ border-bottom: 1px solid var(--gray-50, $gray-50);
padding: 11px 0;
}
@@ -20,16 +22,16 @@
.wiki-last-edit-by {
display: block;
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
strong {
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
}
}
.light {
font-weight: $gl-font-weight-normal;
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
}
.git-clone-holder {
@@ -92,7 +94,7 @@
}
a {
- color: $layout-link-gray;
+ color: var(--gray-400, $gray-400);
&:hover,
&.active {
@@ -105,7 +107,7 @@
}
.active > a {
- color: $black;
+ color: var(--black, $black);
}
ul.wiki-pages,
@@ -134,7 +136,7 @@
ul.wiki-pages-list.content-list {
a {
- color: $blue-600;
+ color: var(--blue-600, $blue-600);
}
ul {
diff --git a/app/assets/stylesheets/pages/alert_management/details.scss b/app/assets/stylesheets/pages/alert_management/details.scss
index a104c06c853..514f228e223 100644
--- a/app/assets/stylesheets/pages/alert_management/details.scss
+++ b/app/assets/stylesheets/pages/alert_management/details.scss
@@ -33,7 +33,7 @@
}
.main-notes-list::before {
- left: 15px !important;
+ left: $gl-spacing-scale-5 !important;
}
.note-header-info {
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 04167cbee1b..d7b4db3840e 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -123,20 +123,13 @@
}
.build-header {
- .ci-header-container,
- .header-action-buttons {
- display: flex;
- }
-
- .ci-header-container {
- min-height: 54px;
- }
-
.page-content-header {
padding: 10px 0 9px;
}
.header-action-buttons {
+ display: flex;
+
@include media-breakpoint-down(xs) {
.sidebar-toggle-btn {
margin-top: 0;
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index e6378fd9168..c55bfeb7b15 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -306,7 +306,6 @@
.commit,
.generic-commit-status,
.branch-commit {
- .autodevops-link,
.commit-sha {
color: $blue-600;
}
diff --git a/app/assets/stylesheets/pages/error_tracking_list.scss b/app/assets/stylesheets/pages/error_tracking_list.scss
deleted file mode 100644
index cc391ca6c97..00000000000
--- a/app/assets/stylesheets/pages/error_tracking_list.scss
+++ /dev/null
@@ -1,5 +0,0 @@
-.error-list {
- .dropdown {
- min-width: auto;
- }
-}
diff --git a/app/assets/stylesheets/pages/experience_level.scss b/app/assets/stylesheets/pages/experience_level.scss
deleted file mode 100644
index e57ad6321a5..00000000000
--- a/app/assets/stylesheets/pages/experience_level.scss
+++ /dev/null
@@ -1,29 +0,0 @@
-.signup-page[data-page^='registrations:experience_levels'] {
- $card-shadow-color: rgba($black, 0.2);
-
- .page-wrap {
- background-color: $white;
- }
-
- .card-deck {
- max-width: 828px;
- }
-
- .card {
- transition: box-shadow 0.3s ease-in-out;
- }
-
- .card:hover {
- box-shadow: 0 $gl-spacing-scale-3 $gl-spacing-scale-5 $card-shadow-color;
- }
-
- @media (min-width: $breakpoint-sm) {
- .card-deck .card {
- margin: 0 $gl-spacing-scale-3;
- }
- }
-
- .stretched-link:hover {
- text-decoration: none;
- }
-}
diff --git a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss
deleted file mode 100644
index dfc56654229..00000000000
--- a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss
+++ /dev/null
@@ -1,60 +0,0 @@
-.signup-page {
- .page-wrap {
- background-color: $gray-light;
- }
-
- .signup-box-container {
- max-width: 960px;
- }
-
- .signup-box {
- background-color: $white;
- box-shadow: 0 0 0 1px $border-color;
- border-radius: $border-radius;
- }
-
- .form-control {
- &:active,
- &:focus {
- background-color: $white;
- }
- }
-
- .devise-errors {
- h2 {
- font-size: $gl-font-size;
- color: $red-700;
- }
- }
-
- .omniauth-divider {
- &::before,
- &::after {
- content: '';
- flex: 1;
- border-bottom: 1px solid $gray-dark;
- margin: $gl-padding-24 0;
- }
-
- &::before {
- margin-right: $gl-padding;
- }
-
- &::after {
- margin-left: $gl-padding;
- }
- }
-
- .omniauth-btn {
- width: 48%;
-
- @include media-breakpoint-down(md) {
- width: 100%;
- }
-
- img {
- width: $default-icon-size;
- height: $default-icon-size;
- }
- }
-}
diff --git a/app/assets/stylesheets/pages/graph.scss b/app/assets/stylesheets/pages/graph.scss
deleted file mode 100644
index bca4d50973a..00000000000
--- a/app/assets/stylesheets/pages/graph.scss
+++ /dev/null
@@ -1,74 +0,0 @@
-.project-network {
- border: 1px solid $border-color;
-
- .controls {
- color: $project-network-controls-color;
- font-size: 14px;
- padding: 5px;
- border-bottom: 1px solid $border-color;
- background: $gray-darker;
- }
-
- .network-graph {
- background: $white;
- height: 500px;
- overflow-y: scroll;
- overflow-x: hidden;
- }
-}
-
-.svg-graph-container {
- width: 100%;
-
- .axis-tick {
- opacity: 0.4;
- }
-
- .tick-text {
- fill: $gl-text-color-secondary;
- }
-
- .x-axis-text {
- fill: $gray-900;
- }
-
- .bar-rect {
- fill: rgba($blue-500, 0.1);
- stroke: $blue-500;
- }
-
- .bar-rect:hover {
- fill: rgba($blue-700, 0.3);
- }
-
- .y-axis-label {
- line {
- stroke: $gray-300;
- }
-
- text {
- font-weight: bold;
- font-size: 12px;
- fill: $gray-700;
- }
- }
-}
-
-.svg-graph-container-with-grab {
- cursor: grab;
-}
-
-.svg-graph-container-grabbed {
- cursor: grabbing;
-}
-
-@keyframes flickerAnimation {
- 0% { opacity: 1; }
- 50% { opacity: 0; }
- 100% { opacity: 1; }
-}
-
-.animate-flicker {
- animation: flickerAnimation 1.5s infinite;
- fill: $gray-300;
-}
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index 69fd094f83b..ee4f74882a1 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -95,6 +95,78 @@
}
}
+.group-home-panel {
+ margin-top: $gl-padding;
+ margin-bottom: $gl-padding;
+
+ .home-panel-avatar {
+ width: $home-panel-title-row-height;
+ height: $home-panel-title-row-height;
+ flex-shrink: 0;
+ flex-basis: $home-panel-title-row-height;
+ }
+
+ .home-panel-title {
+ font-size: 20px;
+ line-height: $gl-line-height-24;
+ font-weight: bold;
+
+ .icon {
+ vertical-align: -1px;
+ }
+
+ .home-panel-topic-list {
+ font-size: $gl-font-size;
+ font-weight: $gl-font-weight-normal;
+
+ .icon {
+ position: relative;
+ top: 3px;
+ margin-right: $gl-padding-4;
+ }
+ }
+ }
+
+ .home-panel-title-row {
+ @include media-breakpoint-down(sm) {
+ .home-panel-avatar {
+ width: $home-panel-avatar-mobile-size;
+ height: $home-panel-avatar-mobile-size;
+ flex-basis: $home-panel-avatar-mobile-size;
+
+ .avatar {
+ font-size: 20px;
+ line-height: 46px;
+ }
+ }
+
+ .home-panel-title {
+ margin-top: 4px;
+ margin-bottom: 2px;
+ font-size: $gl-font-size;
+ line-height: $gl-font-size-large;
+ }
+
+ .home-panel-topic-list,
+ .home-panel-metadata {
+ font-size: $gl-font-size-small;
+ }
+ }
+ }
+
+ .home-panel-metadata {
+ font-weight: normal;
+ font-size: 14px;
+ line-height: $gl-btn-line-height;
+ }
+
+ .home-panel-description {
+ @include media-breakpoint-up(md) {
+ font-size: $gl-font-size-large;
+ }
+ }
+}
+
.home-panel-buttons {
.home-panel-action-button {
vertical-align: top;
diff --git a/app/assets/stylesheets/pages/incident_management_list.scss b/app/assets/stylesheets/pages/incident_management_list.scss
index 316066694a8..c0a1fa72b1f 100644
--- a/app/assets/stylesheets/pages/incident_management_list.scss
+++ b/app/assets/stylesheets/pages/incident_management_list.scss
@@ -7,6 +7,19 @@
table {
@include gl-text-gray-500;
+ tbody {
+ tr:not(.b-table-busy-slot) {
+ // TODO replace with gitlab/ui utilities: https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1791
+ &:hover {
+ border-top-style: double;
+
+ td {
+ border-bottom-style: initial;
+ }
+ }
+ }
+ }
+
tr {
&:focus {
outline: none;
@@ -119,7 +132,7 @@
}
@include media-breakpoint-down(xs) {
- .incident-management-list-header {
+ .list-header {
flex-direction: column-reverse;
}
@@ -127,9 +140,4 @@
@include gl-w-full;
}
}
-
- // TODO: Abstract to `@gitlab/ui` utility set: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/921
- .gl-fill-green-500 {
- fill: $green-500;
- }
}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 53525a4d877..7097c2b10c4 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -117,7 +117,8 @@
}
}
-.assignee {
+.assignee,
+.reviewer {
.merge-icon {
color: $orange-400;
position: absolute;
@@ -240,16 +241,6 @@
.avatar {
margin-left: 0;
}
-
- a.edit-link:not([href]):hover {
- color: rgba($gray-normal, 0.2);
- }
-
- .confidential-edit,
- .lock-edit,
- .edit-link {
- @extend .btn-link;
- }
}
.cross-project-reference,
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 03603f637c8..d2eb00c4a4d 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -1,38 +1,3 @@
-.issues-list {
- &.manual-ordering {
- background-color: $gray-light;
- border-radius: $border-radius-default;
- padding: $gl-padding-8;
-
- .issue {
- background-color: $white;
- margin-bottom: $gl-padding-8;
- border-radius: $border-radius-default;
- border: 1px solid $gray-100;
- box-shadow: 0 1px 2px $issue-boards-card-shadow;
- }
- }
-
- .issue {
- padding: 10px $gl-padding;
- position: relative;
-
- .title {
- margin-bottom: 2px;
- }
-
- .issue-labels,
- .author-link {
- display: inline-block;
- }
-
- .icon-merge-request-unmerged {
- height: 13px;
- margin-bottom: 3px;
- }
- }
-}
-
.issue-realtime-pre-pulse {
opacity: 0;
}
@@ -369,13 +334,3 @@ ul.related-merge-requests > li {
.issuable-header-slide-leave-to {
transform: translateY(-100%);
}
-
-.issuable-list-root {
- .gl-label-link {
- text-decoration: none;
-
- &:hover {
- color: inherit;
- }
- }
-}
diff --git a/app/assets/stylesheets/pages/issues/issues_list.scss b/app/assets/stylesheets/pages/issues/issues_list.scss
deleted file mode 100644
index c0af7a6af6d..00000000000
--- a/app/assets/stylesheets/pages/issues/issues_list.scss
+++ /dev/null
@@ -1,5 +0,0 @@
-.svg-container.jira-logo-container {
- svg {
- vertical-align: text-bottom;
- }
-}
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index e37b26187e7..31606cb3ba5 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -134,11 +134,6 @@
}
}
-.label-description-wrapper {
- margin-right: 8px;
- margin-left: 8px;
-}
-
.prioritized-labels {
margin-bottom: 30px;
@@ -201,10 +196,6 @@
}
}
-.label-options-toggle {
- width: 100%;
-}
-
.label-subscription {
vertical-align: middle;
@@ -239,20 +230,6 @@
}
}
-.label-link {
- display: inline-flex;
- vertical-align: text-bottom;
-
- &:hover .color-label {
- text-decoration: underline;
- }
-
- .label {
- vertical-align: inherit;
- font-size: $label-font-size;
- }
-}
-
.labels-container {
background-color: $gray-light;
border-radius: $border-radius-default;
@@ -270,41 +247,13 @@
.label-badge {
color: $gray-900;
+ display: inline-block;
font-weight: $gl-font-weight-normal;
padding: $gl-padding-4 $gl-padding-8;
border-radius: $border-radius-default;
font-size: $label-font-size;
}
-.label-badge-blue {
- background-color: $theme-blue-100;
-}
-
-.label-badge-gray {
- background-color: $gray-50;
-}
-
-.label-links {
- list-style: none;
- margin: 0;
- padding: 0;
- white-space: nowrap;
-}
-
-.label-link-item {
- padding: 0;
-}
-
-.label-description {
- .description-text {
- margin-bottom: 10px;
-
- .admin-labels & {
- margin-bottom: 0;
- }
- }
-}
-
.label-list-item {
.content-list &::before,
.content-list &::after {
@@ -313,21 +262,12 @@
.label-name {
width: 200px;
- flex-shrink: 0;
.gl-label {
line-height: $gl-line-height;
}
}
- .label-description {
- flex-grow: 1;
-
- a {
- color: $blue-600;
- }
- }
-
.label {
padding: 4px $grid-size;
font-size: $label-font-size;
@@ -382,31 +322,8 @@
text-align: left;
}
- .label-links {
- white-space: normal;
- }
-
.label-description {
order: 3;
- width: 100%;
-
- > .label-description-wrapper {
- margin-left: 0;
- margin-right: 0;
- }
- }
- }
-}
-
-@media (max-width: 910px) {
- .priority-badge {
- display: block;
- width: 100%;
- margin-left: 0;
- margin-top: $gl-padding;
-
- .label-badge {
- display: inline-block;
}
}
}
@@ -426,3 +343,9 @@
box-shadow: 0 0 0 1px inset;
}
}
+
+/* Fix scoped label padding in cases where old markdown uses the old label structure */
+.gl-label-text + .gl-label-text {
+ @include gl-pl-2;
+ @include gl-pr-3;
+}
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index 2d9a9f3029f..922f95ff5df 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -209,6 +209,27 @@
}
}
+
+.members-table {
+ @include media-breakpoint-up(lg) {
+ .col-meta {
+ width: px-to-rem(150px);
+ }
+
+ .col-max-role {
+ width: px-to-rem(175px);
+ }
+
+ .col-expiration {
+ width: px-to-rem(200px);
+ }
+
+ .col-actions {
+ width: px-to-rem(50px);
+ }
+ }
+}
+
.card-mobile {
.content-list.members-list li {
display: block;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 8aaeb92eb7a..6f71177e870 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -471,12 +471,6 @@ $mr-widget-min-height: 69px;
flex: 1;
}
- .issuable-meta {
- .author-link {
- display: inline-block;
- }
- }
-
.merge-request-title {
margin-bottom: 2px;
@@ -770,8 +764,14 @@ $mr-widget-min-height: 69px;
position: -webkit-sticky;
position: sticky;
top: $header-height + $mr-tabs-height;
- margin-left: -16px;
- width: calc(100% + 32px);
+
+ .with-system-header & {
+ top: $header-height + $mr-tabs-height + $system-header-height;
+ }
+
+ .with-system-header.with-performance-bar & {
+ top: $header-height + $mr-tabs-height + $system-header-height + $performance-bar-height;
+ }
.mr-version-menus-container {
flex-wrap: nowrap;
@@ -790,6 +790,14 @@ $mr-widget-min-height: 69px;
background-color: $white;
border-bottom: 1px solid $border-color;
+ .with-system-header & {
+ top: $header-height + $system-header-height;
+ }
+
+ .with-system-header.with-performance-bar & {
+ top: $header-height + $system-header-height + $performance-bar-height;
+ }
+
@include media-breakpoint-up(sm) {
position: -webkit-sticky;
position: sticky;
@@ -868,6 +876,13 @@ $mr-widget-min-height: 69px;
}
}
+.container-fluid {
+ // Negative margins for mobile/tablet screen
+ .diffs.tab-pane {
+ margin: 0 (-$gl-padding);
+ }
+}
+
// Wrap MR tabs/buttons so you don't have to scroll on desktop
@include media-breakpoint-down(md) {
.merge-request-tabs-container,
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index c144fb13322..b510822a20a 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -858,68 +858,28 @@ $note-form-margin-left: 72px;
}
.line-resolve-all-container {
- margin: $gl-padding-4;
-
> div {
white-space: nowrap;
}
- .discussion-next-btn {
- border-radius: 0;
- }
-
- .toggle-all-discussions-btn {
+ .btn-group .btn:first-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
-
- .btn {
- line-height: $gl-line-height;
-
- svg {
- fill: $gray-500;
- }
-
- &.discussion-create-issue-btn {
- border-radius: 0;
- border-right: 0;
-
- a {
- padding: 0;
- line-height: 0;
-
- &:hover {
- text-decoration: none;
- border: 0;
- }
- }
- }
-
- &.discussion-next-btn {
- border-right: 0;
- }
- }
}
.line-resolve-all {
vertical-align: middle;
display: inline-block;
- padding: $gl-padding-4 10px;
+ padding: $gl-padding-8 $gl-padding-12;
background-color: $gray-light;
border: 1px solid $border-color;
+ border-right: 0;
border-radius: $border-radius-default;
- font-size: $gl-btn-small-font-size;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
- border-right: 0;
-
- .line-resolve-btn {
- color: $gray-500;
-
- svg {
- vertical-align: text-top;
- }
- }
+ font-size: $gl-font-size;
+ line-height: 1rem;
@include media-breakpoint-down(xs) {
flex: 1;
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 8b104ce9017..2df43b861b2 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -1,119 +1,3 @@
-@mixin flat-connector-before($length: 44px) {
- &::before {
- content: '';
- position: absolute;
- top: 48%;
- left: -$length;
- border-top: 2px solid $border-color;
- width: $length;
- height: 1px;
- }
-}
-
-@mixin build-content($border-radius: 30px) {
- display: inline-block;
- padding: 8px 10px 9px;
- width: 100%;
- border: 1px solid $border-color;
- border-radius: $border-radius;
- background-color: $white;
-
- &:hover {
- background-color: $gray-darker;
- border: 1px solid $dropdown-toggle-active-border-color;
- color: $gl-text-color;
- }
-}
-
-.pipelines {
- .negative-margin-top {
- margin-top: -$pipelines-table-header-height;
- }
-
- .stage {
- max-width: 90px;
- width: 90px;
- text-align: center;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .table-holder {
- overflow: unset;
- width: 100%;
- }
-
- .commit-title {
- margin: 0;
- white-space: normal;
-
- @include media-breakpoint-down(sm) {
- justify-content: flex-end;
- }
- }
-
- .ci-table {
- .badge {
- margin-bottom: 3px;
- }
-
- .pipeline-id {
- color: $black;
- }
-
- .pipelines-time-ago {
- text-align: right;
- }
-
- .pipeline-actions {
- min-width: 170px; //Guarantees buttons don't break in several lines.
-
- .btn-default {
- color: $gl-text-color-secondary;
- }
-
- .btn.btn-retry:hover,
- .btn.btn-retry:focus {
- border-color: $dropdown-toggle-active-border-color;
- background-color: $white-normal;
- }
-
- svg path {
- fill: $gl-text-color-secondary;
- }
-
- .dropdown-menu {
- max-height: $dropdown-max-height;
- overflow-y: auto;
- }
-
- .dropdown-toggle,
- .dropdown-menu {
- color: $gl-text-color-secondary;
-
- .fa {
- color: $gl-text-color-secondary;
- font-size: 14px;
- }
- }
-
- .btn-group.open .btn-default {
- background-color: $white-normal;
- border-color: $border-white-normal;
- }
-
- .btn .text-center {
- display: inline;
- }
-
- .tooltip {
- white-space: nowrap;
- }
- }
- }
-}
-
@include media-breakpoint-down(md) {
.content-list {
&.builds-content-list {
@@ -124,22 +8,6 @@
}
.ci-table {
- .build.retried {
- background-color: $gray-lightest;
- }
-
- .commit-link {
- a {
- &:focus {
- text-decoration: none;
- }
- }
-
- a:hover {
- text-decoration: none;
- }
- }
-
.avatar {
margin-left: 0;
float: none;
@@ -160,7 +28,10 @@
height: 14px;
width: 14px;
vertical-align: middle;
- fill: $gl-text-color-secondary;
+
+ &:not(.text-warning) {
+ fill: $gl-text-color-secondary;
+ }
}
.sprite {
@@ -191,45 +62,12 @@
}
}
- .icon-container {
- display: inline-block;
-
- &.commit-icon {
- width: 15px;
- text-align: center;
- }
- }
-
- /**
- * Play button with icon in dropdowns
- */
- .no-btn {
- border: 0;
- background: none;
- outline: none;
- width: 100%;
- text-align: left;
-
- .icon-play {
- position: relative;
- top: 2px;
- margin-right: 5px;
- height: 13px;
- width: 12px;
- }
- }
-
.duration,
.finished-at {
color: $gl-text-color-secondary;
margin: 0;
white-space: nowrap;
- .fa {
- font-size: 12px;
- margin-right: 4px;
- }
-
svg {
width: 12px;
height: 12px;
@@ -241,843 +79,20 @@
.build-link a {
color: $gl-text-color;
}
-
- .btn-group.open .dropdown-toggle {
- box-shadow: none;
- }
-
- .pipeline-tags .label-container {
- white-space: normal;
- }
}
-.stage-cell {
- .mini-pipeline-graph-dropdown-toggle {
- svg {
- height: $ci-action-icon-size;
- width: $ci-action-icon-size;
- position: absolute;
- top: -1px;
- left: -1px;
- z-index: 2;
- overflow: visible;
- }
-
- &:hover,
- &:active,
- &:focus {
- svg {
- top: -2px;
- left: -2px;
- }
+[data-page='admin:jobs:index'] {
+ .admin-builds-table {
+ td:last-child {
+ min-width: 120px;
}
}
-
- .stage-container {
- display: inline-block;
- position: relative;
- vertical-align: middle;
- height: $ci-action-icon-size;
- margin: 3px 0;
-
- + .stage-container {
- margin-left: 6px;
- }
-
- // Hack to show a button tooltip inline
- button.has-tooltip + .tooltip {
- min-width: 105px;
- }
-
- // Bootstrap way of showing the content inline for anchors.
- a.has-tooltip {
- white-space: nowrap;
- }
-
- &:not(:last-child) {
- &::after {
- content: '';
- width: 7px;
- position: absolute;
- right: -7px;
- top: 11px;
- border-bottom: 2px solid $border-color;
- }
- }
-
- //delete when all pipelines are updated to new size
- &.mr-widget-pipeline-stages {
- + .stage-container {
- margin-left: 4px;
- }
-
- &:not(:last-child) {
- &::after {
- width: 4px;
- right: -4px;
- top: 11px;
- }
- }
- }
- }
-}
-
-.admin-builds-table {
- .ci-table td:last-child {
- min-width: 120px;
- }
-}
-
-// Pipeline visualization
-.pipeline-actions {
- border-bottom: 0;
-}
-
-.tab-pane {
- &.builds .ci-table tr {
- height: 71px;
- }
-
- .ci-table {
- thead th {
- border-top: 0;
- }
- }
-}
-
-.build-failures {
- .build-state {
- padding: 20px 2px;
-
- .build-name {
- font-weight: $gl-font-weight-normal;
- }
-
- .stage {
- color: $gl-text-color-secondary;
- font-weight: $gl-font-weight-normal;
- vertical-align: middle;
- }
- }
-
- .build-log {
- border: 0;
- line-height: initial;
- }
-
- .build-trace-row td {
- border-top: 0;
- border-bottom-width: 1px;
- border-bottom-style: solid;
- padding-top: 0;
- }
-
- .build-trace {
- width: 100%;
- text-align: left;
- margin-top: $gl-padding;
- }
-
- .build-name {
- width: 196px;
-
- a {
- font-weight: $gl-font-weight-bold;
- color: $gl-text-color;
- text-decoration: none;
-
- &:focus,
- &:hover {
- text-decoration: underline;
- }
- }
- }
-
- .build-actions {
- width: 70px;
- text-align: right;
- }
-
- .build-stage {
- width: 140px;
- }
-
- .ci-status-icon-failed {
- padding: 10px 0 10px 12px;
- width: 12px + 24px; // padding-left + svg width
- }
-
- .build-icon svg {
- width: 24px;
- height: 24px;
- vertical-align: middle;
- }
-
- .build-state,
- .build-trace-row {
- > td:last-child {
- padding-right: 0;
- }
- }
-
- @include media-breakpoint-down(sm) {
- td:empty {
- display: none;
- }
-
- .ci-table {
- margin-top: 2 * $gl-padding;
- }
-
- .build-trace-container {
- padding-top: $gl-padding;
- padding-bottom: $gl-padding;
- }
-
- .build-trace {
- margin-bottom: 0;
- margin-top: 0;
- }
- }
-}
-
-.pipeline-tab-content {
- display: flex;
- width: 100%;
- min-height: $dropdown-max-height-lg;
- background-color: $gray-light;
- padding: $gl-padding 0;
- overflow: auto;
-}
-
-// Pipeline graph
-.pipeline-graph {
- white-space: nowrap;
- transition: max-height 0.3s, padding 0.3s;
-
- .stage-column-list,
- .builds-container > ul {
- padding: 0;
- }
-
- a {
- text-decoration: none;
- color: $gl-text-color;
- }
-
- svg {
- vertical-align: middle;
- }
-
- .stage-column {
- display: inline-block;
- vertical-align: top;
-
- &.left-margin {
- &:not(:first-child) {
- margin-left: 44px;
-
- .left-connector {
- @include flat-connector-before;
- }
- }
- }
-
- &.no-margin {
- margin: 0;
- }
-
- li {
- list-style: none;
- }
-
- // when downstream pipelines are present, the last stage isn't the last column
- &:last-child:not(.has-downstream) {
- .build {
- // Remove right connecting horizontal line from first build in last stage
- &:first-child::after {
- border: 0;
- }
- // Remove right curved connectors from all builds in last stage
- &:not(:first-child)::after {
- border: 0;
- }
- // Remove opposite curve
- .curve::before {
- display: none;
- }
- }
- }
-
- // when upstream pipelines are present, the first stage isn't the first column
- &:first-child:not(.has-upstream) {
- .build {
- // Remove left curved connectors from all builds in first stage
- &:not(:first-child)::before {
- border: 0;
- }
- // Remove opposite curve
- .curve::after {
- display: none;
- }
- }
- }
-
- // Curve first child connecting lines in opposite direction
- .curve {
- display: none;
-
- &::before,
- &::after {
- content: '';
- width: 21px;
- height: 25px;
- position: absolute;
- top: -31px;
- border-top: 2px solid $border-color;
- }
-
- &::after {
- left: -44px;
- border-right: 2px solid $border-color;
- border-radius: 0 20px;
- }
-
- &::before {
- right: -44px;
- border-left: 2px solid $border-color;
- border-radius: 20px 0 0;
- }
- }
- }
-
- .stage-name {
- margin: 0 0 15px 10px;
- font-weight: $gl-font-weight-bold;
- width: 176px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- line-height: 2.2em;
- }
-
- .build {
- position: relative;
- width: 186px;
- margin-bottom: 10px;
- white-space: normal;
-
- .ci-job-dropdown-container {
- // override dropdown.scss
- .dropdown-menu li button {
- padding: 0;
- text-align: center;
- }
- }
-
- // ensure .build-content has hover style when action-icon is hovered
- .ci-job-dropdown-container:hover .build-content {
- @extend .build-content:hover;
- }
-
- .ci-status-icon svg {
- height: 24px;
- width: 24px;
- }
-
- .dropdown-menu-toggle {
- background-color: transparent;
- border: 0;
- padding: 0;
-
- &:focus {
- outline: none;
- }
- }
-
- .build-content {
- @include build-content();
- }
-
- a.build-content:hover,
- button.build-content:hover {
- background-color: $gray-darker;
- border: 1px solid $dropdown-toggle-active-border-color;
- }
-
- // Connect first build in each stage with right horizontal line
- &:first-child {
- &::after {
- content: '';
- position: absolute;
- top: 48%;
- right: -48px;
- border-top: 2px solid $border-color;
- width: 48px;
- height: 1px;
- }
- }
-
- // Connect each build (except for first) with curved lines
- &:not(:first-child) {
- &::after,
- &::before {
- content: '';
- top: -49px;
- position: absolute;
- border-bottom: 2px solid $border-color;
- width: 25px;
- height: 69px;
- }
-
- // Right connecting curves
- &::after {
- right: -25px;
- border-right: 2px solid $border-color;
- border-radius: 0 0 20px;
- }
-
- // Left connecting curves
- &::before {
- left: -25px;
- border-left: 2px solid $border-color;
- border-radius: 0 0 0 20px;
- }
- }
-
- // Connect second build to first build with smaller curved line
- &:nth-child(2) {
- &::after,
- &::before {
- height: 29px;
- top: -9px;
- }
-
- .curve {
- display: block;
- }
- }
- }
-
- .ci-action-icon-container {
- position: absolute;
- right: 5px;
- top: 50%;
- transform: translateY(-50%);
-
- // Action Icons in big pipeline-graph nodes
- &.ci-action-icon-wrapper {
- height: 30px;
- width: 30px;
- border-radius: 100%;
- display: block;
- padding: 0;
- line-height: 0;
-
- svg {
- fill: $gl-text-color-secondary;
- }
-
- .spinner {
- top: 2px;
- }
-
- &.play {
- svg {
- left: 1px;
- top: 1px;
- }
- }
- }
- }
-
- .stage-action svg {
- left: 1px;
- top: -2px;
- }
-}
-
-// Triggers the dropdown in the big pipeline graph
-.dropdown-counter-badge {
- font-weight: 100;
- font-size: 15px;
- position: absolute;
- right: 13px;
- top: 8px;
-}
-
-.ci-build-text,
-.ci-status-text {
- font-weight: 200;
-}
-
-@mixin mini-pipeline-graph-color(
- $color-background-default,
- $color-background-hover-focus,
- $color-background-active,
- $color-foreground-default,
- $color-foreground-hover-focus,
- $color-foreground-active
-) {
- background-color: $color-background-default;
- border-color: $color-foreground-default;
-
- svg {
- fill: $color-foreground-default;
- }
-
- &:hover,
- &:focus {
- background-color: $color-background-hover-focus;
- border-color: $color-foreground-hover-focus;
-
- svg {
- fill: $color-foreground-hover-focus;
- }
- }
-
- &:active {
- background-color: $color-background-active;
- border-color: $color-foreground-active;
-
- svg {
- fill: $color-foreground-active;
- }
- }
-
- &:focus {
- box-shadow: 0 0 4px 1px $blue-300;
- }
-}
-
-@mixin mini-pipeline-item() {
- border-radius: 100px;
- background-color: $white;
- border-width: 1px;
- border-style: solid;
- width: $ci-action-icon-size;
- height: $ci-action-icon-size;
- margin: 0;
- padding: 0;
- position: relative;
- vertical-align: middle;
-
- &:hover,
- &:active,
- &:focus {
- outline: none;
- border-width: 2px;
- }
-
- // Dropdown button animation in mini pipeline graph
- &.ci-status-icon-success {
- @include mini-pipeline-graph-color($white, $green-100, $green-200, $green-500, $green-600, $green-700);
- }
-
- &.ci-status-icon-failed {
- @include mini-pipeline-graph-color($white, $red-100, $red-200, $red-500, $red-600, $red-700);
- }
-
- &.ci-status-icon-pending,
- &.ci-status-icon-waiting-for-resource,
- &.ci-status-icon-success-with-warnings {
- @include mini-pipeline-graph-color($white, $orange-50, $orange-100, $orange-500, $orange-600, $orange-700);
- }
-
- &.ci-status-icon-preparing,
- &.ci-status-icon-running {
- @include mini-pipeline-graph-color($white, $blue-100, $blue-200, $blue-500, $blue-600, $blue-700);
- }
-
- &.ci-status-icon-canceled,
- &.ci-status-icon-scheduled,
- &.ci-status-icon-disabled,
- &.ci-status-icon-not-found,
- &.ci-status-icon-manual {
- @include mini-pipeline-graph-color($white, $gray-500, $gray-700, $gray-900, $gray-950, $black);
- }
-
- &.ci-status-icon-created,
- &.ci-status-icon-skipped {
- @include mini-pipeline-graph-color($white, $gray-100, $gray-200, $gray-300, $gray-400, $gray-500);
- }
-}
-
-// Dropdown button in mini pipeline graph
-button.mini-pipeline-graph-dropdown-toggle {
- @include mini-pipeline-item();
-}
-
-/**
- Action icons inside dropdowns:
- - mini graph in pipelines table
- - dropdown in big graph
- - mini graph in MR widget pipeline
- - mini graph in Commit widget pipeline
-*/
-.big-pipeline-graph-dropdown-menu,
-.mini-pipeline-graph-dropdown-menu {
- width: 240px;
- max-width: 240px;
-
- // override dropdown.scss
- &.dropdown-menu li button,
- &.dropdown-menu li a.ci-action-icon-container {
- padding: 0;
- text-align: center;
- }
-
- .ci-action-icon-container {
- position: absolute;
- right: 8px;
- top: 8px;
-
- &.ci-action-icon-wrapper {
- height: $ci-action-dropdown-button-size;
- width: $ci-action-dropdown-button-size;
- border-radius: 50%;
- display: block;
-
- &:hover {
- box-shadow: inset 0 0 0 0.0625rem $dropdown-toggle-active-border-color;
- background-color: $gray-darker;
-
- svg {
- fill: $gl-text-color;
- }
- }
-
- .spinner,
- svg {
- width: $ci-action-dropdown-svg-size;
- height: $ci-action-dropdown-svg-size;
- fill: $gl-text-color-secondary;
- position: relative;
- top: 1px;
- vertical-align: initial;
- }
- }
- }
-
- // SVGs in the commit widget and mr widget
- a.ci-action-icon-container.ci-action-icon-wrapper svg {
- top: 5px;
- }
-
- .scrollable-menu {
- padding: 0;
- max-height: 245px;
- overflow: auto;
- }
-
- li {
- position: relative;
-
- // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered
- &:hover > .mini-pipeline-graph-dropdown-item,
- &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item {
- @extend .mini-pipeline-graph-dropdown-item:hover;
- }
-
- // link to the build
- .mini-pipeline-graph-dropdown-item {
- align-items: center;
- clear: both;
- display: flex;
- font-weight: normal;
- line-height: $line-height-base;
- white-space: nowrap;
-
- // Match dropdown.scss for all `a` tags
- &.non-details-job-component {
- padding: $gl-padding-8 $gl-btn-horz-padding;
- }
-
- .ci-job-name-component {
- align-items: center;
- display: flex;
- flex: 1;
- }
-
-
- .ci-status-icon {
- @include gl-mr-3;
-
- position: relative;
-
- > svg {
- width: $pipeline-dropdown-status-icon-size;
- height: $pipeline-dropdown-status-icon-size;
- margin: 3px 0;
- position: relative;
- overflow: visible;
- display: block;
- }
- }
-
- &:hover,
- &:focus {
- outline: none;
- text-decoration: none;
- background-color: $gray-darker;
- }
- }
- }
-}
-
-// Dropdown in the big pipeline graph
-.big-pipeline-graph-dropdown-menu {
- width: 195px;
- min-width: 195px;
- left: 100%;
- top: -10px;
- box-shadow: 0 1px 5px $black-transparent;
-
- /**
- * Top arrow in the dropdown in the big pipeline graph
- */
- &::before,
- &::after {
- content: '';
- display: inline-block;
- position: absolute;
- width: 0;
- height: 0;
- border-color: transparent;
- border-style: solid;
- top: 18px;
- }
-
- &::before {
- left: -6px;
- margin-top: 3px;
- border-width: 7px 5px 7px 0;
- border-right-color: $border-color;
- }
-
- &::after {
- left: -5px;
- border-width: 10px 7px 10px 0;
- border-right-color: $white;
- }
-}
-
-/**
- * Top arrow in the dropdown in the mini pipeline graph
- */
-.mini-pipeline-graph-dropdown-menu {
- &::before,
- &::after {
- content: '';
- display: inline-block;
- position: absolute;
- width: 0;
- height: 0;
- border-color: transparent;
- border-style: solid;
- top: -6px;
- left: 50%;
- transform: translate(-50%, 0);
- border-width: 0 5px 6px;
-
- @include media-breakpoint-down(sm) {
- left: 100%;
- margin-left: -12px;
- }
- }
-
- &::before {
- border-width: 0 5px 5px;
- border-bottom-color: $border-color;
- }
-
- &::after {
- margin-top: 1px;
- border-bottom-color: $white;
- }
-
- /**
- * Center dropdown menu in mini graph
- */
- .dropdown &.dropdown-menu {
- transform: translate(-80%, 0);
-
- @media (min-width: map-get($grid-breakpoints, md)) {
- transform: translate(-50%, 0);
- right: auto;
- left: 50%;
- }
- }
-}
-
-/**
- * Terminal
- */
-.terminal-icon {
- margin-left: 3px;
-}
-
-.terminal-container {
- .content-block {
- border-bottom: 0;
- }
-
- #terminal {
- margin-top: 10px;
- min-height: 450px;
- box-sizing: border-box;
-
- > div {
- min-height: 450px;
- }
- }
-}
-
-.ci-header-container {
- min-height: 55px;
-
- .text-center {
- padding-top: 12px;
- }
}
.pipelines-container .top-area .nav-controls > .btn:last-child {
float: none;
}
-.autodevops-title {
- font-weight: $gl-font-weight-normal;
- line-height: 1.5;
-}
-
-.legend-all {
- color: $gl-text-color-secondary;
-}
-
-.legend-success {
- color: $green-500;
-}
-
-.test-reports-table {
- .build-trace {
- @include build-trace();
- }
-}
-
-.codequality-report {
- .media {
- padding: $gl-padding;
- }
-
- .media-body {
- flex-direction: row;
- }
-
- .report-block-container {
- height: auto !important;
- }
-}
-
.progress-bar.bg-primary {
background-color: $blue-500 !important;
}
@@ -1090,6 +105,10 @@ button.mini-pipeline-graph-dropdown-toggle {
width: 8rem;
}
+.stage-rounded {
+ border-radius: 2rem;
+}
+
.stage-left-rounded {
border-radius: 2rem 0 0 2rem;
}
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index 4dc1f2034f3..3605283245f 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -227,6 +227,10 @@
padding-left: 40px;
}
+ .gl-label-scoped {
+ --label-inset-border: inset 0 0 0 1px currentColor;
+ }
+
@include media-breakpoint-up(lg) {
margin-right: 5px;
}
@@ -443,20 +447,3 @@ table.u2f-registrations,
width: 100%;
max-width: $add-to-slack-popup-max-width;
}
-
-.gitlab-slack-right-arrow svg {
- fill: $white-dark;
- width: $right-arrow-size;
- height: $right-arrow-size;
- vertical-align: text-bottom;
-}
-
-.gitlab-slack-double-headed-arrow {
- vertical-align: text-top;
-
- svg {
- fill: $gray-darker;
- width: $double-headed-arrow-width;
- height: $double-headed-arrow-height;
- }
-}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index a2f8447c0b6..938d8d34717 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -1,11 +1,3 @@
-.alert_holder {
- margin: -16px;
-
- .alert-link {
- font-weight: $gl-font-weight-normal;
- }
-}
-
.new_project,
.edit-project,
.import-project {
@@ -67,38 +59,7 @@
}
}
-.classification-label {
- background-color: $red-500;
-}
-
-.toggle-wrapper {
- margin-top: 5px;
-}
-
-.project-feature-row > .toggle-wrapper {
- margin: 10px 0;
-}
-
-.project-visibility-setting,
-.project-feature-settings {
- border: 1px solid $border-color;
- padding: 10px 32px;
-
- @include media-breakpoint-down(xs) {
- padding: 10px 20px;
- }
-}
-
-.project-visibility-setting .request-access {
- line-height: 2;
-}
-
-.project-feature-settings {
- background: $gray-lighter;
- border-top: 0;
- margin-bottom: 16px;
-}
-
+// INFO Scoped to project_feature_setting and settings_panel components in app/assets/javascripts/pages/projects/shared/permissions/components
.project-repo-select {
transition: background 2s ease-out;
@@ -113,63 +74,31 @@
}
}
+// INFO Scoped to project_feature_setting and settings_panel components in app/assets/javascripts/pages/projects/shared/permissions/components
.project-feature-controls {
- display: flex;
- align-items: center;
- margin: $gl-padding-8 0;
max-width: 432px;
-
- .toggle-wrapper {
- flex: 0;
- margin-right: 10px;
- }
-
- .select-wrapper {
- flex: 1;
- }
}
+// INFO Scoped to settings_panel component in app/assets/javascripts/pages/projects/shared/permissions/components
.project-feature-setting-group {
- padding-left: 32px;
-
.project-feature-controls {
max-width: 400px;
}
-
- @include media-breakpoint-down(xs) {
- padding-left: 20px;
- }
}
-.group-home-panel,
.project-home-panel {
- margin-top: $gl-padding;
- margin-bottom: $gl-padding;
-
.home-panel-avatar {
- width: $home-panel-title-row-height;
- height: $home-panel-title-row-height;
- flex-shrink: 0;
flex-basis: $home-panel-title-row-height;
}
.home-panel-title {
- font-size: 20px;
- line-height: $gl-line-height-24;
- font-weight: bold;
-
.icon {
vertical-align: -1px;
}
.home-panel-topic-list {
- font-size: $gl-font-size;
- font-weight: $gl-font-weight-normal;
-
.icon {
- position: relative;
top: 3px;
- margin-right: $gl-padding-4;
}
}
}
@@ -201,24 +130,6 @@
}
}
- .home-panel-metadata {
- font-weight: normal;
- font-size: 14px;
- line-height: $gl-btn-line-height;
-
- .home-panel-license {
- .btn {
- line-height: 0;
- border-width: 0;
- }
- }
-
- .access-request-link {
- padding-left: $gl-padding-8;
- border-left: 1px solid $gl-text-color-secondary;
- }
- }
-
.home-panel-description {
@include media-breakpoint-up(md) {
font-size: $gl-font-size-large;
@@ -778,7 +689,7 @@
}
.btn {
- margin-top: $gl-padding-8;
+ margin-bottom: $gl-padding-8;
padding: $gl-btn-vert-padding $gl-btn-padding;
line-height: $gl-btn-line-height;
@@ -794,11 +705,6 @@
}
.project-buttons {
- .stat-text {
- @extend .btn;
- @extend .btn-default;
- }
-
.nav > li:not(:last-child) {
margin-right: $gl-padding-8;
}
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 4b8e1da4867..a62e28a9b8a 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -198,11 +198,20 @@ input[type='checkbox']:hover {
}
}
+ .search-clear {
+ position: absolute;
+ right: 10px;
+ top: 9px;
+ padding: 0;
+ line-height: 0;
+ background: none;
+ border: 0;
+ }
.search-icon {
position: absolute;
left: 10px;
- top: 10px;
+ top: 9px;
color: $gray-darkest;
pointer-events: none;
}
@@ -247,14 +256,7 @@ input[type='checkbox']:hover {
}
.search-clear {
- position: absolute;
- right: 10px;
- top: 9px;
- padding: 0;
color: $gray-darkest;
- line-height: 0;
- background: none;
- border: 0;
&:hover,
&:focus {
@@ -281,6 +283,10 @@ input[type='checkbox']:hover {
}
}
+.ref-truncated {
+ @include str-truncated(10em);
+}
+
// Disable webkit input icons, link to solution: https://stackoverflow.com/questions/9421551/how-do-i-remove-all-default-webkit-search-field-styling
/* stylelint-disable property-no-vendor-prefix */
input[type='search']::-webkit-search-decoration,
diff --git a/app/assets/stylesheets/pages/serverless.scss b/app/assets/stylesheets/pages/serverless.scss
deleted file mode 100644
index a5b73492380..00000000000
--- a/app/assets/stylesheets/pages/serverless.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-.url-text-field {
- cursor: text;
-}
diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss
index 239123fc3ab..ebf21f58208 100644
--- a/app/assets/stylesheets/pages/settings_ci_cd.scss
+++ b/app/assets/stylesheets/pages/settings_ci_cd.scss
@@ -5,6 +5,10 @@
}
}
+.trigger-description {
+ max-width: 100px;
+}
+
.trigger-actions {
white-space: nowrap;
diff --git a/app/assets/stylesheets/pages/tags.scss b/app/assets/stylesheets/pages/tags.scss
deleted file mode 100644
index a6d30522ff7..00000000000
--- a/app/assets/stylesheets/pages/tags.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-.tag-release-link {
- color: $blue-600 !important;
-}
diff --git a/app/assets/stylesheets/pages/ui_dev_kit.scss b/app/assets/stylesheets/pages/ui_dev_kit.scss
deleted file mode 100644
index 288da4da5c3..00000000000
--- a/app/assets/stylesheets/pages/ui_dev_kit.scss
+++ /dev/null
@@ -1,17 +0,0 @@
-.gitlab-ui-dev-kit {
- > h2 {
- margin: 35px 0 20px;
- font-weight: $gl-font-weight-bold;
- }
-
- .example {
- padding: 15px;
- border: 1px dashed $gray-100;
- margin-bottom: 15px;
-
- &::before {
- content: 'Example';
- color: $ui-dev-kit-example-color;
- }
- }
-}
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index bfbcb8c13c6..66cc4452858 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -163,6 +163,9 @@ body.gl-dark {
--gl-text-color: #{$gray-900};
--border-color: #{$border-color};
+
+ --white: #{$white};
+ --black: #{$black};
}
$border-white-light: $gray-900;
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 9c666331c4f..e236c264f5c 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -44,13 +44,10 @@
}
.border-width-1px { border-width: 1px; }
-.border-bottom-width-1px { border-bottom-width: 1px; }
.border-style-dashed { border-style: dashed; }
.border-style-solid { border-style: solid; }
-.border-bottom-style-solid { border-bottom-style: solid; }
.border-color-blue-300 { border-color: $blue-300; }
.border-color-default { border-color: $border-color; }
-.border-bottom-color-default { border-bottom-color: $border-color; }
.border-radius-default { border-radius: $border-radius-default; }
.border-radius-small { border-radius: $border-radius-small; }
.box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; }
@@ -95,11 +92,6 @@
padding-bottom: $gl-spacing-scale-8;
}
-// move this to GitLab UI once onboarding experiment is considered a success
-.gl-pl-7 {
- padding-left: $gl-spacing-scale-7;
-}
-
.gl-transition-property-stroke-opacity {
transition-property: stroke-opacity;
}
@@ -116,43 +108,17 @@
box-shadow: inset 0 0 3px $gl-border-size-1 $blue-500;
}
-
-.gl-sm-align-items-flex-end {
- @media (min-width: $breakpoint-sm) {
- align-items: flex-end;
- }
-}
-
-.gl-sm-text-body {
- @media (min-width: $breakpoint-sm) {
- color: $body-color;
- }
+// This utility is used to force the z-index to match that of dropdown menu's
+.gl-z-dropdown-menu\! {
+ z-index: 300 !important;
}
-.gl-sm-font-weight-bold {
- @media (min-width: $breakpoint-sm) {
- font-weight: $gl-font-weight-bold;
- }
-}
-
-.gl-min-h-6 {
- min-height: $gl-spacing-scale-6;
-}
-
-.gl-md-justify-content-end {
- @media (min-width: $breakpoint-md) {
- width: auto !important;
- }
-}
-
-.gl-display-md-flex {
- @media (min-width: $breakpoint-md) {
- display: flex;
- }
+.gl-flex-basis-quarter {
+ flex-basis: 25%;
}
-.gl-display-md-none {
+.gl-md-ml-3 {
@media (min-width: $breakpoint-md) {
- display: none;
+ margin-left: $gl-spacing-scale-3;
}
}
diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb
index b2d98d243f9..bdd9d00ca7f 100644
--- a/app/channels/application_cable/connection.rb
+++ b/app/channels/application_cable/connection.rb
@@ -15,12 +15,14 @@ module ApplicationCable
private
def find_user_from_session_store
- session = ActiveSession.sessions_from_ids([session_id.private_id]).first
+ session = ActiveSession.sessions_from_ids(Array.wrap(session_id)).first
Warden::SessionSerializer.new('rack.session' => session).fetch(:user)
end
def session_id
- Rack::Session::SessionId.new(cookies[Gitlab::Application.config.session_options[:key]])
+ session_cookie = cookies[Gitlab::Application.config.session_options[:key]]
+
+ Rack::Session::SessionId.new(session_cookie).private_id if session_cookie.present?
end
def notification_payload(_)
diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb
index 7d8016f763d..5e613c47fc5 100644
--- a/app/controllers/abuse_reports_controller.rb
+++ b/app/controllers/abuse_reports_controller.rb
@@ -3,6 +3,8 @@
class AbuseReportsController < ApplicationController
before_action :set_user, only: [:new]
+ feature_category :users
+
def new
@abuse_report = AbuseReport.new
@abuse_report.user_id = @user.id
diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb
index 31d825c235b..6f80ed3c172 100644
--- a/app/controllers/admin/abuse_reports_controller.rb
+++ b/app/controllers/admin/abuse_reports_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::AbuseReportsController < Admin::ApplicationController
+ feature_category :users
+
def index
@abuse_reports = AbuseReportsFinder.new(params).execute
end
diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb
index 8405f2a5cf8..c2614a158b7 100644
--- a/app/controllers/admin/appearances_controller.rb
+++ b/app/controllers/admin/appearances_controller.rb
@@ -3,6 +3,8 @@
class Admin::AppearancesController < Admin::ApplicationController
before_action :set_appearance, except: :create
+ feature_category :navigation
+
def show
end
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 73f71f7ad55..786ba73a96f 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -2,6 +2,7 @@
class Admin::ApplicationSettingsController < Admin::ApplicationController
include InternalRedirect
+ include ServicesHelper
# NOTE: Use @application_setting in this controller when you need to access
# application_settings after it has been modified. This is because the
@@ -16,6 +17,24 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
push_frontend_feature_flag(:ci_instance_variables_ui, default_enabled: true)
end
+ feature_category :not_owned, [
+ :general, :reporting, :metrics_and_profiling, :network,
+ :preferences, :update, :reset_health_check_token
+ ]
+
+ feature_category :metrics, [
+ :create_self_monitoring_project,
+ :status_create_self_monitoring_project,
+ :delete_self_monitoring_project,
+ :status_delete_self_monitoring_project
+ ]
+
+ feature_category :source_code_management, [:repository, :clear_repository_check_states]
+ feature_category :continuous_integration, [:ci_cd, :reset_registration_token]
+ feature_category :collection, [:usage_data]
+ feature_category :integrations, [:integrations]
+ feature_category :pages, [:lets_encrypt_terms_of_service]
+
VALID_SETTING_PANELS = %w(general repository
ci_cd reporting metrics_and_profiling
network preferences).freeze
@@ -32,6 +51,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
end
def integrations
+ return not_found unless instance_level_integrations?
+
@integrations = Service.find_or_initialize_all(Service.for_instance).sort_by(&:title)
end
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index c017ecee054..449aa90b0e6 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -6,6 +6,8 @@ class Admin::ApplicationsController < Admin::ApplicationController
before_action :set_application, only: [:show, :edit, :update, :destroy]
before_action :load_scopes, only: [:new, :create, :edit, :update]
+ feature_category :authentication_and_authorization
+
def index
applications = ApplicationsFinder.new.execute
@applications = Kaminari.paginate_array(applications).page(params[:page])
diff --git a/app/controllers/admin/background_jobs_controller.rb b/app/controllers/admin/background_jobs_controller.rb
index fc877142418..d4b906d5e33 100644
--- a/app/controllers/admin/background_jobs_controller.rb
+++ b/app/controllers/admin/background_jobs_controller.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
class Admin::BackgroundJobsController < Admin::ApplicationController
+ feature_category :not_owned
end
diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index 3233c765941..4660b0bfbb0 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -5,6 +5,8 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
before_action :finder, only: [:edit, :update, :destroy]
+ feature_category :navigation
+
# rubocop: disable CodeReuse/ActiveRecord
def index
@broadcast_messages = BroadcastMessage.order(ends_at: :desc).page(params[:page])
diff --git a/app/controllers/admin/ci/variables_controller.rb b/app/controllers/admin/ci/variables_controller.rb
index ca9b393550d..f30ee37fa58 100644
--- a/app/controllers/admin/ci/variables_controller.rb
+++ b/app/controllers/admin/ci/variables_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::Ci::VariablesController < Admin::ApplicationController
+ feature_category :continuous_integration
+
def show
respond_to do |format|
format.json { render_instance_variables }
diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb
index e3df98b7917..d5cd9c55422 100644
--- a/app/controllers/admin/cohorts_controller.rb
+++ b/app/controllers/admin/cohorts_controller.rb
@@ -5,6 +5,8 @@ class Admin::CohortsController < Admin::ApplicationController
track_unique_visits :index, target_id: 'i_analytics_cohorts'
+ feature_category :instance_statistics
+
def index
if Gitlab::CurrentSettings.usage_ping_enabled
cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index b7b535e70df..7d981d67840 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -6,6 +6,8 @@ class Admin::DashboardController < Admin::ApplicationController
COUNTED_ITEMS = [Project, User, Group].freeze
+ feature_category :not_owned
+
# rubocop: disable CodeReuse/ActiveRecord
def index
@counts = Gitlab::Database::Count.approximate_counts(COUNTED_ITEMS)
diff --git a/app/controllers/admin/deploy_keys_controller.rb b/app/controllers/admin/deploy_keys_controller.rb
index 180f7d4c803..ed63e65d4df 100644
--- a/app/controllers/admin/deploy_keys_controller.rb
+++ b/app/controllers/admin/deploy_keys_controller.rb
@@ -4,6 +4,8 @@ class Admin::DeployKeysController < Admin::ApplicationController
before_action :deploy_keys, only: [:index]
before_action :deploy_key, only: [:destroy, :edit, :update]
+ feature_category :continuous_delivery
+
def index
end
diff --git a/app/controllers/admin/dev_ops_report_controller.rb b/app/controllers/admin/dev_ops_report_controller.rb
index bed0d51c331..59b2200fb59 100644
--- a/app/controllers/admin/dev_ops_report_controller.rb
+++ b/app/controllers/admin/dev_ops_report_controller.rb
@@ -5,6 +5,8 @@ class Admin::DevOpsReportController < Admin::ApplicationController
track_unique_visits :show, target_id: 'i_analytics_dev_ops_score'
+ feature_category :devops_reports
+
# rubocop: disable CodeReuse/ActiveRecord
def show
@metric = DevOpsReport::Metric.order(:created_at).last&.present
diff --git a/app/controllers/admin/gitaly_servers_controller.rb b/app/controllers/admin/gitaly_servers_controller.rb
index 0a5566bfe70..827791c8a4a 100644
--- a/app/controllers/admin/gitaly_servers_controller.rb
+++ b/app/controllers/admin/gitaly_servers_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::GitalyServersController < Admin::ApplicationController
+ feature_category :gitaly
+
def index
@gitaly_servers = Gitaly::Server.all
end
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index 6414792dd43..032e449f995 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -5,6 +5,8 @@ class Admin::GroupsController < Admin::ApplicationController
before_action :group, only: [:edit, :update, :destroy, :project_update, :members_update]
+ feature_category :subgroups
+
def index
@groups = groups.sort_by_attribute(@sort = params[:sort])
@groups = @groups.search(params[:name]) if params[:name].present?
diff --git a/app/controllers/admin/health_check_controller.rb b/app/controllers/admin/health_check_controller.rb
index 7668c799cba..e013b5fbd72 100644
--- a/app/controllers/admin/health_check_controller.rb
+++ b/app/controllers/admin/health_check_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::HealthCheckController < Admin::ApplicationController
+ feature_category :not_owned
+
def show
@errors = HealthCheck::Utils.process_checks(checks)
end
diff --git a/app/controllers/admin/hook_logs_controller.rb b/app/controllers/admin/hook_logs_controller.rb
index 8301b3aa880..444ad17f86d 100644
--- a/app/controllers/admin/hook_logs_controller.rb
+++ b/app/controllers/admin/hook_logs_controller.rb
@@ -8,6 +8,8 @@ class Admin::HookLogsController < Admin::ApplicationController
respond_to :html
+ feature_category :integrations
+
def show
end
diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb
index 51b0f45c5be..ca24f671b9d 100644
--- a/app/controllers/admin/hooks_controller.rb
+++ b/app/controllers/admin/hooks_controller.rb
@@ -5,6 +5,8 @@ class Admin::HooksController < Admin::ApplicationController
before_action :hook_logs, only: :edit
+ feature_category :integrations
+
def index
@hooks = SystemHook.all
@hook = SystemHook.new
@@ -34,7 +36,7 @@ class Admin::HooksController < Admin::ApplicationController
end
def destroy
- hook.destroy
+ destroy_hook(hook)
redirect_to admin_hooks_path, status: :found
end
diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb
index 327538f1e93..dcec50e882d 100644
--- a/app/controllers/admin/identities_controller.rb
+++ b/app/controllers/admin/identities_controller.rb
@@ -4,6 +4,8 @@ class Admin::IdentitiesController < Admin::ApplicationController
before_action :user
before_action :identity, except: [:index, :new, :create]
+ feature_category :authentication_and_authorization
+
def new
@identity = Identity.new
end
diff --git a/app/controllers/admin/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb
index c35619a944e..c3166d5dd82 100644
--- a/app/controllers/admin/impersonation_tokens_controller.rb
+++ b/app/controllers/admin/impersonation_tokens_controller.rb
@@ -3,6 +3,8 @@
class Admin::ImpersonationTokensController < Admin::ApplicationController
before_action :user
+ feature_category :authentication_and_authorization
+
def index
set_index_vars
end
diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb
index 65fe22bd8f4..6c45b03455e 100644
--- a/app/controllers/admin/impersonations_controller.rb
+++ b/app/controllers/admin/impersonations_controller.rb
@@ -4,6 +4,8 @@ class Admin::ImpersonationsController < Admin::ApplicationController
skip_before_action :authenticate_admin!
before_action :authenticate_impersonator!
+ feature_category :authentication_and_authorization
+
def destroy
original_user = stop_impersonation
redirect_to admin_user_path(original_user), status: :found
diff --git a/app/controllers/admin/instance_review_controller.rb b/app/controllers/admin/instance_review_controller.rb
new file mode 100644
index 00000000000..db304c82dd6
--- /dev/null
+++ b/app/controllers/admin/instance_review_controller.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+class Admin::InstanceReviewController < Admin::ApplicationController
+ feature_category :instance_statistics
+
+ def index
+ redirect_to("#{::Gitlab::SubscriptionPortal::SUBSCRIPTIONS_URL}/instance_review?#{instance_review_params}")
+ end
+
+ def instance_review_params
+ result = {
+ instance_review: {
+ email: current_user.email,
+ last_name: current_user.name,
+ version: ::Gitlab::VERSION
+ }
+ }
+
+ if Gitlab::CurrentSettings.usage_ping_enabled?
+ data = ::Gitlab::UsageData.data
+ counts = data[:counts]
+
+ result[:instance_review].merge!(
+ users_count: data[:active_user_count],
+ projects_count: counts[:projects],
+ groups_count: counts[:groups],
+ issues_count: counts[:issues],
+ merge_requests_count: counts[:merge_requests],
+ internal_pipelines_count: counts[:ci_internal_pipelines],
+ external_pipelines_count: counts[:ci_external_pipelines],
+ labels_count: counts[:labels],
+ milestones_count: counts[:milestones],
+ snippets_count: counts[:snippets],
+ notes_count: counts[:notes]
+ )
+ end
+
+ result.to_query
+ end
+end
diff --git a/app/controllers/admin/instance_statistics_controller.rb b/app/controllers/admin/instance_statistics_controller.rb
index 3aee26b97a2..dfbd704cb0c 100644
--- a/app/controllers/admin/instance_statistics_controller.rb
+++ b/app/controllers/admin/instance_statistics_controller.rb
@@ -7,6 +7,8 @@ class Admin::InstanceStatisticsController < Admin::ApplicationController
track_unique_visits :index, target_id: 'i_analytics_instance_statistics'
+ feature_category :instance_statistics
+
def index
end
diff --git a/app/controllers/admin/integrations_controller.rb b/app/controllers/admin/integrations_controller.rb
index 1e2a99f7078..9a1d5a11f7f 100644
--- a/app/controllers/admin/integrations_controller.rb
+++ b/app/controllers/admin/integrations_controller.rb
@@ -2,6 +2,9 @@
class Admin::IntegrationsController < Admin::ApplicationController
include IntegrationsActions
+ include ServicesHelper
+
+ feature_category :integrations
private
@@ -10,7 +13,7 @@ class Admin::IntegrationsController < Admin::ApplicationController
end
def integrations_enabled?
- true
+ instance_level_integrations?
end
def scoped_edit_integration_path(integration)
diff --git a/app/controllers/admin/jobs_controller.rb b/app/controllers/admin/jobs_controller.rb
index 7b50a45a9cd..b800ca79d6b 100644
--- a/app/controllers/admin/jobs_controller.rb
+++ b/app/controllers/admin/jobs_controller.rb
@@ -3,6 +3,8 @@
class Admin::JobsController < Admin::ApplicationController
BUILDS_PER_PAGE = 30
+ feature_category :continuous_integration
+
def index
# We need all builds for tabs counters
@all_builds = Ci::JobsFinder.new(current_user: current_user).execute
diff --git a/app/controllers/admin/keys_controller.rb b/app/controllers/admin/keys_controller.rb
index 58ea19d1210..03383604e30 100644
--- a/app/controllers/admin/keys_controller.rb
+++ b/app/controllers/admin/keys_controller.rb
@@ -3,6 +3,8 @@
class Admin::KeysController < Admin::ApplicationController
before_action :user, only: [:show, :destroy]
+ feature_category :authentication_and_authorization
+
def show
@key = user.keys.find(params[:id])
diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb
index 6cb206c1686..be63bf4c7ce 100644
--- a/app/controllers/admin/labels_controller.rb
+++ b/app/controllers/admin/labels_controller.rb
@@ -3,6 +3,8 @@
class Admin::LabelsController < Admin::ApplicationController
before_action :set_label, only: [:show, :edit, :update, :destroy]
+ feature_category :issue_tracking
+
def index
@labels = Label.templates.page(params[:page])
end
diff --git a/app/controllers/admin/plan_limits_controller.rb b/app/controllers/admin/plan_limits_controller.rb
index 2620db8aec5..0a5cdc06d61 100644
--- a/app/controllers/admin/plan_limits_controller.rb
+++ b/app/controllers/admin/plan_limits_controller.rb
@@ -5,6 +5,8 @@ class Admin::PlanLimitsController < Admin::ApplicationController
before_action :set_plan_limits
+ feature_category :not_owned
+
def create
redirect_path = referer_path(request) || general_admin_application_settings_path
diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb
index 9fe1f22c342..c4564478462 100644
--- a/app/controllers/admin/projects_controller.rb
+++ b/app/controllers/admin/projects_controller.rb
@@ -6,6 +6,9 @@ class Admin::ProjectsController < Admin::ApplicationController
before_action :project, only: [:show, :transfer, :repository_check, :destroy]
before_action :group, only: [:show, :transfer]
+ feature_category :projects, [:index, :show, :transfer, :destroy]
+ feature_category :source_code_management, [:repository_check]
+
def index
params[:sort] ||= 'latest_activity_desc'
@sort = params[:sort]
diff --git a/app/controllers/admin/requests_profiles_controller.rb b/app/controllers/admin/requests_profiles_controller.rb
index 24383455064..fbbe8c24637 100644
--- a/app/controllers/admin/requests_profiles_controller.rb
+++ b/app/controllers/admin/requests_profiles_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::RequestsProfilesController < Admin::ApplicationController
+ feature_category :not_owned
+
def index
@profile_token = Gitlab::RequestProfiler.profile_token
@profiles = Gitlab::RequestProfiler.all.group_by(&:request_path)
diff --git a/app/controllers/admin/runner_projects_controller.rb b/app/controllers/admin/runner_projects_controller.rb
index 774ce04d079..7761ffaac84 100644
--- a/app/controllers/admin/runner_projects_controller.rb
+++ b/app/controllers/admin/runner_projects_controller.rb
@@ -3,6 +3,8 @@
class Admin::RunnerProjectsController < Admin::ApplicationController
before_action :project, only: [:create]
+ feature_category :continuous_integration
+
def create
@runner = Ci::Runner.find(params[:runner_project][:runner_id])
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 7a377a33d41..576b148fbff 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -1,7 +1,11 @@
# frozen_string_literal: true
class Admin::RunnersController < Admin::ApplicationController
- before_action :runner, except: [:index, :tag_list]
+ include RunnerSetupScripts
+
+ before_action :runner, except: [:index, :tag_list, :runner_setup_scripts]
+
+ feature_category :continuous_integration
def index
finder = Ci::RunnersFinder.new(current_user: current_user, params: params)
@@ -53,6 +57,10 @@ class Admin::RunnersController < Admin::ApplicationController
render json: ActsAsTaggableOn::TagSerializer.new.represent(tags)
end
+ def runner_setup_scripts
+ private_runner_setup_scripts
+ end
+
private
def runner
diff --git a/app/controllers/admin/serverless/domains_controller.rb b/app/controllers/admin/serverless/domains_controller.rb
index 1d4f10e033f..49cd9f7a36d 100644
--- a/app/controllers/admin/serverless/domains_controller.rb
+++ b/app/controllers/admin/serverless/domains_controller.rb
@@ -4,6 +4,8 @@ class Admin::Serverless::DomainsController < Admin::ApplicationController
before_action :check_feature_flag
before_action :domain, only: [:update, :verify, :destroy]
+ feature_category :serverless
+
def index
@domain = PagesDomain.instance_serverless.first_or_initialize
end
diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb
index 1f4250639c4..379e74bb249 100644
--- a/app/controllers/admin/services_controller.rb
+++ b/app/controllers/admin/services_controller.rb
@@ -6,6 +6,8 @@ class Admin::ServicesController < Admin::ApplicationController
before_action :service, only: [:edit, :update]
before_action :whitelist_query_limiting, only: [:index]
+ feature_category :integrations
+
def index
@services = Service.find_or_create_templates.sort_by(&:title)
@existing_instance_types = Service.for_instance.pluck(:type) # rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb
index 0c0bbaf4d93..9c378f4c883 100644
--- a/app/controllers/admin/sessions_controller.rb
+++ b/app/controllers/admin/sessions_controller.rb
@@ -1,12 +1,14 @@
# frozen_string_literal: true
class Admin::SessionsController < ApplicationController
- include Authenticates2FAForAdminMode
+ include AuthenticatesWithTwoFactorForAdminMode
include InternalRedirect
include RendersLdapServers
before_action :user_is_admin!
+ feature_category :authentication_and_authorization
+
def new
if current_user_mode.admin_mode?
redirect_to redirect_path, notice: _('Admin mode already enabled')
@@ -65,7 +67,10 @@ class Admin::SessionsController < ApplicationController
end
def valid_otp_attempt?(user)
- valid_otp_attempt = user.validate_and_consume_otp!(user_params[:otp_attempt])
+ otp_validation_result =
+ ::Users::ValidateOtpService.new(user).execute(user_params[:otp_attempt])
+ valid_otp_attempt = otp_validation_result[:status] == :success
+
return valid_otp_attempt if Gitlab::Database.read_only?
valid_otp_attempt || user.invalidate_otp_backup_code!(user_params[:otp_attempt])
diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb
index 689e502a221..67d991c8b03 100644
--- a/app/controllers/admin/spam_logs_controller.rb
+++ b/app/controllers/admin/spam_logs_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::SpamLogsController < Admin::ApplicationController
+ feature_category :not_owned
+
# rubocop: disable CodeReuse/ActiveRecord
def index
@spam_logs = SpamLog.order(id: :desc).page(params[:page])
diff --git a/app/controllers/admin/system_info_controller.rb b/app/controllers/admin/system_info_controller.rb
index 657aa177ecf..f14305528a3 100644
--- a/app/controllers/admin/system_info_controller.rb
+++ b/app/controllers/admin/system_info_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Admin::SystemInfoController < Admin::ApplicationController
+ feature_category :not_owned
+
EXCLUDED_MOUNT_OPTIONS = %w[
nobrowse
read-only
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index e19b09e1324..bd7b69384b2 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -6,10 +6,14 @@ class Admin::UsersController < Admin::ApplicationController
before_action :user, except: [:index, :new, :create]
before_action :check_impersonation_availability, only: :impersonate
before_action :ensure_destroy_prerequisites_met, only: [:destroy]
+ before_action :check_admin_approval_feature_available!, only: [:approve]
+
+ feature_category :users
def index
@users = User.filter_items(params[:filter]).order_name_asc
@users = @users.search_with_secondary_emails(params[:search_query]) if params[:search_query].present?
+ @users = @users.includes(:authorized_projects) # rubocop: disable CodeReuse/ActiveRecord
@users = @users.sort_by_attribute(@sort = params[:sort])
@users = @users.page(params[:page])
end
@@ -59,6 +63,16 @@ class Admin::UsersController < Admin::ApplicationController
end
end
+ def approve
+ result = Users::ApproveService.new(current_user).execute(user)
+
+ if result[:status] == :success
+ redirect_back_or_admin_user(notice: _("Successfully approved"))
+ else
+ redirect_back_or_admin_user(alert: result[:message])
+ end
+ end
+
def activate
return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user must be unblocked to be activated")) if user.blocked?
@@ -69,6 +83,7 @@ class Admin::UsersController < Admin::ApplicationController
def deactivate
return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user cannot be deactivated")) if user.blocked?
return redirect_back_or_admin_user(notice: _("Successfully deactivated")) if user.deactivated?
+ return redirect_back_or_admin_user(notice: _("Internal users cannot be deactivated")) if user.internal?
return redirect_back_or_admin_user(notice: _("The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days and cannot be deactivated") % { minimum_inactive_days: ::User::MINIMUM_INACTIVE_DAYS }) unless user.can_be_deactivated?
user.deactivate
@@ -78,7 +93,7 @@ class Admin::UsersController < Admin::ApplicationController
def block
result = Users::BlockService.new(current_user).execute(user)
- if result[:status] = :success
+ if result[:status] == :success
redirect_back_or_admin_user(notice: _("Successfully blocked"))
else
redirect_back_or_admin_user(alert: _("Error occurred. User was not blocked"))
@@ -168,7 +183,7 @@ class Admin::UsersController < Admin::ApplicationController
# restore username to keep form action url.
user.username = params[:id]
format.html { render "edit" }
- format.json { render json: [result[:message]], status: result[:status] }
+ format.json { render json: [result[:message]], status: :internal_server_error }
end
end
end
@@ -283,6 +298,10 @@ class Admin::UsersController < Admin::ApplicationController
def log_impersonation_event
Gitlab::AppLogger.info(_("User %{current_user_username} has started impersonating %{username}") % { current_user_username: current_user.username, username: user.username })
end
+
+ def check_admin_approval_feature_available!
+ access_denied! unless Feature.enabled?(:admin_approval_for_new_user_signups, default_enabled: true)
+ end
end
Admin::UsersController.prepend_if_ee('EE::Admin::UsersController')
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 5f05337e59e..05f496c3b99 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -271,6 +271,7 @@ class ApplicationController < ActionController::Base
headers['X-XSS-Protection'] = '1; mode=block'
headers['X-UA-Compatible'] = 'IE=edge'
headers['X-Content-Type-Options'] = 'nosniff'
+ headers[Gitlab::Metrics::RequestsRackMiddleware::FEATURE_CATEGORY_HEADER] = feature_category
end
def default_cache_headers
@@ -465,7 +466,8 @@ class ApplicationController < ActionController::Base
user: -> { auth_user if strong_memoized?(:auth_user) },
project: -> { @project if @project&.persisted? },
namespace: -> { @group if @group&.persisted? },
- caller_id: full_action_name) do
+ caller_id: caller_id,
+ feature_category: feature_category) do
yield
ensure
@current_context = Labkit::Context.current.to_h
@@ -484,7 +486,7 @@ class ApplicationController < ActionController::Base
def set_page_title_header
# Per https://tools.ietf.org/html/rfc5987, headers need to be ISO-8859-1, not UTF-8
- response.headers['Page-Title'] = URI.escape(page_title('GitLab'))
+ response.headers['Page-Title'] = Addressable::URI.encode_component(page_title('GitLab'))
end
def set_current_admin(&block)
@@ -547,10 +549,14 @@ class ApplicationController < ActionController::Base
end
end
- def full_action_name
+ def caller_id
"#{self.class.name}##{action_name}"
end
+ def feature_category
+ self.class.feature_category_for_action(action_name).to_s
+ end
+
def required_signup_info
return unless current_user
return unless current_user.role_required?
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index 99fa17e202a..ac4ee14c6a9 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -3,6 +3,12 @@
class AutocompleteController < ApplicationController
skip_before_action :authenticate_user!, only: [:users, :award_emojis, :merge_request_target_branches]
+ feature_category :users, [:users, :user]
+ feature_category :projects, [:projects]
+ feature_category :issue_tracking, [:award_emojis]
+ feature_category :code_review, [:merge_request_target_branches]
+ feature_category :continuous_delivery, [:deploy_keys_with_owners]
+
def users
group = Autocomplete::GroupFinder
.new(current_user, project, params)
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index a18c80b996e..f5a9b9b61db 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -21,6 +21,8 @@ module Boards
before_action :validate_id_list, only: [:bulk_move]
before_action :can_move_issues?, only: [:bulk_move]
+ feature_category :boards
+
def index
list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params)
issues = issues_from(list_service)
diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb
index 0b8469e8290..aecd287370f 100644
--- a/app/controllers/boards/lists_controller.rb
+++ b/app/controllers/boards/lists_controller.rb
@@ -8,6 +8,8 @@ module Boards
before_action :authorize_read_list, only: [:index]
skip_before_action :authenticate_user!, only: [:index]
+ feature_category :boards
+
def index
lists = Boards::Lists::ListService.new(board.resource_parent, current_user).execute(board)
@@ -42,7 +44,7 @@ module Boards
list = board.lists.destroyable.find(params[:id])
service = Boards::Lists::DestroyService.new(board_parent, current_user)
- if service.execute(list)
+ if service.execute(list).success?
head :ok
else
head :unprocessable_entity
diff --git a/app/controllers/clusters/base_controller.rb b/app/controllers/clusters/base_controller.rb
index 188805c6106..b1ffdf00b87 100644
--- a/app/controllers/clusters/base_controller.rb
+++ b/app/controllers/clusters/base_controller.rb
@@ -8,6 +8,8 @@ class Clusters::BaseController < ApplicationController
helper_method :clusterable
+ feature_category :kubernetes_management
+
private
def cluster
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 7006c23321c..52719e90e04 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -180,13 +180,20 @@ class Clusters::ClustersController < Clusters::BaseController
params.permit(:cleanup)
end
+ def base_permitted_cluster_params
+ [
+ :enabled,
+ :environment_scope,
+ :managed,
+ :namespace_per_environment
+ ]
+ end
+
def update_params
if cluster.provided_by_user?
params.require(:cluster).permit(
- :enabled,
+ *base_permitted_cluster_params,
:name,
- :environment_scope,
- :managed,
:base_domain,
:management_project_id,
platform_kubernetes_attributes: [
@@ -198,9 +205,7 @@ class Clusters::ClustersController < Clusters::BaseController
)
else
params.require(:cluster).permit(
- :enabled,
- :environment_scope,
- :managed,
+ *base_permitted_cluster_params,
:base_domain,
:management_project_id,
platform_kubernetes_attributes: [
@@ -212,10 +217,8 @@ class Clusters::ClustersController < Clusters::BaseController
def create_gcp_cluster_params
params.require(:cluster).permit(
- :enabled,
+ *base_permitted_cluster_params,
:name,
- :environment_scope,
- :managed,
provider_gcp_attributes: [
:gcp_project_id,
:zone,
@@ -232,10 +235,8 @@ class Clusters::ClustersController < Clusters::BaseController
def create_aws_cluster_params
params.require(:cluster).permit(
- :enabled,
+ *base_permitted_cluster_params,
:name,
- :environment_scope,
- :managed,
provider_aws_attributes: [
:kubernetes_version,
:key_name,
@@ -255,10 +256,8 @@ class Clusters::ClustersController < Clusters::BaseController
def create_user_cluster_params
params.require(:cluster).permit(
- :enabled,
+ *base_permitted_cluster_params,
:name,
- :environment_scope,
- :managed,
platform_kubernetes_attributes: [
:namespace,
:api_url,
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index 9ff97f398f5..5c74d79951f 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -89,10 +89,7 @@ module AuthenticatesWithTwoFactor
user.save!
sign_in(user, message: :two_factor_authenticated, event: :authentication)
else
- user.increment_failed_attempts!
- Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=OTP")
- flash.now[:alert] = _('Invalid two-factor code.')
- prompt_for_two_factor(user)
+ handle_two_factor_failure(user, 'OTP', _('Invalid two-factor code.'))
end
end
@@ -101,7 +98,7 @@ module AuthenticatesWithTwoFactor
if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge])
handle_two_factor_success(user)
else
- handle_two_factor_failure(user, 'U2F')
+ handle_two_factor_failure(user, 'U2F', _('Authentication via U2F device failed.'))
end
end
@@ -109,7 +106,7 @@ module AuthenticatesWithTwoFactor
if Webauthn::AuthenticateService.new(user, user_params[:device_response], session[:challenge]).execute
handle_two_factor_success(user)
else
- handle_two_factor_failure(user, 'WebAuthn')
+ handle_two_factor_failure(user, 'WebAuthn', _('Authentication via WebAuthn device failed.'))
end
end
@@ -152,13 +149,19 @@ module AuthenticatesWithTwoFactor
sign_in(user, message: :two_factor_authenticated, event: :authentication)
end
- def handle_two_factor_failure(user, method)
+ def handle_two_factor_failure(user, method, message)
user.increment_failed_attempts!
+ log_failed_two_factor(user, method, request.remote_ip)
+
Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=#{method}")
- flash.now[:alert] = _('Authentication via %{method} device failed.') % { method: method }
+ flash.now[:alert] = message
prompt_for_two_factor(user)
end
+ def log_failed_two_factor(user, method, ip_address)
+ # overridden in EE
+ end
+
def handle_changed_user(user)
clear_two_factor_attempt!
@@ -173,3 +176,5 @@ module AuthenticatesWithTwoFactor
Digest::SHA256.hexdigest(user.encrypted_password) != session[:user_password_hash]
end
end
+
+AuthenticatesWithTwoFactor.prepend_if_ee('EE::AuthenticatesWithTwoFactor')
diff --git a/app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb
index 03783cd75a3..a8155f1e639 100644
--- a/app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-module Authenticates2FAForAdminMode
+module AuthenticatesWithTwoFactorForAdminMode
extend ActiveSupport::Concern
included do
@@ -52,11 +52,7 @@ module Authenticates2FAForAdminMode
# The admin user has successfully passed 2fa, enable admin mode ignoring password
enable_admin_mode
else
- user.increment_failed_attempts!
- Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=OTP")
- flash.now[:alert] = _('Invalid two-factor code.')
-
- admin_mode_prompt_for_two_factor(user)
+ admin_handle_two_factor_failure(user, 'OTP', _('Invalid two-factor code.'))
end
end
@@ -64,7 +60,7 @@ module Authenticates2FAForAdminMode
if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge])
admin_handle_two_factor_success
else
- admin_handle_two_factor_failure(user, 'U2F')
+ admin_handle_two_factor_failure(user, 'U2F', _('Authentication via U2F device failed.'))
end
end
@@ -72,7 +68,7 @@ module Authenticates2FAForAdminMode
if Webauthn::AuthenticateService.new(user, user_params[:device_response], session[:challenge]).execute
admin_handle_two_factor_success
else
- admin_handle_two_factor_failure(user, 'WebAuthn')
+ admin_handle_two_factor_failure(user, 'WebAuthn', _('Authentication via WebAuthn device failed.'))
end
end
@@ -100,11 +96,12 @@ module Authenticates2FAForAdminMode
enable_admin_mode
end
- def admin_handle_two_factor_failure(user, method)
+ def admin_handle_two_factor_failure(user, method, message)
user.increment_failed_attempts!
- Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=#{method}")
- flash.now[:alert] = _('Authentication via %{method} device failed.') % { method: method }
+ log_failed_two_factor(user, method, request.remote_ip)
+ Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=#{method}")
+ flash.now[:alert] = message
admin_mode_prompt_for_two_factor(user)
end
end
diff --git a/app/controllers/concerns/boards_actions.rb b/app/controllers/concerns/boards_actions.rb
index 9d40b9e8c88..b382e338a78 100644
--- a/app/controllers/concerns/boards_actions.rb
+++ b/app/controllers/concerns/boards_actions.rb
@@ -9,7 +9,7 @@ module BoardsActions
before_action :boards, only: :index
before_action :board, only: :show
- before_action :push_wip_limits, only: [:index, :show]
+ before_action :push_licensed_features, only: [:index, :show]
before_action do
push_frontend_feature_flag(:not_issuable_queries, parent, default_enabled: true)
end
@@ -29,7 +29,7 @@ module BoardsActions
private
# Noop on FOSS
- def push_wip_limits
+ def push_licensed_features
end
def boards
diff --git a/app/controllers/concerns/controller_with_feature_category.rb b/app/controllers/concerns/controller_with_feature_category.rb
index f8985cf0950..c1ff9ef2e69 100644
--- a/app/controllers/concerns/controller_with_feature_category.rb
+++ b/app/controllers/concerns/controller_with_feature_category.rb
@@ -5,35 +5,38 @@ module ControllerWithFeatureCategory
include Gitlab::ClassAttributes
class_methods do
- def feature_category(category, config = {})
- validate_config!(config)
+ def feature_category(category, actions = [])
+ feature_category_configuration[category] ||= []
+ feature_category_configuration[category] += actions.map(&:to_s)
- category_config = Config.new(category, config[:only], config[:except], config[:if], config[:unless])
- # Add the config to the beginning. That way, the last defined one takes precedence.
- feature_category_configuration.unshift(category_config)
+ validate_config!(feature_category_configuration)
end
def feature_category_for_action(action)
- category_config = feature_category_configuration.find { |config| config.matches?(action) }
+ category_config = feature_category_configuration.find do |_, actions|
+ actions.empty? || actions.include?(action)
+ end
- category_config&.category || superclass_feature_category_for_action(action)
+ category_config&.first || superclass_feature_category_for_action(action)
end
private
def validate_config!(config)
- invalid_keys = config.keys - [:only, :except, :if, :unless]
- if invalid_keys.any?
- raise ArgumentError, "unknown arguments: #{invalid_keys} "
+ empty = config.find { |_, actions| actions.empty? }
+ duplicate_actions = config.values.flatten.group_by(&:itself).select { |_, v| v.count > 1 }.keys
+
+ if config.length > 1 && empty
+ raise ArgumentError, "#{empty.first} is defined for all actions, but other categories are set"
end
- if config.key?(:only) && config.key?(:except)
- raise ArgumentError, "cannot configure both `only` and `except`"
+ if duplicate_actions.any?
+ raise ArgumentError, "Actions have multiple feature categories: #{duplicate_actions.join(', ')}"
end
end
def feature_category_configuration
- class_attributes[:feature_category_config] ||= []
+ class_attributes[:feature_category_config] ||= {}
end
def superclass_feature_category_for_action(action)
diff --git a/app/controllers/concerns/controller_with_feature_category/config.rb b/app/controllers/concerns/controller_with_feature_category/config.rb
deleted file mode 100644
index 624691ee4f6..00000000000
--- a/app/controllers/concerns/controller_with_feature_category/config.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-module ControllerWithFeatureCategory
- class Config
- attr_reader :category
-
- def initialize(category, only, except, if_proc, unless_proc)
- @category = category.to_sym
- @only, @except = only&.map(&:to_s), except&.map(&:to_s)
- @if_proc, @unless_proc = if_proc, unless_proc
- end
-
- def matches?(action)
- included?(action) && !excluded?(action) &&
- if_proc?(action) && !unless_proc?(action)
- end
-
- private
-
- attr_reader :only, :except, :if_proc, :unless_proc
-
- def if_proc?(action)
- if_proc.nil? || if_proc.call(action)
- end
-
- def unless_proc?(action)
- unless_proc.present? && unless_proc.call(action)
- end
-
- def included?(action)
- only.nil? || only.include?(action)
- end
-
- def excluded?(action)
- except.present? && except.include?(action)
- end
- end
-end
diff --git a/app/controllers/concerns/hooks_execution.rb b/app/controllers/concerns/hooks_execution.rb
index ad1f8341109..87d215f50e7 100644
--- a/app/controllers/concerns/hooks_execution.rb
+++ b/app/controllers/concerns/hooks_execution.rb
@@ -5,6 +5,21 @@ module HooksExecution
private
+ def destroy_hook(hook)
+ result = WebHooks::DestroyService.new(current_user).execute(hook)
+
+ if result[:status] == :success
+ flash[:notice] =
+ if result[:async]
+ _("%{hook_type} was scheduled for deletion") % { hook_type: hook.model_name.human }
+ else
+ _("%{hook_type} was deleted") % { hook_type: hook.model_name.human }
+ end
+ else
+ flash[:alert] = result[:message]
+ end
+ end
+
def set_hook_execution_notice(result)
http_status = result[:http_status]
message = result[:message]
diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb
index 6060dc729af..39f63bbaaec 100644
--- a/app/controllers/concerns/integrations_actions.rb
+++ b/app/controllers/concerns/integrations_actions.rb
@@ -20,7 +20,7 @@ module IntegrationsActions
respond_to do |format|
format.html do
if saved
- PropagateIntegrationWorker.perform_async(integration.id, false)
+ PropagateIntegrationWorker.perform_async(integration.id)
redirect_to scoped_edit_integration_path(integration), notice: success_message
else
render 'shared/integrations/edit'
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index 89ba2175b60..0d7af57328a 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -41,10 +41,13 @@ module IssuableCollections
end
def set_pagination
+ row_count = finder.row_count
+
@issuables = @issuables.page(params[:page])
@issuables = per_page_for_relative_position if params[:sort] == 'relative_position'
+ @issuables = @issuables.without_count if row_count == -1
@issuable_meta_data = Gitlab::IssuableMetadata.new(current_user, @issuables).data
- @total_pages = issuable_page_count(@issuables)
+ @total_pages = page_count_for_relation(@issuables, row_count)
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
@@ -58,14 +61,11 @@ module IssuableCollections
end
# rubocop: enable CodeReuse/ActiveRecord
- def issuable_page_count(relation)
- page_count_for_relation(relation, finder.row_count)
- end
-
def page_count_for_relation(relation, row_count)
limit = relation.limit_value.to_f
return 1 if limit == 0
+ return (params[:page] || 1).to_i + 1 if row_count == -1
(row_count.to_f / limit).ceil
end
diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb
index e3ac117660b..7ed66027da3 100644
--- a/app/controllers/concerns/issuable_collections_action.rb
+++ b/app/controllers/concerns/issuable_collections_action.rb
@@ -59,6 +59,9 @@ module IssuableCollectionsAction
end
def finder_options
- super.merge(non_archived: true)
+ super.merge(
+ non_archived: true,
+ issue_types: Issue::TYPES_FOR_LIST
+ )
end
end
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index 8c7f156f7f8..816a93f14c6 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -22,10 +22,14 @@ module MembershipActions
.new(current_user, update_params)
.execute(member)
- member = present_members([member]).first
-
- respond_to do |format|
- format.js { render 'shared/members/update', locals: { member: member } }
+ if member.expires?
+ render json: {
+ expires_in: helpers.distance_of_time_in_words_to_now(member.expires_at),
+ expires_soon: member.expires_soon?,
+ expires_at_formatted: member.expires_at.to_time.in_time_zone.to_s(:medium)
+ }
+ else
+ render json: {}
end
end
@@ -101,7 +105,7 @@ module MembershipActions
# rubocop: enable CodeReuse/ActiveRecord
def resend_invite
- member = membershipable.members.find(params[:id])
+ member = membershipable_members.find(params[:id])
if member.invite?
member.resend_invite
@@ -118,6 +122,10 @@ module MembershipActions
raise NotImplementedError
end
+ def membershipable_members
+ raise NotImplementedError
+ end
+
def root_params_key
case membershipable
when Namespace
diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb
index 29138e7b014..6470c75dfbd 100644
--- a/app/controllers/concerns/milestone_actions.rb
+++ b/app/controllers/concerns/milestone_actions.rb
@@ -3,13 +3,25 @@
module MilestoneActions
extend ActiveSupport::Concern
+ def issues
+ respond_to do |format|
+ format.html { redirect_to milestone_redirect_path }
+ format.json do
+ render json: tabs_json("shared/milestones/_issues_tab", {
+ issues: @milestone.sorted_issues(current_user), # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ show_project_name: Gitlab::Utils.to_boolean(params[:show_project_name])
+ })
+ end
+ end
+ end
+
def merge_requests
respond_to do |format|
format.html { redirect_to milestone_redirect_path }
format.json do
render json: tabs_json("shared/milestones/_merge_requests_tab", {
merge_requests: @milestone.sorted_merge_requests(current_user), # rubocop:disable Gitlab/ModuleWithInstanceVariables
- show_project_name: true
+ show_project_name: Gitlab::Utils.to_boolean(params[:show_project_name])
})
end
end
diff --git a/app/controllers/concerns/multiple_boards_actions.rb b/app/controllers/concerns/multiple_boards_actions.rb
index 95a6800f55c..370b8c72bfe 100644
--- a/app/controllers/concerns/multiple_boards_actions.rb
+++ b/app/controllers/concerns/multiple_boards_actions.rb
@@ -21,11 +21,13 @@ module MultipleBoardsActions
end
def create
- board = Boards::CreateService.new(parent, current_user, board_params).execute
+ response = Boards::CreateService.new(parent, current_user, board_params).execute
respond_to do |format|
format.json do
- if board.persisted?
+ board = response.payload
+
+ if response.success?
extra_json = { board_path: board_path(board) }
render json: serialize_as_json(board).merge(extra_json)
else
diff --git a/app/controllers/concerns/redis_tracking.rb b/app/controllers/concerns/redis_tracking.rb
index fa5eef981d1..d81bd10d5bb 100644
--- a/app/controllers/concerns/redis_tracking.rb
+++ b/app/controllers/concerns/redis_tracking.rb
@@ -11,12 +11,17 @@
#
# if the feature flag is enabled by default you should use
# track_redis_hll_event :index, :show, name: 'i_analytics_dev_ops_score', feature: :my_feature, feature_default_enabled: true
+#
+# You can also pass custom conditions using `if:`, using the same format as with Rails callbacks.
module RedisTracking
extend ActiveSupport::Concern
class_methods do
- def track_redis_hll_event(*controller_actions, name:, feature:, feature_default_enabled: false)
- after_action only: controller_actions, if: -> { request.format.html? && request.headers['DNT'] != '1' } do
+ def track_redis_hll_event(*controller_actions, name:, feature:, feature_default_enabled: false, if: nil)
+ custom_conditions = Array.wrap(binding.local_variable_get('if'))
+ conditions = [:trackable_request?, *custom_conditions]
+
+ after_action only: controller_actions, if: conditions do
track_unique_redis_hll_event(name, feature, feature_default_enabled)
end
end
@@ -26,12 +31,15 @@ module RedisTracking
def track_unique_redis_hll_event(event_name, feature, feature_default_enabled)
return unless metric_feature_enabled?(feature, feature_default_enabled)
- return unless Gitlab::CurrentSettings.usage_ping_enabled?
return unless visitor_id
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(visitor_id, event_name)
end
+ def trackable_request?
+ request.format.html? && request.headers['DNT'] != '1'
+ end
+
def metric_feature_enabled?(feature, default_enabled)
Feature.enabled?(feature, default_enabled: default_enabled)
end
diff --git a/app/controllers/concerns/runner_setup_scripts.rb b/app/controllers/concerns/runner_setup_scripts.rb
new file mode 100644
index 00000000000..c0e657a32d1
--- /dev/null
+++ b/app/controllers/concerns/runner_setup_scripts.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module RunnerSetupScripts
+ extend ActiveSupport::Concern
+
+ private
+
+ def private_runner_setup_scripts(**kwargs)
+ instructions = Gitlab::Ci::RunnerInstructions.new(current_user: current_user, os: script_params[:os], arch: script_params[:arch], **kwargs)
+ output = {
+ install: instructions.install_script,
+ register: instructions.register_command
+ }
+
+ if instructions.errors.any?
+ render json: { errors: instructions.errors }, status: :bad_request
+ else
+ render json: output
+ end
+ end
+
+ def script_params
+ params.permit(:os, :arch)
+ end
+end
diff --git a/app/controllers/concerns/show_inherited_labels_checker.rb b/app/controllers/concerns/show_inherited_labels_checker.rb
new file mode 100644
index 00000000000..9847226f599
--- /dev/null
+++ b/app/controllers/concerns/show_inherited_labels_checker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module ShowInheritedLabelsChecker
+ extend ActiveSupport::Concern
+
+ private
+
+ def show_inherited_labels?(include_ancestor_groups)
+ Feature.enabled?(:show_inherited_labels, @project || @group, default_enabled: true) || include_ancestor_groups # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+end
diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb
index 4548595d968..e4c3df6ccc3 100644
--- a/app/controllers/concerns/snippets_actions.rb
+++ b/app/controllers/concerns/snippets_actions.rb
@@ -17,13 +17,7 @@ module SnippetsActions
respond_to :html
end
- def edit
- # We need to load some info from the existing blob
- snippet.content = blob.data
- snippet.file_name = blob.path
-
- render 'edit'
- end
+ def edit; end
# This endpoint is being replaced by Snippets::BlobController#raw
# Support for old raw links will be maintainted via this action but
@@ -55,7 +49,6 @@ module SnippetsActions
def show
respond_to do |format|
format.html do
- conditionally_expand_blob(blob)
@note = Note.new(noteable: @snippet, project: @snippet.project)
@noteable = @snippet
@@ -80,29 +73,6 @@ module SnippetsActions
end
end
end
-
- def update
- update_params = snippet_params.merge(spammable_params)
-
- service_response = Snippets::UpdateService.new(@snippet.project, current_user, update_params).execute(@snippet)
- @snippet = service_response.payload[:snippet]
-
- handle_repository_error(:edit)
- end
-
- def destroy
- service_response = Snippets::DestroyService.new(current_user, @snippet).execute
-
- if service_response.success?
- redirect_to gitlab_dashboard_snippets_path(@snippet), status: :found
- elsif service_response.http_status == 403
- access_denied!
- else
- redirect_to gitlab_snippet_path(@snippet),
- status: :found,
- alert: service_response.message
- end
- end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
private
@@ -124,12 +94,4 @@ module SnippetsActions
def convert_line_endings(content)
params[:line_ending] == 'raw' ? content : content.gsub(/\r\n/, "\n")
end
-
- def handle_repository_error(action)
- errors = Array(snippet.errors.delete(:repository))
-
- flash.now[:alert] = errors.first if errors.present?
-
- recaptcha_check_with_fallback(errors.empty?) { render action }
- end
end
diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb
index 5a5b634da40..aed109309e3 100644
--- a/app/controllers/concerns/wiki_actions.rb
+++ b/app/controllers/concerns/wiki_actions.rb
@@ -5,6 +5,7 @@ module WikiActions
include PreviewMarkdown
include SendsBlob
include Gitlab::Utils::StrongMemoize
+ include RedisTracking
extend ActiveSupport::Concern
included do
@@ -31,6 +32,11 @@ module WikiActions
end
end
+ # NOTE: We want to include wiki page views in the same counter as the other
+ # Event-based wiki actions tracked through TrackUniqueEvents, so we use the same event name.
+ track_redis_hll_event :show, name: Gitlab::UsageDataCounters::TrackUniqueEvents::WIKI_ACTION.to_s,
+ feature: :track_unique_wiki_page_views, feature_default_enabled: true
+
helper_method :view_file_button, :diff_file_html_data
end
@@ -44,7 +50,7 @@ module WikiActions
wiki.list_pages(sort: params[:sort], direction: params[:direction])
).page(params[:page])
- @wiki_entries = WikiPage.group_by_directory(@wiki_pages)
+ @wiki_entries = WikiDirectory.group_pages(@wiki_pages)
render 'shared/wikis/pages'
end
diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb
index a27c4027380..c42c9827eaf 100644
--- a/app/controllers/confirmations_controller.rb
+++ b/app/controllers/confirmations_controller.rb
@@ -3,6 +3,8 @@
class ConfirmationsController < Devise::ConfirmationsController
include AcceptsPendingInvitations
+ feature_category :users
+
def almost_there
flash[:notice] = nil
render layout: "devise_empty"
diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb
index f82cde8e10a..23ffcd50369 100644
--- a/app/controllers/dashboard/groups_controller.rb
+++ b/app/controllers/dashboard/groups_controller.rb
@@ -5,6 +5,8 @@ class Dashboard::GroupsController < Dashboard::ApplicationController
skip_cross_project_access_check :index
+ feature_category :subgroups
+
def index
groups = GroupsFinder.new(current_user, all_available: false).execute
render_group_tree(groups)
diff --git a/app/controllers/dashboard/labels_controller.rb b/app/controllers/dashboard/labels_controller.rb
index 89d87c2d5c8..b661efa12c0 100644
--- a/app/controllers/dashboard/labels_controller.rb
+++ b/app/controllers/dashboard/labels_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Dashboard::LabelsController < Dashboard::ApplicationController
+ feature_category :issue_tracking
+
def index
respond_to do |format|
format.json { render json: LabelSerializer.new.represent_appearance(labels) }
@@ -9,8 +11,8 @@ class Dashboard::LabelsController < Dashboard::ApplicationController
def labels
finder_params = { project_ids: projects.select(:id) }
- labels = LabelsFinder.new(current_user, finder_params).execute
- GlobalLabel.build_collection(labels)
+ LabelsFinder.new(current_user, finder_params).execute
+ .select('DISTINCT ON (labels.title) labels.*')
end
end
diff --git a/app/controllers/dashboard/milestones_controller.rb b/app/controllers/dashboard/milestones_controller.rb
index 14f9a026688..e17b16c26a2 100644
--- a/app/controllers/dashboard/milestones_controller.rb
+++ b/app/controllers/dashboard/milestones_controller.rb
@@ -4,6 +4,8 @@ class Dashboard::MilestonesController < Dashboard::ApplicationController
before_action :projects
before_action :groups, only: :index
+ feature_category :issue_tracking
+
def index
respond_to do |format|
format.html do
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index 2bd6fd85381..f7a74f40e4b 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -14,6 +14,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
before_action :projects, only: [:index]
skip_cross_project_access_check :index, :starred
+ feature_category :projects
+
def index
respond_to do |format|
format.html do
diff --git a/app/controllers/dashboard/snippets_controller.rb b/app/controllers/dashboard/snippets_controller.rb
index a8ca3dbd0e7..6fe3d878639 100644
--- a/app/controllers/dashboard/snippets_controller.rb
+++ b/app/controllers/dashboard/snippets_controller.rb
@@ -7,6 +7,8 @@ class Dashboard::SnippetsController < Dashboard::ApplicationController
skip_cross_project_access_check :index
+ feature_category :snippets
+
def index
@snippet_counts = Snippets::CountService
.new(current_user, author: current_user)
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 4fc2f7b0571..0ae326b5d94 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -9,6 +9,8 @@ class Dashboard::TodosController < Dashboard::ApplicationController
before_action :authorize_read_group!, only: :index
before_action :find_todos, only: [:index, :destroy_all]
+ feature_category :issue_tracking
+
def index
@sort = params[:sort]
@todos = @todos.page(params[:page])
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 07cc31fb7d3..a88cf64d842 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -15,6 +15,10 @@ class DashboardController < Dashboard::ApplicationController
respond_to :html
+ feature_category :audit_events, [:activity]
+ feature_category :issue_tracking, [:issues, :issues_calendar]
+ feature_category :code_review, [:merge_requests]
+
def activity
respond_to do |format|
format.html
diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb
index 67db797b80a..aa4196b1c18 100644
--- a/app/controllers/explore/groups_controller.rb
+++ b/app/controllers/explore/groups_controller.rb
@@ -3,6 +3,8 @@
class Explore::GroupsController < Explore::ApplicationController
include GroupTree
+ feature_category :subgroups
+
def index
render_group_tree GroupsFinder.new(current_user).execute
end
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index b3fa089a712..42795e418a4 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -18,6 +18,8 @@ class Explore::ProjectsController < Explore::ApplicationController
rescue_from PageOutOfBoundsError, with: :page_out_of_bounds
+ feature_category :projects
+
def index
@projects = load_projects
diff --git a/app/controllers/explore/snippets_controller.rb b/app/controllers/explore/snippets_controller.rb
index 3a56a48e578..91ab18f2f55 100644
--- a/app/controllers/explore/snippets_controller.rb
+++ b/app/controllers/explore/snippets_controller.rb
@@ -3,6 +3,8 @@
class Explore::SnippetsController < Explore::ApplicationController
include Gitlab::NoteableMetadata
+ feature_category :snippets
+
def index
@snippets = SnippetsFinder.new(current_user, explore: true)
.execute
diff --git a/app/controllers/google_api/authorizations_controller.rb b/app/controllers/google_api/authorizations_controller.rb
index 5723ccc14a7..76a1c43dfa3 100644
--- a/app/controllers/google_api/authorizations_controller.rb
+++ b/app/controllers/google_api/authorizations_controller.rb
@@ -6,6 +6,8 @@ module GoogleApi
before_action :validate_session_key!
+ feature_category :kubernetes_management
+
def callback
token, expires_at = GoogleApi::CloudPlatform::Client
.new(nil, callback_google_api_auth_url)
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index 123102bf793..b5deed70380 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -26,6 +26,8 @@ class GraphqlController < ApplicationController
# callback execution order here
around_action :sessionless_bypass_admin_mode!, if: :sessionless_user?
+ feature_category :not_owned
+
def execute
result = multiplex? ? execute_multiplex : execute_query
@@ -46,6 +48,10 @@ class GraphqlController < ApplicationController
render_error(exception.message, status: :unprocessable_entity)
end
+ rescue_from ::GraphQL::CoercionError do |exception|
+ render_error(exception.message, status: :unprocessable_entity)
+ end
+
private
def set_user_last_activity
@@ -81,7 +87,7 @@ class GraphqlController < ApplicationController
end
def context
- @context ||= { current_user: current_user, is_sessionless_user: !!sessionless_user? }
+ @context ||= { current_user: current_user, is_sessionless_user: !!sessionless_user?, request: request }
end
def build_variables(variable_info)
@@ -113,6 +119,12 @@ class GraphqlController < ApplicationController
# Merging to :metadata will ensure these are logged as top level keys
payload[:metadata] ||= {}
- payload[:metadata].merge!(graphql: { operation_name: params[:operationName] })
+ payload[:metadata].merge!(graphql: logs)
+ end
+
+ def logs
+ RequestStore.store[:graphql_logs].to_h
+ .except(:duration_s, :query_string)
+ .merge(operation_name: params[:operationName])
end
end
diff --git a/app/controllers/groups/avatars_controller.rb b/app/controllers/groups/avatars_controller.rb
index 8e4dc2bb6e9..1f13be449a9 100644
--- a/app/controllers/groups/avatars_controller.rb
+++ b/app/controllers/groups/avatars_controller.rb
@@ -5,6 +5,8 @@ class Groups::AvatarsController < Groups::ApplicationController
skip_cross_project_access_check :destroy
+ feature_category :subgroups
+
def destroy
@group.remove_avatar!
@group.save
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index ea7e83a2caf..b971c5783a8 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -12,6 +12,8 @@ class Groups::BoardsController < Groups::ApplicationController
push_frontend_feature_flag(:boards_with_swimlanes, group, default_enabled: false)
end
+ feature_category :boards
+
private
def assign_endpoint_vars
diff --git a/app/controllers/groups/children_controller.rb b/app/controllers/groups/children_controller.rb
index 236a19a8dc4..718914dea35 100644
--- a/app/controllers/groups/children_controller.rb
+++ b/app/controllers/groups/children_controller.rb
@@ -5,6 +5,8 @@ module Groups
before_action :group
skip_cross_project_access_check :index
+ feature_category :subgroups
+
def index
parent = if params[:parent_id].present?
GroupFinder.new(current_user).execute(id: params[:parent_id])
diff --git a/app/controllers/groups/deploy_tokens_controller.rb b/app/controllers/groups/deploy_tokens_controller.rb
index de951f2cb9f..79152bf2695 100644
--- a/app/controllers/groups/deploy_tokens_controller.rb
+++ b/app/controllers/groups/deploy_tokens_controller.rb
@@ -3,6 +3,8 @@
class Groups::DeployTokensController < Groups::ApplicationController
before_action :authorize_destroy_deploy_token!
+ feature_category :continuous_delivery
+
def revoke
@token = @group.deploy_tokens.find(params[:id])
@token.revoke!
diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb
index c395b93f4e7..3b775af9722 100644
--- a/app/controllers/groups/group_links_controller.rb
+++ b/app/controllers/groups/group_links_controller.rb
@@ -4,6 +4,8 @@ class Groups::GroupLinksController < Groups::ApplicationController
before_action :authorize_admin_group!
before_action :group_link, only: [:update, :destroy]
+ feature_category :subgroups
+
def create
shared_with_group = Group.find(params[:shared_with_group_id]) if params[:shared_with_group_id].present?
@@ -24,6 +26,15 @@ class Groups::GroupLinksController < Groups::ApplicationController
def update
Groups::GroupLinks::UpdateService.new(@group_link).execute(group_link_params)
+
+ if @group_link.expires?
+ render json: {
+ expires_in: helpers.distance_of_time_in_words_to_now(@group_link.expires_at),
+ expires_soon: @group_link.expires_soon?
+ }
+ else
+ render json: {}
+ end
end
def destroy
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index 63311ab983b..5df7ff0632a 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -19,6 +19,8 @@ class Groups::GroupMembersController < Groups::ApplicationController
:approve_access_request, :leave, :resend_invite,
:override
+ feature_category :authentication_and_authorization
+
def index
@sort = params[:sort].presence || sort_value_name
@@ -69,6 +71,10 @@ class Groups::GroupMembersController < Groups::ApplicationController
def filter_params
params.permit(:two_factor, :search).merge(sort: @sort)
end
+
+ def membershipable_members
+ group.members
+ end
end
Groups::GroupMembersController.prepend_if_ee('EE::Groups::GroupMembersController')
diff --git a/app/controllers/groups/imports_controller.rb b/app/controllers/groups/imports_controller.rb
index b611685f9bc..7cf39e378db 100644
--- a/app/controllers/groups/imports_controller.rb
+++ b/app/controllers/groups/imports_controller.rb
@@ -3,6 +3,8 @@
class Groups::ImportsController < Groups::ApplicationController
include ContinueParams
+ feature_category :importers
+
def show
if @group.import_state.nil? || @group.import_state.finished?
if continue_params[:to]
diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb
index 1034ca6cd7b..34856f8d84e 100644
--- a/app/controllers/groups/labels_controller.rb
+++ b/app/controllers/groups/labels_controller.rb
@@ -2,6 +2,7 @@
class Groups::LabelsController < Groups::ApplicationController
include ToggleSubscriptionAction
+ include ShowInheritedLabelsChecker
before_action :label, only: [:edit, :update, :destroy]
before_action :authorize_admin_labels!, only: [:new, :create, :edit, :update, :destroy]
@@ -9,11 +10,14 @@ class Groups::LabelsController < Groups::ApplicationController
respond_to :html
+ feature_category :issue_tracking
+
def index
respond_to do |format|
format.html do
- @labels = GroupLabelsFinder
- .new(current_user, @group, params.merge(sort: sort)).execute
+ # at group level we do not want to list project labels,
+ # we only want `only_group_labels = false` when pulling labels for label filter dropdowns, fetched through json
+ @labels = available_labels(params.merge(only_group_labels: true)).page(params[:page])
end
format.json do
render json: LabelSerializer.new.represent_appearance(available_labels)
@@ -60,13 +64,7 @@ class Groups::LabelsController < Groups::ApplicationController
def destroy
@label.destroy
-
- respond_to do |format|
- format.html do
- redirect_to group_labels_path(@group), status: :found, notice: "#{@label.name} deleted permanently"
- end
- format.js
- end
+ redirect_to group_labels_path(@group), status: :found, notice: "#{@label.name} deleted permanently"
end
protected
@@ -80,7 +78,7 @@ class Groups::LabelsController < Groups::ApplicationController
end
def label
- @label ||= @group.labels.find(params[:id])
+ @label ||= available_labels(params.merge(only_group_labels: true)).find(params[:id])
end
alias_method :subscribable_resource, :label
@@ -108,15 +106,17 @@ class Groups::LabelsController < Groups::ApplicationController
session[:previous_labels_path] = URI(request.referer || '').path
end
- def available_labels
+ def available_labels(options = params)
@available_labels ||=
LabelsFinder.new(
current_user,
group_id: @group.id,
- only_group_labels: params[:only_group_labels],
- include_ancestor_groups: params[:include_ancestor_groups],
- include_descendant_groups: params[:include_descendant_groups],
- search: params[:search]).execute
+ only_group_labels: options[:only_group_labels],
+ include_ancestor_groups: show_inherited_labels?(params[:include_ancestor_groups]),
+ sort: sort,
+ subscribed: options[:subscribed],
+ include_descendant_groups: options[:include_descendant_groups],
+ search: options[:search]).execute
end
def sort
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index df3fb6b67c2..173a24ceb74 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -3,12 +3,14 @@
class Groups::MilestonesController < Groups::ApplicationController
include MilestoneActions
- before_action :milestone, only: [:edit, :show, :update, :merge_requests, :participants, :labels, :destroy]
+ before_action :milestone, only: [:edit, :show, :update, :issues, :merge_requests, :participants, :labels, :destroy]
before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update, :destroy]
before_action do
push_frontend_feature_flag(:burnup_charts, @group)
end
+ feature_category :issue_tracking
+
def index
respond_to do |format|
format.html do
diff --git a/app/controllers/groups/packages_controller.rb b/app/controllers/groups/packages_controller.rb
index 600acc72e67..47f1816cc4c 100644
--- a/app/controllers/groups/packages_controller.rb
+++ b/app/controllers/groups/packages_controller.rb
@@ -4,6 +4,8 @@ module Groups
class PackagesController < Groups::ApplicationController
before_action :verify_packages_enabled!
+ feature_category :package_registry
+
private
def verify_packages_enabled!
diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb
index 14651e0794a..d914e0bffc6 100644
--- a/app/controllers/groups/registry/repositories_controller.rb
+++ b/app/controllers/groups/registry/repositories_controller.rb
@@ -2,9 +2,13 @@
module Groups
module Registry
class RepositoriesController < Groups::ApplicationController
+ include PackagesHelper
+
before_action :verify_container_registry_enabled!
before_action :authorize_read_container_image!
+ feature_category :package_registry
+
def index
respond_to do |format|
format.html
@@ -13,7 +17,7 @@ module Groups
.execute
.with_api_entity_associations
- track_event(:list_repositories)
+ track_package_event(:list_repositories, :container)
serializer = ContainerRepositoriesSerializer
.new(current_user: current_user)
diff --git a/app/controllers/groups/releases_controller.rb b/app/controllers/groups/releases_controller.rb
index 500c57a6f3e..6a42f30b847 100644
--- a/app/controllers/groups/releases_controller.rb
+++ b/app/controllers/groups/releases_controller.rb
@@ -2,6 +2,8 @@
module Groups
class ReleasesController < Groups::ApplicationController
+ feature_category :release_evidence
+
def index
respond_to do |format|
format.json do
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index edebffe2912..dbfd31ebcad 100644
--- a/app/controllers/groups/runners_controller.rb
+++ b/app/controllers/groups/runners_controller.rb
@@ -7,6 +7,8 @@ class Groups::RunnersController < Groups::ApplicationController
before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show]
+ feature_category :continuous_integration
+
def show
render 'shared/runners/show'
end
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index bf3a38ce57b..0c72c8a037b 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -3,6 +3,8 @@
module Groups
module Settings
class CiCdController < Groups::ApplicationController
+ include RunnerSetupScripts
+
skip_cross_project_access_check :show
before_action :authorize_admin_group!
before_action :authorize_update_max_artifacts_size!, only: [:update]
@@ -11,6 +13,8 @@ module Groups
end
before_action :define_variables, only: [:show]
+ feature_category :continuous_integration
+
NUMBER_OF_RUNNERS_PER_PAGE = 4
def show
@@ -49,6 +53,10 @@ module Groups
redirect_to group_settings_ci_cd_path
end
+ def runner_setup_scripts
+ private_runner_setup_scripts(group: group)
+ end
+
private
def define_variables
diff --git a/app/controllers/groups/settings/integrations_controller.rb b/app/controllers/groups/settings/integrations_controller.rb
index e8551a7f270..b089cfdf341 100644
--- a/app/controllers/groups/settings/integrations_controller.rb
+++ b/app/controllers/groups/settings/integrations_controller.rb
@@ -7,6 +7,8 @@ module Groups
before_action :authorize_admin_group!
+ feature_category :integrations
+
def index
@integrations = Service.find_or_initialize_all(Service.for_group(group)).sort_by(&:title)
end
diff --git a/app/controllers/groups/settings/repository_controller.rb b/app/controllers/groups/settings/repository_controller.rb
index e2fbdc39692..ccc1fa12458 100644
--- a/app/controllers/groups/settings/repository_controller.rb
+++ b/app/controllers/groups/settings/repository_controller.rb
@@ -10,6 +10,8 @@ module Groups
push_frontend_feature_flag(:ajax_new_deploy_token, @group)
end
+ feature_category :continuous_delivery
+
def create_deploy_token
result = Groups::DeployTokens::CreateService.new(@group, current_user, deploy_token_params).execute
@new_deploy_token = result[:deploy_token]
diff --git a/app/controllers/groups/shared_projects_controller.rb b/app/controllers/groups/shared_projects_controller.rb
index 30b7bfc70ae..90ec64d4768 100644
--- a/app/controllers/groups/shared_projects_controller.rb
+++ b/app/controllers/groups/shared_projects_controller.rb
@@ -6,6 +6,8 @@ module Groups
before_action :group
skip_cross_project_access_check :index
+ feature_category :subgroups
+
def index
shared_projects = GroupProjectsFinder.new(
group: group,
diff --git a/app/controllers/groups/uploads_controller.rb b/app/controllers/groups/uploads_controller.rb
index 3ae7e36c740..49249f87d31 100644
--- a/app/controllers/groups/uploads_controller.rb
+++ b/app/controllers/groups/uploads_controller.rb
@@ -9,6 +9,8 @@ class Groups::UploadsController < Groups::ApplicationController
before_action :authorize_upload_file!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
+ feature_category :subgroups
+
private
def upload_model_class
diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb
index fb639f6e472..51670325ce3 100644
--- a/app/controllers/groups/variables_controller.rb
+++ b/app/controllers/groups/variables_controller.rb
@@ -6,6 +6,8 @@ module Groups
skip_cross_project_access_check :show, :update
+ feature_category :continuous_integration
+
def show
respond_to do |format|
format.json do
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 2d6f5d0377a..6f8dc75f6bd 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -30,6 +30,7 @@ class GroupsController < Groups::ApplicationController
before_action do
push_frontend_feature_flag(:vue_issuables_list, @group)
+ push_frontend_feature_flag(:deployment_filters)
end
before_action do
@@ -46,6 +47,17 @@ class GroupsController < Groups::ApplicationController
layout :determine_layout
+ feature_category :subgroups, [
+ :index, :new, :create, :show, :edit, :update,
+ :destroy, :details, :transfer
+ ]
+
+ feature_category :audit_events, [:activity]
+ feature_category :issue_tracking, [:issues, :issues_calendar, :preview_markdown]
+ feature_category :code_review, [:merge_requests, :unfoldered_environment_names]
+ feature_category :projects, [:projects]
+ feature_category :importers, [:export, :download_export]
+
def index
redirect_to(current_user ? dashboard_groups_path : explore_groups_path)
end
@@ -168,6 +180,16 @@ class GroupsController < Groups::ApplicationController
end
end
+ def unfoldered_environment_names
+ return render_404 unless Feature.enabled?(:deployment_filters)
+
+ respond_to do |format|
+ format.json do
+ render json: EnvironmentNamesFinder.new(@group, current_user).execute
+ end
+ end
+ end
+
protected
def render_show_html
@@ -230,7 +252,9 @@ class GroupsController < Groups::ApplicationController
:two_factor_grace_period,
:project_creation_level,
:subgroup_creation_level,
- :default_branch_protection
+ :default_branch_protection,
+ :default_branch_name,
+ :allow_mfa_for_subgroups
]
end
diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb
index a1bbcf34f69..5a5200452de 100644
--- a/app/controllers/help_controller.rb
+++ b/app/controllers/help_controller.rb
@@ -2,6 +2,7 @@
class HelpController < ApplicationController
skip_before_action :authenticate_user!, unless: :public_visibility_restricted?
+ feature_category :not_owned
layout 'help'
@@ -26,17 +27,10 @@ class HelpController < ApplicationController
respond_to do |format|
format.any(:markdown, :md, :html) do
- # Note: We are purposefully NOT using `Rails.root.join` because of https://gitlab.com/gitlab-org/gitlab/-/issues/216028.
- path = File.join(Rails.root, 'doc', "#{@path}.md")
-
- if File.exist?(path)
- # Remove YAML frontmatter so that it doesn't look weird
- @markdown = File.read(path).gsub(YAML_FRONT_MATTER_REGEXP, '')
-
- render 'show.html.haml'
+ if redirect_to_documentation_website?
+ redirect_to documentation_url
else
- # Force template to Haml
- render 'errors/not_found.html.haml', layout: 'errors', status: :not_found
+ render_documentation
end
end
@@ -75,4 +69,47 @@ class HelpController < ApplicationController
params
end
+
+ def redirect_to_documentation_website?
+ return false unless Feature.enabled?(:help_page_documentation_redirect)
+ return false unless Gitlab::UrlSanitizer.valid_web?(documentation_url)
+
+ true
+ end
+
+ def documentation_url
+ return unless documentation_base_url
+
+ @documentation_url ||= Gitlab::Utils.append_path(documentation_base_url, documentation_file_path)
+ end
+
+ def documentation_base_url
+ @documentation_base_url ||= Gitlab::CurrentSettings.current_application_settings.help_page_documentation_base_url.presence
+ end
+
+ def documentation_file_path
+ @documentation_file_path ||= [version_segment, 'ee', "#{@path}.html"].compact.join('/')
+ end
+
+ def version_segment
+ return if Gitlab.pre_release?
+
+ version = Gitlab.version_info
+ [version.major, version.minor].join('.')
+ end
+
+ def render_documentation
+ # Note: We are purposefully NOT using `Rails.root.join` because of https://gitlab.com/gitlab-org/gitlab/-/issues/216028.
+ path = File.join(Rails.root, 'doc', "#{@path}.md")
+
+ if File.exist?(path)
+ # Remove YAML frontmatter so that it doesn't look weird
+ @markdown = File.read(path).gsub(YAML_FRONT_MATTER_REGEXP, '')
+
+ render 'show.html.haml'
+ else
+ # Force template to Haml
+ render 'errors/not_found.html.haml', layout: 'errors', status: :not_found
+ end
+ end
end
diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb
index 2c17f5b5542..8c0414ad5da 100644
--- a/app/controllers/ide_controller.rb
+++ b/app/controllers/ide_controller.rb
@@ -11,6 +11,8 @@ class IdeController < ApplicationController
push_frontend_feature_flag(:schema_linting)
end
+ feature_category :web_ide
+
def index
Gitlab::UsageDataCounters::WebIdeCounter.increment_views_count
end
diff --git a/app/controllers/import/available_namespaces_controller.rb b/app/controllers/import/available_namespaces_controller.rb
index 7983b4f20b5..c6211b33d28 100644
--- a/app/controllers/import/available_namespaces_controller.rb
+++ b/app/controllers/import/available_namespaces_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Import::AvailableNamespacesController < ApplicationController
+ feature_category :importers
+
def index
render json: NamespaceSerializer.new.represent(current_user.manageable_groups_with_routes)
end
diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb
index 8a7a4c92b37..151ba46e629 100644
--- a/app/controllers/import/base_controller.rb
+++ b/app/controllers/import/base_controller.rb
@@ -4,6 +4,7 @@ class Import::BaseController < ApplicationController
include ActionView::Helpers::SanitizeHelper
before_action :import_rate_limit, only: [:create]
+ feature_category :importers
def status
respond_to do |format|
diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb
new file mode 100644
index 00000000000..cb2922c2d47
--- /dev/null
+++ b/app/controllers/import/bulk_imports_controller.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+class Import::BulkImportsController < ApplicationController
+ before_action :ensure_group_import_enabled
+ before_action :verify_blocked_uri, only: :status
+
+ feature_category :importers
+
+ rescue_from Gitlab::BulkImport::Client::ConnectionError, with: :bulk_import_connection_error
+
+ def configure
+ session[access_token_key] = params[access_token_key]&.strip
+ session[url_key] = params[url_key]
+
+ redirect_to status_import_bulk_import_url
+ end
+
+ def status
+ respond_to do |format|
+ format.json do
+ render json: { importable_data: serialized_importable_data }
+ end
+
+ format.html
+ end
+ end
+
+ private
+
+ def serialized_importable_data
+ serializer.represent(importable_data, {}, Import::BulkImportEntity)
+ end
+
+ def serializer
+ @serializer ||= BaseSerializer.new(current_user: current_user)
+ end
+
+ def importable_data
+ client.get('groups', top_level_only: true)
+ end
+
+ def client
+ @client ||= Gitlab::BulkImport::Client.new(
+ uri: session[url_key],
+ token: session[access_token_key]
+ )
+ end
+
+ def import_params
+ params.permit(access_token_key, url_key)
+ end
+
+ def ensure_group_import_enabled
+ render_404 unless Feature.enabled?(:bulk_import)
+ end
+
+ def access_token_key
+ :bulk_import_gitlab_access_token
+ end
+
+ def url_key
+ :bulk_import_gitlab_url
+ end
+
+ def verify_blocked_uri
+ Gitlab::UrlBlocker.validate!(
+ session[url_key],
+ **{
+ allow_localhost: allow_local_requests?,
+ allow_local_network: allow_local_requests?,
+ schemes: %w(http https)
+ }
+ )
+ rescue Gitlab::UrlBlocker::BlockedUrlError => e
+ clear_session_data
+
+ redirect_to new_group_path, alert: _('Specified URL cannot be used: "%{reason}"') % { reason: e.message }
+ end
+
+ def allow_local_requests?
+ Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services?
+ end
+
+ def bulk_import_connection_error(error)
+ clear_session_data
+
+ error_message = _("Unable to connect to server: %{error}") % { error: error }
+ flash[:alert] = error_message
+
+ respond_to do |format|
+ format.json do
+ render json: {
+ error: {
+ message: error_message,
+ redirect: new_group_path
+ }
+ }, status: :unprocessable_entity
+ end
+ format.html do
+ redirect_to new_group_path
+ end
+ end
+ end
+
+ def clear_session_data
+ session[url_key] = nil
+ session[access_token_key] = nil
+ end
+end
diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb
index a34bc9c953f..bcbf5938e11 100644
--- a/app/controllers/import/fogbugz_controller.rb
+++ b/app/controllers/import/fogbugz_controller.rb
@@ -136,7 +136,7 @@ class Import::FogbugzController < Import::BaseController
def verify_blocked_uri
Gitlab::UrlBlocker.validate!(
params[:uri],
- {
+ **{
allow_localhost: allow_local_requests?,
allow_local_network: allow_local_requests?,
schemes: %w(http https)
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 29fe34f0734..a1adc6e062a 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -108,7 +108,7 @@ class Import::GithubController < Import::BaseController
@client ||= if Feature.enabled?(:remove_legacy_github_client)
Gitlab::GithubImport::Client.new(session[access_token_key])
else
- Gitlab::LegacyGithubImport::Client.new(session[access_token_key], client_options)
+ Gitlab::LegacyGithubImport::Client.new(session[access_token_key], **client_options)
end
end
diff --git a/app/controllers/import/gitlab_groups_controller.rb b/app/controllers/import/gitlab_groups_controller.rb
index 330af68385e..d8118477a80 100644
--- a/app/controllers/import/gitlab_groups_controller.rb
+++ b/app/controllers/import/gitlab_groups_controller.rb
@@ -6,6 +6,8 @@ class Import::GitlabGroupsController < ApplicationController
before_action :ensure_group_import_enabled
before_action :import_rate_limit, only: %i[create]
+ feature_category :importers
+
def create
unless file_is_valid?(group_params[:file])
return redirect_back_or_default(options: { alert: s_('GroupImport|Unable to process group import file') })
diff --git a/app/controllers/import/manifest_controller.rb b/app/controllers/import/manifest_controller.rb
index 9c47e6d4b0b..fdca6da95c5 100644
--- a/app/controllers/import/manifest_controller.rb
+++ b/app/controllers/import/manifest_controller.rb
@@ -26,8 +26,7 @@ class Import::ManifestController < Import::BaseController
manifest = Gitlab::ManifestImport::Manifest.new(params[:manifest].tempfile)
if manifest.valid?
- session[:manifest_import_repositories] = manifest.projects
- session[:manifest_import_group_id] = group.id
+ manifest_import_metadata.save(manifest.projects, group.id)
redirect_to status_import_manifest_path
else
@@ -96,12 +95,16 @@ class Import::ManifestController < Import::BaseController
# rubocop: disable CodeReuse/ActiveRecord
def group
- @group ||= Group.find_by(id: session[:manifest_import_group_id])
+ @group ||= Group.find_by(id: manifest_import_metadata.group_id)
end
# rubocop: enable CodeReuse/ActiveRecord
+ def manifest_import_metadata
+ @manifest_import_status ||= Gitlab::ManifestImport::Metadata.new(current_user, fallback: session)
+ end
+
def repositories
- @repositories ||= session[:manifest_import_repositories]
+ @repositories ||= manifest_import_metadata.repositories
end
def find_jobs
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index aa9c7d01ba3..c7b8486d1c9 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -4,6 +4,7 @@ class InvitesController < ApplicationController
include Gitlab::Utils::StrongMemoize
before_action :member
+ before_action :ensure_member_exists
before_action :invite_details
skip_before_action :authenticate_user!, only: :decline
@@ -11,14 +12,17 @@ class InvitesController < ApplicationController
respond_to :html
+ feature_category :authentication_and_authorization
+
def show
- track_experiment('opened')
+ track_new_user_invite_experiment('opened')
accept if skip_invitation_prompt?
end
def accept
if member.accept_invite!(current_user)
- track_experiment('accepted')
+ track_new_user_invite_experiment('accepted')
+ track_invitation_reminders_experiment('accepted')
redirect_to invite_details[:path], notice: _("You have been granted %{member_human_access} access to %{title} %{name}.") %
{ member_human_access: member.human_access, title: invite_details[:title], name: invite_details[:name] }
else
@@ -28,6 +32,8 @@ class InvitesController < ApplicationController
def decline
if member.decline_invite!
+ return render layout: 'devise_experimental_onboarding_issues' if !current_user && member.invite_to_unknown_user? && member.created_by
+
path =
if current_user
dashboard_projects_path
@@ -59,14 +65,16 @@ class InvitesController < ApplicationController
end
def member
- return @member if defined?(@member)
-
- @token = params[:id]
- @member = Member.find_by_invite_token(@token)
+ strong_memoize(:member) do
+ @token = params[:id]
+ Member.find_by_invite_token(@token)
+ end
+ end
- return render_404 unless @member
+ def ensure_member_exists
+ return if member
- @member
+ render_404
end
def authenticate_user!
@@ -76,10 +84,7 @@ class InvitesController < ApplicationController
notice << "or create an account" if Gitlab::CurrentSettings.allow_signup?
notice = notice.join(' ') + "."
- # this is temporary finder instead of using member method due to render_404 possibility
- # will be resolved via https://gitlab.com/gitlab-org/gitlab/-/issues/245325
- initial_member = Member.find_by_invite_token(params[:id])
- redirect_params = initial_member ? { invite_email: initial_member.invite_email } : {}
+ redirect_params = member ? { invite_email: member.invite_email } : {}
store_location_for :user, request.fullpath
@@ -87,31 +92,43 @@ class InvitesController < ApplicationController
end
def invite_details
- @invite_details ||= case @member.source
+ @invite_details ||= case member.source
when Project
{
- name: @member.source.full_name,
- url: project_url(@member.source),
+ name: member.source.full_name,
+ url: project_url(member.source),
title: _("project"),
- path: project_path(@member.source)
+ path: project_path(member.source)
}
when Group
{
- name: @member.source.name,
- url: group_url(@member.source),
+ name: member.source.name,
+ url: group_url(member.source),
title: _("group"),
- path: group_path(@member.source)
+ path: group_path(member.source)
}
end
end
- def track_experiment(action)
+ def track_new_user_invite_experiment(action)
return unless params[:new_user_invite]
property = params[:new_user_invite] == 'experiment' ? 'experiment_group' : 'control_group'
+ track_experiment(:invite_email, action, property)
+ end
+
+ def track_invitation_reminders_experiment(action)
+ return unless Gitlab::Experimentation.enabled?(:invitation_reminders)
+
+ property = Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, member.invite_email) ? 'experimental_group' : 'control_group'
+
+ track_experiment(:invitation_reminders, action, property)
+ end
+
+ def track_experiment(experiment_key, action, property)
Gitlab::Tracking.event(
- Gitlab::Experimentation::EXPERIMENTS[:invite_email][:tracking_category],
+ Gitlab::Experimentation.experiment(experiment_key).tracking_category,
action,
property: property,
label: Digest::MD5.hexdigest(member.to_global_id.to_s)
diff --git a/app/controllers/jira_connect/application_controller.rb b/app/controllers/jira_connect/application_controller.rb
index a84f25998a6..9c311f92b69 100644
--- a/app/controllers/jira_connect/application_controller.rb
+++ b/app/controllers/jira_connect/application_controller.rb
@@ -7,6 +7,8 @@ class JiraConnect::ApplicationController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :verify_atlassian_jwt!
+ feature_category :integrations
+
attr_reader :current_jira_installation
private
diff --git a/app/controllers/jira_connect/events_controller.rb b/app/controllers/jira_connect/events_controller.rb
index 8f79c82d847..d833491b8f7 100644
--- a/app/controllers/jira_connect/events_controller.rb
+++ b/app/controllers/jira_connect/events_controller.rb
@@ -1,10 +1,14 @@
# frozen_string_literal: true
class JiraConnect::EventsController < JiraConnect::ApplicationController
+ # See https://developer.atlassian.com/cloud/jira/software/app-descriptor/#lifecycle
+
skip_before_action :verify_atlassian_jwt!, only: :installed
before_action :verify_qsh_claim!, only: :uninstalled
def installed
+ return head :ok if atlassian_jwt_valid?
+
installation = JiraConnectInstallation.new(install_params)
if installation.save
diff --git a/app/controllers/jira_connect/users_controller.rb b/app/controllers/jira_connect/users_controller.rb
new file mode 100644
index 00000000000..571d9f87779
--- /dev/null
+++ b/app/controllers/jira_connect/users_controller.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class JiraConnect::UsersController < ApplicationController
+ feature_category :integrations
+
+ layout 'devise_experimental_onboarding_issues'
+
+ def show
+ @jira_app_link = params.delete(:return_to)
+ end
+end
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
index 3e7755046cd..5199bb25c8c 100644
--- a/app/controllers/jwt_controller.rb
+++ b/app/controllers/jwt_controller.rb
@@ -8,6 +8,8 @@ class JwtController < ApplicationController
# Add this before other actions, since we want to have the user or project
prepend_before_action :auth_user, :authenticate_project_or_user
+ feature_category :authentication_and_authorization
+
SERVICES = {
Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService
}.freeze
diff --git a/app/controllers/notification_settings_controller.rb b/app/controllers/notification_settings_controller.rb
index e5d4a4bb073..7d8c035c852 100644
--- a/app/controllers/notification_settings_controller.rb
+++ b/app/controllers/notification_settings_controller.rb
@@ -3,6 +3,8 @@
class NotificationSettingsController < ApplicationController
before_action :authenticate_user!
+ feature_category :users
+
def create
return render_404 unless can_read?(resource)
diff --git a/app/controllers/oauth/jira/authorizations_controller.rb b/app/controllers/oauth/jira/authorizations_controller.rb
index f552b0dc10c..f23149c8544 100644
--- a/app/controllers/oauth/jira/authorizations_controller.rb
+++ b/app/controllers/oauth/jira/authorizations_controller.rb
@@ -8,6 +8,8 @@ class Oauth::Jira::AuthorizationsController < ApplicationController
skip_before_action :authenticate_user!
skip_before_action :verify_authenticity_token
+ feature_category :integrations
+
# 1. Rewire Jira OAuth initial request to our stablished OAuth authorization URL.
def new
session[:redirect_uri] = params['redirect_uri']
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index b798d6680bc..c9791703413 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -1,8 +1,7 @@
# frozen_string_literal: true
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
- include AuthenticatesWithTwoFactor
- include Authenticates2FAForAdminMode
+ include AuthenticatesWithTwoFactorForAdminMode
include Devise::Controllers::Rememberable
include AuthHelper
include InitializesCurrentUserMode
@@ -12,6 +11,8 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true
+ feature_category :authentication_and_authorization
+
def handle_omniauth
omniauth_flow(Gitlab::Auth::OAuth)
end
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index c27226c3f3f..bc6975f8953 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -7,6 +7,8 @@ class PasswordsController < Devise::PasswordsController
before_action :check_password_authentication_available, only: [:create]
before_action :throttle_reset, only: [:create]
+ feature_category :authentication_and_authorization
+
# rubocop: disable CodeReuse/ActiveRecord
def edit
super
diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb
index b19285e98bb..d8419be9f23 100644
--- a/app/controllers/profiles/accounts_controller.rb
+++ b/app/controllers/profiles/accounts_controller.rb
@@ -3,6 +3,8 @@
class Profiles::AccountsController < Profiles::ApplicationController
include AuthHelper
+ feature_category :users
+
def show
render(locals: show_view_variables)
end
diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb
index e4cd5d65e1a..1233c906406 100644
--- a/app/controllers/profiles/active_sessions_controller.rb
+++ b/app/controllers/profiles/active_sessions_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Profiles::ActiveSessionsController < Profiles::ApplicationController
+ feature_category :users
+
def index
@sessions = ActiveSession.list(current_user).reject(&:is_impersonated)
end
diff --git a/app/controllers/profiles/avatars_controller.rb b/app/controllers/profiles/avatars_controller.rb
index 3378a09628c..d9e4b9a149d 100644
--- a/app/controllers/profiles/avatars_controller.rb
+++ b/app/controllers/profiles/avatars_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Profiles::AvatarsController < Profiles::ApplicationController
+ feature_category :users
+
def destroy
@user = current_user
diff --git a/app/controllers/profiles/chat_names_controller.rb b/app/controllers/profiles/chat_names_controller.rb
index 80b8279e91e..8cfec247b7a 100644
--- a/app/controllers/profiles/chat_names_controller.rb
+++ b/app/controllers/profiles/chat_names_controller.rb
@@ -4,6 +4,8 @@ class Profiles::ChatNamesController < Profiles::ApplicationController
before_action :chat_name_token, only: [:new]
before_action :chat_name_params, only: [:new, :create, :deny]
+ feature_category :users
+
def index
@chat_names = current_user.chat_names
end
diff --git a/app/controllers/profiles/emails_controller.rb b/app/controllers/profiles/emails_controller.rb
index da553e34ef6..6e5b18cb885 100644
--- a/app/controllers/profiles/emails_controller.rb
+++ b/app/controllers/profiles/emails_controller.rb
@@ -5,6 +5,8 @@ class Profiles::EmailsController < Profiles::ApplicationController
before_action -> { rate_limit!(:profile_add_new_email) }, only: [:create]
before_action -> { rate_limit!(:profile_resend_email_confirmation) }, only: [:resend_confirmation_instructions]
+ feature_category :users
+
def index
@primary_email = current_user.email
@emails = current_user.emails.order_id_desc
diff --git a/app/controllers/profiles/gpg_keys_controller.rb b/app/controllers/profiles/gpg_keys_controller.rb
index 8c34a66c374..7f04927f517 100644
--- a/app/controllers/profiles/gpg_keys_controller.rb
+++ b/app/controllers/profiles/gpg_keys_controller.rb
@@ -3,6 +3,8 @@
class Profiles::GpgKeysController < Profiles::ApplicationController
before_action :set_gpg_key, only: [:destroy, :revoke]
+ feature_category :users
+
def index
@gpg_keys = current_user.gpg_keys.with_subkeys
@gpg_key = GpgKey.new
diff --git a/app/controllers/profiles/groups_controller.rb b/app/controllers/profiles/groups_controller.rb
index 04b5ee270dc..e76ee0a6cea 100644
--- a/app/controllers/profiles/groups_controller.rb
+++ b/app/controllers/profiles/groups_controller.rb
@@ -3,6 +3,8 @@
class Profiles::GroupsController < Profiles::ApplicationController
include RoutableActions
+ feature_category :users
+
def update
group = find_routable!(Group, params[:id])
notification_setting = current_user.notification_settings_for(group)
diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb
index 965493955ac..1e6340f285e 100644
--- a/app/controllers/profiles/keys_controller.rb
+++ b/app/controllers/profiles/keys_controller.rb
@@ -3,6 +3,8 @@
class Profiles::KeysController < Profiles::ApplicationController
skip_before_action :authenticate_user!, only: [:get_keys]
+ feature_category :users
+
def index
@keys = current_user.keys.order_id_desc
@key = Key.new
diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb
index bc51830c119..a3e7638cdbc 100644
--- a/app/controllers/profiles/notifications_controller.rb
+++ b/app/controllers/profiles/notifications_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Profiles::NotificationsController < Profiles::ApplicationController
+ feature_category :users
+
# rubocop: disable CodeReuse/ActiveRecord
def show
@user = current_user
diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb
index fccbc29f598..85e901eb3eb 100644
--- a/app/controllers/profiles/passwords_controller.rb
+++ b/app/controllers/profiles/passwords_controller.rb
@@ -9,6 +9,8 @@ class Profiles::PasswordsController < Profiles::ApplicationController
layout :determine_layout
+ feature_category :authentication_and_authorization
+
def new
end
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index 21adc032940..b005347c43a 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
+ feature_category :authentication_and_authorization
+
def index
set_index_vars
@personal_access_token = finder.build
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index ea4d3e861be..4d88491e9a8 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -3,6 +3,8 @@
class Profiles::PreferencesController < Profiles::ApplicationController
before_action :user
+ feature_category :users
+
def show
end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index 5de6d84fdd9..e2f8baa8226 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -6,6 +6,8 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
push_frontend_feature_flag(:webauthn)
end
+ feature_category :authentication_and_authorization
+
def show
unless current_user.two_factor_enabled?
current_user.otp_secret = User.generate_otp_secret(32)
@@ -45,7 +47,10 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
end
def create
- if current_user.validate_and_consume_otp!(params[:pin_code])
+ otp_validation_result =
+ ::Users::ValidateOtpService.new(current_user).execute(params[:pin_code])
+
+ if otp_validation_result[:status] == :success
ActiveSession.destroy_all_but_current(current_user, session)
Users::UpdateService.new(current_user, user: current_user, otp_required_for_login: true).execute! do |user|
diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb
index 84ce4a56e64..32ca303e722 100644
--- a/app/controllers/profiles/u2f_registrations_controller.rb
+++ b/app/controllers/profiles/u2f_registrations_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Profiles::U2fRegistrationsController < Profiles::ApplicationController
+ feature_category :authentication_and_authorization
+
def destroy
u2f_registration = current_user.u2f_registrations.find(params[:id])
u2f_registration.destroy
diff --git a/app/controllers/profiles/webauthn_registrations_controller.rb b/app/controllers/profiles/webauthn_registrations_controller.rb
index 81b1dd6f710..a4a6d84f1ae 100644
--- a/app/controllers/profiles/webauthn_registrations_controller.rb
+++ b/app/controllers/profiles/webauthn_registrations_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Profiles::WebauthnRegistrationsController < Profiles::ApplicationController
+ feature_category :authentication_and_authorization
+
def destroy
webauthn_registration = current_user.webauthn_registrations.find(params[:id])
webauthn_registration.destroy
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 248d5755d92..c85c83688a4 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -2,6 +2,7 @@
class ProfilesController < Profiles::ApplicationController
include ActionView::Helpers::SanitizeHelper
+ include Gitlab::Tracking
before_action :user
before_action :authorize_change_username!, only: :update_username
@@ -10,6 +11,8 @@ class ProfilesController < Profiles::ApplicationController
push_frontend_feature_flag(:webauthn)
end
+ feature_category :users
+
def show
end
@@ -63,6 +66,8 @@ class ProfilesController < Profiles::ApplicationController
@events = AuditEvent.where(entity_type: "User", entity_id: current_user.id)
.order("created_at DESC")
.page(params[:page])
+
+ Gitlab::Tracking.event(self.class.name, 'search_audit_event')
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/alert_management_controller.rb b/app/controllers/projects/alert_management_controller.rb
index 054dc8e6a35..0d0ef9b05cb 100644
--- a/app/controllers/projects/alert_management_controller.rb
+++ b/app/controllers/projects/alert_management_controller.rb
@@ -3,10 +3,13 @@
class Projects::AlertManagementController < Projects::ApplicationController
before_action :authorize_read_alert_management_alert!
+ feature_category :alert_management
+
def index
end
def details
@alert_id = params[:id]
+ push_frontend_feature_flag(:expose_environment_path_in_alert_details, @project)
end
end
diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb
index fef8235628d..2241ded2db8 100644
--- a/app/controllers/projects/alerting/notifications_controller.rb
+++ b/app/controllers/projects/alerting/notifications_controller.rb
@@ -10,6 +10,8 @@ module Projects
prepend_before_action :repository, :project_without_auth
+ feature_category :alert_management
+
def create
token = extract_alert_manager_token(request)
result = notify_service.execute(token)
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index 652687932fd..f6a92b07295 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -15,6 +15,8 @@ class Projects::ArtifactsController < Projects::ApplicationController
MAX_PER_PAGE = 20
+ feature_category :continuous_integration
+
def index
# Loading artifacts is very expensive in projects with a lot of artifacts.
# This feature flag prevents a DOS attack vector.
diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb
index 605d70d440b..e9c533daa80 100644
--- a/app/controllers/projects/autocomplete_sources_controller.rb
+++ b/app/controllers/projects/autocomplete_sources_controller.rb
@@ -3,6 +3,11 @@
class Projects::AutocompleteSourcesController < Projects::ApplicationController
before_action :authorize_read_milestone!, only: :milestones
+ feature_category :issue_tracking, [:issues, :labels, :milestones, :commands]
+ feature_category :code_review, [:merge_requests]
+ feature_category :users, [:members]
+ feature_category :snippets, [:snippets]
+
def members
render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target)
end
diff --git a/app/controllers/projects/avatars_controller.rb b/app/controllers/projects/avatars_controller.rb
index 6e6bf09a32a..f228206032d 100644
--- a/app/controllers/projects/avatars_controller.rb
+++ b/app/controllers/projects/avatars_controller.rb
@@ -5,6 +5,8 @@ class Projects::AvatarsController < Projects::ApplicationController
before_action :authorize_admin_project!, only: [:destroy]
+ feature_category :projects
+
def show
@blob = @repository.blob_at_branch(@repository.root_ref, @project.avatar_in_git)
diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb
index eb47fec2b7e..855965ca6e1 100644
--- a/app/controllers/projects/badges_controller.rb
+++ b/app/controllers/projects/badges_controller.rb
@@ -6,6 +6,8 @@ class Projects::BadgesController < Projects::ApplicationController
before_action :no_cache_headers, only: [:pipeline, :coverage]
before_action :authorize_read_build!, only: [:pipeline, :coverage]
+ feature_category :continuous_integration
+
def pipeline
pipeline_status = Gitlab::Badge::Pipeline::Status
.new(project, params[:ref], opts: {
diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb
index 374b4921dbc..d5de0d38152 100644
--- a/app/controllers/projects/blame_controller.rb
+++ b/app/controllers/projects/blame_controller.rb
@@ -9,6 +9,8 @@ class Projects::BlameController < Projects::ApplicationController
before_action :assign_ref_vars
before_action :authorize_download_code!
+ feature_category :source_code_management
+
def show
@blob = @repository.blob_at(@commit.id, @path)
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 1568d9966dd..c6251d27b05 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -33,13 +33,14 @@ class Projects::BlobController < Projects::ApplicationController
before_action :set_last_commit_sha, only: [:edit, :update]
before_action only: :show do
- push_frontend_feature_flag(:code_navigation, @project, default_enabled: true)
- push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline)
+ push_frontend_experiment(:suggest_pipeline)
push_frontend_feature_flag(:gitlab_ci_yml_preview, @project, default_enabled: false)
end
track_redis_hll_event :create, :update, name: 'g_edit_by_sfe', feature: :track_editor_edit_actions, feature_default_enabled: true
+ feature_category :source_code_management
+
def new
commit unless @repository.empty?
end
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 5ed35094476..193352ffa70 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -12,6 +12,8 @@ class Projects::BoardsController < Projects::ApplicationController
push_frontend_feature_flag(:boards_with_swimlanes, project, default_enabled: false)
end
+ feature_category :boards
+
private
def assign_endpoint_vars
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index 7cfb4a508da..9124728ee25 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -13,6 +13,8 @@ class Projects::BranchesController < Projects::ApplicationController
before_action :redirect_for_legacy_index_sort_or_search, only: [:index]
before_action :limit_diverging_commit_counts!, only: [:diverging_commit_counts]
+ feature_category :source_code_management
+
def index
respond_to do |format|
format.html do
diff --git a/app/controllers/projects/build_artifacts_controller.rb b/app/controllers/projects/build_artifacts_controller.rb
index 99f4524eec5..148080a71f4 100644
--- a/app/controllers/projects/build_artifacts_controller.rb
+++ b/app/controllers/projects/build_artifacts_controller.rb
@@ -8,6 +8,8 @@ class Projects::BuildArtifactsController < Projects::ApplicationController
before_action :extract_ref_name_and_path
before_action :validate_artifacts!, except: [:download]
+ feature_category :continuous_integration
+
def download
redirect_to download_project_job_artifacts_path(project, job, params: request.query_parameters)
end
diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb
index 6b3d70cb720..c5f6ed1c105 100644
--- a/app/controllers/projects/builds_controller.rb
+++ b/app/controllers/projects/builds_controller.rb
@@ -3,6 +3,8 @@
class Projects::BuildsController < Projects::ApplicationController
before_action :authorize_read_build!
+ feature_category :continuous_integration
+
def index
redirect_to project_jobs_path(project)
end
diff --git a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
index b36c5f1aea6..d05ab1b4977 100644
--- a/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
+++ b/app/controllers/projects/ci/daily_build_group_report_results_controller.rb
@@ -6,10 +6,11 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati
MAX_ITEMS = 1000
REPORT_WINDOW = 90.days
- before_action :validate_feature_flag!
before_action :authorize_read_build_report_results!
before_action :validate_param_type!
+ feature_category :continuous_integration
+
def index
respond_to do |format|
format.csv { send_data(render_csv(report_results), type: 'text/csv; charset=utf-8') }
@@ -19,10 +20,6 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati
private
- def validate_feature_flag!
- render_404 unless Feature.enabled?(:ci_download_daily_code_coverage, project, default_enabled: true)
- end
-
def validate_param_type!
respond_422 unless allowed_param_types.include?(param_type)
end
@@ -43,7 +40,7 @@ class Projects::Ci::DailyBuildGroupReportResultsController < Projects::Applicati
end
def report_results
- Ci::DailyBuildGroupReportResultsFinder.new(finder_params).execute
+ Ci::DailyBuildGroupReportResultsFinder.new(**finder_params).execute
end
def finder_params
diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb
index 813a0a9ddd5..7e900fc6051 100644
--- a/app/controllers/projects/ci/lints_controller.rb
+++ b/app/controllers/projects/ci/lints_controller.rb
@@ -6,6 +6,8 @@ class Projects::Ci::LintsController < Projects::ApplicationController
push_frontend_feature_flag(:ci_lint_vue, project)
end
+ feature_category :pipeline_authoring
+
def show
end
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index b0c6f3cc6a1..2e48f2f0e45 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -21,6 +21,8 @@ class Projects::CommitController < Projects::ApplicationController
BRANCH_SEARCH_LIMIT = 1000
+ feature_category :source_code_management
+
def show
apply_diff_view_cookie!
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index b161e44660e..d267ab732f9 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -15,6 +15,8 @@ class Projects::CommitsController < Projects::ApplicationController
before_action :validate_ref!, except: :commits_root
before_action :set_commits, except: :commits_root
+ feature_category :source_code_management
+
def commits_root
redirect_to project_commits_path(@project, @project.default_branch)
end
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index 943277afe95..6be0b465402 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -19,6 +19,8 @@ class Projects::CompareController < Projects::ApplicationController
# Validation
before_action :validate_refs!
+ feature_category :source_code_management
+
def index
end
diff --git a/app/controllers/projects/confluences_controller.rb b/app/controllers/projects/confluences_controller.rb
index d563b34a362..fccbdf0bf91 100644
--- a/app/controllers/projects/confluences_controller.rb
+++ b/app/controllers/projects/confluences_controller.rb
@@ -3,6 +3,8 @@
class Projects::ConfluencesController < Projects::ApplicationController
before_action :ensure_confluence
+ feature_category :integrations
+
def show
end
diff --git a/app/controllers/projects/cycle_analytics/events_controller.rb b/app/controllers/projects/cycle_analytics/events_controller.rb
index c69bf029c73..3a5dd23047c 100644
--- a/app/controllers/projects/cycle_analytics/events_controller.rb
+++ b/app/controllers/projects/cycle_analytics/events_controller.rb
@@ -11,6 +11,8 @@ module Projects
before_action :authorize_read_issue!, only: [:issue, :production]
before_action :authorize_read_merge_request!, only: [:code, :review]
+ feature_category :planning_analytics
+
def issue
render_events(cycle_analytics[:issue].events)
end
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index ef97bc795f9..1ddc9d567e0 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -12,6 +12,8 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
track_unique_visits :show, target_id: 'p_analytics_valuestream'
+ feature_category :planning_analytics
+
def show
@cycle_analytics = ::CycleAnalytics::ProjectLevel.new(@project, options: options(cycle_analytics_project_params))
diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb
index 4f4adaea56e..ce25f86d692 100644
--- a/app/controllers/projects/deploy_keys_controller.rb
+++ b/app/controllers/projects/deploy_keys_controller.rb
@@ -10,6 +10,8 @@ class Projects::DeployKeysController < Projects::ApplicationController
layout 'project_settings'
+ feature_category :continuous_delivery
+
def index
respond_to do |format|
format.html { redirect_to_repository }
diff --git a/app/controllers/projects/deploy_tokens_controller.rb b/app/controllers/projects/deploy_tokens_controller.rb
index 830b1f4fe4a..3c890bbafdf 100644
--- a/app/controllers/projects/deploy_tokens_controller.rb
+++ b/app/controllers/projects/deploy_tokens_controller.rb
@@ -3,6 +3,8 @@
class Projects::DeployTokensController < Projects::ApplicationController
before_action :authorize_admin_project!
+ feature_category :continuous_delivery
+
def revoke
@token = @project.deploy_tokens.find(params[:id])
@token.revoke!
diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb
index 1344cf775e4..231684427fb 100644
--- a/app/controllers/projects/deployments_controller.rb
+++ b/app/controllers/projects/deployments_controller.rb
@@ -3,6 +3,8 @@
class Projects::DeploymentsController < Projects::ApplicationController
before_action :authorize_read_deployment!
+ feature_category :continuous_delivery
+
# rubocop: disable CodeReuse/ActiveRecord
def index
deployments = environment.deployments.reorder(created_at: :desc)
diff --git a/app/controllers/projects/design_management/designs_controller.rb b/app/controllers/projects/design_management/designs_controller.rb
index fec09fa9515..550d8578396 100644
--- a/app/controllers/projects/design_management/designs_controller.rb
+++ b/app/controllers/projects/design_management/designs_controller.rb
@@ -3,6 +3,8 @@
class Projects::DesignManagement::DesignsController < Projects::ApplicationController
before_action :authorize_read_design!
+ feature_category :design_management
+
private
def authorize_read_design!
diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb
index 06231607f73..b9ab1076999 100644
--- a/app/controllers/projects/discussions_controller.rb
+++ b/app/controllers/projects/discussions_controller.rb
@@ -9,6 +9,8 @@ class Projects::DiscussionsController < Projects::ApplicationController
before_action :discussion, only: [:resolve, :unresolve]
before_action :authorize_resolve_discussion!, only: [:resolve, :unresolve]
+ feature_category :issue_tracking
+
def resolve
Discussions::ResolveService.new(project, current_user, one_or_more_discussions: discussion).execute
diff --git a/app/controllers/projects/environments/prometheus_api_controller.rb b/app/controllers/projects/environments/prometheus_api_controller.rb
index f0bb5360f84..97810d7d439 100644
--- a/app/controllers/projects/environments/prometheus_api_controller.rb
+++ b/app/controllers/projects/environments/prometheus_api_controller.rb
@@ -5,6 +5,8 @@ class Projects::Environments::PrometheusApiController < Projects::ApplicationCon
before_action :proxyable
+ feature_category :metrics
+
private
def proxyable
diff --git a/app/controllers/projects/environments/sample_metrics_controller.rb b/app/controllers/projects/environments/sample_metrics_controller.rb
index 9176c7cbd56..3df20810cb3 100644
--- a/app/controllers/projects/environments/sample_metrics_controller.rb
+++ b/app/controllers/projects/environments/sample_metrics_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Projects::Environments::SampleMetricsController < Projects::ApplicationController
+ feature_category :metrics
+
def query
result = Metrics::SampleMetricsService.new(params[:identifier], range_start: params[:start], range_end: params[:end]).query
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 71195fdb892..c37abf82fe9 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -25,6 +25,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? }
after_action :expire_etag_cache, only: [:cancel_auto_stop]
+ feature_category :continuous_delivery
+
def index
@environments = project.environments
.with_state(params[:scope] || :available)
diff --git a/app/controllers/projects/error_tracking/base_controller.rb b/app/controllers/projects/error_tracking/base_controller.rb
index 6efc6d00702..ffbe487d8a1 100644
--- a/app/controllers/projects/error_tracking/base_controller.rb
+++ b/app/controllers/projects/error_tracking/base_controller.rb
@@ -3,6 +3,8 @@
class Projects::ErrorTracking::BaseController < Projects::ApplicationController
POLLING_INTERVAL = 1_000
+ feature_category :error_tracking
+
def set_polling_interval
Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
end
diff --git a/app/controllers/projects/error_tracking/projects_controller.rb b/app/controllers/projects/error_tracking/projects_controller.rb
index 75a2c976d8b..d59cbc25d25 100644
--- a/app/controllers/projects/error_tracking/projects_controller.rb
+++ b/app/controllers/projects/error_tracking/projects_controller.rb
@@ -7,6 +7,8 @@ module Projects
before_action :authorize_read_sentry_issue!
+ feature_category :error_tracking
+
def index
service = ::ErrorTracking::ListProjectsService.new(
project,
diff --git a/app/controllers/projects/feature_flags_clients_controller.rb b/app/controllers/projects/feature_flags_clients_controller.rb
new file mode 100644
index 00000000000..9a1f8932a27
--- /dev/null
+++ b/app/controllers/projects/feature_flags_clients_controller.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class Projects::FeatureFlagsClientsController < Projects::ApplicationController
+ before_action :authorize_admin_feature_flags_client!
+ before_action :feature_flags_client
+
+ feature_category :feature_flags
+
+ def reset_token
+ feature_flags_client.reset_token!
+
+ respond_to do |format|
+ format.json do
+ render json: feature_flags_client_token_json, status: :ok
+ end
+ end
+ end
+
+ private
+
+ def feature_flags_client
+ project.operations_feature_flags_client || not_found
+ end
+
+ def feature_flags_client_token_json
+ FeatureFlagsClientSerializer.new
+ .represent_token(feature_flags_client)
+ end
+end
diff --git a/app/controllers/projects/feature_flags_controller.rb b/app/controllers/projects/feature_flags_controller.rb
new file mode 100644
index 00000000000..e9d450a6ce3
--- /dev/null
+++ b/app/controllers/projects/feature_flags_controller.rb
@@ -0,0 +1,174 @@
+# frozen_string_literal: true
+
+class Projects::FeatureFlagsController < Projects::ApplicationController
+ respond_to :html
+
+ before_action :authorize_read_feature_flag!
+ before_action :authorize_create_feature_flag!, only: [:new, :create]
+ before_action :authorize_update_feature_flag!, only: [:edit, :update]
+ before_action :authorize_destroy_feature_flag!, only: [:destroy]
+
+ before_action :feature_flag, only: [:edit, :update, :destroy]
+
+ before_action :ensure_legacy_flags_writable!, only: [:update]
+
+ before_action do
+ push_frontend_feature_flag(:feature_flag_permissions)
+ push_frontend_feature_flag(:feature_flags_new_version, project, default_enabled: true)
+ push_frontend_feature_flag(:feature_flags_legacy_read_only, project, default_enabled: true)
+ push_frontend_feature_flag(:feature_flags_legacy_read_only_override, project)
+ end
+
+ feature_category :feature_flags
+
+ def index
+ @feature_flags = FeatureFlagsFinder
+ .new(project, current_user, scope: params[:scope])
+ .execute
+ .page(params[:page])
+ .per(30)
+
+ respond_to do |format|
+ format.html
+ format.json do
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
+ render json: { feature_flags: feature_flags_json }.merge(summary_json)
+ end
+ end
+ end
+
+ def new
+ end
+
+ def show
+ respond_to do |format|
+ format.json do
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
+ render_success_json(feature_flag)
+ end
+ end
+ end
+
+ def create
+ result = FeatureFlags::CreateService.new(project, current_user, create_params).execute
+
+ if result[:status] == :success
+ respond_to do |format|
+ format.json { render_success_json(result[:feature_flag]) }
+ end
+ else
+ respond_to do |format|
+ format.json { render_error_json(result[:message]) }
+ end
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ result = FeatureFlags::UpdateService.new(project, current_user, update_params).execute(feature_flag)
+
+ if result[:status] == :success
+ respond_to do |format|
+ format.json { render_success_json(result[:feature_flag]) }
+ end
+ else
+ respond_to do |format|
+ format.json { render_error_json(result[:message]) }
+ end
+ end
+ end
+
+ def destroy
+ result = FeatureFlags::DestroyService.new(project, current_user).execute(feature_flag)
+
+ if result[:status] == :success
+ respond_to do |format|
+ format.html { redirect_to_index(notice: _('Feature flag was successfully removed.')) }
+ format.json { render_success_json(feature_flag) }
+ end
+ else
+ respond_to do |format|
+ format.html { redirect_to_index(alert: _('Feature flag was not removed.')) }
+ format.json { render_error_json(result[:message]) }
+ end
+ end
+ end
+
+ protected
+
+ def feature_flag
+ @feature_flag ||= @noteable = if new_version_feature_flags_enabled?
+ project.operations_feature_flags.find_by_iid!(params[:iid])
+ else
+ project.operations_feature_flags.legacy_flag.find_by_iid!(params[:iid])
+ end
+ end
+
+ def new_version_feature_flags_enabled?
+ ::Feature.enabled?(:feature_flags_new_version, project, default_enabled: true)
+ end
+
+ def ensure_legacy_flags_writable!
+ if ::Feature.enabled?(:feature_flags_legacy_read_only, project, default_enabled: true) &&
+ ::Feature.disabled?(:feature_flags_legacy_read_only_override, project) &&
+ feature_flag.legacy_flag?
+ render_error_json(['Legacy feature flags are read-only'])
+ end
+ end
+
+ def create_params
+ params.require(:operations_feature_flag)
+ .permit(:name, :description, :active, :version,
+ scopes_attributes: [:environment_scope, :active,
+ strategies: [:name, parameters: [:groupId, :percentage, :userIds]]],
+ strategies_attributes: [:name, :user_list_id,
+ parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness],
+ scopes_attributes: [:environment_scope]])
+ end
+
+ def update_params
+ params.require(:operations_feature_flag)
+ .permit(:name, :description, :active,
+ scopes_attributes: [:id, :environment_scope, :active, :_destroy,
+ strategies: [:name, parameters: [:groupId, :percentage, :userIds]]],
+ strategies_attributes: [:id, :name, :user_list_id, :_destroy,
+ parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness],
+ scopes_attributes: [:id, :environment_scope, :_destroy]])
+ end
+
+ def feature_flag_json(feature_flag)
+ FeatureFlagSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(feature_flag)
+ end
+
+ def feature_flags_json
+ FeatureFlagSerializer
+ .new(project: @project, current_user: @current_user)
+ .with_pagination(request, response)
+ .represent(@feature_flags)
+ end
+
+ def summary_json
+ FeatureFlagSummarySerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(@project)
+ end
+
+ def redirect_to_index(**args)
+ redirect_to project_feature_flags_path(@project), status: :found, **args
+ end
+
+ def render_success_json(feature_flag)
+ render json: feature_flag_json(feature_flag), status: :ok
+ end
+
+ def render_error_json(messages)
+ render json: { message: messages },
+ status: :bad_request
+ end
+end
diff --git a/app/controllers/projects/feature_flags_user_lists_controller.rb b/app/controllers/projects/feature_flags_user_lists_controller.rb
new file mode 100644
index 00000000000..7be3254e966
--- /dev/null
+++ b/app/controllers/projects/feature_flags_user_lists_controller.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class Projects::FeatureFlagsUserListsController < Projects::ApplicationController
+ before_action :authorize_admin_feature_flags_user_lists!
+ before_action :user_list, only: [:edit, :show]
+
+ feature_category :feature_flags
+
+ def new
+ end
+
+ def edit
+ end
+
+ def show
+ end
+
+ private
+
+ def user_list
+ @user_list = project.operations_feature_flags_user_lists.find_by_iid!(params[:iid])
+ end
+end
diff --git a/app/controllers/projects/find_file_controller.rb b/app/controllers/projects/find_file_controller.rb
index c026e9ff332..89e72d98a33 100644
--- a/app/controllers/projects/find_file_controller.rb
+++ b/app/controllers/projects/find_file_controller.rb
@@ -10,6 +10,8 @@ class Projects::FindFileController < Projects::ApplicationController
before_action :assign_ref_vars
before_action :authorize_download_code!
+ feature_category :source_code_management
+
def show
return render_404 unless @repository.commit(@ref)
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index c6b6b825bb7..1c2930f6e9b 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -14,6 +14,8 @@ class Projects::ForksController < Projects::ApplicationController
before_action :authorize_fork_project!, only: [:new, :create]
before_action :authorize_fork_namespace!, only: [:create]
+ feature_category :source_code_management
+
def index
@total_forks_count = project.forks.size
@public_forks_count = project.forks.public_only.size
diff --git a/app/controllers/projects/grafana_api_controller.rb b/app/controllers/projects/grafana_api_controller.rb
index c9870f1be2b..9c5d6c8ebc3 100644
--- a/app/controllers/projects/grafana_api_controller.rb
+++ b/app/controllers/projects/grafana_api_controller.rb
@@ -4,6 +4,8 @@ class Projects::GrafanaApiController < Projects::ApplicationController
include RenderServiceResults
include MetricsDashboard
+ feature_category :metrics
+
def proxy
result = ::Grafana::ProxyService.new(
project,
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index 9b889f9e837..2b030793c58 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -11,6 +11,8 @@ class Projects::GraphsController < Projects::ApplicationController
track_unique_visits :charts, target_id: 'p_analytics_repo'
+ feature_category :source_code_management
+
def show
respond_to do |format|
format.html
@@ -57,7 +59,6 @@ class Projects::GraphsController < Projects::ApplicationController
end
def get_daily_coverage_options
- return unless Feature.enabled?(:ci_download_daily_code_coverage, @project, default_enabled: true)
return unless can?(current_user, :read_build_report_results, project)
date_today = Date.current
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index a30c455a7e4..c6c90ffaba2 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -5,6 +5,8 @@ class Projects::GroupLinksController < Projects::ApplicationController
before_action :authorize_admin_project!
before_action :authorize_admin_project_member!, only: [:update]
+ feature_category :subgroups
+
def create
group = Group.find(params[:link_group_id]) if params[:link_group_id].present?
@@ -21,8 +23,17 @@ class Projects::GroupLinksController < Projects::ApplicationController
end
def update
- @group_link = @project.project_group_links.find(params[:id])
- Projects::GroupLinks::UpdateService.new(@group_link).execute(group_link_params)
+ group_link = @project.project_group_links.find(params[:id])
+ Projects::GroupLinks::UpdateService.new(group_link).execute(group_link_params)
+
+ if group_link.expires?
+ render json: {
+ expires_in: helpers.distance_of_time_in_words_to_now(group_link.expires_at),
+ expires_soon: group_link.expires_soon?
+ }
+ else
+ render json: {}
+ end
end
def destroy
diff --git a/app/controllers/projects/hook_logs_controller.rb b/app/controllers/projects/hook_logs_controller.rb
index ed7e7b68acb..99ebe3335c0 100644
--- a/app/controllers/projects/hook_logs_controller.rb
+++ b/app/controllers/projects/hook_logs_controller.rb
@@ -12,6 +12,8 @@ class Projects::HookLogsController < Projects::ApplicationController
layout 'project_settings'
+ feature_category :integrations
+
def show
end
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index 2f4dc1ddc3a..8dabf3e640b 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -12,6 +12,8 @@ class Projects::HooksController < Projects::ApplicationController
layout "project_settings"
+ feature_category :integrations
+
def index
@hooks = @project.hooks
@hook = ProjectHook.new
@@ -50,7 +52,7 @@ class Projects::HooksController < Projects::ApplicationController
end
def destroy
- hook.destroy
+ destroy_hook(hook)
redirect_to action: :index, status: :found
end
diff --git a/app/controllers/projects/import/jira_controller.rb b/app/controllers/projects/import/jira_controller.rb
index 976ac7df976..8418a607659 100644
--- a/app/controllers/projects/import/jira_controller.rb
+++ b/app/controllers/projects/import/jira_controller.rb
@@ -7,6 +7,8 @@ module Projects
before_action :authorize_read_project!
before_action :validate_jira_import_settings!
+ feature_category :integrations
+
def show
end
diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb
index 9bed12fd151..6cdd1c0bc8c 100644
--- a/app/controllers/projects/imports_controller.rb
+++ b/app/controllers/projects/imports_controller.rb
@@ -11,6 +11,8 @@ class Projects::ImportsController < Projects::ApplicationController
before_action :redirect_if_progress, except: :show
before_action :redirect_if_no_import, only: :show
+ feature_category :importers
+
def new
end
diff --git a/app/controllers/projects/incident_management/pager_duty_incidents_controller.rb b/app/controllers/projects/incident_management/pager_duty_incidents_controller.rb
index dac1640dd08..f1264ca4a45 100644
--- a/app/controllers/projects/incident_management/pager_duty_incidents_controller.rb
+++ b/app/controllers/projects/incident_management/pager_duty_incidents_controller.rb
@@ -10,6 +10,8 @@ module Projects
prepend_before_action :project_without_auth
+ feature_category :incident_management
+
def create
result = webhook_processor.execute(params[:token])
diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb
index 12cc4dde1f4..3395e75666e 100644
--- a/app/controllers/projects/incidents_controller.rb
+++ b/app/controllers/projects/incidents_controller.rb
@@ -1,8 +1,45 @@
# frozen_string_literal: true
class Projects::IncidentsController < Projects::ApplicationController
- before_action :authorize_read_incidents!
+ include IssuableActions
+ include Gitlab::Utils::StrongMemoize
+
+ before_action :authorize_read_issue!
+ before_action :load_incident, only: [:show]
+
+ feature_category :incident_management
def index
end
+
+ private
+
+ def incident
+ strong_memoize(:incident) do
+ incident_finder
+ .execute
+ .inc_relations_for_view
+ .iid_in(params[:id])
+ .without_order
+ .first
+ end
+ end
+
+ def load_incident
+ @issue = incident # needed by rendered view
+ return render_404 unless can?(current_user, :read_issue, incident)
+
+ @noteable = incident
+ @note = incident.project.notes.new(noteable: issuable)
+ end
+
+ alias_method :issuable, :incident
+
+ def incident_finder
+ IssuesFinder.new(current_user, project_id: @project.id, issue_types: :incident)
+ end
+
+ def serializer
+ IssueSerializer.new(current_user: current_user, project: incident.project)
+ end
end
diff --git a/app/controllers/projects/issue_links_controller.rb b/app/controllers/projects/issue_links_controller.rb
index 2f7489373ed..35f3e00fae7 100644
--- a/app/controllers/projects/issue_links_controller.rb
+++ b/app/controllers/projects/issue_links_controller.rb
@@ -7,6 +7,8 @@ module Projects
before_action :authorize_admin_issue_link!, only: [:create, :destroy]
before_action :authorize_issue_link_association!, only: :destroy
+ feature_category :issue_tracking
+
private
def authorize_admin_issue_link!
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 7f0d23b79ce..9a8965dbeb6 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -44,8 +44,6 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
push_frontend_feature_flag(:tribute_autocomplete, @project)
push_frontend_feature_flag(:vue_issuables_list, project)
- push_frontend_feature_flag(:design_management_todo_button, project, default_enabled: true)
- push_frontend_feature_flag(:vue_sidebar_labels, @project)
end
before_action only: :show do
@@ -53,10 +51,13 @@ class Projects::IssuesController < Projects::ApplicationController
real_time_enabled = Gitlab::ActionCable::Config.in_app? || Feature.enabled?(real_time_feature_flag, @project)
gon.push({ features: { real_time_feature_flag.to_s.camelize(:lower) => real_time_enabled } }, true)
+
+ record_experiment_user(:invite_members_version_a)
+ record_experiment_user(:invite_members_version_b)
end
before_action only: :index do
- push_frontend_feature_flag(:scoped_labels, @project)
+ push_frontend_feature_flag(:scoped_labels, @project, type: :licensed)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
@@ -65,6 +66,17 @@ class Projects::IssuesController < Projects::ApplicationController
alias_method :designs, :show
+ feature_category :issue_tracking, [
+ :index, :calendar, :show, :new, :create, :edit, :update,
+ :destroy, :move, :reorder, :designs, :toggle_subscription,
+ :discussions, :bulk_update, :realtime_changes,
+ :toggle_award_emoji, :mark_as_spam, :related_branches,
+ :can_create_branch, :create_merge_request
+ ]
+
+ feature_category :service_desk, [:service_desk]
+ feature_category :importers, [:import_csv, :export_csv]
+
def index
@issues = @issuables
@@ -204,7 +216,7 @@ class Projects::IssuesController < Projects::ApplicationController
end
def export_csv
- ExportCsvWorker.perform_async(current_user.id, project.id, finder_options.to_h) # rubocop:disable CodeReuse/Worker
+ IssuableExportCsvWorker.perform_async(:issue, current_user.id, project.id, finder_options.to_h) # rubocop:disable CodeReuse/Worker
index_path = project_issues_path(project)
message = _('Your CSV export has started. It will be emailed to %{email} when complete.') % { email: current_user.notification_email }
@@ -239,7 +251,7 @@ class Projects::IssuesController < Projects::ApplicationController
return @issue if defined?(@issue)
# The Sortable default scope causes performance issues when used with find_by
- @issuable = @noteable = @issue ||= @project.issues.includes(author: :status).where(iid: params[:id]).reorder(nil).take!
+ @issuable = @noteable = @issue ||= @project.issues.inc_relations_for_view.iid_in(params[:id]).without_order.take!
@note = @project.notes.new(noteable: @issuable)
return render_404 unless can?(current_user, :read_issue, @issue)
@@ -313,7 +325,7 @@ class Projects::IssuesController < Projects::ApplicationController
end
def store_uri
- if request.get? && !request.xhr?
+ if request.get? && request.format.html?
store_location_for :user, request.fullpath
end
end
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 3f7f8da3478..3ceb60a6aef 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -4,7 +4,8 @@ class Projects::JobsController < Projects::ApplicationController
include SendFileUpload
include ContinueParams
- before_action :build, except: [:index]
+ before_action :find_job_as_build, except: [:index, :play]
+ before_action :find_job_as_processable, only: [:play]
before_action :authorize_read_build!
before_action :authorize_update_build!,
except: [:index, :show, :status, :raw, :trace, :erase]
@@ -16,6 +17,8 @@ class Projects::JobsController < Projects::ApplicationController
layout 'project'
+ feature_category :continuous_integration
+
def index
# We need all builds for tabs counters
@all_builds = Ci::JobsFinder.new(current_user: current_user, project: @project).execute
@@ -28,11 +31,6 @@ class Projects::JobsController < Projects::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def show
- @pipeline = @build.pipeline
- @builds = @pipeline.builds
- .order('id DESC')
- .present(current_user: current_user)
-
respond_to do |format|
format.html
format.json do
@@ -47,10 +45,10 @@ class Projects::JobsController < Projects::ApplicationController
# rubocop: enable CodeReuse/ActiveRecord
def trace
- build.trace.read do |stream|
+ @build.trace.read do |stream|
respond_to do |format|
format.json do
- build.trace.being_watched!
+ @build.trace.being_watched!
build_trace = Ci::BuildTrace.new(
build: @build,
@@ -75,8 +73,13 @@ class Projects::JobsController < Projects::ApplicationController
def play
return respond_422 unless @build.playable?
- build = @build.play(current_user, play_params[:job_variables_attributes])
- redirect_to build_path(build)
+ job = @build.play(current_user, play_params[:job_variables_attributes])
+
+ if job.is_a?(Ci::Bridge)
+ redirect_to pipeline_path(job.pipeline)
+ else
+ redirect_to build_path(job)
+ end
end
def cancel
@@ -120,7 +123,7 @@ class Projects::JobsController < Projects::ApplicationController
send_params: raw_send_params,
redirect_params: raw_redirect_params)
else
- build.trace.read do |stream|
+ @build.trace.read do |stream|
if stream.file?
workhorse_set_content_type!
send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
@@ -152,19 +155,19 @@ class Projects::JobsController < Projects::ApplicationController
private
def authorize_update_build!
- return access_denied! unless can?(current_user, :update_build, build)
+ return access_denied! unless can?(current_user, :update_build, @build)
end
def authorize_erase_build!
- return access_denied! unless can?(current_user, :erase_build, build)
+ return access_denied! unless can?(current_user, :erase_build, @build)
end
def authorize_use_build_terminal!
- return access_denied! unless can?(current_user, :create_build_terminal, build)
+ return access_denied! unless can?(current_user, :create_build_terminal, @build)
end
def authorize_create_proxy_build!
- return access_denied! unless can?(current_user, :create_build_service_proxy, build)
+ return access_denied! unless can?(current_user, :create_build_service_proxy, @build)
end
def verify_api_request!
@@ -189,14 +192,22 @@ class Projects::JobsController < Projects::ApplicationController
end
def trace_artifact_file
- @trace_artifact_file ||= build.job_artifacts_trace&.file
+ @trace_artifact_file ||= @build.job_artifacts_trace&.file
end
- def build
- @build ||= project.builds.find(params[:id])
+ def find_job_as_build
+ @build = project.builds.find(params[:id])
.present(current_user: current_user)
end
+ def find_job_as_processable
+ if ::Gitlab::Ci::Features.manual_bridges_enabled?(project)
+ @build = project.processables.find(params[:id])
+ else
+ find_job_as_build
+ end
+ end
+
def build_path(build)
project_job_path(build.project, build)
end
@@ -211,10 +222,10 @@ class Projects::JobsController < Projects::ApplicationController
end
def build_service_specification
- build.service_specification(service: params['service'],
- port: params['port'],
- path: params['path'],
- subprotocols: proxy_subprotocol)
+ @build.service_specification(service: params['service'],
+ port: params['port'],
+ path: params['path'],
+ subprotocols: proxy_subprotocol)
end
def proxy_subprotocol
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index b7aeab8f5ff..ba8e6b90971 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -2,6 +2,7 @@
class Projects::LabelsController < Projects::ApplicationController
include ToggleSubscriptionAction
+ include ShowInheritedLabelsChecker
before_action :check_issuables_available!
before_action :label, only: [:edit, :update, :destroy, :promote]
@@ -14,6 +15,8 @@ class Projects::LabelsController < Projects::ApplicationController
respond_to :js, :html
+ feature_category :issue_tracking
+
def index
@prioritized_labels = @available_labels.prioritized(@project)
@labels = @available_labels.unprioritized(@project).page(params[:page])
@@ -161,7 +164,7 @@ class Projects::LabelsController < Projects::ApplicationController
@available_labels ||=
LabelsFinder.new(current_user,
project_id: @project.id,
- include_ancestor_groups: params[:include_ancestor_groups],
+ include_ancestor_groups: show_inherited_labels?(params[:include_ancestor_groups]),
search: params[:search],
subscribed: params[:subscribed],
sort: sort).execute
diff --git a/app/controllers/projects/logs_controller.rb b/app/controllers/projects/logs_controller.rb
index b9027b3a2cb..b9aa9bfc947 100644
--- a/app/controllers/projects/logs_controller.rb
+++ b/app/controllers/projects/logs_controller.rb
@@ -7,6 +7,8 @@ module Projects
before_action :authorize_read_pod_logs!
before_action :ensure_deployments, only: %i(k8s elasticsearch)
+ feature_category :logging
+
def index
if environment || cluster
render :index
diff --git a/app/controllers/projects/mattermosts_controller.rb b/app/controllers/projects/mattermosts_controller.rb
index cfaeddf711a..63a36732d87 100644
--- a/app/controllers/projects/mattermosts_controller.rb
+++ b/app/controllers/projects/mattermosts_controller.rb
@@ -10,6 +10,8 @@ class Projects::MattermostsController < Projects::ApplicationController
before_action :service
before_action :teams, only: [:new]
+ feature_category :integrations
+
def new
end
diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb
index 921da788ad2..9cac9f37eb7 100644
--- a/app/controllers/projects/merge_requests/application_controller.rb
+++ b/app/controllers/projects/merge_requests/application_controller.rb
@@ -5,6 +5,8 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
before_action :merge_request
before_action :authorize_read_merge_request!
+ feature_category :code_review
+
private
def merge_request
@@ -35,6 +37,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
:source_branch,
:source_project_id,
:state_event,
+ :wip_event,
:squash,
:target_branch,
:target_project_id,
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 8aacfdce094..07c38431f0f 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -64,7 +64,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
render: ->(partial, locals) { view_to_html_string(partial, locals) }
}
- options = additional_attributes.merge(diff_view: Feature.enabled?(:unified_diff_lines, @merge_request.project) ? "inline" : diff_view)
+ options = additional_attributes.merge(diff_view: Feature.enabled?(:unified_diff_lines, @merge_request.project, default_enabled: true) ? "inline" : diff_view)
if @merge_request.project.context_commits_enabled?
options[:context_commits] = @merge_request.recent_context_commits
@@ -173,7 +173,6 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
end
def update_diff_discussion_positions!
- return unless Feature.enabled?(:merge_ref_head_comments, @merge_request.target_project, default_enabled: true)
return unless Feature.enabled?(:merge_red_head_comments_position_on_demand, @merge_request.target_project, default_enabled: true)
return if @merge_request.has_any_diff_note_positions?
diff --git a/app/controllers/projects/merge_requests/drafts_controller.rb b/app/controllers/projects/merge_requests/drafts_controller.rb
index f4846b1aa81..ca3f36cafe1 100644
--- a/app/controllers/projects/merge_requests/drafts_controller.rb
+++ b/app/controllers/projects/merge_requests/drafts_controller.rb
@@ -45,7 +45,7 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli
if result[:status] == :success
head :ok
else
- render json: { message: result[:message] }, status: result[:status]
+ render json: { message: result[:message] }, status: :internal_server_error
end
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 92785540172..91a041bb35b 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -27,11 +27,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
before_action only: [:show] do
- push_frontend_feature_flag(:deploy_from_footer, @project, default_enabled: true)
- push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline)
- push_frontend_feature_flag(:code_navigation, @project, default_enabled: true)
+ push_frontend_experiment(:suggest_pipeline)
push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true)
- push_frontend_feature_flag(:merge_ref_head_comments, @project, default_enabled: true)
push_frontend_feature_flag(:mr_commit_neighbor_nav, @project, default_enabled: true)
push_frontend_feature_flag(:multiline_comments, @project, default_enabled: true)
push_frontend_feature_flag(:file_identifier_hash)
@@ -39,25 +36,35 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:approvals_commented_by, @project, default_enabled: true)
push_frontend_feature_flag(:hide_jump_to_next_unresolved_in_threads, default_enabled: true)
push_frontend_feature_flag(:merge_request_widget_graphql, @project)
- push_frontend_feature_flag(:unified_diff_lines, @project)
+ push_frontend_feature_flag(:unified_diff_lines, @project, default_enabled: true)
push_frontend_feature_flag(:highlight_current_diff_row, @project)
push_frontend_feature_flag(:default_merge_ref_for_diffs, @project)
+ push_frontend_feature_flag(:core_security_mr_widget, @project, default_enabled: true)
+
+ record_experiment_user(:invite_members_version_a)
+ record_experiment_user(:invite_members_version_b)
end
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, @project.group)
+ push_frontend_feature_flag(:deployment_filters)
end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions]
after_action :log_merge_request_show, only: [:show]
- feature_category :source_code_management,
- unless: -> (action) { action.ends_with?("_reports") }
- feature_category :code_testing,
- only: [:test_reports, :coverage_reports, :terraform_reports]
- feature_category :accessibility_testing,
- only: [:accessibility_reports]
+ feature_category :code_review, [
+ :assign_related_issues, :bulk_update, :cancel_auto_merge,
+ :ci_environments_status, :commit_change_content, :commits,
+ :context_commits, :destroy, :diff_for_path, :discussions,
+ :edit, :exposed_artifacts, :index, :merge,
+ :pipeline_status, :pipelines, :rebase, :remove_wip, :show,
+ :toggle_award_emoji, :toggle_subscription, :update
+ ]
+
+ feature_category :code_testing, [:test_reports, :coverage_reports, :terraform_reports]
+ feature_category :accessibility_testing, [:accessibility_reports]
def index
@merge_requests = @issuables
diff --git a/app/controllers/projects/metrics/dashboards/builder_controller.rb b/app/controllers/projects/metrics/dashboards/builder_controller.rb
index 2ab574d7d10..96ca6d89111 100644
--- a/app/controllers/projects/metrics/dashboards/builder_controller.rb
+++ b/app/controllers/projects/metrics/dashboards/builder_controller.rb
@@ -6,6 +6,8 @@ module Projects
class BuilderController < Projects::ApplicationController
before_action :authorize_metrics_dashboard!
+ feature_category :metrics
+
def panel_preview
respond_to do |format|
format.json do
diff --git a/app/controllers/projects/metrics_dashboard_controller.rb b/app/controllers/projects/metrics_dashboard_controller.rb
index bc0a701b9fd..3f10749602e 100644
--- a/app/controllers/projects/metrics_dashboard_controller.rb
+++ b/app/controllers/projects/metrics_dashboard_controller.rb
@@ -14,6 +14,8 @@ module Projects
push_frontend_feature_flag(:disable_metric_dashboard_refresh_rate)
end
+ feature_category :metrics
+
def show
if environment
render 'projects/environments/metrics'
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index 16d63cc184f..e6c4af00b29 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -5,7 +5,7 @@ class Projects::MilestonesController < Projects::ApplicationController
include MilestoneActions
before_action :check_issuables_available!
- before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels, :promote]
+ before_action :milestone, only: [:edit, :update, :destroy, :show, :issues, :merge_requests, :participants, :labels, :promote]
before_action do
push_frontend_feature_flag(:burnup_charts, @project)
end
@@ -14,13 +14,15 @@ class Projects::MilestonesController < Projects::ApplicationController
before_action :authorize_read_milestone!
# Allow admin milestone
- before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels]
+ before_action :authorize_admin_milestone!, except: [:index, :show, :issues, :merge_requests, :participants, :labels]
# Allow to promote milestone
before_action :authorize_promote_milestone!, only: :promote
respond_to :html
+ feature_category :issue_tracking
+
def index
@sort = params[:sort] || 'due_date_asc'
@milestones = milestones.sort_by_attribute(@sort)
diff --git a/app/controllers/projects/mirrors_controller.rb b/app/controllers/projects/mirrors_controller.rb
index 518e6a92afa..01abb72fc86 100644
--- a/app/controllers/projects/mirrors_controller.rb
+++ b/app/controllers/projects/mirrors_controller.rb
@@ -10,6 +10,8 @@ class Projects::MirrorsController < Projects::ApplicationController
layout "project_settings"
+ feature_category :source_code_management
+
def show
redirect_to_repository_settings(project, anchor: 'js-push-remote-settings')
end
diff --git a/app/controllers/projects/network_controller.rb b/app/controllers/projects/network_controller.rb
index 47788438da9..89b679fc033 100644
--- a/app/controllers/projects/network_controller.rb
+++ b/app/controllers/projects/network_controller.rb
@@ -11,6 +11,8 @@ class Projects::NetworkController < Projects::ApplicationController
before_action :assign_options
before_action :assign_commit
+ feature_category :source_code_management
+
def show
@url = project_network_path(@project, @ref, @options.merge(format: :json))
@commit_url = project_commit_path(@project, 'ae45ca32').gsub("ae45ca32", "%s")
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index c0b8c9e550d..e50e293a103 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -11,6 +11,8 @@ class Projects::NotesController < Projects::ApplicationController
before_action :authorize_create_note!, only: [:create]
before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
+ feature_category :issue_tracking
+
def delete_attachment
note.remove_attachment!
note.update_attribute(:attachment, nil)
diff --git a/app/controllers/projects/packages/package_files_controller.rb b/app/controllers/projects/packages/package_files_controller.rb
index dd6d875cd1e..32aadb4fcf4 100644
--- a/app/controllers/projects/packages/package_files_controller.rb
+++ b/app/controllers/projects/packages/package_files_controller.rb
@@ -6,6 +6,8 @@ module Projects
include PackagesAccess
include SendFileUpload
+ feature_category :package_registry
+
def download
package_file = project.package_files.find(params[:id])
diff --git a/app/controllers/projects/packages/packages_controller.rb b/app/controllers/projects/packages/packages_controller.rb
index fc4ef7a01dc..15dc11f5df8 100644
--- a/app/controllers/projects/packages/packages_controller.rb
+++ b/app/controllers/projects/packages/packages_controller.rb
@@ -5,20 +5,13 @@ module Projects
class PackagesController < Projects::ApplicationController
include PackagesAccess
- before_action :authorize_destroy_package!, only: [:destroy]
+ feature_category :package_registry
def show
@package = project.packages.find(params[:id])
@package_files = @package.package_files.recent
@maven_metadatum = @package.maven_metadatum
end
-
- def destroy
- @package = project.packages.find(params[:id])
- @package.destroy
-
- redirect_to project_packages_path(@project), status: :found, notice: _('Package was removed')
- end
end
end
end
diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb
index 9ad6bf4095a..0aac517e3e3 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -8,6 +8,8 @@ class Projects::PagesController < Projects::ApplicationController
before_action :authorize_update_pages!, except: [:show, :destroy]
before_action :authorize_remove_pages!, only: [:destroy]
+ feature_category :pages
+
# rubocop: disable CodeReuse/ActiveRecord
def show
@domains = @project.pages_domains.order(:domain).present(current_user: current_user)
diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb
index cccf8fe4358..a6b22a28b17 100644
--- a/app/controllers/projects/pages_domains_controller.rb
+++ b/app/controllers/projects/pages_domains_controller.rb
@@ -9,6 +9,8 @@ class Projects::PagesDomainsController < Projects::ApplicationController
helper_method :domain_presenter
+ feature_category :pages
+
def show
end
diff --git a/app/controllers/projects/performance_monitoring/dashboards_controller.rb b/app/controllers/projects/performance_monitoring/dashboards_controller.rb
index ec5a33f5dd6..51a07c1b7a5 100644
--- a/app/controllers/projects/performance_monitoring/dashboards_controller.rb
+++ b/app/controllers/projects/performance_monitoring/dashboards_controller.rb
@@ -12,6 +12,8 @@ module Projects
respond_error(http_status: :bad_request, message: _('Request parameter %{param} is missing.') % { param: exception.param })
end
+ feature_category :metrics
+
def create
result = ::Metrics::Dashboard::CloneDashboardService.new(project, current_user, dashboard_params).execute
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index e7e8a900060..5655d3b4c0d 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -10,6 +10,8 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create, :play]
before_action :authorize_admin_pipeline_schedule!, only: [:destroy]
+ feature_category :continuous_integration
+
# rubocop: disable CodeReuse/ActiveRecord
def index
@scope = params[:scope]
diff --git a/app/controllers/projects/pipelines/application_controller.rb b/app/controllers/projects/pipelines/application_controller.rb
index 92887750813..c147d697888 100644
--- a/app/controllers/projects/pipelines/application_controller.rb
+++ b/app/controllers/projects/pipelines/application_controller.rb
@@ -10,6 +10,8 @@ module Projects
before_action :pipeline
before_action :authorize_read_pipeline!
+ feature_category :continuous_integration
+
private
def pipeline
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index c1734d2cd8a..953dce4d63c 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -5,17 +5,19 @@ class Projects::PipelinesController < Projects::ApplicationController
include Analytics::UniqueVisitsHelper
before_action :whitelist_query_limiting, only: [:create, :retry]
- before_action :pipeline, except: [:index, :new, :create, :charts]
+ before_action :pipeline, except: [:index, :new, :create, :charts, :config_variables]
before_action :set_pipeline_path, only: [:show]
before_action :authorize_read_pipeline!
before_action :authorize_read_build!, only: [:index]
- before_action :authorize_create_pipeline!, only: [:new, :create]
+ before_action :authorize_create_pipeline!, only: [:new, :create, :config_variables]
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action do
push_frontend_feature_flag(:filter_pipelines_search, project, default_enabled: true)
push_frontend_feature_flag(:dag_pipeline_tab, project, default_enabled: true)
push_frontend_feature_flag(:pipelines_security_report_summary, project)
- push_frontend_feature_flag(:new_pipeline_form)
+ push_frontend_feature_flag(:new_pipeline_form, project)
+ push_frontend_feature_flag(:graphql_pipeline_header, project, type: :development, default_enabled: false)
+ push_frontend_feature_flag(:new_pipeline_form_prefilled_vars, project, type: :development)
end
before_action :ensure_pipeline, only: [:show]
@@ -30,6 +32,8 @@ class Projects::PipelinesController < Projects::ApplicationController
POLLING_INTERVAL = 10_000
+ feature_category :continuous_integration
+
def index
@pipelines = Ci::PipelinesFinder
.new(project, current_user, index_params)
@@ -206,6 +210,14 @@ class Projects::PipelinesController < Projects::ApplicationController
end
end
+ def config_variables
+ respond_to do |format|
+ format.json do
+ render json: Ci::ListConfigVariablesService.new(@project).execute(params[:sha])
+ end
+ end
+ end
+
private
def serialize_pipelines
diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb
index 7f988110977..6e08a889520 100644
--- a/app/controllers/projects/pipelines_settings_controller.rb
+++ b/app/controllers/projects/pipelines_settings_controller.rb
@@ -3,6 +3,8 @@
class Projects::PipelinesSettingsController < Projects::ApplicationController
before_action :authorize_admin_pipeline!
+ feature_category :continuous_integration
+
def show
redirect_to project_settings_ci_cd_path(@project, params: params.to_unsafe_h)
end
diff --git a/app/controllers/projects/product_analytics_controller.rb b/app/controllers/projects/product_analytics_controller.rb
index c019dc191d6..5db7585d8e0 100644
--- a/app/controllers/projects/product_analytics_controller.rb
+++ b/app/controllers/projects/product_analytics_controller.rb
@@ -5,6 +5,8 @@ class Projects::ProductAnalyticsController < Projects::ApplicationController
before_action :authorize_read_product_analytics!
before_action :tracker_variables, only: [:setup, :test]
+ feature_category :product_analytics
+
def index
@events = product_analytics_events.order_by_time.page(params[:page])
end
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index 3e52248f292..631f627838b 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -8,6 +8,8 @@ class Projects::ProjectMembersController < Projects::ApplicationController
# Authorize
before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access]
+ feature_category :authentication_and_authorization
+
def index
@sort = params[:sort].presence || sort_value_name
@@ -55,6 +57,10 @@ class Projects::ProjectMembersController < Projects::ApplicationController
def filter_params
params.permit(:search).merge(sort: @sort)
end
+
+ def membershipable_members
+ project.members
+ end
end
Projects::ProjectMembersController.prepend_if_ee('EE::Projects::ProjectMembersController')
diff --git a/app/controllers/projects/prometheus/alerts_controller.rb b/app/controllers/projects/prometheus/alerts_controller.rb
index c6ae65f7832..2892542e63c 100644
--- a/app/controllers/projects/prometheus/alerts_controller.rb
+++ b/app/controllers/projects/prometheus/alerts_controller.rb
@@ -16,6 +16,8 @@ module Projects
before_action :authorize_read_prometheus_alerts!, except: [:notify]
before_action :alert, only: [:update, :show, :destroy, :metrics_dashboard]
+ feature_category :alert_management
+
def index
render json: serialize_as_json(alerts)
end
diff --git a/app/controllers/projects/prometheus/metrics_controller.rb b/app/controllers/projects/prometheus/metrics_controller.rb
index 0340cb5beb0..d70d29a341f 100644
--- a/app/controllers/projects/prometheus/metrics_controller.rb
+++ b/app/controllers/projects/prometheus/metrics_controller.rb
@@ -6,6 +6,8 @@ module Projects
before_action :authorize_admin_project!
before_action :require_prometheus_metrics!
+ feature_category :metrics
+
def active_common
respond_to do |format|
format.json do
diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb
index 060403a9cd9..4cba1a75330 100644
--- a/app/controllers/projects/protected_refs_controller.rb
+++ b/app/controllers/projects/protected_refs_controller.rb
@@ -10,6 +10,8 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
layout "project_settings"
+ feature_category :source_code_management
+
def index
redirect_to_repository_settings(@project)
end
@@ -62,7 +64,7 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
end
def access_level_attributes
- %i[access_level id _destroy]
+ %i[access_level id _destroy deploy_key_id]
end
end
diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb
index 29f1e4bfd44..a9490c106d4 100644
--- a/app/controllers/projects/raw_controller.rb
+++ b/app/controllers/projects/raw_controller.rb
@@ -15,6 +15,8 @@ class Projects::RawController < Projects::ApplicationController
before_action :no_cache_headers, only: [:show]
before_action :redirect_to_external_storage, only: :show, if: :static_objects_external_storage_enabled?
+ feature_category :source_code_management
+
def show
@blob = @repository.blob_at(@commit.id, @path)
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index db770d3e438..4d23c853334 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -11,6 +11,8 @@ class Projects::RefsController < Projects::ApplicationController
before_action :assign_ref_vars
before_action :authorize_download_code!
+ feature_category :source_code_management
+
def switch
respond_to do |format|
format.html do
diff --git a/app/controllers/projects/registry/application_controller.rb b/app/controllers/projects/registry/application_controller.rb
index 2f891d78c91..e7bf8c8e757 100644
--- a/app/controllers/projects/registry/application_controller.rb
+++ b/app/controllers/projects/registry/application_controller.rb
@@ -8,6 +8,8 @@ module Projects
before_action :verify_registry_enabled!
before_action :authorize_read_container_image!
+ feature_category :container_registry
+
private
def verify_registry_enabled!
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
index 19d0cb9acdc..28a86ecc9f0 100644
--- a/app/controllers/projects/registry/repositories_controller.rb
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -3,6 +3,8 @@
module Projects
module Registry
class RepositoriesController < ::Projects::Registry::ApplicationController
+ include PackagesHelper
+
before_action :authorize_update_container_image!, only: [:destroy]
before_action :ensure_root_container_repository!, only: [:index]
@@ -13,7 +15,7 @@ module Projects
@images = ContainerRepositoriesFinder.new(user: current_user, subject: project, params: params.slice(:name))
.execute
- track_event(:list_repositories)
+ track_package_event(:list_repositories, :container)
serializer = ContainerRepositoriesSerializer
.new(project: project, current_user: current_user)
@@ -31,7 +33,7 @@ module Projects
def destroy
image.delete_scheduled!
DeleteContainerRepositoryWorker.perform_async(current_user.id, image.id) # rubocop:disable CodeReuse/Worker
- track_event(:delete_repository)
+ track_package_event(:delete_repository, :container)
respond_to do |format|
format.json { head :no_content }
diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb
index c42e3f6bdba..ebdb668207f 100644
--- a/app/controllers/projects/registry/tags_controller.rb
+++ b/app/controllers/projects/registry/tags_controller.rb
@@ -3,12 +3,15 @@
module Projects
module Registry
class TagsController < ::Projects::Registry::ApplicationController
+ include PackagesHelper
+
before_action :authorize_destroy_container_image!, only: [:destroy]
LIMIT = 15
def index
- track_event(:list_tags)
+ track_package_event(:list_tags, :tag)
+
respond_to do |format|
format.json do
render json: ContainerTagsSerializer
@@ -23,7 +26,7 @@ module Projects
result = Projects::ContainerRepository::DeleteTagsService
.new(image.project, current_user, tags: [params[:id]])
.execute(image)
- track_event(:delete_tag)
+ track_package_event(:delete_tag, :tag)
respond_to do |format|
format.json { head(result[:status] == :success ? :ok : bad_request) }
@@ -40,7 +43,7 @@ module Projects
result = Projects::ContainerRepository::DeleteTagsService
.new(image.project, current_user, tags: tag_names)
.execute(image)
- track_event(:delete_tag_bulk)
+ track_package_event(:delete_tag_bulk, :tag)
respond_to do |format|
format.json { head(result[:status] == :success ? :no_content : :bad_request) }
diff --git a/app/controllers/projects/releases/evidences_controller.rb b/app/controllers/projects/releases/evidences_controller.rb
index 34e450d903f..1e2dbf8047c 100644
--- a/app/controllers/projects/releases/evidences_controller.rb
+++ b/app/controllers/projects/releases/evidences_controller.rb
@@ -7,6 +7,8 @@ module Projects
before_action :release
before_action :authorize_read_release_evidence!
+ feature_category :release_evidence
+
def show
respond_to do |format|
format.json do
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index bd24aae980c..4e8260d9e53 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -6,18 +6,16 @@ class Projects::ReleasesController < Projects::ApplicationController
before_action :release, only: %i[edit show update downloads]
before_action :authorize_read_release!
before_action do
- push_frontend_feature_flag(:release_issue_summary, project, default_enabled: true)
- push_frontend_feature_flag(:release_evidence_collection, project, default_enabled: true)
- push_frontend_feature_flag(:release_show_page, project, default_enabled: true)
- push_frontend_feature_flag(:release_asset_link_editing, project, default_enabled: true)
- push_frontend_feature_flag(:release_asset_link_type, project, default_enabled: true)
push_frontend_feature_flag(:graphql_release_data, project, default_enabled: true)
push_frontend_feature_flag(:graphql_milestone_stats, project, default_enabled: true)
- push_frontend_feature_flag(:graphql_releases_page, project, default_enabled: false)
+ push_frontend_feature_flag(:graphql_releases_page, project, default_enabled: true)
+ push_frontend_feature_flag(:graphql_individual_release_page, project, default_enabled: true)
end
before_action :authorize_update_release!, only: %i[edit update]
before_action :authorize_create_release!, only: :new
+ feature_category :release_orchestration
+
def index
respond_to do |format|
format.html do
@@ -27,10 +25,6 @@ class Projects::ReleasesController < Projects::ApplicationController
end
end
- def show
- return render_404 unless Feature.enabled?(:release_show_page, project, default_enabled: true)
- end
-
def new
unless Feature.enabled?(:new_release_page, project, default_enabled: true)
redirect_to(new_project_tag_path(@project))
diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb
index 303326bbe23..ba3ab52e3af 100644
--- a/app/controllers/projects/repositories_controller.rb
+++ b/app/controllers/projects/repositories_controller.rb
@@ -18,6 +18,8 @@ class Projects::RepositoriesController < Projects::ApplicationController
before_action :authorize_admin_project!, only: :create
before_action :redirect_to_external_storage, only: :archive, if: :static_objects_external_storage_enabled?
+ feature_category :source_code_management
+
def create
@project.create_repository
diff --git a/app/controllers/projects/runner_projects_controller.rb b/app/controllers/projects/runner_projects_controller.rb
index cbeb32fd610..d225d5e104c 100644
--- a/app/controllers/projects/runner_projects_controller.rb
+++ b/app/controllers/projects/runner_projects_controller.rb
@@ -5,6 +5,8 @@ class Projects::RunnerProjectsController < Projects::ApplicationController
layout 'project_settings'
+ feature_category :continuous_integration
+
def create
@runner = Ci::Runner.find(params[:runner_project][:runner_id])
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index ca62f54813b..544074f9840 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -6,6 +6,8 @@ class Projects::RunnersController < Projects::ApplicationController
layout 'project_settings'
+ feature_category :continuous_integration
+
def index
redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings')
end
@@ -50,6 +52,10 @@ class Projects::RunnersController < Projects::ApplicationController
end
def toggle_shared_runners
+ if Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true) && !project.shared_runners_enabled && project.group && project.group.shared_runners_setting == 'disabled_and_unoverridable'
+ return redirect_to project_runners_path(@project), alert: _("Cannot enable shared runners because parent group does not allow it")
+ end
+
project.toggle!(:shared_runners_enabled)
redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings')
diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb
index 0b55414d390..4168880001c 100644
--- a/app/controllers/projects/serverless/functions_controller.rb
+++ b/app/controllers/projects/serverless/functions_controller.rb
@@ -5,6 +5,8 @@ module Projects
class FunctionsController < Projects::ApplicationController
before_action :authorize_read_cluster!
+ feature_category :serverless
+
def index
respond_to do |format|
format.json do
diff --git a/app/controllers/projects/service_desk_controller.rb b/app/controllers/projects/service_desk_controller.rb
index bcd190bbc2c..f7c0a54fb9e 100644
--- a/app/controllers/projects/service_desk_controller.rb
+++ b/app/controllers/projects/service_desk_controller.rb
@@ -3,6 +3,8 @@
class Projects::ServiceDeskController < Projects::ApplicationController
before_action :authorize_admin_project!
+ feature_category :service_desk
+
def show
json_response
end
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index 9a69ef991dd..93ad549bc50 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -12,13 +12,15 @@ class Projects::ServicesController < Projects::ApplicationController
before_action :set_deprecation_notice_for_prometheus_service, only: [:edit, :update]
before_action :redirect_deprecated_prometheus_service, only: [:update]
before_action only: :edit do
- push_frontend_feature_flag(:jira_issues_integration, @project, { default_enabled: true })
+ push_frontend_feature_flag(:jira_issues_integration, @project, type: :licensed, default_enabled: true)
end
respond_to :html
layout "project_settings"
+ feature_category :integrations
+
def edit
@default_integration = Service.default_integration(service.type, project)
end
diff --git a/app/controllers/projects/settings/access_tokens_controller.rb b/app/controllers/projects/settings/access_tokens_controller.rb
index d6b4c4dd5dc..cbd6716fdf7 100644
--- a/app/controllers/projects/settings/access_tokens_controller.rb
+++ b/app/controllers/projects/settings/access_tokens_controller.rb
@@ -7,6 +7,8 @@ module Projects
before_action :check_feature_availability
+ feature_category :authentication_and_authorization
+
def index
@project_access_token = PersonalAccessToken.new
set_index_vars
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index d0d100fd88c..2963321f803 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -3,15 +3,25 @@
module Projects
module Settings
class CiCdController < Projects::ApplicationController
+ include RunnerSetupScripts
+
before_action :authorize_admin_pipeline!
before_action :define_variables
before_action do
push_frontend_feature_flag(:new_variables_ui, @project, default_enabled: true)
push_frontend_feature_flag(:ajax_new_deploy_token, @project)
- push_frontend_feature_flag(:ci_key_autocomplete, default_enabled: true)
end
+ helper_method :highlight_badge
+
+ feature_category :continuous_integration
+
def show
+ if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project)
+ @triggers_json = ::Ci::TriggerSerializer.new.represent(
+ @project.triggers, current_user: current_user, project: @project
+ ).to_json
+ end
end
def update
@@ -48,8 +58,16 @@ module Projects
redirect_to namespace_project_settings_ci_cd_path
end
+ def runner_setup_scripts
+ private_runner_setup_scripts(project: @project)
+ end
+
private
+ def highlight_badge(name, content, language = nil)
+ Gitlab::Highlight.highlight(name, content, language: language)
+ end
+
def update_params
params.require(:project).permit(*permitted_project_params)
end
@@ -58,9 +76,9 @@ module Projects
[
:runners_token, :builds_enabled, :build_allow_git_fetch,
:build_timeout_human_readable, :build_coverage_regex, :public_builds,
- :auto_cancel_pending_pipelines, :forward_deployment_enabled, :ci_config_path,
+ :auto_cancel_pending_pipelines, :ci_config_path,
auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy],
- ci_cd_settings_attributes: [:default_git_depth]
+ ci_cd_settings_attributes: [:default_git_depth, :forward_deployment_enabled]
].tap do |list|
list << :max_artifacts_size if can?(current_user, :update_max_artifacts_size, project)
end
@@ -116,6 +134,7 @@ module Projects
def define_triggers_variables
@triggers = @project.triggers
.present(current_user: current_user)
+
@trigger = ::Ci::Trigger.new
.present(current_user: current_user)
end
diff --git a/app/controllers/projects/settings/integrations_controller.rb b/app/controllers/projects/settings/integrations_controller.rb
index 96bf7f474d1..fba11ff1497 100644
--- a/app/controllers/projects/settings/integrations_controller.rb
+++ b/app/controllers/projects/settings/integrations_controller.rb
@@ -6,6 +6,8 @@ module Projects
before_action :authorize_admin_project!
layout "project_settings"
+ feature_category :integrations
+
def show
@services = @project.find_or_initialize_services
end
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index 781b850ddfe..c407b15e29f 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -9,6 +9,9 @@ module Projects
respond_to :json, only: [:reset_alerting_token, :reset_pagerduty_token]
helper_method :error_tracking_setting
+ helper_method :tracing_setting
+
+ feature_category :incident_management
def update
result = ::Projects::Operations::UpdateService.new(project, current_user, update_params).execute
@@ -17,15 +20,6 @@ module Projects
render_update_response(result)
end
- # overridden in EE
- def track_events(result)
- if result[:status] == :success
- ::Gitlab::Tracking::IncidentManagement.track_from_params(
- update_params[:incident_management_setting_attributes]
- )
- end
- end
-
def reset_alerting_token
result = ::Projects::Operations::UpdateService
.new(project, current_user, alerting_params)
@@ -55,6 +49,24 @@ module Projects
private
+ def track_events(result)
+ if result[:status] == :success
+ ::Gitlab::Tracking::IncidentManagement.track_from_params(
+ update_params[:incident_management_setting_attributes]
+ )
+ track_tracing_external_url
+ end
+ end
+
+ def track_tracing_external_url
+ external_url_previous_change = project&.tracing_setting&.external_url_previous_change
+
+ return unless external_url_previous_change
+ return unless external_url_previous_change[0].blank? && external_url_previous_change[1].present?
+
+ ::Gitlab::Tracking.event('project:operations:tracing', 'external_url_populated')
+ end
+
def alerting_params
{ alerting_setting_attributes: { regenerate_token: true } }
end
@@ -106,6 +118,10 @@ module Projects
project.build_error_tracking_setting
end
+ def tracing_setting
+ @tracing_setting ||= project.tracing_setting || project.build_tracing_setting
+ end
+
def update_params
params.require(:project).permit(permitted_project_params)
end
@@ -124,7 +140,8 @@ module Projects
project: [:slug, :name, :organization_slug, :organization_name]
],
- grafana_integration_attributes: [:token, :grafana_url, :enabled]
+ grafana_integration_attributes: [:token, :grafana_url, :enabled],
+ tracing_setting_attributes: [:external_url]
}
if Feature.enabled?(:settings_operations_prometheus_service, project)
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index 35ca9336613..0994bebb1d0 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -7,8 +7,12 @@ module Projects
before_action :define_variables, only: [:create_deploy_token]
before_action do
push_frontend_feature_flag(:ajax_new_deploy_token, @project)
+ push_frontend_feature_flag(:deploy_keys_on_protected_branches, @project)
end
+ feature_category :source_code_management, [:show, :cleanup]
+ feature_category :continuous_delivery, [:create_deploy_token]
+
def show
render_show
end
@@ -125,6 +129,7 @@ module Projects
gon.push(protectable_tags_for_dropdown)
gon.push(protectable_branches_for_dropdown)
gon.push(access_levels_options)
+ gon.push(current_project_id: project.id) if project
end
end
end
diff --git a/app/controllers/projects/snippets/application_controller.rb b/app/controllers/projects/snippets/application_controller.rb
index 3f488b07e96..8ee12bf3795 100644
--- a/app/controllers/projects/snippets/application_controller.rb
+++ b/app/controllers/projects/snippets/application_controller.rb
@@ -4,6 +4,8 @@ class Projects::Snippets::ApplicationController < Projects::ApplicationControlle
include FindSnippet
include SnippetAuthorizations
+ feature_category :snippets
+
private
# This overrides the default snippet create authorization
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index 632e8db9796..779e149bb9c 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -7,16 +7,11 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController
before_action :check_snippets_available!
- before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :toggle_award_emoji, :mark_as_spam]
+ before_action :snippet, only: [:show, :edit, :raw, :toggle_award_emoji, :mark_as_spam]
- before_action :authorize_create_snippet!, only: [:new, :create]
- before_action :authorize_read_snippet!, except: [:new, :create, :index]
- before_action :authorize_update_snippet!, only: [:edit, :update]
- before_action :authorize_admin_snippet!, only: [:destroy]
-
- before_action do
- push_frontend_feature_flag(:snippet_multiple_files, current_user)
- end
+ before_action :authorize_create_snippet!, only: :new
+ before_action :authorize_read_snippet!, except: [:new, :index]
+ before_action :authorize_update_snippet!, only: :edit
def index
@snippet_counts = ::Snippets::CountService
@@ -37,14 +32,6 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController
@snippet = @noteable = @project.snippets.build
end
- def create
- create_params = snippet_params.merge(spammable_params)
- service_response = ::Snippets::CreateService.new(project, current_user, create_params).execute
- @snippet = service_response.payload[:snippet]
-
- handle_repository_error(:new)
- end
-
protected
alias_method :awardable, :snippet
@@ -53,8 +40,4 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController
def spammable_path
project_snippet_path(@project, @snippet)
end
-
- def snippet_params
- params.require(:project_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description)
- end
end
diff --git a/app/controllers/projects/starrers_controller.rb b/app/controllers/projects/starrers_controller.rb
index d9654f4f72a..91f49fc4d66 100644
--- a/app/controllers/projects/starrers_controller.rb
+++ b/app/controllers/projects/starrers_controller.rb
@@ -3,6 +3,8 @@
class Projects::StarrersController < Projects::ApplicationController
include SortingHelper
+ feature_category :projects
+
def index
@starrers = UsersStarProjectsFinder.new(@project, params, current_user: @current_user).execute
@sort = params[:sort].presence || sort_value_name
diff --git a/app/controllers/projects/static_site_editor_controller.rb b/app/controllers/projects/static_site_editor_controller.rb
index e97a8db0b79..7e2e32a843f 100644
--- a/app/controllers/projects/static_site_editor_controller.rb
+++ b/app/controllers/projects/static_site_editor_controller.rb
@@ -13,6 +13,8 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController
push_frontend_feature_flag(:sse_image_uploads)
end
+ feature_category :static_site_editor
+
def show
service_response = ::StaticSiteEditor::ConfigService.new(
container: project,
@@ -25,14 +27,32 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController
).execute
if service_response.success?
- @data = service_response.payload
+ Gitlab::UsageDataCounters::StaticSiteEditorCounter.increment_views_count
+
+ @data = serialize_necessary_payload_values_to_json(service_response.payload)
else
- respond_422
+ # TODO: For now, if the service returns any error, the user is redirected
+ # to the root project page with the error message displayed as an alert.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/213285#note_414808004
+ # for discussion of plans to handle this via a page owned by the Static Site Editor.
+ flash[:alert] = service_response.message
+ redirect_to project_path(project)
end
end
private
+ def serialize_necessary_payload_values_to_json(payload)
+ # This will convert booleans, Array-like and Hash-like objects to JSON
+ payload.transform_values do |value|
+ if value.is_a?(String) || value.is_a?(Integer)
+ value
+ else
+ value.to_json
+ end
+ end
+ end
+
def assign_ref_and_path
@ref, @path = extract_ref(params.fetch(:id))
diff --git a/app/controllers/projects/tags/releases_controller.rb b/app/controllers/projects/tags/releases_controller.rb
index c1f4cbce054..8e5539f546b 100644
--- a/app/controllers/projects/tags/releases_controller.rb
+++ b/app/controllers/projects/tags/releases_controller.rb
@@ -8,6 +8,8 @@ class Projects::Tags::ReleasesController < Projects::ApplicationController
before_action :tag
before_action :release
+ feature_category :release_evidence
+
def edit
end
diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb
index 475ca8894e4..1d783241196 100644
--- a/app/controllers/projects/tags_controller.rb
+++ b/app/controllers/projects/tags_controller.rb
@@ -10,6 +10,9 @@ class Projects::TagsController < Projects::ApplicationController
before_action :authorize_download_code!
before_action :authorize_admin_tag!, only: [:new, :create, :destroy]
+ feature_category :source_code_management, [:index, :show, :new, :destroy]
+ feature_category :release_evidence, [:create]
+
# rubocop: disable CodeReuse/ActiveRecord
def index
params[:sort] = params[:sort].presence || sort_value_recently_updated
@@ -76,25 +79,10 @@ class Projects::TagsController < Projects::ApplicationController
def destroy
result = ::Tags::DestroyService.new(project, current_user).execute(params[:id])
- respond_to do |format|
- if result[:status] == :success
- format.html do
- redirect_to project_tags_path(@project), status: :see_other
- end
-
- format.js
- else
- @error = result[:message]
-
- format.html do
- redirect_to project_tags_path(@project),
- alert: @error, status: :see_other
- end
-
- format.js do
- render status: :ok
- end
- end
+ if result[:status] == :success
+ render json: result
+ else
+ render json: { message: result[:message] }, status: result[:return_code]
end
end
diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb
index 95739f96d39..7ab23e39cf0 100644
--- a/app/controllers/projects/templates_controller.rb
+++ b/app/controllers/projects/templates_controller.rb
@@ -5,6 +5,8 @@ class Projects::TemplatesController < Projects::ApplicationController
before_action :authorize_can_read_issuable!
before_action :get_template_class
+ feature_category :templates
+
def show
template = @template_type.find(params[:key], project)
diff --git a/app/controllers/projects/todos_controller.rb b/app/controllers/projects/todos_controller.rb
index 33205b93317..6ba89ab34f8 100644
--- a/app/controllers/projects/todos_controller.rb
+++ b/app/controllers/projects/todos_controller.rb
@@ -6,6 +6,8 @@ class Projects::TodosController < Projects::ApplicationController
before_action :authenticate_user!, only: [:create]
+ feature_category :issue_tracking
+
private
def issuable
diff --git a/app/controllers/projects/tracings_controller.rb b/app/controllers/projects/tracings_controller.rb
new file mode 100644
index 00000000000..2bc0c590e8d
--- /dev/null
+++ b/app/controllers/projects/tracings_controller.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Projects
+ class TracingsController < Projects::ApplicationController
+ content_security_policy do |p|
+ next if p.directives.blank?
+
+ global_frame_src = p.frame_src
+
+ p.frame_src -> { frame_src_csp_policy(global_frame_src) }
+ end
+
+ before_action :authorize_update_environment!
+
+ feature_category :tracing
+
+ def show
+ end
+
+ private
+
+ def frame_src_csp_policy(global_frame_src)
+ external_url = @project&.tracing_setting&.external_url
+
+ external_url.presence || global_frame_src
+ end
+ end
+end
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 638e1a05c18..b5cfc3990b2 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -15,6 +15,8 @@ class Projects::TreeController < Projects::ApplicationController
before_action :authorize_download_code!
before_action :authorize_edit_tree!, only: [:create_dir]
+ feature_category :source_code_management
+
def show
return render_404 unless @commit
diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb
index 7159d0243a3..eec35fcec8d 100644
--- a/app/controllers/projects/triggers_controller.rb
+++ b/app/controllers/projects/triggers_controller.rb
@@ -8,6 +8,8 @@ class Projects::TriggersController < Projects::ApplicationController
layout 'project_settings'
+ feature_category :continuous_integration
+
def index
redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers')
end
diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb
index 72251988b5e..c15768e7bbb 100644
--- a/app/controllers/projects/uploads_controller.rb
+++ b/app/controllers/projects/uploads_controller.rb
@@ -11,6 +11,8 @@ class Projects::UploadsController < Projects::ApplicationController
before_action :authorize_upload_file!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
+ feature_category :not_owned
+
private
def upload_model_class
diff --git a/app/controllers/projects/usage_ping_controller.rb b/app/controllers/projects/usage_ping_controller.rb
index 0e2c8f879b1..9b4ddb326c1 100644
--- a/app/controllers/projects/usage_ping_controller.rb
+++ b/app/controllers/projects/usage_ping_controller.rb
@@ -3,6 +3,8 @@
class Projects::UsagePingController < Projects::ApplicationController
before_action :authenticate_user!
+ feature_category :collection
+
def web_ide_clientside_preview
return render_404 unless Gitlab::CurrentSettings.web_ide_clientside_preview_enabled?
diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb
index 0fd047f90cf..d8efc1b7b54 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -3,6 +3,8 @@
class Projects::VariablesController < Projects::ApplicationController
before_action :authorize_admin_build!
+ feature_category :continuous_integration
+
def show
respond_to do |format|
format.json do
diff --git a/app/controllers/projects/web_ide_schemas_controller.rb b/app/controllers/projects/web_ide_schemas_controller.rb
index 3d16a6fafd4..84a191815f4 100644
--- a/app/controllers/projects/web_ide_schemas_controller.rb
+++ b/app/controllers/projects/web_ide_schemas_controller.rb
@@ -3,6 +3,8 @@
class Projects::WebIdeSchemasController < Projects::ApplicationController
before_action :authenticate_user!
+ feature_category :web_ide
+
def show
return respond_422 unless branch_sha
diff --git a/app/controllers/projects/web_ide_terminals_controller.rb b/app/controllers/projects/web_ide_terminals_controller.rb
index 76bcaa9e80c..1d179765ad9 100644
--- a/app/controllers/projects/web_ide_terminals_controller.rb
+++ b/app/controllers/projects/web_ide_terminals_controller.rb
@@ -8,6 +8,8 @@ class Projects::WebIdeTerminalsController < Projects::ApplicationController
before_action :authorize_read_web_ide_terminal!, except: [:check_config, :create]
before_action :authorize_update_web_ide_terminal!, only: [:cancel, :retry]
+ feature_category :web_ide
+
def check_config
return respond_422 unless branch_sha
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index d0aa733cadb..8f794512486 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -5,6 +5,8 @@ class Projects::WikisController < Projects::ApplicationController
alias_method :container, :project
+ feature_category :wiki
+
def git_access
end
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 848625ff6b5..09e7563cefd 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -37,7 +37,7 @@ class ProjectsController < Projects::ApplicationController
# Experiments
before_action only: [:new, :create] do
frontend_experimentation_tracking_data(:new_create_project_ui, 'click_tab')
- push_frontend_feature_flag(:new_create_project_ui) if experiment_enabled?(:new_create_project_ui)
+ push_frontend_experiment(:new_create_project_ui)
end
before_action only: [:edit] do
@@ -47,6 +47,17 @@ class ProjectsController < Projects::ApplicationController
layout :determine_layout
+ feature_category :projects, [
+ :index, :show, :new, :create, :edit, :update, :transfer,
+ :destroy, :resolve, :archive, :unarchive, :toggle_star
+ ]
+
+ feature_category :source_code_management, [:remove_fork, :housekeeping, :refs]
+ feature_category :issue_tracking, [:preview_markdown, :new_issuable_address]
+ feature_category :importers, [:export, :remove_export, :generate_new_export, :download_export]
+ feature_category :audit_events, [:activity]
+ feature_category :code_review, [:unfoldered_environment_names]
+
def index
redirect_to(current_user ? root_path : explore_root_path)
end
@@ -305,6 +316,16 @@ class ProjectsController < Projects::ApplicationController
end
end
+ def unfoldered_environment_names
+ return render_404 unless Feature.enabled?(:deployment_filters)
+
+ respond_to do |format|
+ format.json do
+ render json: EnvironmentNamesFinder.new(@project, current_user).execute
+ end
+ end
+ end
+
private
# Render project landing depending of which features are available
diff --git a/app/controllers/registrations/experience_levels_controller.rb b/app/controllers/registrations/experience_levels_controller.rb
index 38cffff91eb..23126983eb5 100644
--- a/app/controllers/registrations/experience_levels_controller.rb
+++ b/app/controllers/registrations/experience_levels_controller.rb
@@ -7,12 +7,20 @@ module Registrations
before_action :check_experiment_enabled
before_action :ensure_namespace_path_param
+ feature_category :navigation
+
def update
current_user.experience_level = params[:experience_level]
if current_user.save
hide_advanced_issues
- redirect_to group_path(params[:namespace_path])
+ record_experiment_user(:default_to_issues_board)
+
+ if experiment_enabled?(:default_to_issues_board) && learn_gitlab.available?
+ redirect_to namespace_project_board_path(params[:namespace_path], learn_gitlab.project, learn_gitlab.board)
+ else
+ redirect_to group_path(params[:namespace_path])
+ end
else
render :show
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 204520a3e71..b3dc0e986f4 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -6,19 +6,19 @@ class RegistrationsController < Devise::RegistrationsController
include RecaptchaExperimentHelper
include InvisibleCaptchaOnSignup
+ BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval'.freeze
+
layout :choose_layout
skip_before_action :required_signup_info, :check_two_factor_requirement, only: [:welcome, :update_registration]
prepend_before_action :check_captcha, only: :create
before_action :whitelist_query_limiting, :ensure_destroy_prerequisites_met, only: [:destroy]
- before_action :ensure_terms_accepted,
- if: -> { action_name == 'create' && Gitlab::CurrentSettings.current_application_settings.enforce_terms? }
before_action :load_recaptcha, only: :new
+ feature_category :authentication_and_authorization
+
def new
if experiment_enabled?(:signup_flow)
- track_experiment_event(:terms_opt_in, 'start')
-
@resource = build_resource
else
redirect_to new_user_session_path(anchor: 'register-pane')
@@ -26,18 +26,18 @@ class RegistrationsController < Devise::RegistrationsController
end
def create
+ set_user_state
accept_pending_invitations
super do |new_user|
persist_accepted_terms_if_required(new_user)
set_role_required(new_user)
- track_terms_experiment(new_user)
yield new_user if block_given?
end
- # Devise sets a flash message on `create` for a successful signup,
- # which we don't want to show.
- flash[:notice] = nil
+ # Devise sets a flash message on both successful & failed signups,
+ # but we only want to show a message if the resource is blocked by a pending approval.
+ flash[:notice] = nil unless resource.blocked_pending_approval?
rescue Gitlab::Access::AccessDeniedError
redirect_to(new_user_session_path)
end
@@ -58,6 +58,8 @@ class RegistrationsController < Devise::RegistrationsController
end
def update_registration
+ return redirect_to new_user_registration_path unless current_user
+
user_params = params.require(:user).permit(:role, :setup_for_company)
result = ::Users::SignupService.new(current_user, user_params).execute
@@ -81,10 +83,8 @@ class RegistrationsController < Devise::RegistrationsController
return unless new_user.persisted?
return unless Gitlab::CurrentSettings.current_application_settings.enforce_terms?
- if terms_accepted?
- terms = ApplicationSetting::Term.latest
- Users::RespondToTermsService.new(new_user, terms).execute(accepted: true)
- end
+ terms = ApplicationSetting::Term.latest
+ Users::RespondToTermsService.new(new_user, terms).execute(accepted: true)
end
def set_role_required(new_user)
@@ -119,6 +119,8 @@ class RegistrationsController < Devise::RegistrationsController
def after_inactive_sign_up_path_for(resource)
Gitlab::AppLogger.info(user_created_message)
+ return new_user_session_path(anchor: 'login-pane') if resource.blocked_pending_approval?
+
Feature.enabled?(:soft_email_confirmation) ? dashboard_projects_path : users_almost_there_path
end
@@ -178,18 +180,6 @@ class RegistrationsController < Devise::RegistrationsController
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42380')
end
- def ensure_terms_accepted
- return if terms_accepted?
-
- redirect_to new_user_session_path, alert: _('You must accept our Terms of Service and privacy policy in order to register an account')
- end
-
- def terms_accepted?
- return true if experiment_enabled?(:terms_opt_in)
-
- Gitlab::Utils.to_boolean(params[:terms_opt_in])
- end
-
def path_for_signed_in_user(user)
if requires_confirmation?(user)
users_almost_there_path
@@ -206,13 +196,6 @@ class RegistrationsController < Devise::RegistrationsController
true
end
- def track_terms_experiment(new_user)
- return unless new_user.persisted?
-
- track_experiment_event(:terms_opt_in, 'end')
- record_experiment_user(:terms_opt_in)
- end
-
def load_recaptcha
Gitlab::Recaptcha.load_configurations!
end
@@ -233,6 +216,13 @@ class RegistrationsController < Devise::RegistrationsController
!helpers.in_oauth_flow? &&
!helpers.in_trial_flow?
end
+
+ def set_user_state
+ return unless Feature.enabled?(:admin_approval_for_new_user_signups, default_enabled: true)
+ return unless Gitlab::CurrentSettings.require_admin_approval_after_user_signup
+
+ resource.state = BLOCKED_PENDING_APPROVAL_STATE
+ end
end
RegistrationsController.prepend_if_ee('EE::RegistrationsController')
diff --git a/app/controllers/repositories/git_http_client_controller.rb b/app/controllers/repositories/git_http_client_controller.rb
index e02955433b2..de452aa69b7 100644
--- a/app/controllers/repositories/git_http_client_controller.rb
+++ b/app/controllers/repositories/git_http_client_controller.rb
@@ -20,6 +20,8 @@ module Repositories
prepend_before_action :authenticate_user, :parse_repo_path
+ feature_category :source_code_management
+
private
def download_request?
diff --git a/app/controllers/runner_setup_controller.rb b/app/controllers/runner_setup_controller.rb
new file mode 100644
index 00000000000..e0e9c5b7c23
--- /dev/null
+++ b/app/controllers/runner_setup_controller.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class RunnerSetupController < ApplicationController
+ feature_category :continuous_integration
+
+ def platforms
+ render json: Gitlab::Ci::RunnerInstructions::OS.merge(Gitlab::Ci::RunnerInstructions::OTHER_ENVIRONMENTS)
+ end
+end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index dedaf0c903a..0380bc1c548 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -8,7 +8,8 @@ class SearchController < ApplicationController
SCOPE_PRELOAD_METHOD = {
projects: :with_web_entity_associations,
- issues: :with_web_entity_associations
+ issues: :with_web_entity_associations,
+ epics: :with_web_entity_associations
}.freeze
track_redis_hll_event :show, name: 'i_search_total', feature: :search_track_unique_users, feature_default_enabled: true
@@ -24,6 +25,8 @@ class SearchController < ApplicationController
layout 'search'
+ feature_category :global_search
+
def show
@project = search_service.project
@group = search_service.group
@@ -38,6 +41,7 @@ class SearchController < ApplicationController
@show_snippets = search_service.show_snippets?
@search_results = search_service.search_results
@search_objects = search_service.search_objects(preload_method)
+ @search_highlight = search_service.search_highlight
render_commits if @scope == 'commits'
eager_load_user_status if @scope == 'users'
@@ -96,8 +100,6 @@ class SearchController < ApplicationController
end
def eager_load_user_status
- return if Feature.disabled?(:users_search, default_enabled: true)
-
@search_objects = @search_objects.eager_load(:status) # rubocop:disable CodeReuse/ActiveRecord
end
diff --git a/app/controllers/sent_notifications_controller.rb b/app/controllers/sent_notifications_controller.rb
index 20134de81a0..db07b212d00 100644
--- a/app/controllers/sent_notifications_controller.rb
+++ b/app/controllers/sent_notifications_controller.rb
@@ -3,6 +3,8 @@
class SentNotificationsController < ApplicationController
skip_before_action :authenticate_user!
+ feature_category :users
+
def unsubscribe
@sent_notification = SentNotification.for(params[:id])
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 318553b5e0a..61120c5b7d1 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -49,6 +49,8 @@ class SessionsController < Devise::SessionsController
# token mismatch.
protect_from_forgery with: :exception, prepend: true, except: :destroy
+ feature_category :authentication_and_authorization
+
CAPTCHA_HEADER = 'X-GitLab-Show-Login-Captcha'
MAX_FAILED_LOGIN_ATTEMPTS = 5
@@ -262,8 +264,11 @@ class SessionsController < Devise::SessionsController
end
def valid_otp_attempt?(user)
- user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
- user.invalidate_otp_backup_code!(user_params[:otp_attempt])
+ otp_validation_result =
+ ::Users::ValidateOtpService.new(user).execute(user_params[:otp_attempt])
+ return true if otp_validation_result[:status] == :success
+
+ user.invalidate_otp_backup_code!(user_params[:otp_attempt])
end
def log_audit_event(user, resource, options = {})
diff --git a/app/controllers/snippets/application_controller.rb b/app/controllers/snippets/application_controller.rb
index a533e46a75d..f259f4569ef 100644
--- a/app/controllers/snippets/application_controller.rb
+++ b/app/controllers/snippets/application_controller.rb
@@ -4,6 +4,8 @@ class Snippets::ApplicationController < ApplicationController
include FindSnippet
include SnippetAuthorizations
+ feature_category :snippets
+
private
def authorize_read_snippet!
diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb
index a7e8ef0798b..8532257cb8d 100644
--- a/app/controllers/snippets/notes_controller.rb
+++ b/app/controllers/snippets/notes_controller.rb
@@ -8,6 +8,8 @@ class Snippets::NotesController < ApplicationController
before_action :authorize_read_snippet!, only: [:show, :index]
before_action :authorize_create_note!, only: [:create]
+ feature_category :snippets
+
private
def note
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index 486c7f1d028..913b1e3bb6e 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -6,21 +6,16 @@ class SnippetsController < Snippets::ApplicationController
include ToggleAwardEmoji
include SpammableActions
- before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :toggle_award_emoji, :mark_as_spam]
+ before_action :snippet, only: [:show, :edit, :raw, :toggle_award_emoji, :mark_as_spam]
- before_action :authorize_create_snippet!, only: [:new, :create]
+ before_action :authorize_create_snippet!, only: :new
before_action :authorize_read_snippet!, only: [:show, :raw]
- before_action :authorize_update_snippet!, only: [:edit, :update]
- before_action :authorize_admin_snippet!, only: [:destroy]
+ before_action :authorize_update_snippet!, only: :edit
skip_before_action :authenticate_user!, only: [:index, :show, :raw]
layout 'snippets'
- before_action do
- push_frontend_feature_flag(:snippet_multiple_files, current_user)
- end
-
def index
if params[:username].present?
@user = UserFinder.new(params[:username]).find_by_username!
@@ -44,18 +39,6 @@ class SnippetsController < Snippets::ApplicationController
@snippet = PersonalSnippet.new
end
- def create
- create_params = snippet_params.merge(files: params.delete(:files))
- service_response = Snippets::CreateService.new(nil, current_user, create_params).execute
- @snippet = service_response.payload[:snippet]
-
- if service_response.error? && @snippet.errors[:repository].present?
- handle_repository_error(:new)
- else
- recaptcha_check_with_fallback { render :new }
- end
- end
-
protected
alias_method :awardable, :snippet
@@ -64,8 +47,4 @@ class SnippetsController < Snippets::ApplicationController
def spammable_path
snippet_path(@snippet)
end
-
- def snippet_params
- params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description).merge(spammable_params)
- end
end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 9510734bc9b..6692c285335 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -25,6 +25,8 @@ class UploadsController < ApplicationController
before_action :authorize_create_access!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
+ feature_category :not_owned
+
def uploader_class
PersonalFileUploader
end
diff --git a/app/controllers/user_callouts_controller.rb b/app/controllers/user_callouts_controller.rb
index 06f422b9d90..cfec9d6d905 100644
--- a/app/controllers/user_callouts_controller.rb
+++ b/app/controllers/user_callouts_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class UserCalloutsController < ApplicationController
+ feature_category :navigation
+
def create
callout = ensure_callout
diff --git a/app/controllers/users/terms_controller.rb b/app/controllers/users/terms_controller.rb
index 231e449f733..be670658048 100644
--- a/app/controllers/users/terms_controller.rb
+++ b/app/controllers/users/terms_controller.rb
@@ -14,6 +14,8 @@ module Users
layout 'terms'
+ feature_category :users
+
def index
@redirect = redirect_path
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 75a861423ed..672f36dedc0 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -21,6 +21,8 @@ class UsersController < ApplicationController
before_action :authorize_read_user_profile!,
only: [:calendar, :calendar_activities, :groups, :projects, :contributed_projects, :starred_projects, :snippets]
+ feature_category :users
+
def show
respond_to do |format|
format.html
@@ -106,7 +108,7 @@ class UsersController < ApplicationController
def calendar_activities
@calendar_date = Date.parse(params[:date]) rescue Date.today
- @events = contributions_calendar.events_by_date(@calendar_date)
+ @events = contributions_calendar.events_by_date(@calendar_date).map(&:present)
render 'calendar_activities', layout: false
end
diff --git a/app/controllers/whats_new_controller.rb b/app/controllers/whats_new_controller.rb
new file mode 100644
index 00000000000..7156faa4e49
--- /dev/null
+++ b/app/controllers/whats_new_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class WhatsNewController < ApplicationController
+ include Gitlab::WhatsNew
+
+ skip_before_action :authenticate_user!
+
+ before_action :check_feature_flag
+
+ feature_category :navigation
+
+ def index
+ respond_to do |format|
+ format.js do
+ render json: whats_new_most_recent_release_items
+ end
+ end
+ end
+
+ private
+
+ def check_feature_flag
+ render_404 unless Feature.enabled?(:whats_new_drawer, current_user)
+ end
+end
diff --git a/app/finders/README.md b/app/finders/README.md
index 1a1c69dea38..52f7378c484 100644
--- a/app/finders/README.md
+++ b/app/finders/README.md
@@ -1,10 +1,11 @@
# Finders
-This type of classes responsible for collection items based on different conditions.
-To prevent lookup methods in models like this:
+These types of classes are responsible for retrieving collection items based on different conditions.
+They prevent lookup methods in models like this:
+
```ruby
-class Project
+class Project < ApplicationRecord
def issues_for_user_filtered_by(user, filter)
# A lot of logic not related to project model itself
end
@@ -13,10 +14,10 @@ end
issues = project.issues_for_user_filtered_by(user, params)
```
-Better use this:
+The GitLab approach is to use a Finder:
```ruby
issues = IssuesFinder.new(project, user, filter).execute
```
-It will help keep models thiner.
+It will help keep models thinner.
diff --git a/app/finders/alert_management/alerts_finder.rb b/app/finders/alert_management/alerts_finder.rb
index cb35be43c15..1d6f790af31 100644
--- a/app/finders/alert_management/alerts_finder.rb
+++ b/app/finders/alert_management/alerts_finder.rb
@@ -2,8 +2,8 @@
module AlertManagement
class AlertsFinder
- # @return [Hash<Integer,Integer>] Mapping of status id to count
- # ex) { 0: 6, ...etc }
+ # @return [Hash<Symbol,Integer>] Mapping of status id to count
+ # ex) { triggered: 6, ...etc }
def self.counts_by_status(current_user, project, params = {})
new(current_user, project, params).execute.counts_by_status
end
@@ -19,8 +19,10 @@ module AlertManagement
collection = project.alert_management_alerts
collection = by_status(collection)
- collection = by_search(collection)
collection = by_iid(collection)
+ collection = by_assignee(collection)
+ collection = by_search(collection)
+
sort(collection)
end
@@ -35,7 +37,7 @@ module AlertManagement
end
def by_status(collection)
- values = AlertManagement::Alert::STATUSES.values & Array(params[:status])
+ values = AlertManagement::Alert.status_names & Array(params[:status])
values.present? ? collection.for_status(values) : collection
end
@@ -48,6 +50,10 @@ module AlertManagement
params[:sort] ? collection.sort_by_attribute(params[:sort]) : collection
end
+ def by_assignee(collection)
+ params[:assignee_username].present? ? collection.for_assignee_username(params[:assignee_username]) : collection
+ end
+
def authorized?
Ability.allowed?(current_user, :read_alert_management_alert, project)
end
diff --git a/app/finders/ci/jobs_finder.rb b/app/finders/ci/jobs_finder.rb
index 8515b77ec0b..40c610f8209 100644
--- a/app/finders/ci/jobs_finder.rb
+++ b/app/finders/ci/jobs_finder.rb
@@ -25,7 +25,7 @@ module Ci
attr_reader :current_user, :pipeline, :project, :params, :type
def init_collection
- if Feature.enabled?(:ci_jobs_finder_refactor)
+ if Feature.enabled?(:ci_jobs_finder_refactor, default_enabled: true)
pipeline_jobs || project_jobs || all_jobs
else
project ? project_builds : all_jobs
@@ -59,7 +59,7 @@ module Ci
end
def filter_by_scope(builds)
- if Feature.enabled?(:ci_jobs_finder_refactor)
+ if Feature.enabled?(:ci_jobs_finder_refactor, default_enabled: true)
return filter_by_statuses!(params[:scope], builds) if params[:scope].is_a?(Array)
end
diff --git a/app/finders/concerns/time_frame_filter.rb b/app/finders/concerns/time_frame_filter.rb
index e0baba25b64..d1ebed730f6 100644
--- a/app/finders/concerns/time_frame_filter.rb
+++ b/app/finders/concerns/time_frame_filter.rb
@@ -11,4 +11,11 @@ module TimeFrameFilter
rescue ArgumentError
items
end
+
+ def containing_date(items)
+ return items unless params[:containing_date]
+
+ date = params[:containing_date].to_date
+ items.within_timeframe(date, date)
+ end
end
diff --git a/app/finders/environment_names_finder.rb b/app/finders/environment_names_finder.rb
new file mode 100644
index 00000000000..a92998921c7
--- /dev/null
+++ b/app/finders/environment_names_finder.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+# Finder for obtaining the unique environment names of a project or group.
+#
+# This finder exists so that the merge requests "environments" filter can be
+# populated with a unique list of environment names. If we retrieve _just_ the
+# environments, duplicates may be present (e.g. multiple projects in a group
+# having a "staging" environment).
+#
+# In addition, this finder only produces unfoldered environments. We do this
+# because when searching for environments we want to exclude review app
+# environments.
+class EnvironmentNamesFinder
+ attr_reader :project_or_group, :current_user
+
+ def initialize(project_or_group, current_user)
+ @project_or_group = project_or_group
+ @current_user = current_user
+ end
+
+ def execute
+ all_environments.unfoldered.order_by_name.pluck_unique_names
+ end
+
+ def all_environments
+ if project_or_group.is_a?(Namespace)
+ namespace_environments
+ else
+ project_environments
+ end
+ end
+
+ def namespace_environments
+ projects =
+ project_or_group.all_projects.public_or_visible_to_user(current_user)
+
+ Environment.for_project(projects)
+ end
+
+ def project_environments
+ if current_user.can?(:read_environment, project_or_group)
+ project_or_group.environments
+ else
+ Environment.none
+ end
+ end
+end
diff --git a/app/finders/group_labels_finder.rb b/app/finders/group_labels_finder.rb
deleted file mode 100644
index a668a0f0fae..00000000000
--- a/app/finders/group_labels_finder.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-class GroupLabelsFinder
- attr_reader :current_user, :group, :params
-
- def initialize(current_user, group, params = {})
- @current_user = current_user
- @group = group
- @params = params
- end
-
- def execute
- group.labels
- .optionally_subscribed_by(subscriber_id)
- .optionally_search(params[:search])
- .order_by(params[:sort])
- .page(params[:page])
- end
-
- private
-
- def subscriber_id
- current_user&.id if subscribed?
- end
-
- def subscribed?
- params[:subscribed] == 'true'
- end
-end
diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb
index ce0d52ad97a..09283f061c0 100644
--- a/app/finders/group_members_finder.rb
+++ b/app/finders/group_members_finder.rb
@@ -17,9 +17,8 @@ class GroupMembersFinder < UnionFinder
@params = params
end
- # rubocop: disable CodeReuse/ActiveRecord
def execute(include_relations: [:inherited, :direct])
- group_members = group.members
+ group_members = group_members_list
relations = []
return group_members if include_relations == [:direct]
@@ -27,17 +26,13 @@ class GroupMembersFinder < UnionFinder
relations << group_members if include_relations.include?(:direct)
if include_relations.include?(:inherited) && group.parent
- parents_members = GroupMember.non_request.non_minimal_access
- .where(source_id: group.ancestors.select(:id))
- .where.not(user_id: group.users.select(:id))
+ parents_members = relation_group_members(group.ancestors)
relations << parents_members
end
if include_relations.include?(:descendants)
- descendant_members = GroupMember.non_request.non_minimal_access
- .where(source_id: group.descendants.select(:id))
- .where.not(user_id: group.users.select(:id))
+ descendant_members = relation_group_members(group.descendants)
relations << descendant_members
end
@@ -47,7 +42,6 @@ class GroupMembersFinder < UnionFinder
members = find_union(relations, GroupMember)
filter_members(members)
end
- # rubocop: enable CodeReuse/ActiveRecord
private
@@ -67,6 +61,22 @@ class GroupMembersFinder < UnionFinder
def can_manage_members
Ability.allowed?(user, :admin_group_member, group)
end
+
+ def group_members_list
+ group.members
+ end
+
+ def relation_group_members(relation)
+ all_group_members(relation).non_minimal_access
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def all_group_members(relation)
+ GroupMember.non_request
+ .where(source_id: relation.select(:id))
+ .where.not(user_id: group.users.select(:id))
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
end
GroupMembersFinder.prepend_if_ee('EE::GroupMembersFinder')
diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb
index 54715557399..4b6b2716c64 100644
--- a/app/finders/groups_finder.rb
+++ b/app/finders/groups_finder.rb
@@ -12,6 +12,8 @@
# all_available: boolean (defaults to true)
# min_access_level: integer
# exclude_group_ids: array of integers
+# include_parent_descendants: boolean (defaults to false) - includes descendant groups when
+# filtering by parent. The parent param must be present.
#
# Users with full private access can see all groups. The `owned` and `parent`
# params can be used to restrict the groups that are returned.
@@ -84,7 +86,11 @@ class GroupsFinder < UnionFinder
def by_parent(groups)
return groups unless params[:parent]
- groups.where(parent: params[:parent])
+ if include_parent_descendants?
+ groups.id_in(params[:parent].descendants)
+ else
+ groups.where(parent: params[:parent])
+ end
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -100,6 +106,10 @@ class GroupsFinder < UnionFinder
params.fetch(:all_available, true)
end
+ def include_parent_descendants?
+ params.fetch(:include_parent_descendants, false)
+ end
+
def min_access_level?
current_user && params[:min_access_level].present?
end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index f13dc8c2451..9c4aecedd93 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -102,7 +102,7 @@ class IssuableFinder
items = filter_items(items)
# Let's see if we have to negate anything
- items = filter_negated_items(items)
+ items = filter_negated_items(items) if should_filter_negated_args?
# This has to be last as we use a CTE as an optimization fence
# for counts by passing the force_cte param and passing the
@@ -134,13 +134,15 @@ class IssuableFinder
by_my_reaction_emoji(items)
end
- # Negates all params found in `negatable_params`
- def filter_negated_items(items)
- return items unless Feature.enabled?(:not_issuable_queries, params.group || params.project, default_enabled: true)
+ def should_filter_negated_args?
+ return false unless Feature.enabled?(:not_issuable_queries, params.group || params.project, default_enabled: true)
# API endpoints send in `nil` values so we test if there are any non-nil
- return items unless not_params.present? && not_params.values.any?
+ not_params.present? && not_params.values.any?
+ end
+ # Negates all params found in `negatable_params`
+ def filter_negated_items(items)
items = by_negated_author(items)
items = by_negated_assignee(items)
items = by_negated_label(items)
@@ -151,7 +153,11 @@ class IssuableFinder
end
def row_count
- Gitlab::IssuablesCountForState.new(self).for_state_or_opened(params[:state])
+ fast_fail = Feature.enabled?(:soft_fail_count_by_state, params.group || params.project)
+
+ Gitlab::IssuablesCountForState
+ .new(self, nil, fast_fail: fast_fail)
+ .for_state_or_opened(params[:state])
end
# We often get counts for each state by running a query per state, and
diff --git a/app/finders/keys_finder.rb b/app/finders/keys_finder.rb
index e7e78d71a58..9c357e12205 100644
--- a/app/finders/keys_finder.rb
+++ b/app/finders/keys_finder.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
class KeysFinder
+ delegate :find, :find_by_id, to: :execute
+
InvalidFingerprint = Class.new(StandardError)
GitLabAccessDeniedError = Class.new(StandardError)
diff --git a/app/finders/merge_requests/by_approvals_finder.rb b/app/finders/merge_requests/by_approvals_finder.rb
new file mode 100644
index 00000000000..e6ab1467f06
--- /dev/null
+++ b/app/finders/merge_requests/by_approvals_finder.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ # Used to filter MergeRequest collections by approvers
+ class ByApprovalsFinder
+ attr_reader :usernames, :ids
+
+ # We apply a limitation to the amount of elements that can be part of the filter condition
+ MAX_FILTER_ELEMENTS = 5
+
+ # Initialize the finder
+ #
+ # @param [Array<String>] usernames
+ # @param [Array<Integers>] ids
+ def initialize(usernames, ids)
+ # rubocop:disable CodeReuse/ActiveRecord
+ @usernames = Array(usernames).map(&:to_s).uniq.take(MAX_FILTER_ELEMENTS)
+ @ids = Array(ids).uniq.take(MAX_FILTER_ELEMENTS)
+ # rubocop:enable CodeReuse/ActiveRecord
+ end
+
+ # Filter MergeRequest collections by approvers
+ #
+ # @param [ActiveRecord::Relation] items the activerecord relation
+ def execute(items)
+ if by_no_approvals?
+ without_approvals(items)
+ elsif by_any_approvals?
+ with_any_approvals(items)
+ elsif ids.present?
+ find_approved_by_ids(items)
+ elsif usernames.present?
+ find_approved_by_names(items)
+ else
+ items
+ end
+ end
+
+ private
+
+ # Is param using special condition: "None" ?
+ #
+ # @return [Boolean] whether special condition "None" is being used
+ def by_no_approvals?
+ includes_special_label?(IssuableFinder::Params::FILTER_NONE)
+ end
+
+ # Is param using special condition: "Any" ?
+ #
+ # @return [Boolean] whether special condition "Any" is being used
+ def by_any_approvals?
+ includes_special_label?(IssuableFinder::Params::FILTER_ANY)
+ end
+
+ # Check if we have the special label in ids or usernames field
+ #
+ # @param [String] label the special label
+ # @return [Boolean] whether ids or usernames includes the special label
+ def includes_special_label?(label)
+ ids.first.to_s.downcase == label || usernames.map(&:downcase).include?(label)
+ end
+
+ # Merge Requests without any approval
+ #
+ # @param [ActiveRecord::Relation] items
+ def without_approvals(items)
+ items.without_approvals
+ end
+
+ # Merge Requests with any number of approvals
+ #
+ # @param [ActiveRecord::Relation] items the activerecord relation
+ def with_any_approvals(items)
+ items.select_from_union([
+ items.with_approvals
+ ])
+ end
+
+ # Merge Requests approved by given usernames
+ #
+ # @param [ActiveRecord::Relation] items the activerecord relation
+ def find_approved_by_names(items)
+ items.approved_by_users_with_usernames(*usernames)
+ end
+
+ # Merge Requests approved by given user IDs
+ #
+ # @param [ActiveRecord::Relation] items the activerecord relation
+ def find_approved_by_ids(items)
+ items.approved_by_users_with_ids(*ids)
+ end
+ end
+end
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index 37da29b32ff..c998de75ab2 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -33,7 +33,21 @@ class MergeRequestsFinder < IssuableFinder
include MergedAtFilter
def self.scalar_params
- @scalar_params ||= super + [:wip, :draft, :target_branch, :merged_after, :merged_before]
+ @scalar_params ||= super + [
+ :approved_by_ids,
+ :deployed_after,
+ :deployed_before,
+ :draft,
+ :environment,
+ :merged_after,
+ :merged_before,
+ :target_branch,
+ :wip
+ ]
+ end
+
+ def self.array_params
+ @array_params ||= super.merge(approved_by_usernames: [])
end
def klass
@@ -42,11 +56,13 @@ class MergeRequestsFinder < IssuableFinder
def filter_items(_items)
items = by_commit(super)
- items = by_deployment(items)
items = by_source_branch(items)
items = by_draft(items)
items = by_target_branch(items)
items = by_merged_at(items)
+ items = by_approvals(items)
+ items = by_deployments(items)
+
by_source_project_id(items)
end
@@ -80,17 +96,21 @@ class MergeRequestsFinder < IssuableFinder
items.where(target_branch: target_branch)
end
+ # rubocop: enable CodeReuse/ActiveRecord
def source_project_id
@source_project_id ||= params[:source_project_id].presence
end
+ # rubocop: disable CodeReuse/ActiveRecord
def by_source_project_id(items)
return items unless source_project_id
items.where(source_project_id: source_project_id)
end
+ # rubocop: enable CodeReuse/ActiveRecord
+ # rubocop: disable CodeReuse/ActiveRecord
def by_draft(items)
draft_param = params[:draft] || params[:wip]
@@ -102,6 +122,7 @@ class MergeRequestsFinder < IssuableFinder
items
end
end
+ # rubocop: enable CodeReuse/ActiveRecord
# WIP is deprecated in favor of Draft. Currently both options are supported
def wip_match(table)
@@ -121,16 +142,54 @@ class MergeRequestsFinder < IssuableFinder
.or(table[:title].matches('(Draft)%'))
end
+ # rubocop: disable CodeReuse/ActiveRecord
def by_deployment(items)
return items unless deployment_id
items.includes(:deployment_merge_requests)
.where(deployment_merge_requests: { deployment_id: deployment_id })
end
+ # rubocop: enable CodeReuse/ActiveRecord
def deployment_id
@deployment_id ||= params[:deployment_id].presence
end
+
+ # Filter by merge requests that had been approved by specific users
+ # rubocop: disable CodeReuse/Finder
+ def by_approvals(items)
+ MergeRequests::ByApprovalsFinder
+ .new(params[:approved_by_usernames], params[:approved_by_ids])
+ .execute(items)
+ end
+ # rubocop: enable CodeReuse/Finder
+
+ def by_deployments(items)
+ # Until this feature flag is enabled permanently, we retain the old
+ # filtering behaviour/code.
+ return by_deployment(items) unless Feature.enabled?(:deployment_filters)
+
+ env = params[:environment]
+ before = params[:deployed_before]
+ after = params[:deployed_after]
+ id = params[:deployment_id]
+
+ return items if !env && !before && !after && !id
+
+ # Each filter depends on the same JOIN+WHERE. To prevent this JOIN+WHERE
+ # from being duplicated for every filter, we only produce it once. The
+ # filter methods in turn expect the JOIN+WHERE to already be present.
+ #
+ # This approach ensures that query performance doesn't degrade as the number
+ # of deployment related filters increases.
+ deploys = DeploymentMergeRequest.join_deployments_for_merge_requests
+ deploys = deploys.by_deployment_id(id) if id
+ deploys = deploys.deployed_to(env) if env
+ deploys = deploys.deployed_before(before) if before
+ deploys = deploys.deployed_after(after) if after
+
+ items.where_exists(deploys)
+ end
end
MergeRequestsFinder.prepend_if_ee('EE::MergeRequestsFinder')
diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb
index 16e59b31b36..5d2a54ac979 100644
--- a/app/finders/milestones_finder.rb
+++ b/app/finders/milestones_finder.rb
@@ -9,6 +9,8 @@
# order - Orders by field default due date asc.
# title - filter by title.
# state - filters by state.
+# start_date & end_date - filters by timeframe (see TimeFrameFilter)
+# containing_date - filters by point in time (see TimeFrameFilter)
class MilestonesFinder
include FinderMethods
@@ -28,6 +30,7 @@ class MilestonesFinder
items = by_search_title(items)
items = by_state(items)
items = by_timeframe(items)
+ items = containing_date(items)
order(items)
end
diff --git a/app/finders/packages/generic/package_finder.rb b/app/finders/packages/generic/package_finder.rb
new file mode 100644
index 00000000000..3a260e11fa3
--- /dev/null
+++ b/app/finders/packages/generic/package_finder.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Packages
+ module Generic
+ class PackageFinder
+ def initialize(project)
+ @project = project
+ end
+
+ def execute!(package_name, package_version)
+ project
+ .packages
+ .generic
+ .by_name_and_version!(package_name, package_version)
+ end
+
+ private
+
+ attr_reader :project
+ end
+ end
+end
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 471029c1ef9..14b84d0bfa6 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -50,7 +50,12 @@ class ProjectsFinder < UnionFinder
use_cte = params.delete(:use_cte)
collection = Project.wrap_with_cte(collection) if use_cte
collection = filter_projects(collection)
- sort(collection)
+
+ if params[:sort] == 'similarity' && params[:search] && Feature.enabled?(:project_finder_similarity_sort)
+ collection.sorted_by_similarity_desc(params[:search])
+ else
+ sort(collection)
+ end
end
private
@@ -209,7 +214,11 @@ class ProjectsFinder < UnionFinder
end
def sort(items)
- params[:sort].present? ? items.sort_by_attribute(params[:sort]) : items.projects_order_id_desc
+ if params[:sort].present?
+ items.sort_by_attribute(params[:sort])
+ else
+ items.projects_order_id_desc
+ end
end
def by_archived(projects)
diff --git a/app/finders/releases_finder.rb b/app/finders/releases_finder.rb
index e961ad4c0ca..da72178169e 100644
--- a/app/finders/releases_finder.rb
+++ b/app/finders/releases_finder.rb
@@ -9,6 +9,9 @@ class ReleasesFinder
@parent = parent
@current_user = current_user
@params = params
+
+ params[:order_by] ||= 'released_at'
+ params[:sort] ||= 'desc'
end
def execute(preload: true)
@@ -17,7 +20,8 @@ class ReleasesFinder
releases = get_releases
releases = by_tag(releases)
releases = releases.preloaded if preload
- releases.sorted
+ releases = order_releases(releases)
+ releases
end
private
@@ -57,4 +61,8 @@ class ReleasesFinder
releases.where(tag: params[:tag])
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def order_releases(releases)
+ releases.sort_by_attribute("#{params[:order_by]}_#{params[:sort]}")
+ end
end
diff --git a/app/graphql/batch_loaders/merge_request_diff_summary_batch_loader.rb b/app/graphql/batch_loaders/merge_request_diff_summary_batch_loader.rb
new file mode 100644
index 00000000000..9b8737a6703
--- /dev/null
+++ b/app/graphql/batch_loaders/merge_request_diff_summary_batch_loader.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module BatchLoaders
+ class MergeRequestDiffSummaryBatchLoader
+ NIL_STATS = { additions: 0, deletions: 0, file_count: 0 }.freeze
+
+ def self.load_for(merge_request)
+ BatchLoader::GraphQL.for(merge_request).batch(key: :diff_stats_summary) do |merge_requests, loader, args|
+ Preloaders::MergeRequestDiffPreloader.new(merge_requests).preload_all
+
+ merge_requests.each do |merge_request|
+ metrics = merge_request.metrics
+
+ summary = if metrics && metrics.added_lines && metrics.removed_lines
+ { additions: metrics.added_lines, deletions: metrics.removed_lines, file_count: merge_request.merge_request_diff&.files_count || 0 }
+ elsif merge_request.diff_stats.blank?
+ NIL_STATS
+ else
+ merge_request.diff_stats.each_with_object(NIL_STATS.dup) do |status, summary|
+ summary.merge!(additions: status.additions, deletions: status.deletions, file_count: 1) { |_, x, y| x + y }
+ end
+ end
+
+ loader.call(merge_request, summary)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/award_emojis/base.rb b/app/graphql/mutations/award_emojis/base.rb
index 583744c3884..df6b883529e 100644
--- a/app/graphql/mutations/award_emojis/base.rb
+++ b/app/graphql/mutations/award_emojis/base.rb
@@ -6,7 +6,7 @@ module Mutations
authorize :award_emoji
argument :awardable_id,
- GraphQL::ID_TYPE,
+ ::Types::GlobalIDType[::Awardable],
required: true,
description: 'The global id of the awardable resource'
@@ -23,7 +23,10 @@ module Mutations
private
def find_object(id:)
- GitlabSchema.object_from_id(id)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::Awardable].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
end
# Called by mutations methods after performing an authorization check
diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb
index 577f10545b3..ac5ddc5bd4c 100644
--- a/app/graphql/mutations/base_mutation.rb
+++ b/app/graphql/mutations/base_mutation.rb
@@ -4,6 +4,7 @@ module Mutations
class BaseMutation < GraphQL::Schema::RelayClassicMutation
prepend Gitlab::Graphql::Authorize::AuthorizeResource
prepend Gitlab::Graphql::CopyFieldDescription
+ prepend ::Gitlab::Graphql::GlobalIDCompatibility
ERROR_MESSAGE = 'You cannot perform write operations on a read-only instance'
diff --git a/app/graphql/mutations/boards/create.rb b/app/graphql/mutations/boards/create.rb
new file mode 100644
index 00000000000..e381205242e
--- /dev/null
+++ b/app/graphql/mutations/boards/create.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Boards
+ class Create < ::Mutations::BaseMutation
+ include Mutations::ResolvesGroup
+ include ResolvesProject
+
+ graphql_name 'CreateBoard'
+
+ field :board,
+ Types::BoardType,
+ null: true,
+ description: 'The board after mutation.'
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: false,
+ description: 'The project full path the board is associated with.'
+ argument :group_path, GraphQL::ID_TYPE,
+ required: false,
+ description: 'The group full path the board is associated with.'
+ argument :name,
+ GraphQL::STRING_TYPE,
+ required: false,
+ description: 'The board name.'
+ argument :assignee_id,
+ GraphQL::STRING_TYPE,
+ required: false,
+ description: 'The ID of the user to be assigned to the board.'
+ argument :milestone_id,
+ GraphQL::ID_TYPE,
+ required: false,
+ description: 'The ID of the milestone to be assigned to the board.'
+ argument :weight,
+ GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: 'The weight of the board.'
+ argument :label_ids,
+ [GraphQL::ID_TYPE],
+ required: false,
+ description: 'The IDs of labels to be added to the board.'
+
+ authorize :admin_board
+
+ def resolve(args)
+ group_path = args.delete(:group_path)
+ project_path = args.delete(:project_path)
+
+ board_parent = authorized_find!(group_path: group_path, project_path: project_path)
+ response = ::Boards::CreateService.new(board_parent, current_user, args).execute
+
+ {
+ board: response.payload,
+ errors: response.errors
+ }
+ end
+
+ def ready?(**args)
+ if args.values_at(:project_path, :group_path).compact.blank?
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ 'group_path or project_path arguments are required'
+ end
+
+ super
+ end
+
+ private
+
+ def find_object(group_path: nil, project_path: nil)
+ if group_path
+ resolve_group(full_path: group_path)
+ else
+ resolve_project(full_path: project_path)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/boards/lists/destroy.rb b/app/graphql/mutations/boards/lists/destroy.rb
new file mode 100644
index 00000000000..61ffae7c047
--- /dev/null
+++ b/app/graphql/mutations/boards/lists/destroy.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Boards
+ module Lists
+ class Destroy < ::Mutations::BaseMutation
+ graphql_name 'DestroyBoardList'
+
+ field :list,
+ Types::BoardListType,
+ null: true,
+ description: 'The list after mutation.'
+
+ argument :list_id, ::Types::GlobalIDType[::List],
+ required: true,
+ loads: Types::BoardListType,
+ description: 'Global ID of the list to destroy. Only label lists are accepted.'
+
+ def resolve(list:)
+ raise_resource_not_available_error! unless can_admin_list?(list)
+
+ response = ::Boards::Lists::DestroyService.new(list.board.resource_parent, current_user)
+ .execute(list)
+
+ {
+ list: response.success? ? nil : list,
+ errors: response.errors
+ }
+ end
+
+ private
+
+ def can_admin_list?(list)
+ return false unless list.present?
+
+ Ability.allowed?(current_user, :admin_list, list.board)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/ci/base.rb b/app/graphql/mutations/ci/base.rb
index 09df4487a50..aaece2a3021 100644
--- a/app/graphql/mutations/ci/base.rb
+++ b/app/graphql/mutations/ci/base.rb
@@ -3,13 +3,18 @@
module Mutations
module Ci
class Base < BaseMutation
- argument :id, ::Types::GlobalIDType[::Ci::Pipeline],
+ PipelineID = ::Types::GlobalIDType[::Ci::Pipeline]
+
+ argument :id, PipelineID,
required: true,
description: 'The id of the pipeline to mutate'
private
def find_object(id:)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = PipelineID.coerce_isolated_input(id)
GlobalID::Locator.locate(id)
end
end
diff --git a/app/graphql/mutations/concerns/mutations/spammable_mutation_fields.rb b/app/graphql/mutations/concerns/mutations/spammable_mutation_fields.rb
new file mode 100644
index 00000000000..7aef55f8011
--- /dev/null
+++ b/app/graphql/mutations/concerns/mutations/spammable_mutation_fields.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Mutations
+ module SpammableMutationFields
+ extend ActiveSupport::Concern
+
+ included do
+ field :spam,
+ GraphQL::BOOLEAN_TYPE,
+ null: true,
+ description: 'Indicates whether the operation returns a record detected as spam'
+ end
+
+ def with_spam_params(&block)
+ request = Feature.enabled?(:snippet_spam) ? context[:request] : nil
+
+ yield.merge({ api: true, request: request })
+ end
+
+ def with_spam_fields(spammable, &block)
+ { spam: spammable.spam? }.merge!(yield)
+ end
+ end
+end
diff --git a/app/graphql/mutations/design_management/move.rb b/app/graphql/mutations/design_management/move.rb
index 6126af8b68b..aed4cfec0fd 100644
--- a/app/graphql/mutations/design_management/move.rb
+++ b/app/graphql/mutations/design_management/move.rb
@@ -21,7 +21,7 @@ module Mutations
description: "The current state of the collection"
def resolve(**args)
- service = ::DesignManagement::MoveDesignsService.new(current_user, parameters(args))
+ service = ::DesignManagement::MoveDesignsService.new(current_user, parameters(**args))
{ design_collection: service.collection, errors: service.execute.errors }
end
@@ -29,11 +29,18 @@ module Mutations
private
def parameters(**args)
- args.transform_values { |id| GitlabSchema.find_by_gid(id) }.transform_values(&:sync).tap do |hash|
+ args.transform_values { |id| find_design(id) }.transform_values(&:sync).tap do |hash|
hash.each { |k, design| not_found(args[k]) unless current_user.can?(:read_design, design) }
end
end
+ def find_design(id)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = DesignID.coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+
def not_found(gid)
raise Gitlab::Graphql::Errors::ResourceNotAvailable, "Resource not available: #{gid}"
end
diff --git a/app/graphql/mutations/discussions/toggle_resolve.rb b/app/graphql/mutations/discussions/toggle_resolve.rb
index 41fd22c6b55..4492da74706 100644
--- a/app/graphql/mutations/discussions/toggle_resolve.rb
+++ b/app/graphql/mutations/discussions/toggle_resolve.rb
@@ -8,7 +8,7 @@ module Mutations
description 'Toggles the resolved state of a discussion'
argument :id,
- GraphQL::ID_TYPE,
+ Types::GlobalIDType[Discussion],
required: true,
description: 'The global id of the discussion'
@@ -54,7 +54,10 @@ module Mutations
end
def find_object(id:)
- GitlabSchema.object_from_id(id, expected_type: ::Discussion)
+ # TODO: remove explicit coercion once compatibility layer has been removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = Types::GlobalIDType[Discussion].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
end
def resolve!(discussion)
diff --git a/app/graphql/mutations/issues/common_mutation_arguments.rb b/app/graphql/mutations/issues/common_mutation_arguments.rb
new file mode 100644
index 00000000000..4b5b246281f
--- /dev/null
+++ b/app/graphql/mutations/issues/common_mutation_arguments.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Issues
+ module CommonMutationArguments
+ extend ActiveSupport::Concern
+
+ included do
+ argument :description, GraphQL::STRING_TYPE,
+ required: false,
+ description: copy_field_description(Types::IssueType, :description)
+
+ argument :due_date, GraphQL::Types::ISO8601Date,
+ required: false,
+ description: copy_field_description(Types::IssueType, :due_date)
+
+ argument :confidential, GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: copy_field_description(Types::IssueType, :confidential)
+
+ argument :locked, GraphQL::BOOLEAN_TYPE,
+ as: :discussion_locked,
+ required: false,
+ description: copy_field_description(Types::IssueType, :discussion_locked)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/issues/create.rb b/app/graphql/mutations/issues/create.rb
new file mode 100644
index 00000000000..1454916bc77
--- /dev/null
+++ b/app/graphql/mutations/issues/create.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Issues
+ class Create < BaseMutation
+ include ResolvesProject
+ graphql_name 'CreateIssue'
+
+ authorize :create_issue
+
+ include CommonMutationArguments
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'Project full path the issue is associated with'
+
+ argument :iid, GraphQL::INT_TYPE,
+ required: false,
+ description: 'The IID (internal ID) of a project issue. Only admins and project owners can modify'
+
+ argument :title, GraphQL::STRING_TYPE,
+ required: true,
+ description: copy_field_description(Types::IssueType, :title)
+
+ argument :milestone_id, ::Types::GlobalIDType[::Milestone],
+ required: false,
+ description: 'The ID of the milestone to assign to the issue. On update milestone will be removed if set to null'
+
+ argument :labels, [GraphQL::STRING_TYPE],
+ required: false,
+ description: copy_field_description(Types::IssueType, :labels)
+
+ argument :label_ids, [::Types::GlobalIDType[::Label]],
+ required: false,
+ description: 'The IDs of labels to be added to the issue'
+
+ argument :created_at, Types::TimeType,
+ required: false,
+ description: 'Timestamp when the issue was created. Available only for admins and project owners'
+
+ argument :merge_request_to_resolve_discussions_of, ::Types::GlobalIDType[::MergeRequest],
+ required: false,
+ description: 'The IID of a merge request for which to resolve discussions'
+
+ argument :discussion_to_resolve, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'The ID of a discussion to resolve. Also pass `merge_request_to_resolve_discussions_of`'
+
+ argument :assignee_ids, [::Types::GlobalIDType[::User]],
+ required: false,
+ description: 'The array of user IDs to assign to the issue'
+
+ field :issue,
+ Types::IssueType,
+ null: true,
+ description: 'The issue after mutation'
+
+ def ready?(**args)
+ if args.slice(*mutually_exclusive_label_args).size > 1
+ arg_str = mutually_exclusive_label_args.map { |x| x.to_s.camelize(:lower) }.join(' or ')
+ raise Gitlab::Graphql::Errors::ArgumentError, "one and only one of #{arg_str} is required."
+ end
+
+ if args[:discussion_to_resolve].present? && args[:merge_request_to_resolve_discussions_of].blank?
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ 'to resolve a discussion please also provide `merge_request_to_resolve_discussions_of` parameter'
+ end
+
+ super
+ end
+
+ def resolve(project_path:, **attributes)
+ project = authorized_find!(full_path: project_path)
+ params = build_create_issue_params(attributes.merge(author_id: current_user.id))
+
+ issue = ::Issues::CreateService.new(project, current_user, params).execute
+
+ if issue.spam?
+ issue.errors.add(:base, 'Spam detected.')
+ end
+
+ {
+ issue: issue.valid? ? issue : nil,
+ errors: errors_on_object(issue)
+ }
+ end
+
+ private
+
+ def build_create_issue_params(params)
+ params[:milestone_id] &&= params[:milestone_id]&.model_id
+ params[:assignee_ids] &&= params[:assignee_ids].map { |assignee_id| assignee_id&.model_id }
+ params[:label_ids] &&= params[:label_ids].map { |label_id| label_id&.model_id }
+
+ params
+ end
+
+ def mutually_exclusive_label_args
+ [:labels, :label_ids]
+ end
+
+ def find_object(full_path:)
+ resolve_project(full_path: full_path)
+ end
+ end
+ end
+end
+
+Mutations::Issues::Create.prepend_if_ee('::EE::Mutations::Issues::Create')
diff --git a/app/graphql/mutations/issues/move.rb b/app/graphql/mutations/issues/move.rb
new file mode 100644
index 00000000000..e6971c9df8c
--- /dev/null
+++ b/app/graphql/mutations/issues/move.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Issues
+ class Move < Base
+ graphql_name 'IssueMove'
+
+ argument :target_project_path,
+ GraphQL::ID_TYPE,
+ required: true,
+ description: 'The project to move the issue to'
+
+ def resolve(project_path:, iid:, target_project_path:)
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/-/issues/267762')
+
+ issue = authorized_find!(project_path: project_path, iid: iid)
+ source_project = issue.project
+ target_project = resolve_project(full_path: target_project_path).sync
+
+ begin
+ moved_issue = ::Issues::MoveService.new(source_project, current_user).execute(issue, target_project)
+ rescue ::Issues::MoveService::MoveError => error
+ errors = error.message
+ end
+
+ {
+ issue: moved_issue,
+ errors: Array.wrap(errors)
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb
index cc03d32731b..9b216b31f9b 100644
--- a/app/graphql/mutations/issues/update.rb
+++ b/app/graphql/mutations/issues/update.rb
@@ -5,46 +5,27 @@ module Mutations
class Update < Base
graphql_name 'UpdateIssue'
- argument :title,
- GraphQL::STRING_TYPE,
- required: false,
- description: copy_field_description(Types::IssueType, :title)
+ include CommonMutationArguments
- argument :description,
- GraphQL::STRING_TYPE,
- required: false,
- description: copy_field_description(Types::IssueType, :description)
-
- argument :due_date,
- Types::TimeType,
- required: false,
- description: copy_field_description(Types::IssueType, :due_date)
-
- argument :confidential,
- GraphQL::BOOLEAN_TYPE,
+ argument :title, GraphQL::STRING_TYPE,
required: false,
- description: copy_field_description(Types::IssueType, :confidential)
+ description: copy_field_description(Types::IssueType, :title)
- argument :locked,
- GraphQL::BOOLEAN_TYPE,
- as: :discussion_locked,
+ argument :milestone_id, GraphQL::ID_TYPE,
required: false,
- description: copy_field_description(Types::IssueType, :discussion_locked)
+ description: 'The ID of the milestone to assign to the issue. On update milestone will be removed if set to null'
- argument :add_label_ids,
- [GraphQL::ID_TYPE],
+ argument :add_label_ids, [GraphQL::ID_TYPE],
required: false,
- description: 'The IDs of labels to be added to the issue.'
+ description: 'The IDs of labels to be added to the issue'
- argument :remove_label_ids,
- [GraphQL::ID_TYPE],
+ argument :remove_label_ids, [GraphQL::ID_TYPE],
required: false,
- description: 'The IDs of labels to be removed from the issue.'
+ description: 'The IDs of labels to be removed from the issue'
- argument :milestone_id,
- GraphQL::ID_TYPE,
- required: false,
- description: 'The ID of the milestone to be assigned, milestone will be removed if set to null.'
+ argument :state_event, Types::IssueStateEventEnum,
+ description: 'Close or reopen an issue',
+ required: false
def resolve(project_path:, iid:, **args)
issue = authorized_find!(project_path: project_path, iid: iid)
diff --git a/app/graphql/mutations/merge_requests/set_milestone.rb b/app/graphql/mutations/merge_requests/set_milestone.rb
index b3412dd9ed2..abcb1bda1f3 100644
--- a/app/graphql/mutations/merge_requests/set_milestone.rb
+++ b/app/graphql/mutations/merge_requests/set_milestone.rb
@@ -6,7 +6,7 @@ module Mutations
graphql_name 'MergeRequestSetMilestone'
argument :milestone_id,
- GraphQL::ID_TYPE,
+ ::Types::GlobalIDType[::Milestone],
required: false,
loads: Types::MilestoneType,
description: <<~DESC
diff --git a/app/graphql/mutations/metrics/dashboard/annotations/create.rb b/app/graphql/mutations/metrics/dashboard/annotations/create.rb
index f99688aeac6..b064f55825f 100644
--- a/app/graphql/mutations/metrics/dashboard/annotations/create.rb
+++ b/app/graphql/mutations/metrics/dashboard/annotations/create.rb
@@ -18,12 +18,12 @@ module Mutations
description: 'The created annotation'
argument :environment_id,
- GraphQL::ID_TYPE,
+ ::Types::GlobalIDType[::Environment],
required: false,
description: 'The global id of the environment to add an annotation to'
argument :cluster_id,
- GraphQL::ID_TYPE,
+ ::Types::GlobalIDType[::Clusters::Cluster],
required: false,
description: 'The global id of the cluster to add an annotation to'
@@ -80,11 +80,11 @@ module Mutations
raise Gitlab::Graphql::Errors::ArgumentError, ANNOTATION_SOURCE_ARGUMENT_ERROR
end
- super(args)
+ super(**args)
end
def find_object(id:)
- GitlabSchema.object_from_id(id)
+ GitlabSchema.find_by_gid(id)
end
def annotation_create_params(args)
@@ -96,7 +96,16 @@ module Mutations
end
def annotation_source(args)
- annotation_source_id = args[:cluster_id] || args[:environment_id]
+ # TODO: remove these lines when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ annotation_source_id = if args[:cluster_id]
+ ::Types::GlobalIDType[::Clusters::Cluster].coerce_isolated_input(args[:cluster_id])
+ else
+ ::Types::GlobalIDType[::Environment].coerce_isolated_input(args[:environment_id])
+ end
+
+ # TODO: uncomment following line once lines above are removed
+ # annotation_source_id = args[:cluster_id] || args[:environment_id]
authorized_find!(id: annotation_source_id)
end
end
diff --git a/app/graphql/mutations/notes/base.rb b/app/graphql/mutations/notes/base.rb
index 31dabc0a660..f2678211335 100644
--- a/app/graphql/mutations/notes/base.rb
+++ b/app/graphql/mutations/notes/base.rb
@@ -11,21 +11,10 @@ module Mutations
private
def find_object(id:)
- GitlabSchema.object_from_id(id)
- end
-
- def check_object_is_noteable!(object)
- unless object.is_a?(Noteable)
- raise Gitlab::Graphql::Errors::ResourceNotAvailable,
- 'Cannot add notes to this resource'
- end
- end
-
- def check_object_is_note!(object)
- unless object.is_a?(Note)
- raise Gitlab::Graphql::Errors::ResourceNotAvailable,
- 'Resource is not a note'
- end
+ # TODO: remove explicit coercion once compatibility layer has been removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::Note].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
end
end
end
diff --git a/app/graphql/mutations/notes/create/base.rb b/app/graphql/mutations/notes/create/base.rb
index f081eac368e..3cfdaf84760 100644
--- a/app/graphql/mutations/notes/create/base.rb
+++ b/app/graphql/mutations/notes/create/base.rb
@@ -9,7 +9,7 @@ module Mutations
authorize :create_note
argument :noteable_id,
- GraphQL::ID_TYPE,
+ ::Types::GlobalIDType[::Noteable],
required: true,
description: 'The global id of the resource to add a note to'
@@ -26,8 +26,6 @@ module Mutations
def resolve(args)
noteable = authorized_find!(id: args[:noteable_id])
- check_object_is_noteable!(noteable)
-
note = ::Notes::CreateService.new(
noteable.project,
current_user,
@@ -42,6 +40,13 @@ module Mutations
private
+ def find_object(id:)
+ # TODO: remove explicit coercion once compatibility layer has been removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::Noteable].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+
def create_note_params(noteable, args)
{
noteable: noteable,
diff --git a/app/graphql/mutations/notes/create/note.rb b/app/graphql/mutations/notes/create/note.rb
index 5236e48026e..e97037171f7 100644
--- a/app/graphql/mutations/notes/create/note.rb
+++ b/app/graphql/mutations/notes/create/note.rb
@@ -7,7 +7,7 @@ module Mutations
graphql_name 'CreateNote'
argument :discussion_id,
- GraphQL::ID_TYPE,
+ ::Types::GlobalIDType[::Discussion],
required: false,
description: 'The global id of the discussion this note is in reply to'
@@ -17,7 +17,11 @@ module Mutations
discussion_id = nil
if args[:discussion_id]
- discussion = GitlabSchema.object_from_id(args[:discussion_id])
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ discussion_gid = ::Types::GlobalIDType[::Discussion].coerce_isolated_input(args[:discussion_id])
+ discussion = GitlabSchema.find_by_gid(discussion_gid)
+
authorize_discussion!(discussion)
discussion_id = discussion.id
diff --git a/app/graphql/mutations/notes/destroy.rb b/app/graphql/mutations/notes/destroy.rb
index a81322bc9b7..63e5eeb5ecf 100644
--- a/app/graphql/mutations/notes/destroy.rb
+++ b/app/graphql/mutations/notes/destroy.rb
@@ -8,15 +8,13 @@ module Mutations
authorize :admin_note
argument :id,
- GraphQL::ID_TYPE,
- required: true,
- description: 'The global id of the note to destroy'
+ ::Types::GlobalIDType[::Note],
+ required: true,
+ description: 'The global id of the note to destroy'
def resolve(id:)
note = authorized_find!(id: id)
- check_object_is_note!(note)
-
::Notes::DestroyService.new(note.project, current_user).execute(note)
{
diff --git a/app/graphql/mutations/notes/update/base.rb b/app/graphql/mutations/notes/update/base.rb
index 8a2a78a29ec..1d5738ada77 100644
--- a/app/graphql/mutations/notes/update/base.rb
+++ b/app/graphql/mutations/notes/update/base.rb
@@ -9,7 +9,7 @@ module Mutations
authorize :admin_note
argument :id,
- GraphQL::ID_TYPE,
+ ::Types::GlobalIDType[::Note],
required: true,
description: 'The global id of the note to update'
diff --git a/app/graphql/mutations/notes/update/image_diff_note.rb b/app/graphql/mutations/notes/update/image_diff_note.rb
index 7aad3af1e04..ef70a8d2bf4 100644
--- a/app/graphql/mutations/notes/update/image_diff_note.rb
+++ b/app/graphql/mutations/notes/update/image_diff_note.rb
@@ -28,12 +28,12 @@ module Mutations
'body or position arguments are required'
end
- super(args)
+ super(**args)
end
private
- def pre_update_checks!(note, args)
+ def pre_update_checks!(note, _args)
unless note.is_a?(DiffNote) && note.position.on_image?
raise Gitlab::Graphql::Errors::ResourceNotAvailable,
'Resource is not an ImageDiffNote'
diff --git a/app/graphql/mutations/notes/update/note.rb b/app/graphql/mutations/notes/update/note.rb
index ca97dad6ded..73b9b9bc49a 100644
--- a/app/graphql/mutations/notes/update/note.rb
+++ b/app/graphql/mutations/notes/update/note.rb
@@ -18,8 +18,8 @@ module Mutations
private
- def pre_update_checks!(note, _args)
- check_object_is_note!(note)
+ def pre_update_checks!(_note, _args)
+ # no-op
end
end
end
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index a8aeb15afcd..37c0f80310c 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -3,6 +3,7 @@
module Mutations
module Snippets
class Create < BaseMutation
+ include SpammableMutationFields
include ResolvesProject
graphql_name 'CreateSnippet'
@@ -56,10 +57,12 @@ module Mutations
::Gitlab::UsageDataCounters::EditorUniqueCounter.track_snippet_editor_edit_action(author: current_user)
end
- {
- snippet: service_response.success? ? snippet : nil,
- errors: errors_on_object(snippet)
- }
+ with_spam_fields(snippet) do
+ {
+ snippet: service_response.success? ? snippet : nil,
+ errors: errors_on_object(snippet)
+ }
+ end
end
private
@@ -81,14 +84,16 @@ module Mutations
end
def create_params(args)
- args.tap do |create_args|
- # We need to rename `blob_actions` into `snippet_actions` because
- # it's the expected key param
- create_args[:snippet_actions] = create_args.delete(:blob_actions)&.map(&:to_h)
-
- # We need to rename `uploaded_files` into `files` because
- # it's the expected key param
- create_args[:files] = create_args.delete(:uploaded_files)
+ with_spam_params do
+ args.tap do |create_args|
+ # We need to rename `blob_actions` into `snippet_actions` because
+ # it's the expected key param
+ create_args[:snippet_actions] = create_args.delete(:blob_actions)&.map(&:to_h)
+
+ # We need to rename `uploaded_files` into `files` because
+ # it's the expected key param
+ create_args[:files] = create_args.delete(:uploaded_files)
+ end
end
end
end
diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb
index d0db5fa2eb9..74266880806 100644
--- a/app/graphql/mutations/snippets/update.rb
+++ b/app/graphql/mutations/snippets/update.rb
@@ -3,6 +3,8 @@
module Mutations
module Snippets
class Update < Base
+ include SpammableMutationFields
+
graphql_name 'UpdateSnippet'
argument :id,
@@ -39,10 +41,12 @@ module Mutations
::Gitlab::UsageDataCounters::EditorUniqueCounter.track_snippet_editor_edit_action(author: current_user)
end
- {
- snippet: result.success? ? snippet : snippet.reset,
- errors: errors_on_object(snippet)
- }
+ with_spam_fields(snippet) do
+ {
+ snippet: result.success? ? snippet : snippet.reset,
+ errors: errors_on_object(snippet)
+ }
+ end
end
private
@@ -52,10 +56,12 @@ module Mutations
end
def update_params(args)
- args.tap do |update_args|
- # We need to rename `blob_actions` into `snippet_actions` because
- # it's the expected key param
- update_args[:snippet_actions] = update_args.delete(:blob_actions)&.map(&:to_h)
+ with_spam_params do
+ args.tap do |update_args|
+ # We need to rename `blob_actions` into `snippet_actions` because
+ # it's the expected key param
+ update_args[:snippet_actions] = update_args.delete(:blob_actions)&.map(&:to_h)
+ end
end
end
end
diff --git a/app/graphql/mutations/todos/base.rb b/app/graphql/mutations/todos/base.rb
index 2a72019fbac..6db863796bc 100644
--- a/app/graphql/mutations/todos/base.rb
+++ b/app/graphql/mutations/todos/base.rb
@@ -6,7 +6,10 @@ module Mutations
private
def find_object(id:)
- GitlabSchema.object_from_id(id)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::Todo].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
end
def map_to_global_ids(ids)
@@ -16,7 +19,7 @@ module Mutations
end
def to_global_id(id)
- ::URI::GID.build(app: GlobalID.app, model_name: Todo.name, model_id: id, params: nil).to_s
+ Gitlab::GlobalId.as_global_id(id, model_name: Todo.name).to_s
end
end
end
diff --git a/app/graphql/mutations/todos/mark_done.rb b/app/graphql/mutations/todos/mark_done.rb
index 748e02d8782..3d73022f266 100644
--- a/app/graphql/mutations/todos/mark_done.rb
+++ b/app/graphql/mutations/todos/mark_done.rb
@@ -8,7 +8,7 @@ module Mutations
authorize :update_todo
argument :id,
- GraphQL::ID_TYPE,
+ ::Types::GlobalIDType[::Todo],
required: true,
description: 'The global id of the todo to mark as done'
diff --git a/app/graphql/mutations/todos/restore.rb b/app/graphql/mutations/todos/restore.rb
index a0a1772db0a..7c8f92d32f5 100644
--- a/app/graphql/mutations/todos/restore.rb
+++ b/app/graphql/mutations/todos/restore.rb
@@ -8,7 +8,7 @@ module Mutations
authorize :update_todo
argument :id,
- GraphQL::ID_TYPE,
+ ::Types::GlobalIDType[::Todo],
required: true,
description: 'The global id of the todo to restore'
diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb
index c5e2750768c..ea5f5414134 100644
--- a/app/graphql/mutations/todos/restore_many.rb
+++ b/app/graphql/mutations/todos/restore_many.rb
@@ -8,7 +8,7 @@ module Mutations
MAX_UPDATE_AMOUNT = 50
argument :ids,
- [GraphQL::ID_TYPE],
+ [::Types::GlobalIDType[::Todo]],
required: true,
description: 'The global ids of the todos to restore (a maximum of 50 is supported at once)'
@@ -37,24 +37,18 @@ module Mutations
private
def gids_of(ids)
- ids.map { |id| ::URI::GID.build(app: GlobalID.app, model_name: Todo.name, model_id: id, params: nil).to_s }
+ ids.map { |id| Gitlab::GlobalId.as_global_id(id, model_name: Todo.name).to_s }
end
def model_ids_of(ids)
ids.map do |gid|
- parsed_gid = ::URI::GID.parse(gid)
- parsed_gid.model_id.to_i if accessible_todo?(parsed_gid)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ gid = ::Types::GlobalIDType[::Todo].coerce_isolated_input(gid)
+ gid.model_id.to_i
end.compact
end
- def accessible_todo?(gid)
- gid.app == GlobalID.app && todo?(gid)
- end
-
- def todo?(gid)
- GlobalID.parse(gid)&.model_class&.ancestors&.include?(Todo)
- end
-
def raise_too_many_todos_requested_error
raise Gitlab::Graphql::Errors::ArgumentError, 'Too many todos requested.'
end
diff --git a/app/assets/javascripts/repository/queries/path_last_commit.query.graphql b/app/graphql/queries/repository/path_last_commit.query.graphql
index 51f3f790a5d..d845f7c6224 100644
--- a/app/assets/javascripts/repository/queries/path_last_commit.query.graphql
+++ b/app/graphql/queries/repository/path_last_commit.query.graphql
@@ -1,8 +1,12 @@
query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
project(fullPath: $projectPath) {
+ __typename
repository {
+ __typename
tree(path: $path, ref: $ref) {
+ __typename
lastCommit {
+ __typename
sha
title
titleHtml
@@ -13,15 +17,20 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
authorName
authorGravatar
author {
+ __typename
name
avatarUrl
webPath
}
signatureHtml
pipelines(ref: $ref, first: 1) {
+ __typename
edges {
+ __typename
node {
+ __typename
detailedStatus {
+ __typename
detailsPath
icon
tooltip
diff --git a/app/graphql/resolvers/alert_management/alert_resolver.rb b/app/graphql/resolvers/alert_management/alert_resolver.rb
index 71a7615685a..dc9b1dbb5f4 100644
--- a/app/graphql/resolvers/alert_management/alert_resolver.rb
+++ b/app/graphql/resolvers/alert_management/alert_resolver.rb
@@ -22,6 +22,10 @@ module Resolvers
description: 'Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.',
required: false
+ argument :assignee_username, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Username of a user assigned to the issue'
+
type Types::AlertManagement::AlertType, null: true
def resolve_with_lookahead(**args)
diff --git a/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb b/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb
index a45de21002f..96ea4610aff 100644
--- a/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb
+++ b/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb
@@ -9,6 +9,10 @@ module Resolvers
description: 'Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.',
required: false
+ argument :assignee_username, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Username of a user assigned to the issue'
+
def resolve(**args)
::Gitlab::AlertManagement::AlertStatusCounts.new(context[:current_user], object, args)
end
diff --git a/app/graphql/resolvers/assigned_merge_requests_resolver.rb b/app/graphql/resolvers/assigned_merge_requests_resolver.rb
index fa08b142a7e..172a8e298ad 100644
--- a/app/graphql/resolvers/assigned_merge_requests_resolver.rb
+++ b/app/graphql/resolvers/assigned_merge_requests_resolver.rb
@@ -2,6 +2,8 @@
module Resolvers
class AssignedMergeRequestsResolver < UserMergeRequestsResolver
+ accept_author
+
def user_role
:assignee
end
diff --git a/app/graphql/resolvers/authored_merge_requests_resolver.rb b/app/graphql/resolvers/authored_merge_requests_resolver.rb
index e19bc9e8715..bc796f8685a 100644
--- a/app/graphql/resolvers/authored_merge_requests_resolver.rb
+++ b/app/graphql/resolvers/authored_merge_requests_resolver.rb
@@ -2,6 +2,8 @@
module Resolvers
class AuthoredMergeRequestsResolver < UserMergeRequestsResolver
+ accept_assignee
+
def user_role
:author
end
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index 791c6eab42f..2b8854fb4d0 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -4,6 +4,9 @@ module Resolvers
class BaseResolver < GraphQL::Schema::Resolver
extend ::Gitlab::Utils::Override
include ::Gitlab::Utils::StrongMemoize
+ include ::Gitlab::Graphql::GlobalIDCompatibility
+
+ argument_class ::Types::BaseArgument
def self.single
@single ||= Class.new(self) do
diff --git a/app/graphql/resolvers/board_list_issues_resolver.rb b/app/graphql/resolvers/board_list_issues_resolver.rb
index dba9f99edeb..3421e1024c0 100644
--- a/app/graphql/resolvers/board_list_issues_resolver.rb
+++ b/app/graphql/resolvers/board_list_issues_resolver.rb
@@ -14,7 +14,7 @@ module Resolvers
def resolve(**args)
filter_params = issue_filters(args[:filters]).merge(board_id: list.board.id, id: list.id)
- service = Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
+ service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(service.execute)
end
diff --git a/app/graphql/resolvers/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb
index b1d43934f24..3384b37e2ce 100644
--- a/app/graphql/resolvers/board_lists_resolver.rb
+++ b/app/graphql/resolvers/board_lists_resolver.rb
@@ -2,6 +2,7 @@
module Resolvers
class BoardListsResolver < BaseResolver
+ include BoardIssueFilterable
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::BoardListType, null: true
@@ -10,12 +11,17 @@ module Resolvers
required: false,
description: 'Find a list by its global ID'
+ argument :issue_filters, Types::Boards::BoardIssueInputType,
+ required: false,
+ description: 'Filters applied when getting issue metadata in the board list'
+
alias_method :board, :object
- def resolve(lookahead: nil, id: nil)
+ def resolve(lookahead: nil, id: nil, issue_filters: {})
authorize!(board)
lists = board_lists(id)
+ context.scoped_set!(:issue_filters, issue_filters(issue_filters))
if load_preferences?(lookahead)
List.preload_preferences_for_user(lists, context[:current_user])
@@ -27,7 +33,7 @@ module Resolvers
private
def board_lists(id)
- service = Boards::Lists::ListService.new(
+ service = ::Boards::Lists::ListService.new(
board.resource_parent,
context[:current_user],
list_id: extract_list_id(id)
diff --git a/app/graphql/resolvers/board_resolver.rb b/app/graphql/resolvers/board_resolver.rb
new file mode 100644
index 00000000000..517f4e514c9
--- /dev/null
+++ b/app/graphql/resolvers/board_resolver.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class BoardResolver < BaseResolver.single
+ alias_method :parent, :synchronized_object
+
+ type Types::BoardType, null: true
+
+ argument :id, ::Types::GlobalIDType[::Board],
+ required: true,
+ description: 'The board\'s ID'
+
+ def resolve(id: nil)
+ return unless parent
+
+ ::Boards::ListService.new(parent, context[:current_user], board_id: extract_board_id(id)).execute(create_default_board: false).first
+ rescue ActiveRecord::RecordNotFound
+ nil
+ end
+
+ private
+
+ def extract_board_id(gid)
+ GitlabSchema.parse_gid(gid, expected_type: ::Board).model_id
+ end
+ end
+end
diff --git a/app/graphql/resolvers/boards_resolver.rb b/app/graphql/resolvers/boards_resolver.rb
index eceb5b38031..82efd92d33f 100644
--- a/app/graphql/resolvers/boards_resolver.rb
+++ b/app/graphql/resolvers/boards_resolver.rb
@@ -16,7 +16,7 @@ module Resolvers
return Board.none unless parent
- Boards::ListService.new(parent, context[:current_user], board_id: extract_board_id(id)).execute(create_default_board: false)
+ ::Boards::ListService.new(parent, context[:current_user], board_id: extract_board_id(id)).execute(create_default_board: false)
rescue ActiveRecord::RecordNotFound
Board.none
end
diff --git a/app/graphql/resolvers/ci/runner_platforms_resolver.rb b/app/graphql/resolvers/ci/runner_platforms_resolver.rb
new file mode 100644
index 00000000000..9677c5139b4
--- /dev/null
+++ b/app/graphql/resolvers/ci/runner_platforms_resolver.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ci
+ class RunnerPlatformsResolver < BaseResolver
+ type Types::Ci::RunnerPlatformType, null: false
+
+ def resolve(**args)
+ runner_instructions.map do |platform, data|
+ {
+ name: platform, human_readable_name: data[:human_readable_name],
+ architectures: parse_architectures(data[:download_locations])
+ }
+ end
+ end
+
+ private
+
+ def runner_instructions
+ Gitlab::Ci::RunnerInstructions::OS.merge(Gitlab::Ci::RunnerInstructions::OTHER_ENVIRONMENTS)
+ end
+
+ def parse_architectures(download_locations)
+ download_locations&.map do |architecture, download_location|
+ { name: architecture, download_location: download_location }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/concerns/group_issuable_resolver.rb b/app/graphql/resolvers/concerns/group_issuable_resolver.rb
new file mode 100644
index 00000000000..49a79683e9f
--- /dev/null
+++ b/app/graphql/resolvers/concerns/group_issuable_resolver.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module GroupIssuableResolver
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def include_subgroups(name_of_things)
+ argument :include_subgroups, GraphQL::BOOLEAN_TYPE,
+ required: false,
+ default_value: false,
+ description: "Include #{name_of_things} belonging to subgroups"
+ end
+ end
+end
diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
index 2b14d8275d1..fe6fa0bb262 100644
--- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
+++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
@@ -18,9 +18,15 @@ module IssueResolverArguments
argument :milestone_title, GraphQL::STRING_TYPE.to_list_type,
required: false,
description: 'Milestone applied to this issue'
+ argument :author_username, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Username of the author of the issue'
argument :assignee_username, GraphQL::STRING_TYPE,
required: false,
description: 'Username of a user assigned to the issue'
+ argument :assignee_usernames, [GraphQL::STRING_TYPE],
+ required: false,
+ description: 'Usernames of users assigned to the issue'
argument :assignee_id, GraphQL::STRING_TYPE,
required: false,
description: 'ID of a user assigned to the issues, "none" and "any" values supported'
diff --git a/app/graphql/resolvers/concerns/looks_ahead.rb b/app/graphql/resolvers/concerns/looks_ahead.rb
index e7230287e13..61f23920ebb 100644
--- a/app/graphql/resolvers/concerns/looks_ahead.rb
+++ b/app/graphql/resolvers/concerns/looks_ahead.rb
@@ -3,8 +3,6 @@
module LooksAhead
extend ActiveSupport::Concern
- FEATURE_FLAG = :graphql_lookahead_support
-
included do
attr_accessor :lookahead
end
@@ -16,8 +14,6 @@ module LooksAhead
end
def apply_lookahead(query)
- return query unless Feature.enabled?(FEATURE_FLAG)
-
selection = node_selection
includes = preloads.each.flat_map do |name, requirements|
diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
index 0c01efd4f9a..ab83476ddea 100644
--- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb
+++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
@@ -12,7 +12,7 @@ module ResolvesMergeRequests
def resolve_with_lookahead(**args)
mr_finder = MergeRequestsFinder.new(current_user, args.compact)
- finder = Gitlab::Graphql::Loaders::IssuableLoader.new(project, mr_finder)
+ finder = Gitlab::Graphql::Loaders::IssuableLoader.new(mr_parent, mr_finder)
select_result(finder.batching_find_all { |query| apply_lookahead(query) })
end
@@ -29,6 +29,10 @@ module ResolvesMergeRequests
private
+ def mr_parent
+ project
+ end
+
def unconditional_includes
[:target_project]
end
@@ -40,7 +44,8 @@ module ResolvesMergeRequests
author: [:author],
merged_at: [:metrics],
commit_count: [:metrics],
- approved_by: [:approver_users],
+ diff_stats_summary: [:metrics],
+ approved_by: [:approved_by_users],
milestone: [:milestone],
head_pipeline: [:merge_request_diff, { head_pipeline: [:merge_request] }]
}
diff --git a/app/graphql/resolvers/concerns/time_frame_arguments.rb b/app/graphql/resolvers/concerns/time_frame_arguments.rb
index ef333dd05a5..94bfe6f7f9f 100644
--- a/app/graphql/resolvers/concerns/time_frame_arguments.rb
+++ b/app/graphql/resolvers/concerns/time_frame_arguments.rb
@@ -3,21 +3,33 @@
module TimeFrameArguments
extend ActiveSupport::Concern
+ OVERLAPPING_TIMEFRAME_DESC = 'List items overlapping a time frame defined by startDate..endDate (if one date is provided, both must be present)'
+
included do
argument :start_date, Types::TimeType,
required: false,
- description: 'List items within a time frame where items.start_date is between startDate and endDate parameters (endDate parameter must be present)'
+ description: OVERLAPPING_TIMEFRAME_DESC,
+ deprecated: { reason: 'Use timeframe.start', milestone: '13.5' }
argument :end_date, Types::TimeType,
required: false,
- description: 'List items within a time frame where items.end_date is between startDate and endDate parameters (startDate parameter must be present)'
+ description: OVERLAPPING_TIMEFRAME_DESC,
+ deprecated: { reason: 'Use timeframe.end', milestone: '13.5' }
+
+ argument :timeframe, Types::TimeframeInputType,
+ required: false,
+ description: 'List items overlapping the given timeframe'
end
+ # TODO: remove when the start_date and end_date arguments are removed
def validate_timeframe_params!(args)
- return unless args[:start_date].present? || args[:end_date].present?
+ return unless %i[start_date end_date timeframe].any? { |k| args[k].present? }
+ return if args[:timeframe] && %i[start_date end_date].all? { |k| args[k].nil? }
error_message =
- if args[:start_date].nil? || args[:end_date].nil?
+ if args[:timeframe].present?
+ "startDate and endDate are deprecated in favor of timeframe. Please use only timeframe."
+ elsif args[:start_date].nil? || args[:end_date].nil?
"Both startDate and endDate must be present."
elsif args[:start_date] > args[:end_date]
"startDate is after endDate"
diff --git a/app/graphql/resolvers/group_issues_resolver.rb b/app/graphql/resolvers/group_issues_resolver.rb
index ac51011eea8..1fa6c78e730 100644
--- a/app/graphql/resolvers/group_issues_resolver.rb
+++ b/app/graphql/resolvers/group_issues_resolver.rb
@@ -2,9 +2,8 @@
module Resolvers
class GroupIssuesResolver < IssuesResolver
- argument :include_subgroups, GraphQL::BOOLEAN_TYPE,
- required: false,
- default_value: false,
- description: 'Include issues belonging to subgroups.'
+ include GroupIssuableResolver
+
+ include_subgroups 'issues'
end
end
diff --git a/app/graphql/resolvers/group_merge_requests_resolver.rb b/app/graphql/resolvers/group_merge_requests_resolver.rb
new file mode 100644
index 00000000000..5ee72e3f781
--- /dev/null
+++ b/app/graphql/resolvers/group_merge_requests_resolver.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class GroupMergeRequestsResolver < MergeRequestsResolver
+ include GroupIssuableResolver
+
+ alias_method :group, :synchronized_object
+
+ include_subgroups 'merge requests'
+ accept_assignee
+ accept_author
+
+ def project
+ nil
+ end
+
+ def mr_parent
+ group
+ end
+
+ def no_results_possible?(args)
+ group.nil? || some_argument_is_empty?(args)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb
index 677f84e5795..cb4a76243ae 100644
--- a/app/graphql/resolvers/merge_requests_resolver.rb
+++ b/app/graphql/resolvers/merge_requests_resolver.rb
@@ -6,6 +6,18 @@ module Resolvers
alias_method :project, :synchronized_object
+ def self.accept_assignee
+ argument :assignee_username, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Username of the assignee'
+ end
+
+ def self.accept_author
+ argument :author_username, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Username of the author'
+ end
+
argument :iids, [GraphQL::STRING_TYPE],
required: false,
description: 'Array of IIDs of merge requests, for example `[1, 2]`'
diff --git a/app/graphql/resolvers/milestones_resolver.rb b/app/graphql/resolvers/milestones_resolver.rb
index 5f80506c01b..84712b674db 100644
--- a/app/graphql/resolvers/milestones_resolver.rb
+++ b/app/graphql/resolvers/milestones_resolver.rb
@@ -13,6 +13,18 @@ module Resolvers
required: false,
description: 'Filter milestones by state'
+ argument :title, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'The title of the milestone'
+
+ argument :search_title, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'A search string for the title'
+
+ argument :containing_date, Types::TimeType,
+ required: false,
+ description: 'A date that the milestone contains'
+
type Types::MilestoneType, null: true
def resolve(**args)
@@ -29,9 +41,18 @@ module Resolvers
{
ids: parse_gids(args[:ids]),
state: args[:state] || 'all',
- start_date: args[:start_date],
- end_date: args[:end_date]
- }.merge(parent_id_parameters(args))
+ title: args[:title],
+ search_title: args[:search_title],
+ containing_date: args[:containing_date]
+ }.merge!(timeframe_parameters(args)).merge!(parent_id_parameters(args))
+ end
+
+ def timeframe_parameters(args)
+ if args[:timeframe]
+ args[:timeframe].transform_keys { |k| :"#{k}_date" }
+ else
+ args.slice(:start_date, :end_date)
+ end
end
def parent
diff --git a/app/graphql/resolvers/project_merge_requests_resolver.rb b/app/graphql/resolvers/project_merge_requests_resolver.rb
index 0526ccd315f..ba13cb6e52c 100644
--- a/app/graphql/resolvers/project_merge_requests_resolver.rb
+++ b/app/graphql/resolvers/project_merge_requests_resolver.rb
@@ -2,11 +2,7 @@
module Resolvers
class ProjectMergeRequestsResolver < MergeRequestsResolver
- argument :assignee_username, GraphQL::STRING_TYPE,
- required: false,
- description: 'Username of the assignee'
- argument :author_username, GraphQL::STRING_TYPE,
- required: false,
- description: 'Username of the author'
+ accept_assignee
+ accept_author
end
end
diff --git a/app/graphql/resolvers/projects/jira_projects_resolver.rb b/app/graphql/resolvers/projects/jira_projects_resolver.rb
index ed382ac82d0..d017f973e17 100644
--- a/app/graphql/resolvers/projects/jira_projects_resolver.rb
+++ b/app/graphql/resolvers/projects/jira_projects_resolver.rb
@@ -22,7 +22,7 @@ module Resolvers
projects_array,
# override default max_page_size to whatever the size of the response is,
# see https://gitlab.com/gitlab-org/gitlab/-/issues/231394
- args.merge({ max_page_size: projects_array.size })
+ **args.merge({ max_page_size: projects_array.size })
)
else
raise Gitlab::Graphql::Errors::BaseError, response.message
diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb
index 3bbadf87a71..69438229a50 100644
--- a/app/graphql/resolvers/projects_resolver.rb
+++ b/app/graphql/resolvers/projects_resolver.rb
@@ -13,8 +13,16 @@ module Resolvers
description: 'Search query for project name, path, or description'
argument :ids, [GraphQL::ID_TYPE],
- required: false,
- description: 'Filter projects by IDs'
+ required: false,
+ description: 'Filter projects by IDs'
+
+ argument :search_namespaces, GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: 'Include namespace in project search'
+
+ argument :sort, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Sort order of results'
def resolve(**args)
ProjectsFinder
@@ -28,7 +36,9 @@ module Resolvers
{
without_deleted: true,
non_public: params[:membership],
- search: params[:search]
+ search: params[:search],
+ search_namespaces: params[:search_namespaces],
+ sort: params[:sort]
}.compact
end
diff --git a/app/graphql/resolvers/snippets/blobs_resolver.rb b/app/graphql/resolvers/snippets/blobs_resolver.rb
new file mode 100644
index 00000000000..dc28358cab6
--- /dev/null
+++ b/app/graphql/resolvers/snippets/blobs_resolver.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Snippets
+ class BlobsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ alias_method :snippet, :object
+
+ argument :paths, [GraphQL::STRING_TYPE],
+ required: false,
+ description: 'Paths of the blobs'
+
+ def resolve(**args)
+ authorize!(snippet)
+
+ return [snippet.blob] if snippet.empty_repo?
+
+ paths = Array(args.fetch(:paths, []))
+
+ if paths.empty?
+ snippet.blobs
+ else
+ snippet.repository.blobs_at(transformed_blob_paths(paths))
+ end
+ end
+
+ def authorized_resource?(snippet)
+ Ability.allowed?(context[:current_user], :read_snippet, snippet)
+ end
+
+ private
+
+ def transformed_blob_paths(paths)
+ ref = snippet.default_branch
+ paths.map { |path| [ref, path] }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/terraform/states_resolver.rb b/app/graphql/resolvers/terraform/states_resolver.rb
new file mode 100644
index 00000000000..38b26a948b1
--- /dev/null
+++ b/app/graphql/resolvers/terraform/states_resolver.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Terraform
+ class StatesResolver < BaseResolver
+ type Types::Terraform::StateType, null: true
+
+ alias_method :project, :object
+
+ def resolve(**args)
+ return ::Terraform::State.none unless can_read_terraform_states?
+
+ project.terraform_states.ordered_by_name
+ end
+
+ private
+
+ def can_read_terraform_states?
+ current_user.can?(:read_terraform_state, project)
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb b/app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb
index 13c67442c2e..c6ca5963588 100644
--- a/app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb
+++ b/app/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum.rb
@@ -8,12 +8,16 @@ module Types
graphql_name 'MeasurementIdentifier'
description 'Possible identifier types for a measurement'
- value 'PROJECTS', 'Project count', value: :projects
- value 'USERS', 'User count', value: :users
- value 'ISSUES', 'Issue count', value: :issues
- value 'MERGE_REQUESTS', 'Merge request count', value: :merge_requests
- value 'GROUPS', 'Group count', value: :groups
- value 'PIPELINES', 'Pipeline count', value: :pipelines
+ value 'PROJECTS', 'Project count', value: 'projects'
+ value 'USERS', 'User count', value: 'users'
+ value 'ISSUES', 'Issue count', value: 'issues'
+ value 'MERGE_REQUESTS', 'Merge request count', value: 'merge_requests'
+ value 'GROUPS', 'Group count', value: 'groups'
+ value 'PIPELINES', 'Pipeline count', value: 'pipelines'
+ value 'PIPELINES_SUCCEEDED', 'Pipeline count with success status', value: 'pipelines_succeeded'
+ value 'PIPELINES_FAILED', 'Pipeline count with failed status', value: 'pipelines_failed'
+ value 'PIPELINES_CANCELED', 'Pipeline count with canceled status', value: 'pipelines_canceled'
+ value 'PIPELINES_SKIPPED', 'Pipeline count with skipped status', value: 'pipelines_skipped'
end
end
end
diff --git a/app/graphql/types/alert_management/alert_status_counts_type.rb b/app/graphql/types/alert_management/alert_status_counts_type.rb
index f80b289eabc..a84be705445 100644
--- a/app/graphql/types/alert_management/alert_status_counts_type.rb
+++ b/app/graphql/types/alert_management/alert_status_counts_type.rb
@@ -9,11 +9,11 @@ module Types
authorize :read_alert_management_alert
- ::Gitlab::AlertManagement::AlertStatusCounts::STATUSES.each_key do |status|
+ ::AlertManagement::Alert.status_names.each do |status|
field status,
GraphQL::INT_TYPE,
null: true,
- description: "Number of alerts with status #{status.upcase} for the project"
+ description: "Number of alerts with status #{status.to_s.upcase} for the project"
end
field :open,
diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb
index 2da97030b88..623762de208 100644
--- a/app/graphql/types/alert_management/alert_type.rb
+++ b/app/graphql/types/alert_management/alert_type.rb
@@ -40,7 +40,8 @@ module Types
field :status,
AlertManagement::StatusEnum,
null: true,
- description: 'Status of the alert'
+ description: 'Status of the alert',
+ method: :status_name
field :service,
GraphQL::STRING_TYPE,
@@ -67,6 +68,11 @@ module Types
null: true,
description: 'Timestamp the alert ended'
+ field :environment,
+ Types::EnvironmentType,
+ null: true,
+ description: 'Environment for the alert'
+
field :event_count,
GraphQL::INT_TYPE,
null: true,
diff --git a/app/graphql/types/alert_management/status_enum.rb b/app/graphql/types/alert_management/status_enum.rb
index 4ff6c4a9505..9d2c7316254 100644
--- a/app/graphql/types/alert_management/status_enum.rb
+++ b/app/graphql/types/alert_management/status_enum.rb
@@ -6,8 +6,8 @@ module Types
graphql_name 'AlertManagementStatus'
description 'Alert status values'
- ::AlertManagement::Alert::STATUSES.each do |name, value|
- value name.upcase, value: value, description: "#{name.to_s.titleize} status"
+ ::AlertManagement::Alert.status_names.each do |status|
+ value status.to_s.upcase, value: status, description: "#{status.to_s.titleize} status"
end
end
end
diff --git a/app/graphql/types/base_argument.rb b/app/graphql/types/base_argument.rb
new file mode 100644
index 00000000000..11774d0b59d
--- /dev/null
+++ b/app/graphql/types/base_argument.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ class BaseArgument < GraphQL::Schema::Argument
+ include GitlabStyleDeprecations
+
+ def initialize(*args, **kwargs, &block)
+ kwargs = gitlab_deprecation(kwargs)
+ kwargs.delete(:deprecation_reason)
+
+ super(*args, **kwargs, &block)
+ end
+ end
+end
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
index 1e72a4cddf5..5c8aabfe163 100644
--- a/app/graphql/types/base_field.rb
+++ b/app/graphql/types/base_field.rb
@@ -5,6 +5,8 @@ module Types
prepend Gitlab::Graphql::Authorize
include GitlabStyleDeprecations
+ argument_class ::Types::BaseArgument
+
DEFAULT_COMPLEXITY = 1
def initialize(*args, **kwargs, &block)
diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb
index 24faf1fe8bc..6ee76b0d1f1 100644
--- a/app/graphql/types/board_list_type.rb
+++ b/app/graphql/types/board_list_type.rb
@@ -32,17 +32,14 @@ module Types
metadata[:size]
end
- def total_weight
- metadata[:total_weight]
- end
-
def metadata
strong_memoize(:metadata) do
list = self.object
user = context[:current_user]
+ params = (context[:issue_filters] || {}).merge(board_id: list.board_id, id: list.id)
::Boards::Issues::ListService
- .new(list.board.resource_parent, user, board_id: list.board_id, id: list.id)
+ .new(list.board.resource_parent, user, params)
.metadata
end
end
diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb
index 90b5283fc9a..f4a50115ee6 100644
--- a/app/graphql/types/ci/detailed_status_type.rb
+++ b/app/graphql/types/ci/detailed_status_type.rb
@@ -6,24 +6,39 @@ module Types
class DetailedStatusType < BaseObject
graphql_name 'DetailedStatus'
- field :group, GraphQL::STRING_TYPE, null: false,
- description: 'Group of the pipeline status'
- field :icon, GraphQL::STRING_TYPE, null: false,
- description: 'Icon of the pipeline status'
- field :favicon, GraphQL::STRING_TYPE, null: false,
- description: 'Favicon of the pipeline status'
- field :details_path, GraphQL::STRING_TYPE, null: false,
- description: 'Path of the details for the pipeline status'
- field :has_details, GraphQL::BOOLEAN_TYPE, null: false,
- description: 'Indicates if the pipeline status has further details',
+ field :group, GraphQL::STRING_TYPE, null: true,
+ description: 'Group of the status'
+ field :icon, GraphQL::STRING_TYPE, null: true,
+ description: 'Icon of the status'
+ field :favicon, GraphQL::STRING_TYPE, null: true,
+ description: 'Favicon of the status'
+ field :details_path, GraphQL::STRING_TYPE, null: true,
+ description: 'Path of the details for the status'
+ field :has_details, GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Indicates if the status has further details',
method: :has_details?
- field :label, GraphQL::STRING_TYPE, null: false,
- description: 'Label of the pipeline status'
- field :text, GraphQL::STRING_TYPE, null: false,
- description: 'Text of the pipeline status'
- field :tooltip, GraphQL::STRING_TYPE, null: false,
- description: 'Tooltip associated with the pipeline status',
+ field :label, GraphQL::STRING_TYPE, null: true,
+ description: 'Label of the status'
+ field :text, GraphQL::STRING_TYPE, null: true,
+ description: 'Text of the status'
+ field :tooltip, GraphQL::STRING_TYPE, null: true,
+ description: 'Tooltip associated with the status',
method: :status_tooltip
+ field :action, Types::Ci::StatusActionType, null: true,
+ description: 'Action information for the status. This includes method, button title, icon, path, and title',
+ resolve: -> (obj, _args, _ctx) {
+ if obj.has_action?
+ {
+ button_title: obj.action_button_title,
+ icon: obj.icon,
+ method: obj.action_method,
+ path: obj.action_path,
+ title: obj.action_title
+ }
+ else
+ nil
+ end
+ }
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/ci/group_type.rb b/app/graphql/types/ci/group_type.rb
index 04c0eb93068..d930ae311b7 100644
--- a/app/graphql/types/ci/group_type.rb
+++ b/app/graphql/types/ci/group_type.rb
@@ -12,6 +12,9 @@ module Types
description: 'Size of the group'
field :jobs, Ci::JobType.connection_type, null: true,
description: 'Jobs in group'
+ field :detailed_status, Types::Ci::DetailedStatusType, null: true,
+ description: 'Detailed status of the group',
+ resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) }
end
end
end
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index 4c18f3ffd52..0ee1ad47b62 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -10,6 +10,11 @@ module Types
description: 'Name of the job'
field :needs, JobType.connection_type, null: true,
description: 'Builds that must complete before the jobs run'
+ field :detailed_status, Types::Ci::DetailedStatusType, null: true,
+ description: 'Detailed status of the job',
+ resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) }
+ field :scheduled_at, Types::TimeType, null: true,
+ description: 'Schedule for the build'
end
end
end
diff --git a/app/graphql/types/ci/runner_architecture_type.rb b/app/graphql/types/ci/runner_architecture_type.rb
new file mode 100644
index 00000000000..526348abd9d
--- /dev/null
+++ b/app/graphql/types/ci/runner_architecture_type.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class RunnerArchitectureType < BaseObject
+ graphql_name 'RunnerArchitecture'
+
+ field :name, GraphQL::STRING_TYPE, null: false,
+ description: 'Name of the runner platform architecture'
+ field :download_location, GraphQL::STRING_TYPE, null: false,
+ description: 'Download location for the runner for the platform architecture'
+ end
+ end
+end
diff --git a/app/graphql/types/ci/runner_platform_type.rb b/app/graphql/types/ci/runner_platform_type.rb
new file mode 100644
index 00000000000..64719bc4908
--- /dev/null
+++ b/app/graphql/types/ci/runner_platform_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class RunnerPlatformType < BaseObject
+ graphql_name 'RunnerPlatform'
+
+ field :name, GraphQL::STRING_TYPE, null: false,
+ description: 'Name slug of the runner platform'
+ field :human_readable_name, GraphQL::STRING_TYPE, null: false,
+ description: 'Human readable name of the runner platform'
+ field :architectures, Types::Ci::RunnerArchitectureType.connection_type, null: true,
+ description: 'Runner architectures supported for the platform'
+ end
+ end
+end
diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb
index 278c4d4d748..fc2c72d0d06 100644
--- a/app/graphql/types/ci/stage_type.rb
+++ b/app/graphql/types/ci/stage_type.rb
@@ -10,6 +10,9 @@ module Types
description: 'Name of the stage'
field :groups, Ci::GroupType.connection_type, null: true,
description: 'Group of jobs for the stage'
+ field :detailed_status, Types::Ci::DetailedStatusType, null: true,
+ description: 'Detailed status of the stage',
+ resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) }
end
end
end
diff --git a/app/graphql/types/ci/status_action_type.rb b/app/graphql/types/ci/status_action_type.rb
new file mode 100644
index 00000000000..08cbb6d3b59
--- /dev/null
+++ b/app/graphql/types/ci/status_action_type.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class StatusActionType < BaseObject
+ graphql_name 'StatusAction'
+
+ field :button_title, GraphQL::STRING_TYPE, null: true,
+ description: 'Title for the button, for example: Retry this job'
+ field :icon, GraphQL::STRING_TYPE, null: true,
+ description: 'Icon used in the action button'
+ field :method, GraphQL::STRING_TYPE, null: true,
+ description: 'Method for the action, for example: :post',
+ resolver_method: :action_method
+ field :path, GraphQL::STRING_TYPE, null: true,
+ description: 'Path for the action'
+ field :title, GraphQL::STRING_TYPE, null: true,
+ description: 'Title for the action, for example: Retry'
+
+ def action_method
+ object[:method]
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/date_type.rb b/app/graphql/types/date_type.rb
new file mode 100644
index 00000000000..7129b75b8bb
--- /dev/null
+++ b/app/graphql/types/date_type.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ class DateType < BaseScalar
+ graphql_name 'Date'
+ description 'Date represented in ISO 8601'
+
+ def self.coerce_input(value, ctx)
+ return if value.nil?
+
+ Date.iso8601(value)
+ rescue ArgumentError, TypeError => e
+ raise GraphQL::CoercionError, e.message
+ end
+
+ def self.coerce_result(value, ctx)
+ return if value.nil?
+
+ value.to_date.iso8601
+ end
+ end
+end
diff --git a/app/graphql/types/design_management/design_collection_copy_state_enum.rb b/app/graphql/types/design_management/design_collection_copy_state_enum.rb
new file mode 100644
index 00000000000..7e7303c50ef
--- /dev/null
+++ b/app/graphql/types/design_management/design_collection_copy_state_enum.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Types
+ module DesignManagement
+ class DesignCollectionCopyStateEnum < BaseEnum
+ graphql_name 'DesignCollectionCopyState'
+ description 'Copy state of a DesignCollection'
+
+ DESCRIPTION_VARIANTS = {
+ in_progress: 'is being copied',
+ error: 'encountered an error during a copy',
+ ready: 'has no copy in progress'
+ }.freeze
+
+ def self.description_variant(copy_state)
+ DESCRIPTION_VARIANTS[copy_state.to_sym] ||
+ (raise ArgumentError, "Unknown copy state: #{copy_state}")
+ end
+
+ ::DesignManagement::DesignCollection.state_machines[:copy_state].states.keys.each do |copy_state|
+ value copy_state.upcase,
+ value: copy_state.to_s,
+ description: "The DesignCollection #{description_variant(copy_state)}"
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/design_management/design_collection_type.rb b/app/graphql/types/design_management/design_collection_type.rb
index 904fb270e11..9af1f4db425 100644
--- a/app/graphql/types/design_management/design_collection_type.rb
+++ b/app/graphql/types/design_management/design_collection_type.rb
@@ -39,6 +39,10 @@ module Types
null: true,
resolver: ::Resolvers::DesignManagement::DesignResolver,
description: 'Find a specific design'
+
+ field :copy_state, ::Types::DesignManagement::DesignCollectionCopyStateEnum,
+ null: true,
+ description: 'Copy state of the design collection'
end
end
end
diff --git a/app/graphql/types/design_management/design_type.rb b/app/graphql/types/design_management/design_type.rb
index 4e11a7aaf09..bab22015dc4 100644
--- a/app/graphql/types/design_management/design_type.rb
+++ b/app/graphql/types/design_management/design_type.rb
@@ -30,7 +30,7 @@ module Types
# most recent `Version` for an issue
Gitlab::SafeRequestStore.fetch([request_cache_base_key, 'stateful_version', object.issue_id, version_gid]) do
if version_gid
- GitlabSchema.object_from_id(version_gid)&.sync
+ GitlabSchema.object_from_id(version_gid, expected_type: ::DesignManagement::Version)&.sync
else
object.issue.design_versions.most_recent
end
diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb
index 239b26f9c38..e4631a4a903 100644
--- a/app/graphql/types/environment_type.rb
+++ b/app/graphql/types/environment_type.rb
@@ -5,6 +5,8 @@ module Types
graphql_name 'Environment'
description 'Describes where code is deployed for a project'
+ present_using ::EnvironmentPresenter
+
authorize :read_environment
field :name, GraphQL::STRING_TYPE, null: false,
@@ -16,6 +18,10 @@ module Types
field :state, GraphQL::STRING_TYPE, null: false,
description: 'State of the environment, for example: available/stopped'
+ field :path, GraphQL::STRING_TYPE, null: true,
+ description: 'The path to the environment. Will always return null ' \
+ 'if `expose_environment_path_in_alert_details` feature flag is disabled'
+
field :metrics_dashboard, Types::Metrics::DashboardType, null: true,
description: 'Metrics dashboard schema for the environment',
resolver: Resolvers::Metrics::DashboardResolver
@@ -23,6 +29,6 @@ module Types
field :latest_opened_most_severe_alert,
Types::AlertManagement::AlertType,
null: true,
- description: 'The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.'
+ description: 'The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned'
end
end
diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb
index a3964ba83e1..9ae9ba32c13 100644
--- a/app/graphql/types/global_id_type.rb
+++ b/app/graphql/types/global_id_type.rb
@@ -1,5 +1,21 @@
# frozen_string_literal: true
+module GraphQLExtensions
+ module ScalarExtensions
+ # Allow ID to unify with GlobalID Types
+ def ==(other)
+ if name == 'ID' && other.is_a?(self.class) &&
+ other.type_class.ancestors.include?(::Types::GlobalIDType)
+ return true
+ end
+
+ super
+ end
+ end
+end
+
+::GraphQL::ScalarType.prepend(GraphQLExtensions::ScalarExtensions)
+
module Types
class GlobalIDType < BaseScalar
graphql_name 'GlobalID'
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 60b2e3c7b6e..199cc0308c5 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -46,9 +46,15 @@ module Types
field :issues,
Types::IssueType.connection_type,
null: true,
- description: 'Issues of the group',
+ description: 'Issues for projects in this group',
resolver: Resolvers::GroupIssuesResolver
+ field :merge_requests,
+ Types::MergeRequestType.connection_type,
+ null: true,
+ description: 'Merge requests for projects in this group',
+ resolver: Resolvers::GroupMergeRequestsResolver
+
field :milestones, Types::MilestoneType.connection_type, null: true,
description: 'Milestones of the group',
resolver: Resolvers::GroupMilestonesResolver
@@ -64,7 +70,7 @@ module Types
Types::BoardType,
null: true,
description: 'A single board of the group',
- resolver: Resolvers::BoardsResolver.single
+ resolver: Resolvers::BoardResolver
field :label,
Types::LabelType,
diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb
index e458d6e02c5..08762264b1b 100644
--- a/app/graphql/types/issue_sort_enum.rb
+++ b/app/graphql/types/issue_sort_enum.rb
@@ -8,6 +8,8 @@ module Types
value 'DUE_DATE_ASC', 'Due date by ascending order', value: :due_date_asc
value 'DUE_DATE_DESC', 'Due date by descending order', value: :due_date_desc
value 'RELATIVE_POSITION_ASC', 'Relative position by ascending order', value: :relative_position_asc
+ value 'SEVERITY_ASC', 'Severity from less critical to more critical', value: :severity_asc
+ value 'SEVERITY_DESC', 'Severity from more critical to less critical', value: :severity_desc
end
end
diff --git a/app/graphql/types/issue_state_event_enum.rb b/app/graphql/types/issue_state_event_enum.rb
new file mode 100644
index 00000000000..6a9d840831d
--- /dev/null
+++ b/app/graphql/types/issue_state_event_enum.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ class IssueStateEventEnum < BaseEnum
+ graphql_name 'IssueStateEvent'
+ description 'Values for issue state events'
+
+ value 'REOPEN', 'Reopens the issue', value: 'reopen'
+ value 'CLOSE', 'Closes the issue', value: 'close'
+ end
+end
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index d6253f74ce5..487508f448f 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -36,8 +36,7 @@ module Types
end
field :author, Types::UserType, null: false,
- description: 'User that created the issue',
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find }
+ description: 'User that created the issue'
field :assignees, Types::UserType.connection_type, null: true,
description: 'Assignees of the issue'
@@ -45,16 +44,14 @@ module Types
field :labels, Types::LabelType.connection_type, null: true,
description: 'Labels of the issue'
field :milestone, Types::MilestoneType, null: true,
- description: 'Milestone of the issue',
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find }
+ description: 'Milestone of the issue'
field :due_date, Types::TimeType, null: true,
description: 'Due date of the issue'
field :confidential, GraphQL::BOOLEAN_TYPE, null: false,
description: 'Indicates the issue is confidential'
field :discussion_locked, GraphQL::BOOLEAN_TYPE, null: false,
- description: 'Indicates discussion is locked on the issue',
- resolve: -> (obj, _args, _ctx) { !!obj.discussion_locked }
+ description: 'Indicates discussion is locked on the issue'
field :upvotes, GraphQL::INT_TYPE, null: false,
description: 'Number of upvotes the issue has received'
@@ -108,6 +105,18 @@ module Types
field :severity, Types::IssuableSeverityEnum, null: true,
description: 'Severity level of the incident'
+
+ def author
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
+ end
+
+ def milestone
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, object.milestone_id).find
+ end
+
+ def discussion_locked
+ !!object.discussion_locked
+ end
end
end
diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb
index 738a00ad616..28dee2a9db5 100644
--- a/app/graphql/types/label_type.rb
+++ b/app/graphql/types/label_type.rb
@@ -4,6 +4,8 @@ module Types
class LabelType < BaseObject
graphql_name 'Label'
+ connection_type_class(Types::CountableConnectionType)
+
authorize :read_label
field :id, GraphQL::ID_TYPE, null: false,
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 56c88491684..372aeac055b 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -152,6 +152,25 @@ module Types
field :auto_merge_enabled, GraphQL::BOOLEAN_TYPE, null: false,
description: 'Indicates if auto merge is enabled for the merge request'
+ field :approved_by, Types::UserType.connection_type, null: true,
+ description: 'Users who approved the merge request'
+
+ def approved_by
+ object.approved_by_users
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def user_notes_count
+ BatchLoader::GraphQL.for(object.id).batch(key: :merge_request_user_notes_count) do |ids, loader, args|
+ counts = Note.where(noteable_type: 'MergeRequest', noteable_id: ids).user.group(:noteable_id).count
+
+ ids.each do |id|
+ loader.call(id, counts[id] || 0)
+ end
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
def diff_stats(path: nil)
stats = Array.wrap(object.diff_stats&.to_a)
@@ -163,21 +182,12 @@ module Types
end
def diff_stats_summary
- nil_stats = { additions: 0, deletions: 0, file_count: 0 }
- return nil_stats unless object.diff_stats.present?
-
- object.diff_stats.each_with_object(nil_stats) do |status, hash|
- hash.merge!(additions: status.additions, deletions: status.deletions, file_count: 1) { |_, x, y| x + y }
- end
+ BatchLoaders::MergeRequestDiffSummaryBatchLoader.load_for(object)
end
def commit_count
object&.metrics&.commits_count
end
-
- def approvers
- object.approver_users
- end
end
end
Types::MergeRequestType.prepend_if_ee('::EE::Types::MergeRequestType')
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index b2732d83aac..3f48e7b4a16 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -14,13 +14,16 @@ module Types
mount_mutation Mutations::AwardEmojis::Add
mount_mutation Mutations::AwardEmojis::Remove
mount_mutation Mutations::AwardEmojis::Toggle
+ mount_mutation Mutations::Boards::Create
mount_mutation Mutations::Boards::Destroy
mount_mutation Mutations::Boards::Issues::IssueMoveList
mount_mutation Mutations::Boards::Lists::Create
mount_mutation Mutations::Boards::Lists::Update
+ mount_mutation Mutations::Boards::Lists::Destroy
mount_mutation Mutations::Branches::Create, calls_gitaly: true
mount_mutation Mutations::Commits::Create, calls_gitaly: true
mount_mutation Mutations::Discussions::ToggleResolve
+ mount_mutation Mutations::Issues::Create
mount_mutation Mutations::Issues::SetAssignees
mount_mutation Mutations::Issues::SetConfidential
mount_mutation Mutations::Issues::SetLocked
@@ -28,6 +31,7 @@ module Types
mount_mutation Mutations::Issues::SetSeverity
mount_mutation Mutations::Issues::SetSubscription
mount_mutation Mutations::Issues::Update
+ mount_mutation Mutations::Issues::Move
mount_mutation Mutations::MergeRequests::Create
mount_mutation Mutations::MergeRequests::Update
mount_mutation Mutations::MergeRequests::SetLabels
@@ -71,4 +75,5 @@ module Types
end
::Types::MutationType.prepend(::Types::DeprecatedMutations)
+::Types::MutationType.prepend_if_ee('EE::Types::DeprecatedMutations')
::Types::MutationType.prepend_if_ee('::EE::Types::MutationType')
diff --git a/app/graphql/types/notes/noteable_type.rb b/app/graphql/types/notes/noteable_type.rb
index 3a16d54f9cd..602634d9292 100644
--- a/app/graphql/types/notes/noteable_type.rb
+++ b/app/graphql/types/notes/noteable_type.rb
@@ -8,24 +8,24 @@ module Types
field :notes, Types::Notes::NoteType.connection_type, null: false, description: "All notes on this noteable"
field :discussions, Types::Notes::DiscussionType.connection_type, null: false, description: "All discussions on this noteable"
- definition_methods do
- def resolve_type(object, context)
- case object
- when Issue
- Types::IssueType
- when MergeRequest
- Types::MergeRequestType
- when Snippet
- Types::SnippetType
- when ::DesignManagement::Design
- Types::DesignManagement::DesignType
- when ::AlertManagement::Alert
- Types::AlertManagement::AlertType
- else
- raise "Unknown GraphQL type for #{object}"
- end
+ def self.resolve_type(object, context)
+ case object
+ when Issue
+ Types::IssueType
+ when MergeRequest
+ Types::MergeRequestType
+ when Snippet
+ Types::SnippetType
+ when ::DesignManagement::Design
+ Types::DesignManagement::DesignType
+ when ::AlertManagement::Alert
+ Types::AlertManagement::AlertType
+ else
+ raise "Unknown GraphQL type for #{object}"
end
end
end
end
end
+
+Types::Notes::NoteableType.prepend_if_ee('::EE::Types::Notes::NoteableType')
diff --git a/app/graphql/types/package_type_enum.rb b/app/graphql/types/package_type_enum.rb
index bc03b8f5f8b..6f50c166da3 100644
--- a/app/graphql/types/package_type_enum.rb
+++ b/app/graphql/types/package_type_enum.rb
@@ -2,8 +2,14 @@
module Types
class PackageTypeEnum < BaseEnum
+ PACKAGE_TYPE_NAMES = {
+ pypi: 'PyPI',
+ npm: 'NPM'
+ }.freeze
+
::Packages::Package.package_types.keys.each do |package_type|
- value package_type.to_s.upcase, "Packages from the #{package_type} package manager", value: package_type.to_s
+ type_name = PACKAGE_TYPE_NAMES.fetch(package_type.to_sym, package_type.capitalize)
+ value package_type.to_s.upcase, "Packages from the #{type_name} package manager", value: package_type.to_s
end
end
end
diff --git a/app/graphql/types/project_member_type.rb b/app/graphql/types/project_member_type.rb
index f08781238d0..01731531ae2 100644
--- a/app/graphql/types/project_member_type.rb
+++ b/app/graphql/types/project_member_type.rb
@@ -12,7 +12,10 @@ module Types
authorize :read_project
field :project, Types::ProjectType, null: true,
- description: 'Project that User is a member of',
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, obj.source_id).find }
+ description: 'Project that User is a member of'
+
+ def project
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.source_id).find
+ end
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 0fd54af1538..c7fc193abe8 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -234,7 +234,7 @@ module Types
Types::BoardType,
null: true,
description: 'A single board of the project',
- resolver: Resolvers::BoardsResolver.single
+ resolver: Resolvers::BoardResolver
field :jira_imports,
Types::JiraImportType.connection_type,
@@ -294,6 +294,12 @@ module Types
description: 'Title of the label'
end
+ field :terraform_states,
+ Types::Terraform::StateType.connection_type,
+ null: true,
+ description: 'Terraform states associated with the project',
+ resolver: Resolvers::Terraform::StatesResolver
+
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args|
LabelsFinder
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 447ac63a294..bd4b53bdaa7 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -49,8 +49,7 @@ module Types
field :milestone, ::Types::MilestoneType,
null: true,
- description: 'Find a milestone',
- resolve: -> (_obj, args, _ctx) { GitlabSchema.find_by_gid(args[:id]) } do
+ description: 'Find a milestone' do
argument :id, ::Types::GlobalIDType[Milestone],
required: true,
description: 'Find a milestone by its ID'
@@ -81,12 +80,26 @@ module Types
description: 'Get statistics on the instance',
resolver: Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsResolver
+ field :runner_platforms, Types::Ci::RunnerPlatformType.connection_type,
+ null: true, description: 'Supported runner platforms',
+ resolver: Resolvers::Ci::RunnerPlatformsResolver
+
def design_management
DesignManagementObject.new(nil)
end
def issue(id:)
- GitlabSchema.object_from_id(id, expected_type: ::Issue)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::Issue].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+
+ def milestone(id:)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[Milestone].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
end
end
end
diff --git a/app/graphql/types/range_input_type.rb b/app/graphql/types/range_input_type.rb
new file mode 100644
index 00000000000..766e523a99e
--- /dev/null
+++ b/app/graphql/types/range_input_type.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class RangeInputType < BaseInputObject
+ def self.[](type, closed = true)
+ @subtypes ||= {}
+
+ @subtypes[[type, closed]] ||= Class.new(self) do
+ argument :start, type,
+ required: closed,
+ description: 'The start of the range'
+
+ argument :end, type,
+ required: closed,
+ description: 'The end of the range'
+ end
+ end
+
+ def prepare
+ if self[:end] && self[:start] && self[:end] < self[:start]
+ raise ::Gitlab::Graphql::Errors::ArgumentError, 'start must be before end'
+ end
+
+ to_h
+ end
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb
index 3acc1d9ca44..224e8c7ee03 100644
--- a/app/graphql/types/root_storage_statistics_type.rb
+++ b/app/graphql/types/root_storage_statistics_type.rb
@@ -13,5 +13,6 @@ module Types
field :packages_size, GraphQL::FLOAT_TYPE, null: false, description: 'The packages size in bytes'
field :wiki_size, GraphQL::FLOAT_TYPE, null: false, description: 'The wiki size in bytes'
field :snippets_size, GraphQL::FLOAT_TYPE, null: false, description: 'The snippets size in bytes'
+ field :pipeline_artifacts_size, GraphQL::FLOAT_TYPE, null: false, description: 'The CI pipeline artifacts size in bytes'
end
end
diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb
index db98e62c10a..495c25c1776 100644
--- a/app/graphql/types/snippet_type.rb
+++ b/app/graphql/types/snippet_type.rb
@@ -24,16 +24,14 @@ module Types
field :project, Types::ProjectType,
description: 'The project the snippet is associated with',
null: true,
- authorize: :read_project,
- resolve: -> (snippet, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, snippet.project_id).find }
+ authorize: :read_project
# Author can be nil in some scenarios. For example,
# when the admin setting restricted visibility
# level is set to public
field :author, Types::UserType,
description: 'The owner of the snippet',
- null: true,
- resolve: -> (snippet, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, snippet.author_id).find }
+ null: true
field :file_name, GraphQL::STRING_TYPE,
description: 'File Name of the snippet',
@@ -69,10 +67,11 @@ module Types
null: false,
deprecated: { reason: 'Use `blobs`', milestone: '13.3' }
- field :blobs, type: [Types::Snippets::BlobType],
+ field :blobs, type: Types::Snippets::BlobType.connection_type,
description: 'Snippet blobs',
calls_gitaly: true,
- null: false
+ null: true,
+ resolver: Resolvers::Snippets::BlobsResolver
field :ssh_url_to_repo, type: GraphQL::STRING_TYPE,
description: 'SSH URL to the snippet repository',
@@ -85,5 +84,13 @@ module Types
null: true
markdown_field :description_html, null: true, method: :description
+
+ def author
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
+ end
+
+ def project
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find
+ end
end
end
diff --git a/app/graphql/types/sort_enum.rb b/app/graphql/types/sort_enum.rb
index 3245cb33e0d..d0a6eecb672 100644
--- a/app/graphql/types/sort_enum.rb
+++ b/app/graphql/types/sort_enum.rb
@@ -5,9 +5,16 @@ module Types
graphql_name 'Sort'
description 'Common sort values'
- value 'updated_desc', 'Updated at descending order'
- value 'updated_asc', 'Updated at ascending order'
- value 'created_desc', 'Created at descending order'
- value 'created_asc', 'Created at ascending order'
+ # Deprecated, as we prefer uppercase enums
+ # https://gitlab.com/groups/gitlab-org/-/epics/1838
+ value 'updated_desc', 'Updated at descending order', deprecated: { reason: 'Use UPDATED_DESC', milestone: '13.5' }
+ value 'updated_asc', 'Updated at ascending order', deprecated: { reason: 'Use UPDATED_ASC', milestone: '13.5' }
+ value 'created_desc', 'Created at descending order', deprecated: { reason: 'Use CREATED_DESC', milestone: '13.5' }
+ value 'created_asc', 'Created at ascending order', deprecated: { reason: 'Use CREATED_ASC', milestone: '13.5' }
+
+ value 'UPDATED_DESC', 'Updated at descending order', value: :updated_desc
+ value 'UPDATED_ASC', 'Updated at ascending order', value: :updated_asc
+ value 'CREATED_DESC', 'Created at descending order', value: :created_desc
+ value 'CREATED_ASC', 'Created at ascending order', value: :created_asc
end
end
diff --git a/app/graphql/types/terraform/state_type.rb b/app/graphql/types/terraform/state_type.rb
new file mode 100644
index 00000000000..f25f3a7789b
--- /dev/null
+++ b/app/graphql/types/terraform/state_type.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Types
+ module Terraform
+ class StateType < BaseObject
+ graphql_name 'TerraformState'
+
+ authorize :read_terraform_state
+
+ field :id, GraphQL::ID_TYPE,
+ null: false,
+ description: 'ID of the Terraform state'
+
+ field :name, GraphQL::STRING_TYPE,
+ null: false,
+ description: 'Name of the Terraform state'
+
+ field :locked_by_user, Types::UserType,
+ null: true,
+ authorize: :read_user,
+ description: 'The user currently holding a lock on the Terraform state',
+ resolve: -> (state, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, state.locked_by_user_id).find }
+
+ field :locked_at, Types::TimeType,
+ null: true,
+ description: 'Timestamp the Terraform state was locked'
+
+ field :created_at, Types::TimeType,
+ null: false,
+ description: 'Timestamp the Terraform state was created'
+
+ field :updated_at, Types::TimeType,
+ null: false,
+ description: 'Timestamp the Terraform state was updated'
+ end
+ end
+end
diff --git a/app/graphql/types/timeframe_input_type.rb b/app/graphql/types/timeframe_input_type.rb
new file mode 100644
index 00000000000..79c1bc5cf01
--- /dev/null
+++ b/app/graphql/types/timeframe_input_type.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class TimeframeInputType < RangeInputType[::Types::DateType]
+ graphql_name 'Timeframe'
+ description 'A time-frame defined as a closed inclusive range of two dates'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
diff --git a/app/helpers/analytics/navbar_helper.rb b/app/helpers/analytics/navbar_helper.rb
index ddf2655c887..bc0b5e7c74f 100644
--- a/app/helpers/analytics/navbar_helper.rb
+++ b/app/helpers/analytics/navbar_helper.rb
@@ -28,7 +28,7 @@ module Analytics
private
def navbar_sub_item(args)
- NavbarSubItem.new(args)
+ NavbarSubItem.new(**args)
end
def cycle_analytics_navbar_link(project, current_user)
diff --git a/app/helpers/analytics/unique_visits_helper.rb b/app/helpers/analytics/unique_visits_helper.rb
index ded7f54e44e..4c709b2ed23 100644
--- a/app/helpers/analytics/unique_visits_helper.rb
+++ b/app/helpers/analytics/unique_visits_helper.rb
@@ -14,8 +14,7 @@ module Analytics
end
def track_visit(target_id)
- return unless Feature.enabled?(:track_unique_visits)
- return unless Gitlab::CurrentSettings.usage_ping_enabled?
+ return unless Feature.enabled?(:track_unique_visits, default_enabled: true)
return unless visitor_id
Gitlab::Analytics::UniqueVisits.new.track_visit(visitor_id, target_id)
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index a81225c8954..2a6b00c0bd8 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -6,10 +6,18 @@ require 'uri'
module ApplicationHelper
include StartupCssHelper
- # See https://docs.gitlab.com/ee/development/ee_features.html#code-in-app-views
+ # See https://docs.gitlab.com/ee/development/ee_features.html#code-in-appviews
# rubocop: disable CodeReuse/ActiveRecord
- def render_if_exists(partial, locals = {})
- render(partial, locals) if partial_exists?(partial)
+ # We allow partial to be nil so that collection views can be passed in
+ # `render partial: 'some/view', collection: @some_collection`
+ def render_if_exists(partial = nil, **options)
+ return unless partial_exists?(partial || options[:partial])
+
+ if partial.nil?
+ render(**options)
+ else
+ render(partial, options)
+ end
end
def partial_exists?(partial)
@@ -204,6 +212,10 @@ module ApplicationHelper
Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/'
end
+ def instance_review_permitted?
+ ::Gitlab::CurrentSettings.instance_review_permitted? && current_user&.admin?
+ end
+
def static_objects_external_storage_enabled?
Gitlab::CurrentSettings.static_objects_external_storage_enabled?
end
@@ -349,6 +361,12 @@ module ApplicationHelper
}
end
+ def add_page_specific_style(path)
+ content_for :page_specific_styles do
+ stylesheet_link_tag_defer path
+ end
+ end
+
def page_startup_api_calls
@api_startup_calls
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 9245cc1cb1c..9c408efe8cd 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -168,7 +168,7 @@ module ApplicationSettingsHelper
def visible_attributes
[
- :admin_notification_email,
+ :abuse_notification_email,
:after_sign_out_path,
:after_sign_up_text,
:akismet_api_key,
@@ -230,6 +230,7 @@ module ApplicationSettingsHelper
:hashed_storage_enabled,
:help_page_hide_commercial_content,
:help_page_support_url,
+ :help_page_documentation_base_url,
:help_page_text,
:hide_third_party_offers,
:home_page_url,
@@ -265,6 +266,7 @@ module ApplicationSettingsHelper
:receive_max_input_size,
:repository_checks_enabled,
:repository_storages_weighted,
+ :require_admin_approval_after_user_signup,
:require_two_factor_authentication,
:restricted_visibility_levels,
:rsa_key_restriction,
@@ -345,6 +347,12 @@ module ApplicationSettingsHelper
]
end
+ def deprecated_attributes
+ [
+ :admin_notification_email # ok to remove in REST API v5
+ ]
+ end
+
def expanded_by_default?
Rails.env.test?
end
@@ -382,6 +390,10 @@ module ApplicationSettingsHelper
Gitlab::CurrentSettings.self_monitoring_project&.full_path
}
end
+
+ def show_documentation_base_url_field?
+ Feature.enabled?(:help_page_documentation_redirect)
+ end
end
ApplicationSettingsHelper.prepend_if_ee('EE::ApplicationSettingsHelper')
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index 68dbc5b65d1..5457f96d506 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -60,7 +60,7 @@ module AvatarsHelper
avatar_size = options[:size] || 16
user_name = options[:user].try(:name) || options[:user_name]
- avatar_url = user_avatar_url_for(options.merge(size: avatar_size))
+ avatar_url = user_avatar_url_for(**options.merge(size: avatar_size))
has_tooltip = options[:has_tooltip].nil? ? true : options[:has_tooltip]
data_attributes = options[:data] || {}
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 2eff87ae0ec..806fea3ab44 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -1,12 +1,6 @@
# frozen_string_literal: true
module BlobHelper
- def highlight(file_name, file_content, language: nil, plain: false)
- highlighted = Gitlab::Highlight.highlight(file_name, file_content, plain: plain, language: language)
-
- raw %(<pre class="code highlight"><code>#{highlighted}</code></pre>)
- end
-
def no_highlight_files
%w(credits changelog news copying copyright license authors)
end
@@ -33,11 +27,19 @@ module BlobHelper
end
def ide_fork_and_edit_path(project = @project, ref = @ref, path = @path, options = {})
- if current_user
- project_forks_path(project,
- namespace_key: current_user&.namespace&.id,
- continue: edit_blob_fork_params(ide_edit_path(project, ref, path)))
- end
+ fork_path_for_current_user(project, ide_edit_path(project, ref, path))
+ end
+
+ def fork_and_edit_path(project = @project, ref = @ref, path = @path, options = {})
+ fork_path_for_current_user(project, edit_blob_path(project, ref, path, options))
+ end
+
+ def fork_path_for_current_user(project, path)
+ return unless current_user
+
+ project_forks_path(project,
+ namespace_key: current_user.namespace&.id,
+ continue: edit_blob_fork_params(path))
end
def encode_ide_path(path)
@@ -148,7 +150,7 @@ module BlobHelper
# mode - File unix mode
# mode - File name
def blob_icon(mode, name)
- icon("#{file_type_icon_class('file', mode, name)} fw")
+ sprite_icon(file_type_icon_class('file', mode, name))
end
def blob_raw_url(**kwargs)
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index 6a4a7a8dfb2..c827fb4dd95 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -14,10 +14,14 @@ module BoardsHelper
root_path: root_path,
full_path: full_path,
bulk_update_path: @bulk_issues_path,
+ can_update: (!!can?(current_user, :admin_issue, board)).to_s,
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s,
recent_boards_endpoint: recent_boards_path,
parent: current_board_parent.model_name.param_key,
- group_id: @group&.id
+ group_id: @group&.id,
+ labels_filter_base_path: build_issue_link_base,
+ labels_fetch_path: labels_fetch_path,
+ labels_manage_path: labels_manage_path
}
end
@@ -37,6 +41,22 @@ module BoardsHelper
end
end
+ def labels_fetch_path
+ if board.group_board?
+ group_labels_path(@group, format: :json, only_group_labels: true, include_ancestor_groups: true)
+ else
+ project_labels_path(@project, format: :json, include_ancestor_groups: true)
+ end
+ end
+
+ def labels_manage_path
+ if board.group_board?
+ group_labels_path(@group)
+ else
+ project_labels_path(@project)
+ end
+ end
+
def board_base_url
if board.group_board?
group_boards_url(@group)
diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb
index 8cdb28b2874..552acf61f47 100644
--- a/app/helpers/ci/runners_helper.rb
+++ b/app/helpers/ci/runners_helper.rb
@@ -39,6 +39,14 @@ module Ci
runner.contacted_at
end
end
+
+ def group_shared_runners_settings_data(group)
+ {
+ update_path: api_v4_groups_path(id: group.id),
+ shared_runners_availability: group.shared_runners_setting,
+ parent_shared_runners_availability: group.parent&.shared_runners_setting
+ }
+ end
end
end
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index caad215e996..cc633df77f9 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -12,6 +12,18 @@ module ClustersHelper
end
end
+ def display_cluster_agents?(_clusterable)
+ false
+ end
+
+ def js_cluster_agents_list_data(clusterable_project)
+ {
+ default_branch_name: clusterable_project.default_branch,
+ empty_state_image: image_path('illustrations/clusters_empty.svg'),
+ project_path: clusterable_project.full_path
+ }
+ end
+
def js_clusters_list_data(path = nil)
{
ancestor_help_path: help_page_path('user/group/clusters/index', anchor: 'cluster-precedence'),
@@ -42,14 +54,6 @@ module ClustersHelper
}
end
- # This method is depreciated and will be removed when associated HAML files are moved to JavaScript
- def provider_icon(provider = nil)
- img_data = js_clusters_list_data.dig(:img_tags, provider&.to_sym) ||
- js_clusters_list_data.dig(:img_tags, :default)
-
- image_tag img_data[:path], alt: img_data[:text], class: 'gl-h-full'
- end
-
def render_gcp_signup_offer
return if Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers?
return unless show_gcp_signup_offer?
diff --git a/app/helpers/container_expiration_policies_helper.rb b/app/helpers/container_expiration_policies_helper.rb
index cc6d717ce35..52f68ac53f0 100644
--- a/app/helpers/container_expiration_policies_helper.rb
+++ b/app/helpers/container_expiration_policies_helper.rb
@@ -24,4 +24,9 @@ module ContainerExpirationPoliciesHelper
end
end
end
+
+ def container_expiration_policies_historic_entry_enabled?(project)
+ Gitlab::CurrentSettings.container_expiration_policies_enable_historic_entries ||
+ Feature.enabled?(:container_expiration_policies_historic_entry, project)
+ end
end
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index 84aa08281f6..e1378e485e4 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -103,7 +103,7 @@ module DropdownsHelper
def dropdown_filter(placeholder, search_id: nil)
content_tag :div, class: "dropdown-input" do
filter_output = search_field_tag search_id, nil, class: "dropdown-input-field qa-dropdown-input-field", placeholder: placeholder, autocomplete: 'off'
- filter_output << icon('search', class: "dropdown-input-search")
+ filter_output << sprite_icon('search', css_class: 'dropdown-input-search')
filter_output << sprite_icon('close', size: 16, css_class: 'dropdown-input-clear js-dropdown-input-clear')
filter_output.html_safe
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index d5c22927991..0a0dc77e5e2 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -218,8 +218,28 @@ module EmailsHelper
_('Please contact your administrator with any questions.')
end
+ def change_reviewer_notification_text(new_reviewers, previous_reviewers, html_tag = nil)
+ new = new_reviewers.any? ? users_to_sentence(new_reviewers) : s_('ChangeReviewer|Unassigned')
+ old = previous_reviewers.any? ? users_to_sentence(previous_reviewers) : nil
+
+ if html_tag.present?
+ new = content_tag(html_tag, new)
+ old = content_tag(html_tag, old) if old.present?
+ end
+
+ if old.present?
+ s_('ChangeReviewer|Reviewer changed from %{old} to %{new}').html_safe % { old: old, new: new }
+ else
+ s_('ChangeReviewer|Reviewer changed to %{new}').html_safe % { new: new }
+ end
+ end
+
private
+ def users_to_sentence(users)
+ sanitize_name(users.map(&:name).to_sentence)
+ end
+
def generate_link(text, url)
link_to(text, url, target: :_blank, rel: 'noopener noreferrer')
end
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index 0167f2ef698..f40755b9439 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -28,19 +28,7 @@ module EventsHelper
end
def event_action_name(event)
- target = if event.target_type
- if event.design? || event.design_note?
- 'design'
- elsif event.wiki_page?
- 'wiki page'
- elsif event.note?
- event.note_target_type
- else
- event.target_type.titleize.downcase
- end
- else
- 'project'
- end
+ target = event.note_target_type_name || event.target_type_name
[event.action_name, target].join(" ")
end
@@ -229,7 +217,7 @@ module EventsHelper
def event_note_title_html(event)
if event.note_target
capture do
- concat content_tag(:span, event.note_target_type, class: "event-target-type gl-mr-2")
+ concat content_tag(:span, event.note_target_type_name, class: "event-target-type gl-mr-2")
concat link_to(event.note_target_reference, event_note_target_url(event), title: event.target_title, class: 'has-tooltip event-target-link gl-mr-2')
end
else
diff --git a/app/helpers/external_link_helper.rb b/app/helpers/external_link_helper.rb
index 9dbad1f5032..bf47087543f 100644
--- a/app/helpers/external_link_helper.rb
+++ b/app/helpers/external_link_helper.rb
@@ -3,7 +3,7 @@
module ExternalLinkHelper
def external_link(body, url, options = {})
link_to url, { target: '_blank', rel: 'noopener noreferrer' }.merge(options) do
- "#{body} #{icon('external-link')}".html_safe
+ "#{body} #{sprite_icon('external-link')}".html_safe
end
end
end
diff --git a/app/helpers/feature_flags_helper.rb b/app/helpers/feature_flags_helper.rb
new file mode 100644
index 00000000000..e50191a471f
--- /dev/null
+++ b/app/helpers/feature_flags_helper.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module FeatureFlagsHelper
+ include ::API::Helpers::RelatedResourcesHelpers
+
+ def unleash_api_url(project)
+ expose_url(api_v4_feature_flags_unleash_path(project_id: project.id))
+ end
+
+ def unleash_api_instance_id(project)
+ project.feature_flags_client_token
+ end
+
+ def feature_flag_issues_links_endpoint(_project, _feature_flag, _user)
+ ''
+ end
+end
+
+FeatureFlagsHelper.prepend_if_ee('::EE::FeatureFlagsHelper')
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 3dde5afcb92..8a8d708b0b2 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -56,7 +56,7 @@ module FormHelper
end
def reviewers_dropdown_options(issuable_type)
- {
+ dropdown_data = {
toggle_class: 'js-reviewer-search js-multiselect js-save-user-data',
title: 'Request review from',
filter: true,
@@ -69,13 +69,20 @@ module FormHelper
project_id: (@target_project || @project)&.id,
field_name: "#{issuable_type}[reviewer_ids][]",
default_label: 'Unassigned',
- 'dropdown-header': 'Reviewer(s)',
+ 'max-select': 1,
+ 'dropdown-header': 'Reviewer',
multi_select: true,
'input-meta': 'name',
'always-show-selectbox': true,
current_user_info: UserSerializer.new.represent(current_user)
}
}
+
+ if merge_request_supports_multiple_reviewers?
+ dropdown_data = multiple_reviewers_dropdown_options(dropdown_data)
+ end
+
+ dropdown_data
end
# Overwritten
@@ -88,6 +95,11 @@ module FormHelper
false
end
+ # Overwritten
+ def merge_request_supports_multiple_reviewers?
+ false
+ end
+
private
def multiple_assignees_dropdown_options(options)
@@ -99,6 +111,16 @@ module FormHelper
new_options
end
+
+ def multiple_reviewers_dropdown_options(options)
+ new_options = options.dup
+
+ new_options[:title] = _('Select reviewer(s)')
+ new_options[:data][:'dropdown-header'] = _('Reviewer(s)')
+ new_options[:data].delete(:'max-select')
+
+ new_options
+ end
end
FormHelper.prepend_if_ee('::EE::FormHelper')
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index d71e6b4c004..7df6bef7914 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -343,6 +343,18 @@ module GitlabRoutingHelper
Gitlab::UrlBuilder.wiki_page_url(wiki, page, only_path: true, **options)
end
+ def gitlab_ide_merge_request_path(merge_request)
+ target_project = merge_request.target_project
+ source_project = merge_request.source_project
+ params = {}
+
+ if target_project != source_project
+ params = { target_project: target_project.full_path }
+ end
+
+ ide_merge_request_path(source_project.namespace, source_project, merge_request, params)
+ end
+
private
def snippet_query_params(snippet, *args)
diff --git a/app/helpers/gitpod_helper.rb b/app/helpers/gitpod_helper.rb
new file mode 100644
index 00000000000..7edf7dc218d
--- /dev/null
+++ b/app/helpers/gitpod_helper.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module GitpodHelper
+ def gitpod_enable_description
+ link_start = '<a href="https://gitpod.io/" target="_blank" rel="noopener noreferrer">'.html_safe
+ link_end = "#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}</a>".html_safe
+
+ s_('Enable %{link_start}Gitpod%{link_end} integration to launch a development environment in your browser directly from GitLab.').html_safe % { link_start: link_start, link_end: link_end }
+ end
+end
diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb
index dcff2be34da..ee90585112b 100644
--- a/app/helpers/groups/group_members_helper.rb
+++ b/app/helpers/groups/group_members_helper.rb
@@ -10,17 +10,34 @@ module Groups::GroupMembersHelper
end
def render_invite_member_for_group(group, default_access_level)
- render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: GroupMember.access_level_roles, default_access_level: default_access_level
+ render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: group.access_level_roles, default_access_level: default_access_level
end
def linked_groups_data_json(group_links)
- GroupGroupLinkSerializer.new.represent(group_links).to_json
+ GroupGroupLinkSerializer.new.represent(group_links, { current_user: current_user }).to_json
end
def members_data_json(group, members)
members_data(group, members).to_json
end
+ # Overridden in `ee/app/helpers/ee/groups/group_members_helper.rb`
+ def group_members_list_data_attributes(group, members)
+ {
+ members: members_data_json(group, members),
+ member_path: group_group_member_path(group, ':id'),
+ group_id: group.id
+ }
+ end
+
+ def linked_groups_list_data_attributes(group)
+ {
+ members: linked_groups_data_json(group.shared_with_group_links),
+ member_path: group_group_link_path(group, ':id'),
+ group_id: group.id
+ }
+ end
+
private
def members_data(group, members)
@@ -35,7 +52,6 @@ module Groups::GroupMembersHelper
requested_at: member.requested_at,
can_update: member.can_update?,
can_remove: member.can_remove?,
- can_override: member.can_override?,
access_level: {
string_value: member.human_access,
integer_value: member.access_level
@@ -44,13 +60,14 @@ module Groups::GroupMembersHelper
id: source.id,
name: source.full_name,
web_url: Gitlab::UrlBuilder.build(source)
- }
+ },
+ valid_roles: member.valid_level_roles
}.merge(member_created_by_data(member.created_by))
- if user.present?
- data[:user] = member_user_data(user)
- else
+ if member.invite?
data[:invite] = member_invite_data(member)
+ elsif user.present?
+ data[:user] = member_user_data(user)
end
data
@@ -77,6 +94,17 @@ module Groups::GroupMembersHelper
avatar_url: avatar_icon_for_user(user, AVATAR_SIZE),
blocked: user.blocked?,
two_factor_enabled: user.two_factor_enabled?
+ }.merge(member_user_status_data(user.status))
+ end
+
+ def member_user_status_data(status)
+ return {} unless status.present?
+
+ {
+ status: {
+ emoji: status.emoji,
+ message_html: status.message_html
+ }
}
end
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index 0352b0ddf28..1d0001fde72 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -124,7 +124,7 @@ module IconsHelper
def file_type_icon_class(type, mode, name)
if type == 'folder'
- icon_class = 'folder'
+ icon_class = 'folder-o'
elsif type == 'archive'
icon_class = 'archive'
elsif mode == '120000'
@@ -135,36 +135,36 @@ module IconsHelper
case File.extname(name).downcase
when '.pdf'
- icon_class = 'file-pdf-o'
+ icon_class = 'document'
when '.jpg', '.jpeg', '.jif', '.jfif',
'.jp2', '.jpx', '.j2k', '.j2c',
'.apng', '.png', '.gif', '.tif', '.tiff',
'.svg', '.ico', '.bmp', '.webp'
- icon_class = 'file-image-o'
+ icon_class = 'doc-image'
when '.zip', '.zipx', '.tar', '.gz', '.gzip', '.tgz', '.bz', '.bzip',
'.bz2', '.bzip2', '.car', '.tbz', '.xz', 'txz', '.rar', '.7z',
'.lz', '.lzma', '.tlz'
- icon_class = 'file-archive-o'
+ icon_class = 'doc-compressed'
when '.mp3', '.wma', '.ogg', '.oga', '.wav', '.flac', '.aac', '.3ga',
'.ac3', '.midi', '.m4a', '.ape', '.mpa'
- icon_class = 'file-audio-o'
+ icon_class = 'volume-up'
when '.mp4', '.m4p', '.m4v',
'.mpg', '.mp2', '.mpeg', '.mpe', '.mpv',
'.mpg', '.mpeg', '.m2v', '.m2ts',
'.avi', '.mkv', '.flv', '.ogv', '.mov',
'.3gp', '.3g2'
- icon_class = 'file-video-o'
+ icon_class = 'live-preview'
when '.doc', '.dot', '.docx', '.docm', '.dotx', '.dotm', '.docb',
'.odt', '.ott', '.uot', '.rtf'
- icon_class = 'file-word-o'
+ icon_class = 'doc-text'
when '.xls', '.xlt', '.xlm', '.xlsx', '.xlsm', '.xltx', '.xltm',
'.xlsb', '.xla', '.xlam', '.xll', '.xlw', '.ots', '.ods', '.uos'
- icon_class = 'file-excel-o'
+ icon_class = 'document'
when '.ppt', '.pot', '.pps', '.pptx', '.pptm', '.potx', '.potm',
'.ppam', '.ppsx', '.ppsm', '.sldx', '.sldm', '.odp', '.otp', '.uop'
- icon_class = 'file-powerpoint-o'
+ icon_class = 'doc-chart'
else
- icon_class = 'file-text-o'
+ icon_class = 'doc-text'
end
end
diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb
new file mode 100644
index 00000000000..ac6ac9979b3
--- /dev/null
+++ b/app/helpers/invite_members_helper.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module InviteMembersHelper
+ include Gitlab::Utils::StrongMemoize
+
+ def invite_members_allowed?(group)
+ Feature.enabled?(:invite_members_group_modal, group) && can?(current_user, :admin_group_member, group)
+ end
+
+ def directly_invite_members?
+ strong_memoize(:directly_invite_members) do
+ experiment_enabled?(:invite_members_version_a) && can_import_members?
+ end
+ end
+
+ def indirectly_invite_members?
+ strong_memoize(:indirectly_invite_members) do
+ experiment_enabled?(:invite_members_version_b) && !can_import_members?
+ end
+ end
+end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index b255597b18d..f8e7711959a 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -4,7 +4,10 @@ module IssuablesHelper
include GitlabRoutingHelper
def sidebar_gutter_toggle_icon
- sidebar_gutter_collapsed? ? icon('angle-double-left', { 'aria-hidden': 'true' }) : icon('angle-double-right', { 'aria-hidden': 'true' })
+ content_tag(:span, class: 'js-sidebar-toggle-container', data: { is_expanded: !sidebar_gutter_collapsed? }) do
+ sprite_icon('chevron-double-lg-left', css_class: "js-sidebar-expand #{'hidden' unless sidebar_gutter_collapsed?}") +
+ sprite_icon('chevron-double-lg-right', css_class: "js-sidebar-collapse #{'hidden' if sidebar_gutter_collapsed?}")
+ end
end
def sidebar_gutter_collapsed_class
@@ -46,12 +49,6 @@ module IssuablesHelper
"#{due_date.to_s(:medium)} (#{remaining_days_in_words(due_date, start_date)})"
end
- def sidebar_label_filter_path(base_path, label_name)
- query_params = { label_name: [label_name] }.to_query
-
- "#{base_path}?#{query_params}"
- end
-
def multi_label_name(current_labels, default_label)
return default_label if current_labels.blank?
@@ -79,6 +76,7 @@ module IssuablesHelper
when Issue
IssueSerializer
when MergeRequest
+ opts[:experiment_enabled] = :suggest_pipeline if experiment_enabled?(:suggest_pipeline) && opts[:serializer] == 'widget'
MergeRequestSerializer
end
@@ -206,7 +204,7 @@ module IssuablesHelper
end
if access = project.team.human_max_access(issuable.author_id)
- output << content_tag(:span, access, class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3 ", title: _("This user is a %{access} of the %{name} project.") % { access: access.downcase, name: project.name })
+ output << content_tag(:span, access, class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3 ", title: _("This user has the %{access} role in the %{name} project.") % { access: access.downcase, name: project.name })
elsif project.team.contributor?(issuable.author_id)
output << content_tag(:span, _("Contributor"), class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3", title: _("This user has previously committed to the %{name} project.") % { name: project.name })
end
@@ -224,19 +222,6 @@ module IssuablesHelper
nil
end
- def issuable_labels_tooltip(labels, limit: 5)
- first, last = labels.partition.with_index { |_, i| i < limit }
-
- if labels && labels.any?
- label_names = first.collect { |label| label.fetch(:title) }
- label_names << "and #{last.size} more" unless last.empty?
-
- label_names.join(', ')
- else
- _("Labels")
- end
- end
-
def issuables_state_counter_text(issuable_type, state, display_count)
titles = {
opened: "Open"
@@ -247,7 +232,22 @@ module IssuablesHelper
if display_count
count = issuables_count_for_state(issuable_type, state)
- html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge badge-pill')
+ tag =
+ if count == -1
+ tooltip = _("Couldn't calculate number of %{issuables}.") % { issuables: issuable_type.to_s.humanize(capitalize: false) }
+
+ content_tag(
+ :span,
+ '?',
+ class: 'badge badge-pill has-tooltip',
+ aria: { label: tooltip },
+ title: tooltip
+ )
+ else
+ content_tag(:span, number_with_delimiter(count), class: 'badge badge-pill')
+ end
+
+ html << " " << tag
end
html.html_safe
@@ -342,6 +342,12 @@ module IssuablesHelper
issuable.closed? ^ should_inverse ? reopen_issuable_path(issuable) : close_issuable_path(issuable)
end
+ def toggle_draft_issuable_path(issuable)
+ wip_event = issuable.work_in_progress? ? 'unwip' : 'wip'
+
+ issuable_path(issuable, { merge_request: { wip_event: wip_event } })
+ end
+
def issuable_path(issuable, *options)
polymorphic_path(issuable, *options)
end
@@ -386,6 +392,12 @@ module IssuablesHelper
end
end
+ def reviewer_sidebar_data(reviewer, merge_request: nil)
+ { avatar_url: reviewer.avatar_url, name: reviewer.name, username: reviewer.username }.tap do |data|
+ data[:can_merge] = merge_request.can_be_merged_by?(reviewer) if merge_request
+ end
+ end
+
def issuable_squash_option?(issuable, project)
if issuable.persisted?
issuable.squash
@@ -420,7 +432,7 @@ module IssuablesHelper
def issuable_todo_button_data(issuable, is_collapsed)
{
- todo_text: _('Add a To Do'),
+ todo_text: _('Add a to do'),
mark_text: _('Mark as done'),
todo_icon: sprite_icon('todo-add'),
mark_icon: sprite_icon('todo-done', css_class: 'todo-undone'),
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index e8ea39d7ffc..dbf284e70e4 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -137,6 +137,21 @@ module IssuesHelper
issue.moved_from.project.service_desk_enabled? && !issue.project.service_desk_enabled?
end
+
+ def use_startup_call?
+ request.query_parameters.empty? && @sort == 'created_date'
+ end
+
+ def startup_call_params
+ {
+ state: 'opened',
+ with_labels_details: 'true',
+ page: 1,
+ per_page: 20,
+ order_by: 'created_at',
+ sort: 'desc'
+ }
+ end
end
IssuesHelper.prepend_if_ee('EE::IssuesHelper')
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index 3142d7d7782..312d535a92c 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -36,11 +36,11 @@ module LabelsHelper
# link_to_label(label) { "My Custom Label Text" }
#
# Returns a String
- def link_to_label(label, type: :issue, tooltip: true, small: false, &block)
+ def link_to_label(label, type: :issue, tooltip: true, small: false, css_class: nil, &block)
link = label.filter_path(type: type)
if block_given?
- link_to link, &block
+ link_to link, class: css_class, &block
else
render_label(label, link: link, tooltip: tooltip, small: small)
end
@@ -61,7 +61,7 @@ module LabelsHelper
render_label_text(
label.name,
suffix: suffix,
- css_class: text_color_class_for_bg(label.color),
+ css_class: "gl-label-text #{text_color_class_for_bg(label.color)}",
bg_color: label.color
)
end
@@ -241,29 +241,14 @@ module LabelsHelper
}.merge(opts)
end
- def sidebar_label_dropdown_data(issuable_type, issuable_sidebar)
- label_dropdown_data(nil, {
- default_label: "Labels",
- field_name: "#{issuable_type}[label_names][]",
- ability_name: issuable_type,
- namespace_path: issuable_sidebar[:namespace_path],
- project_path: issuable_sidebar[:project_path],
- issue_update: issuable_sidebar[:issuable_json_path],
- labels: issuable_sidebar[:project_labels_path],
- display: 'static'
- })
- end
-
- def label_from_hash(hash)
- klass = hash[:group_id] ? GroupLabel : ProjectLabel
-
- klass.new(hash.slice(:color, :description, :title, :group_id, :project_id))
- end
-
def issuable_types
['issues', 'merge requests']
end
+ def show_labels_full_path?(project, group)
+ project || group&.subgroup?
+ end
+
private
def render_label_link(label_html, link:, title:, dataset:)
@@ -281,7 +266,7 @@ module LabelsHelper
def render_label_text(name, suffix: '', css_class: nil, bg_color: nil)
<<~HTML.chomp.html_safe
<span
- class="gl-label-text #{css_class}"
+ class="#{css_class}"
data-container="body"
data-html="true"
#{"style=\"background-color: #{bg_color}\"" if bg_color}
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 1125ecb9b41..9cb7edbaeb6 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -109,10 +109,6 @@ module MergeRequestsHelper
@merge_request_diffs.size - @merge_request_diffs.index(merge_request_diff)
end
- def different_base?(version1, version2)
- version1 && version2 && version1.base_commit_sha != version2.base_commit_sha
- end
-
def merge_params(merge_request)
{
auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS,
diff --git a/app/helpers/mirror_helper.rb b/app/helpers/mirror_helper.rb
index 50fc5e521fc..9d23ab87b98 100644
--- a/app/helpers/mirror_helper.rb
+++ b/app/helpers/mirror_helper.rb
@@ -9,7 +9,11 @@ module MirrorHelper
end
def mirror_lfs_sync_message
- html_escape(_('The Git LFS objects will %{strong_open}not%{strong_close} be synced.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
+ docs_link_url = help_page_path('topics/git/lfs/index')
+ docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url }
+
+ html_escape(_('Git LFS objects will be synced if LFS is %{docs_link_start}enabled for the project%{docs_link_end}. Push mirrors will %{strong_open}not%{strong_close} sync LFS objects over SSH.')) %
+ { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
end
end
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 81451e398f2..8cf5cd49322 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -53,7 +53,7 @@ module NamespacesHelper
selected = options.delete(:selected) || :current_user
options[:groups] = current_user.manageable_groups_with_routes(include_groups_with_developer_maintainer_access: true)
- namespaces_options(selected, options)
+ namespaces_options(selected, **options)
end
private
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 578c7ae7923..3c757a4ef26 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -55,7 +55,8 @@ module NavHelper
current_path?('projects/merge_requests/conflicts#show') ||
current_path?('issues#show') ||
current_path?('milestones#show') ||
- current_path?('issues#designs')
+ current_path?('issues#designs') ||
+ current_path?('incidents#show')
end
def admin_monitoring_nav_links
diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb
index 521f394a920..9965a705a01 100644
--- a/app/helpers/operations_helper.rb
+++ b/app/helpers/operations_helper.rb
@@ -27,7 +27,7 @@ module OperationsHelper
'authorization_key' => alerts_service.token,
'prometheus_url' => notify_project_prometheus_alerts_url(@project, format: :json),
'url' => alerts_service.url,
- 'alerts_setup_url' => help_page_path('user/project/integrations/generic_alerts.md', anchor: 'setting-up-generic-alerts'),
+ 'alerts_setup_url' => help_page_path('operations/incident_management/alert_integrations.md', anchor: 'generic-http-endpoint'),
'alerts_usage_url' => project_alert_management_index_path(@project),
'disabled' => disabled.to_s
}
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index e6ecc403a88..8f365fd0786 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -34,26 +34,22 @@ module PackagesHelper
expose_url(api_v4_group___packages_composer_packages_path(id: group_id, format: '.json'))
end
- def packages_coming_soon_enabled?(resource)
- ::Feature.enabled?(:packages_coming_soon, resource) && ::Gitlab.dev_env_or_com?
- end
-
- def packages_coming_soon_data(resource)
- return unless packages_coming_soon_enabled?(resource)
-
- {
- project_path: ::Gitlab.com? ? 'gitlab-org/gitlab' : 'gitlab-org/gitlab-test',
- suggested_contributions: help_page_path('user/packages/index', anchor: 'suggested-contributions')
- }
+ def composer_config_repository_name(group_id)
+ "#{Gitlab.config.gitlab.host}/#{group_id}"
end
def packages_list_data(type, resource)
{
resource_id: resource.id,
page_type: type,
- empty_list_help_url: help_page_path('administration/packages/index'),
+ empty_list_help_url: help_page_path('user/packages/package_registry/index'),
empty_list_illustration: image_path('illustrations/no-packages.svg'),
- coming_soon_json: packages_coming_soon_data(resource).to_json
+ package_help_url: help_page_path('user/packages/index')
}
end
+
+ def track_package_event(event_name, scope, **args)
+ ::Packages::CreateEventService.new(nil, current_user, event_name: event_name, scope: scope).execute
+ track_event(event_name, **args)
+ end
end
diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb
index a44760e85ca..6808ffc3e27 100644
--- a/app/helpers/page_layout_helper.rb
+++ b/app/helpers/page_layout_helper.rb
@@ -40,6 +40,14 @@ module PageLayoutHelper
end
end
+ def page_canonical_link(link = nil)
+ if link
+ @page_canonical_link = link
+ else
+ @page_canonical_link
+ end
+ end
+
def favicon
Gitlab::Favicon.main
end
diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb
index d05153c9d4b..3167142e193 100644
--- a/app/helpers/pagination_helper.rb
+++ b/app/helpers/pagination_helper.rb
@@ -1,11 +1,13 @@
# frozen_string_literal: true
module PaginationHelper
- def paginate_collection(collection, remote: nil)
+ # total_pages will be inferred from the collection if nil. It is ignored if
+ # the collection is a Kaminari::PaginatableWithoutCount
+ def paginate_collection(collection, remote: nil, total_pages: nil)
if collection.is_a?(Kaminari::PaginatableWithoutCount)
paginate_without_count(collection)
elsif collection.respond_to?(:total_pages)
- paginate_with_count(collection, remote: remote)
+ paginate_with_count(collection, remote: remote, total_pages: total_pages)
end
end
@@ -17,7 +19,7 @@ module PaginationHelper
)
end
- def paginate_with_count(collection, remote: nil)
- paginate(collection, remote: remote, theme: 'gitlab')
+ def paginate_with_count(collection, remote: nil, total_pages: nil)
+ paginate(collection, remote: remote, theme: 'gitlab', total_pages: total_pages)
end
end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 2c406641882..9bf819febb0 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -61,8 +61,8 @@ module PreferencesHelper
@user_application_theme ||= Gitlab::Themes.for_user(current_user).css_class
end
- def user_application_theme_name
- @user_application_theme_name ||= Gitlab::Themes.for_user(current_user).name.downcase.tr(' ', '_')
+ def user_application_theme_css_filename
+ @user_application_theme_css_filename ||= Gitlab::Themes.for_user(current_user).css_filename
end
def user_color_scheme
diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb
index c2f0b8854e1..5ce3736c8ef 100644
--- a/app/helpers/projects/alert_management_helper.rb
+++ b/app/helpers/projects/alert_management_helper.rb
@@ -9,7 +9,9 @@ module Projects::AlertManagementHelper
'populating-alerts-help-url' => help_page_url('operations/incident_management/index.md', anchor: 'enable-alert-management'),
'empty-alert-svg-path' => image_path('illustrations/alert-management-empty-state.svg'),
'user-can-enable-alert-management' => can?(current_user, :admin_operations, project).to_s,
- 'alert-management-enabled' => alert_management_enabled?(project).to_s
+ 'alert-management-enabled' => alert_management_enabled?(project).to_s,
+ 'text-query': params[:search],
+ 'assignee-username-query': params[:assignee_username]
}
end
diff --git a/app/helpers/projects/incidents_helper.rb b/app/helpers/projects/incidents_helper.rb
index e96f0f5a384..63504cb55b9 100644
--- a/app/helpers/projects/incidents_helper.rb
+++ b/app/helpers/projects/incidents_helper.rb
@@ -1,14 +1,17 @@
# frozen_string_literal: true
module Projects::IncidentsHelper
- def incidents_data(project)
+ def incidents_data(project, params)
{
'project-path' => project.full_path,
'new-issue-path' => new_project_issue_path(project),
'incident-template-name' => 'incident',
'incident-type' => 'incident',
'issue-path' => project_issues_path(project),
- 'empty-list-svg-path' => image_path('illustrations/incident-empty-state.svg')
+ 'empty-list-svg-path' => image_path('illustrations/incident-empty-state.svg'),
+ 'text-query': params[:search],
+ 'author-username-query': params[:author_username],
+ 'assignee-username-query': params[:assignee_username]
}
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 72cc07b13a5..ae46135e890 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -301,7 +301,6 @@ module ProjectsHelper
!disabled && !compact_mode
end
- # overridden in EE
def settings_operations_available?
can?(current_user, :read_environment, @project)
end
@@ -468,16 +467,25 @@ module ProjectsHelper
serverless: :read_cluster,
error_tracking: :read_sentry_issue,
alert_management: :read_alert_management_alert,
- incidents: :read_incidents,
+ incidents: :read_issue,
labels: :read_label,
issues: :read_issue,
project_members: :read_project_member,
- wiki: :read_wiki
+ wiki: :read_wiki,
+ feature_flags: :read_feature_flag
}
end
def can_view_operations_tab?(current_user, project)
- [:read_environment, :read_cluster, :metrics_dashboard].any? do |ability|
+ [
+ :metrics_dashboard,
+ :read_alert_management_alert,
+ :read_environment,
+ :read_issue,
+ :read_sentry_issue,
+ :read_cluster,
+ :read_feature_flag
+ ].any? do |ability|
can?(current_user, ability, project)
end
end
@@ -555,7 +563,11 @@ module ProjectsHelper
end
def sidebar_operations_link_path(project = @project)
- metrics_project_environments_path(project) if can?(current_user, :read_environment, project)
+ if can?(current_user, :read_environment, project)
+ metrics_project_environments_path(project)
+ else
+ project_feature_flags_path(project)
+ end
end
def project_last_activity(project)
@@ -748,6 +760,8 @@ module ProjectsHelper
logs
product_analytics
metrics_dashboard
+ feature_flags
+ tracings
]
end
@@ -758,10 +772,6 @@ module ProjectsHelper
!project.repository.gitlab_ci_yml
end
- def native_code_navigation_enabled?(project)
- Feature.enabled?(:code_navigation, project, default_enabled: true)
- end
-
def show_visibility_confirm_modal?(project)
project.unlink_forks_upon_visibility_decrease_enabled? && project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && project.forks_count > 0
end
@@ -772,9 +782,7 @@ module ProjectsHelper
end
def project_access_token_available?(project)
- return false if ::Gitlab.com?
-
- ::Feature.enabled?(:resource_access_token, project, default_enabled: true)
+ can?(current_user, :admin_resource_access_tokens, project)
end
end
diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb
index 979a68ecb7b..050b27840a0 100644
--- a/app/helpers/releases_helper.rb
+++ b/app/helpers/releases_helper.rb
@@ -29,6 +29,14 @@ module ReleasesHelper
end
end
+ def data_for_show_page
+ {
+ project_id: @project.id,
+ project_path: @project.full_path,
+ tag_name: @release.tag
+ }
+ end
+
def data_for_edit_release_page
new_edit_pages_shared_data.merge(
tag_name: @release.tag,
@@ -48,6 +56,7 @@ module ReleasesHelper
def new_edit_pages_shared_data
{
project_id: @project.id,
+ project_path: @project.full_path,
markdown_preview_path: preview_markdown_path(@project),
markdown_docs_path: help_page_path('user/markdown'),
update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release'),
diff --git a/app/helpers/reminder_emails_helper.rb b/app/helpers/reminder_emails_helper.rb
new file mode 100644
index 00000000000..bffb3cf7751
--- /dev/null
+++ b/app/helpers/reminder_emails_helper.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module ReminderEmailsHelper
+ def invitation_reminder_salutation(reminder_index, format: nil)
+ case reminder_index
+ when 0
+ s_('InviteReminderEmail|Invitation pending')
+ when 1
+ if format == :html
+ s_('InviteReminderEmail|Hey there %{wave_emoji}').html_safe % { wave_emoji: Gitlab::Emoji.gl_emoji_tag('wave') }
+ else
+ s_('InviteReminderEmail|Hey there!')
+ end
+ when 2
+ s_('InviteReminderEmail|In case you missed it...')
+ end
+ end
+
+ def invitation_reminder_body(member, reminder_index, format: nil)
+ options = {
+ inviter: sanitize_name(member.created_by.name),
+ strong_start: '',
+ strong_end: '',
+ project_or_group_name: member_source.human_name,
+ project_or_group: member_source.model_name.singular,
+ role: member.human_access.downcase
+ }
+
+ if format == :html
+ options.merge!(
+ inviter: (link_to member.created_by.name, user_url(member.created_by)).html_safe,
+ strong_start: '<strong>'.html_safe,
+ strong_end: '</strong>'.html_safe
+ )
+ end
+
+ if reminder_index == 2
+ options[:invitation_age] = (Date.current - member.created_at.to_date).to_i
+ end
+
+ body = invitation_reminder_body_text(reminder_index)
+
+ (format == :html ? html_escape(body) : body ) % options
+ end
+
+ def invitation_reminder_accept_link(token, format: nil)
+ case format
+ when :html
+ link_to s_('InviteReminderEmail|Accept invitation'), invite_url(token), class: 'invite-btn-join'
+ else
+ s_('InviteReminderEmail|Accept invitation: %{invite_url}') % { invite_url: invite_url(token) }
+ end
+ end
+
+ def invitation_reminder_decline_link(token, format: nil)
+ case format
+ when :html
+ link_to s_('InviteReminderEmail|Decline invitation'), decline_invite_url(token), class: 'invite-btn-decline'
+ else
+ s_('InviteReminderEmail|Decline invitation: %{decline_url}') % { decline_url: decline_invite_url(token) }
+ end
+ end
+
+ private
+
+ def invitation_reminder_body_text(reminder_index)
+ case reminder_index
+ when 0
+ s_('InviteReminderEmail|%{inviter} is waiting for you to join the %{strong_start}%{project_or_group_name}%{strong_end} %{project_or_group} as a %{role}.')
+ when 1
+ s_('InviteReminderEmail|This is a friendly reminder that %{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end} %{project_or_group} as a %{role}.')
+ when 2
+ s_("InviteReminderEmail|It's been %{invitation_age} days since %{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end} %{project_or_group} as a %{role}. What would you like to do?")
+ end
+ end
+end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index d55ad878b92..3467f6e9a44 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -1,14 +1,13 @@
# frozen_string_literal: true
module SearchHelper
- SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets, :state].freeze
+ SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets, :sort, :state, :confidential].freeze
def search_autocomplete_opts(term)
return unless current_user
resources_results = [
- recent_merge_requests_autocomplete(term),
- recent_issues_autocomplete(term),
+ recent_items_autocomplete(term),
groups_autocomplete(term),
projects_autocomplete(term)
].flatten
@@ -27,6 +26,10 @@ module SearchHelper
end
end
+ def recent_items_autocomplete(term)
+ recent_merge_requests_autocomplete(term) + recent_issues_autocomplete(term)
+ end
+
def search_entries_info(collection, scope, term)
return if collection.to_a.empty?
@@ -86,13 +89,18 @@ module SearchHelper
}).html_safe
end
+ def repository_ref(project)
+ # Always #to_s the repository_ref param in case the value is also a number
+ params[:repository_ref].to_s.presence || project.default_branch
+ end
+
# Overridden in EE
def search_blob_title(project, path)
path
end
def search_service
- @search_service ||= ::SearchService.new(current_user, params)
+ @search_service ||= ::SearchService.new(current_user, params.merge(confidential: Gitlab::Utils.to_boolean(params[:confidential])))
end
private
@@ -123,8 +131,7 @@ module SearchHelper
{ category: "Help", label: _("Rake Tasks Help"), url: help_page_path("raketasks/README") },
{ category: "Help", label: _("SSH Keys Help"), url: help_page_path("ssh/README") },
{ category: "Help", label: _("System Hooks Help"), url: help_page_path("system_hooks/system_hooks") },
- { category: "Help", label: _("Webhooks Help"), url: help_page_path("user/project/integrations/webhooks") },
- { category: "Help", label: _("Workflow Help"), url: help_page_path("workflow/README") }
+ { category: "Help", label: _("Webhooks Help"), url: help_page_path("user/project/integrations/webhooks") }
]
end
@@ -181,10 +188,10 @@ module SearchHelper
end
end
- def recent_merge_requests_autocomplete(term, limit = 5)
+ def recent_merge_requests_autocomplete(term)
return [] unless current_user
- ::Gitlab::Search::RecentMergeRequests.new(user: current_user).search(term).limit(limit).map do |mr|
+ ::Gitlab::Search::RecentMergeRequests.new(user: current_user).search(term).map do |mr|
{
category: "Recent merge requests",
id: mr.id,
@@ -195,10 +202,10 @@ module SearchHelper
end
end
- def recent_issues_autocomplete(term, limit = 5)
+ def recent_issues_autocomplete(term)
return [] unless current_user
- ::Gitlab::Search::RecentIssues.new(user: current_user).search(term).limit(limit).map do |i|
+ ::Gitlab::Search::RecentIssues.new(user: current_user).search(term).map do |i|
{
category: "Recent issues",
id: i.id,
@@ -255,11 +262,15 @@ module SearchHelper
opts[:data]['labels-endpoint'] = project_labels_path(@project)
opts[:data]['milestones-endpoint'] = project_milestones_path(@project)
opts[:data]['releases-endpoint'] = project_releases_path(@project)
+ opts[:data]['environments-endpoint'] =
+ unfoldered_environment_names_project_path(@project)
elsif @group.present?
opts[:data]['group-id'] = @group.id
opts[:data]['labels-endpoint'] = group_labels_path(@group)
opts[:data]['milestones-endpoint'] = group_milestones_path(@group)
opts[:data]['releases-endpoint'] = group_releases_path(@group)
+ opts[:data]['environments-endpoint'] =
+ unfoldered_environment_names_group_path(@group)
else
opts[:data]['labels-endpoint'] = dashboard_labels_path
opts[:data]['milestones-endpoint'] = dashboard_milestones_path
@@ -294,9 +305,25 @@ module SearchHelper
sanitize(html, tags: %w(a p ol ul li pre code))
end
- def show_user_search_tab?
- return false if Feature.disabled?(:users_search, default_enabled: true)
+ def simple_search_highlight_and_truncate(text, phrase, options = {})
+ text = Truncato.truncate(
+ text,
+ count_tags: false,
+ count_tail: false,
+ max_length: options.delete(:length) { 200 }
+ )
+
+ highlight(text, phrase.split, options)
+ end
+
+ # _search_highlight is used in EE override
+ def highlight_and_truncate_issue(issue, search_term, _search_highlight)
+ return unless issue.description.present?
+ simple_search_highlight_and_truncate(issue.description, search_term, highlighter: '<span class="gl-text-black-normal gl-font-weight-bold">\1</span>')
+ end
+
+ def show_user_search_tab?
if @project
project_search_tabs?(:members)
else
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index 6b5de73a831..114bbf59ae1 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -24,7 +24,7 @@ module ServicesHelper
when "commit", "commit_events"
s_("ProjectService|Event will be triggered when a commit is created/updated")
when "deployment"
- s_("ProjectService|Event will be triggered when a deployment finishes")
+ s_("ProjectService|Event will be triggered when a deployment starts or finishes")
when "alert"
s_("ProjectService|Event will be triggered when a new, unique alert is recorded")
end
@@ -124,6 +124,10 @@ module ServicesHelper
@group.present? && Feature.enabled?(:group_level_integrations, @group)
end
+ def instance_level_integrations?
+ !Gitlab.com?
+ end
+
extend self
private
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index 94c46feb8ae..1be7e240c1a 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -32,31 +32,6 @@ module SnippetsHelper
end
end
- # Get an array of line numbers surrounding a matching
- # line, bounded by min/max.
- #
- # @returns Array of line numbers
- def bounded_line_numbers(line, min, max, surrounding_lines)
- lower = line - surrounding_lines > min ? line - surrounding_lines : min
- upper = line + surrounding_lines < max ? line + surrounding_lines : max
- (lower..upper).to_a
- end
-
- def snippet_embed_tag(snippet)
- content_tag(:script, nil, src: gitlab_snippet_url(snippet, format: :js))
- end
-
- def snippet_embed_input(snippet)
- content_tag(:input,
- nil,
- type: :text,
- readonly: true,
- class: 'js-snippet-url-area snippet-embed-input form-control',
- data: { url: gitlab_snippet_url(snippet) },
- value: snippet_embed_tag(snippet),
- autocomplete: 'off')
- end
-
def snippet_badge(snippet)
return unless attrs = snippet_badge_attributes(snippet)
diff --git a/app/helpers/ssh_keys_helper.rb b/app/helpers/ssh_keys_helper.rb
new file mode 100644
index 00000000000..381db893943
--- /dev/null
+++ b/app/helpers/ssh_keys_helper.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module SshKeysHelper
+ def ssh_key_delete_modal_data(key, path)
+ {
+ path: path,
+ method: 'delete',
+ qa_selector: 'delete_ssh_key_button',
+ modal_attributes: {
+ 'data-qa-selector': 'ssh_key_delete_modal',
+ title: _('Are you sure you want to delete this SSH key?'),
+ message: _('This action cannot be undone, and will permanently delete the %{key} SSH key') % { key: key.title },
+ okVariant: 'danger',
+ okTitle: _('Delete')
+ }
+ }
+ end
+end
diff --git a/app/helpers/startupjs_helper.rb b/app/helpers/startupjs_helper.rb
new file mode 100644
index 00000000000..b595590c7c9
--- /dev/null
+++ b/app/helpers/startupjs_helper.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module StartupjsHelper
+ def page_startup_graphql_calls
+ @graphql_startup_calls
+ end
+
+ def add_page_startup_graphql_call(query, variables = {})
+ @graphql_startup_calls ||= []
+ file_location = File.join(Rails.root, "app/graphql/queries/#{query}.query.graphql")
+
+ return unless File.exist?(file_location)
+
+ query_str = File.read(file_location)
+ @graphql_startup_calls << { query: query_str, variables: variables }
+ end
+end
diff --git a/app/helpers/suggest_pipeline_helper.rb b/app/helpers/suggest_pipeline_helper.rb
index aa67f0ea770..d64e8d6f2cd 100644
--- a/app/helpers/suggest_pipeline_helper.rb
+++ b/app/helpers/suggest_pipeline_helper.rb
@@ -2,7 +2,7 @@
module SuggestPipelineHelper
def should_suggest_gitlab_ci_yml?
- Feature.enabled?(:suggest_pipeline) &&
+ experiment_enabled?(:suggest_pipeline) &&
current_user &&
params[:suggest_gitlab_ci_yml] == 'true'
end
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 0227ad1092d..79f4810e13a 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -2,6 +2,8 @@
module SystemNoteHelper
ICON_NAMES_BY_ACTION = {
+ 'approved' => 'approval',
+ 'unapproved' => 'unapproval',
'cherry_pick' => 'cherry-pick-commit',
'commit' => 'commit',
'description' => 'pencil-square',
@@ -11,6 +13,7 @@ module SystemNoteHelper
'closed' => 'issue-close',
'time_tracking' => 'timer',
'assignee' => 'user',
+ 'reviewer' => 'user',
'title' => 'pencil-square',
'task' => 'task-done',
'label' => 'label',
@@ -34,7 +37,8 @@ module SystemNoteHelper
'designs_discussion_added' => 'doc-image',
'status' => 'status',
'alert_issue_added' => 'issues',
- 'new_alert_added' => 'warning'
+ 'new_alert_added' => 'warning',
+ 'severity' => 'information-o'
}.freeze
def system_note_icon_name(note)
diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb
index 4984b51555d..bfc8803f514 100644
--- a/app/helpers/tags_helper.rb
+++ b/app/helpers/tags_helper.rb
@@ -38,4 +38,13 @@ module TagsHelper
text.html_safe
end
+
+ def delete_tag_modal_attributes(tag_name)
+ {
+ title: s_('TagsPage|Delete tag'),
+ message: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag_name },
+ okVariant: 'danger',
+ okTitle: s_('TagsPage|Delete tag')
+ }.to_json
+ end
end
diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb
index 34919f994ee..bbf8cf7dac3 100644
--- a/app/helpers/timeboxes_helper.rb
+++ b/app/helpers/timeboxes_helper.rb
@@ -228,8 +228,8 @@ module TimeboxesHelper
end
alias_method :milestone_date_range, :timebox_date_range
- def milestone_tab_path(milestone, tab)
- url_for(action: tab, format: :json)
+ def milestone_tab_path(milestone, tab, params = {})
+ url_for(params.merge(action: tab, format: :json))
end
def update_milestone_path(milestone, params = {})
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 9865f7dfbef..7b0e0df8998 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -16,6 +16,7 @@ module TodosHelper
def todo_action_name(todo)
case todo.action
when Todo::ASSIGNED then todo.self_added? ? 'assigned' : 'assigned you'
+ when Todo::REVIEW_REQUESTED then 'requested a review of'
when Todo::MENTIONED then "mentioned #{todo_action_subject(todo)} on"
when Todo::BUILD_FAILED then 'The build failed for'
when Todo::MARKED then 'added a todo for'
@@ -26,6 +27,13 @@ module TodosHelper
end
end
+ def todo_self_addressing(todo)
+ case todo.action
+ when Todo::ASSIGNED then 'to yourself'
+ when Todo::REVIEW_REQUESTED then 'from yourself'
+ end
+ end
+
def todo_target_link(todo)
text = raw(todo_target_type_name(todo) + ' ') +
if todo.for_commit?
@@ -141,6 +149,7 @@ module TodosHelper
[
{ id: '', text: 'Any Action' },
{ id: Todo::ASSIGNED, text: 'Assigned' },
+ { id: Todo::REVIEW_REQUESTED, text: 'Review requested' },
{ id: Todo::MENTIONED, text: 'Mentioned' },
{ id: Todo::MARKED, text: 'Added' },
{ id: Todo::BUILD_FAILED, text: 'Pipelines' },
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index 7644ed783eb..563450159b5 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -1,6 +1,9 @@
# frozen_string_literal: true
module TreeHelper
+ include BlobHelper
+ include WebIdeButtonHelper
+
FILE_LIMIT = 1_000
# Sorts a repository's tree so that folders are before files and renders
@@ -31,7 +34,7 @@ module TreeHelper
# mode - File unix mode
# name - File name
def tree_icon(type, mode, name)
- icon([file_type_icon_class(type, mode, name), 'fw'])
+ sprite_icon(file_type_icon_class(type, mode, name))
end
# Using Rails `*_path` methods can be slow, especially when generating
@@ -199,38 +202,26 @@ module TreeHelper
}
end
- def ide_base_path(project)
- can_push_code = current_user&.can?(:push_code, project)
- fork_path = current_user&.fork_of(project)&.full_path
+ def web_ide_button_data(options = {})
+ {
+ project_path: project_to_use.full_path,
+ ref: ActionDispatch::Journey::Router::Utils.escape_path(@ref),
- if can_push_code
- project.full_path
- else
- fork_path || project.full_path
- end
- end
+ is_fork: fork?,
+ needs_to_fork: needs_to_fork?,
+ gitpod_enabled: !current_user.nil? && current_user.gitpod_enabled,
+ is_blob: !options[:blob].nil?,
- def vue_ide_link_data(project, ref)
- can_collaborate = can_collaborate_with_project?(project)
- can_create_mr_from_fork = can?(current_user, :fork_project, project) && can?(current_user, :create_merge_request_in, project)
- show_web_ide_button = (can_collaborate || current_user&.already_forked?(project) || can_create_mr_from_fork)
+ show_edit_button: show_edit_button?,
+ show_web_ide_button: show_web_ide_button?,
+ show_gitpod_button: show_gitpod_button?,
- {
- ide_base_path: ide_base_path(project),
- needs_to_fork: !can_collaborate && !current_user&.already_forked?(project),
- show_web_ide_button: show_web_ide_button,
- show_gitpod_button: show_web_ide_button && Gitlab::Gitpod.feature_and_settings_enabled?(project),
- gitpod_url: full_gitpod_url(project, ref),
- gitpod_enabled: current_user&.gitpod_enabled
+ web_ide_url: web_ide_url,
+ edit_url: edit_url,
+ gitpod_url: gitpod_url
}
end
- def full_gitpod_url(project, ref)
- return "" unless Gitlab::Gitpod.feature_and_settings_enabled?(project)
-
- "#{Gitlab::CurrentSettings.gitpod_url}##{project_tree_url(project, tree_join(ref, @path || ''))}"
- end
-
def directory_download_links(project, ref, archive_prefix)
Gitlab::Workhorse::ARCHIVE_FORMATS.map do |fmt|
{
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
index 967271a8431..0cdf53d6174 100644
--- a/app/helpers/user_callouts_helper.rb
+++ b/app/helpers/user_callouts_helper.rb
@@ -9,7 +9,7 @@ module UserCalloutsHelper
TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight'
WEBHOOKS_MOVED = 'webhooks_moved'
CUSTOMIZE_HOMEPAGE = 'customize_homepage'
- WEB_IDE_ALERT_DISMISSED = 'web_ide_alert_dismissed'
+ FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version'
def show_admin_integrations_moved?
!user_dismissed?(ADMIN_INTEGRATIONS_MOVED)
@@ -51,8 +51,8 @@ module UserCalloutsHelper
customize_homepage && !user_dismissed?(CUSTOMIZE_HOMEPAGE)
end
- def show_web_ide_alert?
- !user_dismissed?(WEB_IDE_ALERT_DISMISSED)
+ def show_feature_flags_new_version?
+ !user_dismissed?(FEATURE_FLAGS_NEW_VERSION)
end
private
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index c1bca6b4c41..f47937e6d57 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -84,7 +84,7 @@ module UsersHelper
def user_badges_in_admin_section(user)
[].tap do |badges|
- badges << { text: s_('AdminUsers|Blocked'), variant: 'danger' } if user.blocked?
+ badges << blocked_user_badge(user) if user.blocked?
badges << { text: s_('AdminUsers|Admin'), variant: 'success' } if user.admin?
badges << { text: s_('AdminUsers|External'), variant: 'secondary' } if user.external?
badges << { text: s_("AdminUsers|It's you!"), variant: nil } if current_user == user
@@ -106,8 +106,19 @@ module UsersHelper
end
end
+ def can_force_email_confirmation?(user)
+ !user.confirmed?
+ end
+
private
+ def blocked_user_badge(user)
+ pending_approval_badge = { text: s_('AdminUsers|Pending approval'), variant: 'info' }
+ return pending_approval_badge if user.blocked_pending_approval?
+
+ { text: s_('AdminUsers|Blocked'), variant: 'danger' }
+ end
+
def get_profile_tabs
tabs = []
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index 304b58d232a..a7b9e17c898 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -23,8 +23,6 @@ module VisibilityLevelHelper
project_visibility_level_description(level)
when Group
group_visibility_level_description(level)
- when Snippet
- snippet_visibility_level_description(level, form_model)
end
end
@@ -50,21 +48,6 @@ module VisibilityLevelHelper
end
end
- def snippet_visibility_level_description(level, snippet = nil)
- case level
- when Gitlab::VisibilityLevel::PRIVATE
- if snippet.is_a? ProjectSnippet
- _("The snippet is visible only to project members.")
- else
- _("The snippet is visible only to me.")
- end
- when Gitlab::VisibilityLevel::INTERNAL
- _("The snippet is visible to any logged in user.")
- when Gitlab::VisibilityLevel::PUBLIC
- _("The snippet can be accessed without any authentication.")
- end
- end
-
# Note: these messages closely mirror the form validation strings found in the project
# model and any changes or additons to these may also need to be made there.
def disallowed_project_visibility_level_description(level, project)
diff --git a/app/helpers/web_ide_button_helper.rb b/app/helpers/web_ide_button_helper.rb
new file mode 100644
index 00000000000..0a4d47eed52
--- /dev/null
+++ b/app/helpers/web_ide_button_helper.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module WebIdeButtonHelper
+ def project_fork
+ current_user&.fork_of(@project)
+ end
+
+ def project_to_use
+ fork? ? project_fork : @project
+ end
+
+ def can_collaborate?
+ can_collaborate_with_project?(@project)
+ end
+
+ def can_create_mr_from_fork?
+ can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project)
+ end
+
+ def show_web_ide_button?
+ can_collaborate? || can_create_mr_from_fork?
+ end
+
+ def show_edit_button?
+ readable_blob? && show_web_ide_button?
+ end
+
+ def show_gitpod_button?
+ show_web_ide_button? && Gitlab::Gitpod.feature_and_settings_enabled?(@project)
+ end
+
+ def can_push_code?
+ current_user&.can?(:push_code, @project)
+ end
+
+ def fork?
+ !project_fork.nil? && !can_push_code?
+ end
+
+ def readable_blob?
+ !readable_blob({}, @path, @project, @ref).nil?
+ end
+
+ def needs_to_fork?
+ !can_collaborate? && !current_user&.already_forked?(@project)
+ end
+
+ def web_ide_url
+ ide_edit_path(project_to_use, @ref, @path || '')
+ end
+
+ def edit_url
+ readable_blob? ? edit_blob_path(@project, @ref, @path || '') : ''
+ end
+
+ def gitpod_url
+ return "" unless Gitlab::Gitpod.feature_and_settings_enabled?(@project)
+
+ "#{Gitlab::CurrentSettings.gitpod_url}##{project_tree_url(@project, tree_join(@ref, @path || ''))}"
+ end
+end
diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb
index 345ddcf023a..170e3c45a21 100644
--- a/app/helpers/webpack_helper.rb
+++ b/app/helpers/webpack_helper.rb
@@ -57,10 +57,12 @@ module WebpackHelper
end
def webpack_public_host
- if Rails.env.test? && Rails.configuration.webpack.dev_server.enabled
- host = Rails.configuration.webpack.dev_server.host
- port = Rails.configuration.webpack.dev_server.port
- protocol = Rails.configuration.webpack.dev_server.https ? 'https' : 'http'
+ # We do not proxy the webpack output in the 'test' environment,
+ # so we must reference the webpack dev server directly.
+ if Rails.env.test? && Gitlab.config.webpack.dev_server.enabled
+ host = Gitlab.config.webpack.dev_server.host
+ port = Gitlab.config.webpack.dev_server.port
+ protocol = Gitlab.config.webpack.dev_server.https ? 'https' : 'http'
"#{protocol}://#{host}:#{port}"
else
ActionController::Base.asset_host.try(:chomp, '/')
@@ -68,8 +70,8 @@ module WebpackHelper
end
def webpack_public_path
- relative_path = Rails.application.config.relative_url_root
- webpack_path = Rails.application.config.webpack.public_path
+ relative_path = Gitlab.config.gitlab.relative_url_root
+ webpack_path = Gitlab.config.webpack.public_path
File.join(webpack_public_host.to_s, relative_path.to_s, webpack_path.to_s, '')
end
end
diff --git a/app/helpers/whats_new_helper.rb b/app/helpers/whats_new_helper.rb
index f0044daa645..c183ed7f12a 100644
--- a/app/helpers/whats_new_helper.rb
+++ b/app/helpers/whats_new_helper.rb
@@ -1,24 +1,27 @@
# frozen_string_literal: true
module WhatsNewHelper
- EMPTY_JSON = ''.to_json
+ include Gitlab::WhatsNew
- def whats_new_most_recent_release_items
- YAML.load_file(most_recent_release_file_path).to_json
+ def whats_new_most_recent_release_items_count
+ Gitlab::ProcessMemoryCache.cache_backend.fetch('whats_new:release_items_count', expires_in: CACHE_DURATION) do
+ whats_new_most_recent_release_items&.count
+ end
+ end
- rescue => e
- Gitlab::ErrorTracking.track_exception(e, yaml_file_path: most_recent_release_file_path)
+ def whats_new_storage_key
+ return unless whats_new_most_recent_version
- EMPTY_JSON
+ ['display-whats-new-notification', whats_new_most_recent_version].join('-')
end
private
- def most_recent_release_file_path
- Dir.glob(files_path).max
- end
-
- def files_path
- Rails.root.join('data', 'whats_new', '*.yml')
+ def whats_new_most_recent_version
+ Gitlab::ProcessMemoryCache.cache_backend.fetch('whats_new:release_version', expires_in: CACHE_DURATION) do
+ if whats_new_most_recent_release_items
+ whats_new_most_recent_release_items.first.try(:[], 'release')
+ end
+ end
end
end
diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb
index 8c756b9370b..786081ca815 100644
--- a/app/helpers/wiki_helper.rb
+++ b/app/helpers/wiki_helper.rb
@@ -142,7 +142,8 @@ module WikiHelper
'wiki-format' => page.format,
'wiki-title-size' => page.title.bytesize,
'wiki-content-size' => page.raw_content.bytesize,
- 'wiki-directory-nest-level' => page.path.scan('/').count
+ 'wiki-directory-nest-level' => page.path.scan('/').count,
+ 'wiki-container-type' => page.wiki.container.class.name
}
end
diff --git a/app/mailers/abuse_report_mailer.rb b/app/mailers/abuse_report_mailer.rb
index 0f2f63b43f5..20aabb6fe58 100644
--- a/app/mailers/abuse_report_mailer.rb
+++ b/app/mailers/abuse_report_mailer.rb
@@ -11,7 +11,7 @@ class AbuseReportMailer < ApplicationMailer
@abuse_report = AbuseReport.find(abuse_report_id)
mail(
- to: Gitlab::CurrentSettings.admin_notification_email,
+ to: Gitlab::CurrentSettings.abuse_notification_email,
subject: "#{@abuse_report.user.name} (#{@abuse_report.user.username}) was reported for abuse"
)
end
@@ -19,6 +19,6 @@ class AbuseReportMailer < ApplicationMailer
private
def deliverable?
- Gitlab::CurrentSettings.admin_notification_email.present?
+ Gitlab::CurrentSettings.abuse_notification_email.present?
end
end
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index bcf60bea0e0..10a1da90e9e 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -93,7 +93,7 @@ module Emails
def issues_csv_email(user, project, csv_data, export_status)
@project = project
- @issues_count = export_status.fetch(:rows_expected)
+ @count = export_status.fetch(:rows_expected)
@written_count = export_status.fetch(:rows_written)
@truncated = export_status.fetch(:truncated)
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index 3a13c5949bd..57e4c7df440 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -77,6 +77,38 @@ module Emails
Gitlab::Tracking.event(Gitlab::Experimentation::EXPERIMENTS[:invite_email][:tracking_category], 'sent', property: 'control_group')
end
end
+
+ if member.invite_to_unknown_user? && Gitlab::Experimentation.enabled?(:invitation_reminders)
+ Gitlab::Tracking.event(
+ Gitlab::Experimentation.experiment(:invitation_reminders).tracking_category,
+ 'sent',
+ property: Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, member.invite_email) ? 'experimental_group' : 'control_group',
+ label: Digest::MD5.hexdigest(member.to_global_id.to_s)
+ )
+ end
+ end
+
+ def member_invited_reminder_email(member_source_type, member_id, token, reminder_index)
+ @member_source_type = member_source_type
+ @member_id = member_id
+ @token = token
+ @reminder_index = reminder_index
+
+ return unless member_exists? && member.created_by && member.invite_to_unknown_user?
+
+ subjects = {
+ 0 => s_("InviteReminderEmail|%{inviter}'s invitation to GitLab is pending"),
+ 1 => s_('InviteReminderEmail|%{inviter} is waiting for you to join GitLab'),
+ 2 => s_('InviteReminderEmail|%{inviter} is still waiting for you to join GitLab')
+ }
+
+ subject_line = subjects[reminder_index] % { inviter: member.created_by.name }
+
+ member_email_with_layout(
+ layout: 'experiment_mailer',
+ to: member.invite_email,
+ subject: subject(subject_line)
+ )
end
def member_invite_accepted_email(member_source_type, member_id)
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index c709c2950d6..28ac752f550 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -34,6 +34,17 @@ module Emails
end
# rubocop: enable CodeReuse/ActiveRecord
+ # rubocop: disable CodeReuse/ActiveRecord
+ def changed_reviewer_of_merge_request_email(recipient_id, merge_request_id, previous_reviewer_ids, updated_by_user_id, reason = nil)
+ setup_merge_request_mail(merge_request_id, recipient_id)
+
+ @previous_reviewers = []
+ @previous_reviewers = User.where(id: previous_reviewer_ids) if previous_reviewer_ids.any?
+
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
def relabeled_merge_request_email(recipient_id, merge_request_id, label_names, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@@ -99,6 +110,20 @@ module Emails
mail_answer_thread(@merge_request, merge_request_thread_options(mwps_set_by_user_id, recipient_id, reason))
end
+ def merge_requests_csv_email(user, project, csv_data, export_status)
+ @project = project
+ @count = export_status.fetch(:rows_expected)
+ @written_count = export_status.fetch(:rows_written)
+ @truncated = export_status.fetch(:truncated)
+
+ filename = "#{project.full_path.parameterize}_merge_requests_#{Date.current.iso8601}.csv"
+ attachments[filename] = { content: csv_data, mime_type: 'text/csv' }
+ mail(to: user.notification_email_for(@project.group), subject: subject("Exported merge requests")) do |format|
+ format.html { render layout: 'mailer' }
+ format.text { render layout: 'mailer' }
+ end
+ end
+
private
def setup_merge_request_mail(merge_request_id, recipient_id, present: false)
diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb
index fdf40a77ca4..17ef8b41e79 100644
--- a/app/mailers/emails/projects.rb
+++ b/app/mailers/emails/projects.rb
@@ -56,16 +56,14 @@ module Emails
subject: @message.subject)
end
- def prometheus_alert_fired_email(project_id, user_id, alert_payload)
+ def prometheus_alert_fired_email(project_id, user_id, alert_attributes)
@project = ::Project.find(project_id)
user = ::User.find(user_id)
- @alert = ::Gitlab::Alerting::Alert
- .new(project: @project, payload: alert_payload)
- .present
- return unless @alert.valid?
+ @alert = AlertManagement::Alert.new(alert_attributes.with_indifferent_access).present
+ return unless @alert.parsed_payload.has_required_attributes?
- subject_text = "Alert: #{@alert.full_title}"
+ subject_text = "Alert: #{@alert.email_title}"
mail(to: user.notification_email_for(@project.group), subject: subject(subject_text))
end
end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index f9aba3fe4f2..ebf6dd68ec7 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -4,6 +4,7 @@ class Notify < ApplicationMailer
include ActionDispatch::Routing::PolymorphicRoutes
include GitlabRoutingHelper
include EmailsHelper
+ include ReminderEmailsHelper
include IssuablesHelper
include Emails::Issues
@@ -26,6 +27,7 @@ class Notify < ApplicationMailer
helper DiffHelper
helper BlobHelper
helper EmailsHelper
+ helper ReminderEmailsHelper
helper MembersHelper
helper AvatarsHelper
helper GitlabRoutingHelper
diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb
index e9b89af45c6..61cc15a522e 100644
--- a/app/models/alert_management/alert.rb
+++ b/app/models/alert_management/alert.rb
@@ -20,18 +20,7 @@ module AlertManagement
resolved: 2,
ignored: 3
}.freeze
-
- STATUS_EVENTS = {
- triggered: :trigger,
- acknowledged: :acknowledge,
- resolved: :resolve,
- ignored: :ignore
- }.freeze
-
- OPEN_STATUSES = [
- :triggered,
- :acknowledged
- ].freeze
+ private_constant :STATUSES
belongs_to :project
belongs_to :issue, optional: true
@@ -49,12 +38,16 @@ module AlertManagement
sha_attribute :fingerprint
+ TITLE_MAX_LENGTH = 200
+ DESCRIPTION_MAX_LENGTH = 1_000
+ SERVICE_MAX_LENGTH = 100
+ TOOL_MAX_LENGTH = 100
HOSTS_MAX_LENGTH = 255
- validates :title, length: { maximum: 200 }, presence: true
- validates :description, length: { maximum: 1_000 }
- validates :service, length: { maximum: 100 }
- validates :monitoring_tool, length: { maximum: 100 }
+ validates :title, length: { maximum: TITLE_MAX_LENGTH }, presence: true
+ validates :description, length: { maximum: DESCRIPTION_MAX_LENGTH }
+ validates :service, length: { maximum: SERVICE_MAX_LENGTH }
+ validates :monitoring_tool, length: { maximum: TOOL_MAX_LENGTH }
validates :project, presence: true
validates :events, presence: true
validates :severity, presence: true
@@ -65,7 +58,7 @@ module AlertManagement
conditions: -> { not_resolved },
message: -> (object, data) { _('Cannot have multiple unresolved alerts') }
}, unless: :resolved?
- validate :hosts_length
+ validate :hosts_format
enum severity: {
critical: 0,
@@ -121,12 +114,13 @@ module AlertManagement
delegate :details_url, to: :present
scope :for_iid, -> (iid) { where(iid: iid) }
- scope :for_status, -> (status) { where(status: status) }
+ scope :for_status, -> (status) { with_status(status) }
scope :for_fingerprint, -> (project, fingerprint) { where(project: project, fingerprint: fingerprint) }
scope :for_environment, -> (environment) { where(environment: environment) }
+ scope :for_assignee_username, -> (assignee_username) { joins(:assignees).merge(User.by_username(assignee_username)) }
scope :search, -> (query) { fuzzy_search(query, [:title, :description, :monitoring_tool, :service]) }
- scope :open, -> { with_status(OPEN_STATUSES) }
- scope :not_resolved, -> { where.not(status: STATUSES[:resolved]) }
+ scope :open, -> { with_status(open_statuses) }
+ scope :not_resolved, -> { without_status(:resolved) }
scope :with_prometheus_alert, -> { includes(:prometheus_alert) }
scope :order_start_time, -> (sort_order) { order(started_at: sort_order) }
@@ -142,13 +136,33 @@ module AlertManagement
# Ascending sort order sorts statuses: Ignored > Resolved > Acknowledged > Triggered
# Descending sort order sorts statuses: Triggered > Acknowledged > Resolved > Ignored
# https://gitlab.com/gitlab-org/gitlab/-/issues/221242#what-is-the-expected-correct-behavior
- scope :order_status, -> (sort_order) { order(status: sort_order == :asc ? :desc : :asc) }
+ scope :order_status, -> (sort_order) { order(status: sort_order == :asc ? :desc : :asc) }
- scope :counts_by_status, -> { group(:status).count }
scope :counts_by_project_id, -> { group(:project_id).count }
alias_method :state, :status_name
+ def self.state_machine_statuses
+ @state_machine_statuses ||= state_machines[:status].states.to_h { |s| [s.name, s.value] }
+ end
+ private_class_method :state_machine_statuses
+
+ def self.status_value(name)
+ state_machine_statuses[name]
+ end
+
+ def self.status_name(raw_status)
+ state_machine_statuses.key(raw_status)
+ end
+
+ def self.counts_by_status
+ group(:status).count.transform_keys { |k| status_name(k) }
+ end
+
+ def self.status_names
+ @status_names ||= state_machine_statuses.keys
+ end
+
def self.sort_by_attribute(method)
case method.to_s
when 'started_at_asc' then order_start_time(:asc)
@@ -190,8 +204,25 @@ module AlertManagement
reference.to_i > 0 && reference.to_i <= Gitlab::Database::MAX_INT_VALUE
end
+ def self.open_statuses
+ [:triggered, :acknowledged]
+ end
+
+ def self.open_status?(status)
+ open_statuses.include?(status)
+ end
+
+ def status_event_for(status)
+ self.class.state_machines[:status].events.transitions_for(self, to: status.to_s.to_sym).first&.event
+ end
+
+ def change_status_to(new_status)
+ event = status_event_for(new_status)
+ event && fire_status_event(event)
+ end
+
def prometheus?
- monitoring_tool == Gitlab::AlertManagement::AlertParams::MONITORING_TOOLS[:prometheus]
+ monitoring_tool == Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus]
end
def register_new_event!
@@ -224,10 +255,11 @@ module AlertManagement
Gitlab::DataBuilder::Alert.build(self)
end
- def hosts_length
+ def hosts_format
return unless hosts
errors.add(:hosts, "hosts array is over #{HOSTS_MAX_LENGTH} chars") if hosts.join.length > HOSTS_MAX_LENGTH
+ errors.add(:hosts, "hosts array cannot be nested") if hosts.flatten != hosts
end
end
end
diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb
new file mode 100644
index 00000000000..7f954e1d384
--- /dev/null
+++ b/app/models/alert_management/http_integration.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class HttpIntegration < ApplicationRecord
+ belongs_to :project, inverse_of: :alert_management_http_integrations
+
+ attr_encrypted :token,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm'
+
+ validates :project, presence: true
+ validates :active, inclusion: { in: [true, false] }
+
+ validates :token, presence: true
+ validates :name, presence: true, length: { maximum: 255 }
+ validates :endpoint_identifier, presence: true, length: { maximum: 255 }
+ validates :endpoint_identifier, uniqueness: { scope: [:project_id, :active] }, if: :active?
+
+ before_validation :prevent_token_assignment
+ before_validation :ensure_token
+
+ private
+
+ def prevent_token_assignment
+ if token.present? && token_changed?
+ self.token = nil
+ self.encrypted_token = encrypted_token_was
+ self.encrypted_token_iv = encrypted_token_iv_was
+ end
+ end
+
+ def ensure_token
+ self.token = generate_token if token.blank?
+ end
+
+ def generate_token
+ SecureRandom.hex
+ end
+ end
+end
diff --git a/app/models/analytics/instance_statistics/measurement.rb b/app/models/analytics/instance_statistics/measurement.rb
index eaaf9e999b3..76cc1111e90 100644
--- a/app/models/analytics/instance_statistics/measurement.rb
+++ b/app/models/analytics/instance_statistics/measurement.rb
@@ -3,13 +3,19 @@
module Analytics
module InstanceStatistics
class Measurement < ApplicationRecord
+ EXPERIMENTAL_IDENTIFIERS = %i[pipelines_succeeded pipelines_failed pipelines_canceled pipelines_skipped].freeze
+
enum identifier: {
projects: 1,
users: 2,
issues: 3,
merge_requests: 4,
groups: 5,
- pipelines: 6
+ pipelines: 6,
+ pipelines_succeeded: 7,
+ pipelines_failed: 8,
+ pipelines_canceled: 9,
+ pipelines_skipped: 10
}
IDENTIFIER_QUERY_MAPPING = {
@@ -18,7 +24,11 @@ module Analytics
identifiers[:issues] => -> { Issue },
identifiers[:merge_requests] => -> { MergeRequest },
identifiers[:groups] => -> { Group },
- identifiers[:pipelines] => -> { Ci::Pipeline }
+ identifiers[:pipelines] => -> { Ci::Pipeline },
+ identifiers[:pipelines_succeeded] => -> { Ci::Pipeline.success },
+ identifiers[:pipelines_failed] => -> { Ci::Pipeline.failed },
+ identifiers[:pipelines_canceled] => -> { Ci::Pipeline.canceled },
+ identifiers[:pipelines_skipped] => -> { Ci::Pipeline.skipped }
}.freeze
validates :recorded_at, :identifier, :count, presence: true
@@ -26,6 +36,14 @@ module Analytics
scope :order_by_latest, -> { order(recorded_at: :desc) }
scope :with_identifier, -> (identifier) { where(identifier: identifier) }
+
+ def self.measurement_identifier_values
+ if Feature.enabled?(:store_ci_pipeline_counts_by_status, default_enabled: true)
+ identifiers.values
+ else
+ identifiers.values - EXPERIMENTAL_IDENTIFIERS.map { |identifier| identifiers[identifier] }
+ end
+ end
end
end
end
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 6ffb9b7642a..3542bb90dc0 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -52,6 +52,16 @@ class ApplicationRecord < ActiveRecord::Base
end
end
+ # Start a new transaction with a shorter-than-usual statement timeout. This is
+ # currently one third of the default 15-second timeout
+ def self.with_fast_statement_timeout
+ transaction(requires_new: true) do
+ connection.exec_query("SET LOCAL statement_timeout = 5000")
+
+ yield
+ end
+ end
+
def self.safe_find_or_create_by(*args, &block)
safe_ensure_unique(retries: 1) do
find_or_create_by(*args, &block)
@@ -61,4 +71,8 @@ class ApplicationRecord < ActiveRecord::Base
def self.underscore
Gitlab::SafeRequestStore.fetch("model:#{self}:underscore") { self.to_s.underscore }
end
+
+ def self.where_exists(query)
+ where('EXISTS (?)', query.select(1))
+ end
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index e9a3dcf39df..d034630a085 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -11,6 +11,7 @@ class ApplicationSetting < ApplicationRecord
ignore_column :instance_statistics_visibility_private, remove_with: '13.6', remove_after: '2020-10-22'
ignore_column :snowplow_iglu_registry_url, remove_with: '13.6', remove_after: '2020-11-22'
+ INSTANCE_REVIEW_MIN_USERS = 50
GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \
'Admin Area > Settings > Metrics and profiling > Metrics - Grafana'
@@ -91,11 +92,16 @@ class ApplicationSetting < ApplicationRecord
addressable_url: true,
if: :help_page_support_url_column_exists?
+ validates :help_page_documentation_base_url,
+ length: { maximum: 255, message: _("is too long (maximum is %{count} characters)") },
+ allow_blank: true,
+ addressable_url: true
+
validates :after_sign_out_path,
allow_blank: true,
addressable_url: true
- validates :admin_notification_email,
+ validates :abuse_notification_email,
devise_email: true,
allow_blank: true
@@ -432,6 +438,14 @@ class ApplicationSetting < ApplicationRecord
!!(sourcegraph_url =~ /\Ahttps:\/\/(www\.)?sourcegraph\.com/)
end
+ def instance_review_permitted?
+ users_count = Rails.cache.fetch('limited_users_count', expires_in: 1.day) do
+ ::User.limit(INSTANCE_REVIEW_MIN_USERS + 1).count(:all)
+ end
+
+ users_count >= INSTANCE_REVIEW_MIN_USERS
+ end
+
def self.create_from_defaults
check_schema!
diff --git a/app/models/application_setting/term.rb b/app/models/application_setting/term.rb
index 723540c9b91..bab036f5697 100644
--- a/app/models/application_setting/term.rb
+++ b/app/models/application_setting/term.rb
@@ -14,6 +14,8 @@ class ApplicationSetting
end
def accepted_by_user?(user)
+ return true if user.project_bot?
+
user.accepted_term_id == id ||
term_agreements.accepted.where(user: user).exists?
end
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 7a869d16a31..8a7bd5a7ad9 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -82,6 +82,7 @@ module ApplicationSettingImplementation
group_import_limit: 6,
help_page_hide_commercial_content: false,
help_page_text: nil,
+ help_page_documentation_base_url: nil,
hide_third_party_offers: false,
housekeeping_bitmaps_enabled: true,
housekeeping_enabled: true,
@@ -119,6 +120,7 @@ module ApplicationSettingImplementation
repository_checks_enabled: true,
repository_storages_weighted: { default: 100 },
repository_storages: ['default'],
+ require_admin_approval_after_user_signup: false,
require_two_factor_authentication: false,
restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'],
rsa_key_restriction: 0,
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index f46803be057..34f03e769a0 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -4,8 +4,15 @@ class AuditEvent < ApplicationRecord
include CreatedAtFilterable
include IgnorableColumns
include BulkInsertSafe
+ include EachBatch
- PARALLEL_PERSISTENCE_COLUMNS = [:author_name, :entity_path, :target_details, :target_type].freeze
+ PARALLEL_PERSISTENCE_COLUMNS = [
+ :author_name,
+ :entity_path,
+ :target_details,
+ :target_type,
+ :target_id
+ ].freeze
ignore_column :type, remove_with: '13.6', remove_after: '2020-11-22'
@@ -16,6 +23,7 @@ class AuditEvent < ApplicationRecord
validates :author_id, presence: true
validates :entity_id, presence: true
validates :entity_type, presence: true
+ validates :ip_address, ip_address: true
scope :by_entity_type, -> (entity_type) { where(entity_type: entity_type) }
scope :by_entity_id, -> (entity_id) { where(entity_id: entity_id) }
@@ -47,7 +55,9 @@ class AuditEvent < ApplicationRecord
end
def initialize_details
- self.details = {} if details.nil?
+ return unless self.has_attribute?(:details)
+
+ self.details = {} if details&.nil?
end
def author_name
@@ -59,8 +69,8 @@ class AuditEvent < ApplicationRecord
end
def lazy_author
- BatchLoader.for(author_id).batch(default_value: default_author_value) do |author_ids, loader|
- User.where(id: author_ids).find_each do |user|
+ BatchLoader.for(author_id).batch(default_value: default_author_value, replace_methods: false) do |author_ids, loader|
+ User.select(:id, :name, :username).where(id: author_ids).find_each do |user|
loader.call(user.id, user)
end
end
diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb
index 1ac3c5fbd9c..ac6e08caf50 100644
--- a/app/models/authentication_event.rb
+++ b/app/models/authentication_event.rb
@@ -1,12 +1,22 @@
# frozen_string_literal: true
class AuthenticationEvent < ApplicationRecord
+ include UsageStatistics
+
belongs_to :user, optional: true
validates :provider, :user_name, :result, presence: true
+ validates :ip_address, ip_address: true
enum result: {
failed: 0,
success: 1
}
+
+ scope :for_provider, ->(provider) { where(provider: provider) }
+ scope :ldap, -> { where('provider LIKE ?', 'ldap%')}
+
+ def self.providers
+ distinct.pluck(:provider)
+ end
end
diff --git a/app/models/blob_viewer/balsamiq.rb b/app/models/blob_viewer/balsamiq.rb
index 1af6c5474d7..6ab73730222 100644
--- a/app/models/blob_viewer/balsamiq.rb
+++ b/app/models/blob_viewer/balsamiq.rb
@@ -8,7 +8,7 @@ module BlobViewer
self.partial_name = 'balsamiq'
self.extensions = %w(bmpr)
self.binary = true
- self.switcher_icon = 'file-image-o'
+ self.switcher_icon = 'doc-image'
self.switcher_title = 'preview'
end
end
diff --git a/app/models/blob_viewer/markup.rb b/app/models/blob_viewer/markup.rb
index f525180048e..37a8e01d0f1 100644
--- a/app/models/blob_viewer/markup.rb
+++ b/app/models/blob_viewer/markup.rb
@@ -9,5 +9,15 @@ module BlobViewer
self.extensions = Gitlab::MarkupHelper::EXTENSIONS
self.file_types = %i(readme)
self.binary = false
+
+ def banzai_render_context
+ {}.tap do |h|
+ h[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup)
+
+ if Feature.enabled?(:cached_markdown_blob, blob.project, default_enabled: true)
+ h[:cache_key] = ['blob', blob.id, 'commit', blob.commit_id]
+ end
+ end
+ end
end
end
diff --git a/app/models/blob_viewer/pdf.rb b/app/models/blob_viewer/pdf.rb
index 2cf7752585c..e3542b91d5c 100644
--- a/app/models/blob_viewer/pdf.rb
+++ b/app/models/blob_viewer/pdf.rb
@@ -8,7 +8,7 @@ module BlobViewer
self.partial_name = 'pdf'
self.extensions = %w(pdf)
self.binary = true
- self.switcher_icon = 'file-pdf-o'
+ self.switcher_icon = 'document'
self.switcher_title = 'PDF'
end
end
diff --git a/app/models/blob_viewer/sketch.rb b/app/models/blob_viewer/sketch.rb
index 659ab11f30b..90bc9be29f4 100644
--- a/app/models/blob_viewer/sketch.rb
+++ b/app/models/blob_viewer/sketch.rb
@@ -8,7 +8,7 @@ module BlobViewer
self.partial_name = 'sketch'
self.extensions = %w(sketch)
self.binary = true
- self.switcher_icon = 'file-image-o'
+ self.switcher_icon = 'doc-image'
self.switcher_title = 'preview'
end
end
diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb
new file mode 100644
index 00000000000..cabff86a9f9
--- /dev/null
+++ b/app/models/bulk_import.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class BulkImport < ApplicationRecord
+ belongs_to :user, optional: false
+
+ has_one :configuration, class_name: 'BulkImports::Configuration'
+ has_many :entities, class_name: 'BulkImports::Entity'
+
+ validates :source_type, :status, presence: true
+
+ enum source_type: { gitlab: 0 }
+
+ state_machine :status, initial: :created do
+ state :created, value: 0
+ end
+end
diff --git a/app/models/bulk_imports/configuration.rb b/app/models/bulk_imports/configuration.rb
new file mode 100644
index 00000000000..8c3aff6f749
--- /dev/null
+++ b/app/models/bulk_imports/configuration.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class BulkImports::Configuration < ApplicationRecord
+ self.table_name = 'bulk_import_configurations'
+
+ belongs_to :bulk_import, inverse_of: :configuration, optional: false
+
+ validates :url, :access_token, length: { maximum: 255 }, presence: true
+ validates :url, public_url: { schemes: %w[http https], enforce_sanitization: true, ascii_only: true },
+ allow_nil: true
+
+ attr_encrypted :url,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ mode: :per_attribute_iv,
+ algorithm: 'aes-256-gcm'
+ attr_encrypted :access_token,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ mode: :per_attribute_iv,
+ algorithm: 'aes-256-gcm'
+end
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
new file mode 100644
index 00000000000..2d0bba7bccc
--- /dev/null
+++ b/app/models/bulk_imports/entity.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+class BulkImports::Entity < ApplicationRecord
+ self.table_name = 'bulk_import_entities'
+
+ belongs_to :bulk_import, optional: false
+ belongs_to :parent, class_name: 'BulkImports::Entity', optional: true
+
+ belongs_to :project, optional: true
+ belongs_to :group, foreign_key: :namespace_id, optional: true
+
+ validates :project, absence: true, if: :group
+ validates :group, absence: true, if: :project
+ validates :source_type, :source_full_path, :destination_name,
+ :destination_namespace, presence: true
+
+ validate :validate_parent_is_a_group, if: :parent
+ validate :validate_imported_entity_type
+
+ enum source_type: { group_entity: 0, project_entity: 1 }
+
+ state_machine :status, initial: :created do
+ state :created, value: 0
+ end
+
+ private
+
+ def validate_parent_is_a_group
+ unless parent.group_entity?
+ errors.add(:parent, s_('BulkImport|must be a group'))
+ end
+ end
+
+ def validate_imported_entity_type
+ if group.present? && project_entity?
+ errors.add(:group, s_('BulkImport|expected an associated Project but has an associated Group'))
+ end
+
+ if project.present? && group_entity?
+ errors.add(:project, s_('BulkImport|expected an associated Group but has an associated Project'))
+ end
+ end
+end
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 1697067f633..2e725e0baff 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -27,7 +27,7 @@ module Ci
# rubocop:enable Cop/ActiveRecordSerialize
state_machine :status do
- after_transition created: :pending do |bridge|
+ after_transition [:created, :manual] => :pending do |bridge|
next unless bridge.downstream_project
bridge.run_after_commit do
@@ -46,6 +46,10 @@ module Ci
event :scheduled do
transition all => :scheduled
end
+
+ event :actionize do
+ transition created: :manual
+ end
end
def self.retry(bridge, current_user)
@@ -126,9 +130,27 @@ module Ci
false
end
+ def playable?
+ return false unless ::Gitlab::Ci::Features.manual_bridges_enabled?(project)
+
+ action? && !archived? && manual?
+ end
+
def action?
- false
+ return false unless ::Gitlab::Ci::Features.manual_bridges_enabled?(project)
+
+ %w[manual].include?(self.when)
+ end
+
+ # rubocop: disable CodeReuse/ServiceClass
+ # We don't need it but we are taking `job_variables_attributes` parameter
+ # to make it consistent with `Ci::Build#play` method.
+ def play(current_user, job_variables_attributes = nil)
+ Ci::PlayBridgeService
+ .new(project, current_user)
+ .execute(self)
end
+ # rubocop: enable CodeReuse/ServiceClass
def artifacts?
false
@@ -185,6 +207,10 @@ module Ci
[]
end
+ def target_revision_ref
+ downstream_pipeline_params.dig(:target_revision, :ref)
+ end
+
private
def cross_project_params
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 99580a52e96..9ff70ece947 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -327,6 +327,8 @@ module Ci
after_transition any => [:success, :failed, :canceled] do |build|
build.run_after_commit do
+ build.run_status_commit_hooks!
+
BuildFinishedWorker.perform_async(id)
end
end
@@ -524,7 +526,6 @@ module Ci
.concat(job_jwt_variables)
.concat(scoped_variables)
.concat(job_variables)
- .concat(environment_changed_page_variables)
.concat(persisted_environment_variables)
.to_runner_variables
end
@@ -561,15 +562,6 @@ module Ci
end
end
- def environment_changed_page_variables
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- break variables unless environment_status && Feature.enabled?(:modifed_path_ci_variables, project)
-
- variables.append(key: 'CI_MERGE_REQUEST_CHANGED_PAGE_PATHS', value: environment_status.changed_paths.join(','))
- variables.append(key: 'CI_MERGE_REQUEST_CHANGED_PAGE_URLS', value: environment_status.changed_urls.join(','))
- end
- end
-
def deploy_token_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
break variables unless gitlab_deploy_token
@@ -780,6 +772,11 @@ module Ci
end
end
+ def has_expired_locked_archive_artifacts?
+ locked_artifacts? &&
+ artifacts_expire_at.present? && artifacts_expire_at < Time.current
+ end
+
def has_expiring_archive_artifacts?
has_expiring_artifacts? && job_artifacts_archive.present?
end
@@ -901,7 +898,11 @@ module Ci
def collect_test_reports!(test_reports)
test_reports.get_suite(group_name).tap do |test_suite|
each_report(Ci::JobArtifact::TEST_REPORT_FILE_TYPES) do |file_type, blob|
- Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, test_suite, job: self)
+ Gitlab::Ci::Parsers.fabricate!(file_type).parse!(
+ blob,
+ test_suite,
+ job: self
+ )
end
end
end
@@ -963,8 +964,30 @@ module Ci
pending_state.try(:delete)
end
+ def run_on_status_commit(&block)
+ status_commit_hooks.push(block)
+ end
+
+ def max_test_cases_per_report
+ # NOTE: This is temporary and will be replaced later by a value
+ # that would come from an actual application limit.
+ ::Gitlab.com? ? 500_000 : 0
+ end
+
+ protected
+
+ def run_status_commit_hooks!
+ status_commit_hooks.reverse_each do |hook|
+ instance_eval(&hook)
+ end
+ end
+
private
+ def status_commit_hooks
+ @status_commit_hooks ||= []
+ end
+
def auto_retry
strong_memoize(:auto_retry) do
Gitlab::Ci::Build::AutoRetry.new(self)
diff --git a/app/models/ci/build_pending_state.rb b/app/models/ci/build_pending_state.rb
index 45f323adec2..299c67f441d 100644
--- a/app/models/ci/build_pending_state.rb
+++ b/app/models/ci/build_pending_state.rb
@@ -9,4 +9,10 @@ class Ci::BuildPendingState < ApplicationRecord
enum failure_reason: CommitStatus.failure_reasons
validates :build, presence: true
+
+ def crc32
+ trace_checksum.try do |checksum|
+ checksum.to_s.split('crc32:').last.to_i(16)
+ end
+ end
end
diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb
index 444742062d9..6926ccd9438 100644
--- a/app/models/ci/build_trace_chunk.rb
+++ b/app/models/ci/build_trace_chunk.rb
@@ -3,9 +3,11 @@
module Ci
class BuildTraceChunk < ApplicationRecord
extend ::Gitlab::Ci::Model
+ include ::Comparable
include ::FastDestroyAll
include ::Checksummable
include ::Gitlab::ExclusiveLeaseHelpers
+ include ::Gitlab::OptimisticLocking
belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id
@@ -29,6 +31,7 @@ module Ci
}
scope :live, -> { redis }
+ scope :persisted, -> { not_redis.order(:chunk_index) }
class << self
def all_stores
@@ -63,12 +66,24 @@ module Ci
get_store_class(store).delete_keys(value)
end
end
+
+ ##
+ # Sometimes we do not want to read raw data. This method makes it easier
+ # to find attributes that are just metadata excluding raw data.
+ #
+ def metadata_attributes
+ attribute_names - %w[raw_data]
+ end
end
def data
@data ||= get_data.to_s
end
+ def crc32
+ checksum.to_i
+ end
+
def truncate(offset = 0)
raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0
return if offset == size # Skip the following process as it doesn't affect anything
@@ -102,22 +117,47 @@ module Ci
(start_offset...end_offset)
end
- def persist_data!
- in_lock(*lock_params) { unsafe_persist_data! }
- end
-
def schedule_to_persist!
- return if persisted?
+ return if flushed?
Ci::BuildTraceChunkFlushWorker.perform_async(id)
end
- def persisted?
- !redis?
- end
+ ##
+ # It is possible that we run into two concurrent migrations. It might
+ # happen that a chunk gets migrated after being loaded by another worker
+ # but before the worker acquires a lock to perform the migration.
+ #
+ # We are using Redis locking to ensure that we perform this operation
+ # inside an exclusive lock, but this does not prevent us from running into
+ # race conditions related to updating a model representation in the
+ # database. Optimistic locking is another mechanism that help here.
+ #
+ # We are using optimistic locking combined with Redis locking to ensure
+ # that a chunk gets migrated properly.
+ #
+ # We are catching an exception related to an exclusive lock not being
+ # acquired because it is creating a lot of noise, and is a result of
+ # duplicated workers running in parallel for the same build trace chunk.
+ #
+ def persist_data!
+ in_lock(*lock_params) do # exclusive Redis lock is acquired first
+ raise FailedToPersistDataError, 'Modifed build trace chunk detected' if has_changes_to_save?
- def live?
- redis?
+ self.reset.then do |chunk| # we ensure having latest lock_version
+ chunk.unsafe_persist_data! # we migrate the data and update data store
+ end
+ end
+ rescue FailedToObtainLockError
+ metrics.increment_trace_operation(operation: :stalled)
+ rescue ActiveRecord::StaleObjectError
+ raise FailedToPersistDataError, <<~MSG
+ Data migration race condition detected
+
+ store: #{data_store}
+ build: #{build.id}
+ index: #{chunk_index}
+ MSG
end
##
@@ -126,11 +166,28 @@ module Ci
# no chunk with higher index in the database.
#
def final?
- build.pending_state.present? &&
- build.trace_chunks.maximum(:chunk_index).to_i == chunk_index
+ build.pending_state.present? && chunks_max_index == chunk_index
end
- private
+ def flushed?
+ !redis?
+ end
+
+ def migrated?
+ flushed?
+ end
+
+ def live?
+ redis?
+ end
+
+ def <=>(other)
+ return unless self.build_id == other.build_id
+
+ self.chunk_index <=> other.chunk_index
+ end
+
+ protected
def get_data
# Redis / database return UTF-8 encoded string by default
@@ -145,12 +202,19 @@ module Ci
current_size = current_data&.bytesize.to_i
unless current_size == CHUNK_SIZE || final?
- raise FailedToPersistDataError, 'Data is not fulfilled in a bucket'
+ raise FailedToPersistDataError, <<~MSG
+ data is not fulfilled in a bucket
+
+ size: #{current_size}
+ state: #{pending_state?}
+ max: #{chunks_max_index}
+ index: #{chunk_index}
+ MSG
end
self.raw_data = nil
self.data_store = new_store
- self.checksum = crc32(current_data)
+ self.checksum = self.class.crc32(current_data)
##
# We need to so persist data then save a new store identifier before we
@@ -199,10 +263,20 @@ module Ci
size == CHUNK_SIZE
end
+ private
+
+ def pending_state?
+ build.pending_state.present?
+ end
+
def current_store
self.class.get_store_class(data_store)
end
+ def chunks_max_index
+ build.trace_chunks.maximum(:chunk_index).to_i
+ end
+
def lock_params
["trace_write:#{build_id}:chunks:#{chunk_index}",
{ ttl: WRITE_LOCK_TTL,
diff --git a/app/models/ci/build_trace_chunks/database.rb b/app/models/ci/build_trace_chunks/database.rb
index ea8072099c6..7448afba4c2 100644
--- a/app/models/ci/build_trace_chunks/database.rb
+++ b/app/models/ci/build_trace_chunks/database.rb
@@ -17,6 +17,8 @@ module Ci
def data(model)
model.raw_data
+ rescue ActiveModel::MissingAttributeError
+ model.reset.raw_data
end
def set_data(model, new_data)
diff --git a/app/models/ci/deleted_object.rb b/app/models/ci/deleted_object.rb
new file mode 100644
index 00000000000..e74946eda16
--- /dev/null
+++ b/app/models/ci/deleted_object.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Ci
+ class DeletedObject < ApplicationRecord
+ extend Gitlab::Ci::Model
+
+ mount_uploader :file, DeletedObjectUploader
+
+ scope :ready_for_destruction, ->(limit) do
+ where('pick_up_at < ?', Time.current).limit(limit)
+ end
+
+ scope :lock_for_destruction, ->(limit) do
+ ready_for_destruction(limit)
+ .select(:id)
+ .order(:pick_up_at)
+ .lock('FOR UPDATE SKIP LOCKED')
+ end
+
+ def self.bulk_import(artifacts)
+ attributes = artifacts.each.with_object([]) do |artifact, accumulator|
+ record = artifact.to_deleted_object_attrs
+ accumulator << record if record[:store_dir] && record[:file]
+ end
+
+ self.insert_all(attributes) if attributes.any?
+ end
+
+ def delete_file_from_storage
+ file.remove!
+ true
+ rescue => exception
+ Gitlab::ErrorTracking.track_exception(exception)
+ false
+ end
+ end
+end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 8bbb92e319f..02e17afdab0 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -46,7 +46,8 @@ module Ci
terraform: 'tfplan.json',
cluster_applications: 'gl-cluster-applications.json',
requirements: 'requirements.json',
- coverage_fuzzing: 'gl-coverage-fuzzing.json'
+ coverage_fuzzing: 'gl-coverage-fuzzing.json',
+ api_fuzzing: 'gl-api-fuzzing-report.json'
}.freeze
INTERNAL_TYPES = {
@@ -65,11 +66,8 @@ module Ci
cluster_applications: :gzip,
lsif: :zip,
- # All these file formats use `raw` as we need to store them uncompressed
- # for Frontend to fetch the files and do analysis
- # When they will be only used by backend, they can be `gzipped`.
- accessibility: :raw,
- codequality: :raw,
+ # Security reports and license scanning reports are raw artifacts
+ # because they used to be fetched by the frontend, but this is not the case anymore.
sast: :raw,
secret_detection: :raw,
dependency_scanning: :raw,
@@ -77,16 +75,24 @@ module Ci
dast: :raw,
license_management: :raw,
license_scanning: :raw,
+
+ # All these file formats use `raw` as we need to store them uncompressed
+ # for Frontend to fetch the files and do analysis
+ # When they will be only used by backend, they can be `gzipped`.
+ accessibility: :raw,
+ codequality: :raw,
performance: :raw,
browser_performance: :raw,
load_performance: :raw,
terraform: :raw,
requirements: :raw,
- coverage_fuzzing: :raw
+ coverage_fuzzing: :raw,
+ api_fuzzing: :raw
}.freeze
DOWNLOADABLE_TYPES = %w[
accessibility
+ api_fuzzing
archive
cobertura
codequality
@@ -194,7 +200,8 @@ module Ci
requirements: 22, ## EE-specific
coverage_fuzzing: 23, ## EE-specific
browser_performance: 24, ## EE-specific
- load_performance: 25 ## EE-specific
+ load_performance: 25, ## EE-specific
+ api_fuzzing: 26 ## EE-specific
}
# `file_location` indicates where actual files are stored.
@@ -283,6 +290,15 @@ module Ci
max_size&.megabytes.to_i
end
+ def to_deleted_object_attrs
+ {
+ file_store: file_store,
+ store_dir: file.store_dir.to_s,
+ file: file_identifier,
+ pick_up_at: expire_at || Time.current
+ }
+ end
+
private
def set_size
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 47eba685afe..684b6387ab1 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -27,6 +27,13 @@ module Ci
sha_attribute :source_sha
sha_attribute :target_sha
+ # Ci::CreatePipelineService returns Ci::Pipeline so this is the only place
+ # where we can pass additional information from the service. This accessor
+ # is used for storing the processed CI YAML contents for linting purposes.
+ # There is an open issue to address this:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/259010
+ attr_accessor :merged_yaml
+
belongs_to :project, inverse_of: :all_pipelines
belongs_to :user
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
@@ -42,6 +49,7 @@ module Ci
has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :latest_statuses_ordered_by_stage, -> { latest.order(:stage_idx, :stage) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :latest_statuses, -> { latest }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline
has_many :bridges, class_name: 'Ci::Bridge', foreign_key: :commit_id, inverse_of: :pipeline
has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline
@@ -577,11 +585,11 @@ module Ci
end
def retried
- @retried ||= (statuses.order(id: :desc) - statuses.latest)
+ @retried ||= (statuses.order(id: :desc) - latest_statuses)
end
def coverage
- coverage_array = statuses.latest.map(&:coverage).compact
+ coverage_array = latest_statuses.map(&:coverage).compact
if coverage_array.size >= 1
'%.2f' % (coverage_array.reduce(:+) / coverage_array.size)
end
@@ -821,16 +829,28 @@ module Ci
end
def same_family_pipeline_ids
- if ::Gitlab::Ci::Features.child_of_child_pipeline_enabled?(project)
- ::Gitlab::Ci::PipelineObjectHierarchy.new(
- base_and_ancestors(same_project: true), options: { same_project: true }
- ).base_and_descendants.select(:id)
- else
- # If pipeline is a child of another pipeline, include the parent
- # and the siblings, otherwise return only itself and children.
- parent = parent_pipeline || self
- [parent.id] + parent.child_pipelines.pluck(:id)
- end
+ ::Gitlab::Ci::PipelineObjectHierarchy.new(
+ base_and_ancestors(same_project: true), options: { same_project: true }
+ ).base_and_descendants.select(:id)
+ end
+
+ def build_with_artifacts_in_self_and_descendants(name)
+ builds_in_self_and_descendants
+ .ordered_by_pipeline # find job in hierarchical order
+ .with_downloadable_artifacts
+ .find_by_name(name)
+ end
+
+ def builds_in_self_and_descendants
+ Ci::Build.latest.where(pipeline: self_and_descendants)
+ end
+
+ # Without using `unscoped`, caller scope is also included into the query.
+ # Using `unscoped` here will be redundant after Rails 6.1
+ def self_and_descendants
+ ::Gitlab::Ci::PipelineObjectHierarchy
+ .new(self.class.unscoped.where(id: id), options: { same_project: true })
+ .base_and_descendants
end
def bridge_triggered?
@@ -875,7 +895,7 @@ module Ci
end
def builds_with_coverage
- builds.with_coverage
+ builds.latest.with_coverage
end
def has_reports?(reports_scope)
diff --git a/app/models/ci_platform_metric.rb b/app/models/ci_platform_metric.rb
index 5e6e3eddce9..ac4ab391bbf 100644
--- a/app/models/ci_platform_metric.rb
+++ b/app/models/ci_platform_metric.rb
@@ -14,7 +14,7 @@ class CiPlatformMetric < ApplicationRecord
numericality: { only_integer: true, greater_than: 0 }
CI_VARIABLE_KEY = 'AUTO_DEVOPS_PLATFORM_TARGET'
- ALLOWED_TARGETS = %w[ECS FARGATE].freeze
+ ALLOWED_TARGETS = %w[ECS FARGATE EC2].freeze
def self.insert_auto_devops_platform_targets!
recorded_at = Time.zone.now
diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb
index 874415e7bf4..5feb3b0a1e6 100644
--- a/app/models/clusters/agent.rb
+++ b/app/models/clusters/agent.rb
@@ -8,6 +8,7 @@ module Clusters
has_many :agent_tokens, class_name: 'Clusters::AgentToken'
+ scope :ordered_by_name, -> { order(:name) }
scope :with_name, -> (name) { where(name: name) }
validates :name,
diff --git a/app/models/clusters/applications/fluentd.rb b/app/models/clusters/applications/fluentd.rb
index 3fd6e870edc..c608d37be77 100644
--- a/app/models/clusters/applications/fluentd.rb
+++ b/app/models/clusters/applications/fluentd.rb
@@ -22,7 +22,11 @@ module Clusters
validate :has_at_least_one_log_enabled?
def chart
- 'stable/fluentd'
+ 'fluentd/fluentd'
+ end
+
+ def repository
+ 'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive'
end
def install_command
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index 1d08f38a2f1..d5412714858 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -46,7 +46,11 @@ module Clusters
end
def chart
- 'stable/nginx-ingress'
+ "#{name}/nginx-ingress"
+ end
+
+ def repository
+ 'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive'
end
def values
@@ -60,6 +64,7 @@ module Clusters
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
+ repository: repository,
version: VERSION,
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index dd6a4144608..7679296699f 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -51,7 +51,11 @@ module Clusters
end
def chart
- 'stable/prometheus'
+ "#{name}/prometheus"
+ end
+
+ def repository
+ 'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive'
end
def service_name
@@ -65,6 +69,7 @@ module Clusters
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
+ repository: repository,
version: VERSION,
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
@@ -76,6 +81,7 @@ module Clusters
def patch_command(values)
::Gitlab::Kubernetes::Helm::PatchCommand.new(
name: name,
+ repository: repository,
version: version,
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index f0b3c11ba1d..d07ea7b71dc 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Runner < ApplicationRecord
- VERSION = '0.20.2'
+ VERSION = '0.21.1'
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index 7af78960e35..b85a902d58b 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -11,6 +11,7 @@ module Clusters
RESERVED_NAMESPACES = %w(gitlab-managed-apps).freeze
self.table_name = 'cluster_platforms_kubernetes'
+ self.reactive_cache_work_type = :external_dependency
belongs_to :cluster, inverse_of: :platform_kubernetes, class_name: 'Clusters::Cluster'
@@ -101,7 +102,7 @@ module Clusters
def terminals(environment, data)
pods = filter_by_project_environment(data[:pods], environment.project.full_path_slug, environment.slug)
terminals = pods.flat_map { |pod| terminals_for_pod(api_url, environment.deployment_namespace, pod) }.compact
- terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) }
+ terminals.each { |terminal| add_terminal_auth(terminal, **terminal_auth) }
end
def kubeclient
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 5e0fceb23a4..83400c9e533 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -29,12 +29,6 @@ class Commit
delegate :repository, to: :container
delegate :project, to: :repository, allow_nil: true
- DIFF_SAFE_LINES = Gitlab::Git::DiffCollection::DEFAULT_LIMITS[:max_lines]
-
- # Commits above this size will not be rendered in HTML
- DIFF_HARD_LIMIT_FILES = 1000
- DIFF_HARD_LIMIT_LINES = 50000
-
MIN_SHA_LENGTH = Gitlab::Git::Commit::MIN_SHA_LENGTH
COMMIT_SHA_PATTERN = /\h{#{MIN_SHA_LENGTH},40}/.freeze
EXACT_COMMIT_SHA_PATTERN = /\A#{COMMIT_SHA_PATTERN}\z/.freeze
@@ -80,10 +74,30 @@ class Commit
sha[0..MIN_SHA_LENGTH]
end
- def max_diff_options
+ def diff_safe_lines(project: nil)
+ Gitlab::Git::DiffCollection.default_limits(project: project)[:max_lines]
+ end
+
+ def diff_hard_limit_files(project: nil)
+ if Feature.enabled?(:increased_diff_limits, project)
+ 2000
+ else
+ 1000
+ end
+ end
+
+ def diff_hard_limit_lines(project: nil)
+ if Feature.enabled?(:increased_diff_limits, project)
+ 75000
+ else
+ 50000
+ end
+ end
+
+ def max_diff_options(project: nil)
{
- max_files: DIFF_HARD_LIMIT_FILES,
- max_lines: DIFF_HARD_LIMIT_LINES
+ max_files: diff_hard_limit_files(project: project),
+ max_lines: diff_hard_limit_lines(project: project)
}
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 2f0596c93cc..4498e08d754 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -48,6 +48,7 @@ class CommitStatus < ApplicationRecord
scope :ordered_by_stage, -> { order(stage_idx: :asc) }
scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) }
scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) }
+ scope :ordered_by_pipeline, -> { order(pipeline_id: :asc) }
scope :before_stage, -> (index) { where('stage_idx < ?', index) }
scope :for_stage, -> (index) { where(stage_idx: index) }
scope :after_stage, -> (index) { where('stage_idx > ?', index) }
@@ -204,8 +205,13 @@ class CommitStatus < ApplicationRecord
# 'rspec:linux: 1/10' => 'rspec:linux'
common_name = name.to_s.gsub(%r{\d+[\s:\/\\]+\d+\s*}, '')
- # 'rspec:linux: [aws, max memory]' => 'rspec:linux'
- common_name.gsub!(%r{: \[.*, .*\]\s*\z}, '')
+ if ::Gitlab::Ci::Features.one_dimensional_matrix_enabled?
+ # 'rspec:linux: [aws, max memory]' => 'rspec:linux', 'rspec:linux: [aws]' => 'rspec:linux'
+ common_name.gsub!(%r{: \[.*\]\s*\z}, '')
+ else
+ # 'rspec:linux: [aws, max memory]' => 'rspec:linux', 'rspec:linux: [aws]' => 'rspec:linux: [aws]'
+ common_name.gsub!(%r{: \[.*, .*\]\s*\z}, '')
+ end
common_name.strip!
common_name
diff --git a/app/models/concerns/approvable_base.rb b/app/models/concerns/approvable_base.rb
index d07c4ec43ac..c2d94b50f8d 100644
--- a/app/models/concerns/approvable_base.rb
+++ b/app/models/concerns/approvable_base.rb
@@ -2,10 +2,34 @@
module ApprovableBase
extend ActiveSupport::Concern
+ include FromUnion
included do
has_many :approvals, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :approved_by_users, through: :approvals, source: :user
+
+ scope :without_approvals, -> { left_outer_joins(:approvals).where(approvals: { id: nil }) }
+ scope :with_approvals, -> { joins(:approvals) }
+ scope :approved_by_users_with_ids, -> (*user_ids) do
+ with_approvals
+ .merge(Approval.with_user)
+ .where(users: { id: user_ids })
+ .group(:id)
+ .having("COUNT(users.id) = ?", user_ids.size)
+ end
+ scope :approved_by_users_with_usernames, -> (*usernames) do
+ with_approvals
+ .merge(Approval.with_user)
+ .where(users: { username: usernames })
+ .group(:id)
+ .having("COUNT(users.id) = ?", usernames.size)
+ end
+ end
+
+ class_methods do
+ def select_from_union(relations)
+ where(id: from_union(relations))
+ end
end
def approved_by?(user)
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index 0dd55ab67b5..d342b526677 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -3,16 +3,11 @@
module Avatarable
extend ActiveSupport::Concern
- ALLOWED_IMAGE_SCALER_WIDTHS = [
- 400,
- 200,
- 64,
- 48,
- 40,
- 26,
- 20,
- 16
- ].freeze
+ USER_AVATAR_SIZES = [16, 20, 23, 24, 26, 32, 36, 38, 40, 48, 60, 64, 90, 96, 120, 160].freeze
+ PROJECT_AVATAR_SIZES = [15, 40, 48, 64, 88].freeze
+ GROUP_AVATAR_SIZES = [15, 37, 38, 39, 40, 64, 96].freeze
+
+ ALLOWED_IMAGE_SCALER_WIDTHS = (USER_AVATAR_SIZES | PROJECT_AVATAR_SIZES | GROUP_AVATAR_SIZES).freeze
included do
prepend ShadowMethods
diff --git a/app/models/concerns/checksummable.rb b/app/models/concerns/checksummable.rb
index d6d17bfc604..056abafd0ce 100644
--- a/app/models/concerns/checksummable.rb
+++ b/app/models/concerns/checksummable.rb
@@ -3,11 +3,11 @@
module Checksummable
extend ActiveSupport::Concern
- def crc32(data)
- Zlib.crc32(data)
- end
-
class_methods do
+ def crc32(data)
+ Zlib.crc32(data)
+ end
+
def hexdigest(path)
::Digest::SHA256.file(path).hexdigest
end
diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb
index a5c7393e8f7..b468415c4c7 100644
--- a/app/models/concerns/counter_attribute.rb
+++ b/app/models/concerns/counter_attribute.rb
@@ -20,6 +20,14 @@
# To increment the counter we can use the method:
# delayed_increment_counter(:commit_count, 3)
#
+# It is possible to register callbacks to be executed after increments have
+# been flushed to the database. Callbacks are not executed if there are no increments
+# to flush.
+#
+# counter_attribute_after_flush do |statistic|
+# Namespaces::ScheduleAggregationWorker.perform_async(statistic.namespace_id)
+# end
+#
module CounterAttribute
extend ActiveSupport::Concern
extend AfterCommitQueue
@@ -48,6 +56,15 @@ module CounterAttribute
def counter_attributes
@counter_attributes ||= Set.new
end
+
+ def after_flush_callbacks
+ @after_flush_callbacks ||= []
+ end
+
+ # perform registered callbacks after increments have been flushed to the database
+ def counter_attribute_after_flush(&callback)
+ after_flush_callbacks << callback
+ end
end
# This method must only be called by FlushCounterIncrementsWorker
@@ -75,6 +92,8 @@ module CounterAttribute
unsafe_update_counters(id, attribute => increment_value)
redis_state { |redis| redis.del(flushed_key) }
end
+
+ execute_after_flush_callbacks
end
end
@@ -108,13 +127,13 @@ module CounterAttribute
counter_key(attribute) + ':lock'
end
- private
-
def counter_attribute_enabled?(attribute)
Feature.enabled?(:efficient_counter_attribute, project) &&
self.class.counter_attributes.include?(attribute)
end
+ private
+
def steal_increments(increment_key, flushed_key)
redis_state do |redis|
redis.eval(LUA_STEAL_INCREMENT_SCRIPT, keys: [increment_key, flushed_key])
@@ -129,6 +148,12 @@ module CounterAttribute
self.class.update_counters(id, increments)
end
+ def execute_after_flush_callbacks
+ self.class.after_flush_callbacks.each do |callback|
+ callback.call(self)
+ end
+ end
+
def redis_state(&block)
Gitlab::Redis::SharedState.with(&block)
end
diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb
index d909b67d7ba..978a54bdee7 100644
--- a/app/models/concerns/has_repository.rb
+++ b/app/models/concerns/has_repository.rb
@@ -71,6 +71,10 @@ module HasRepository
raise NotImplementedError
end
+ def lfs_enabled?
+ false
+ end
+
def empty_repo?
repository.empty?
end
@@ -80,7 +84,11 @@ module HasRepository
end
def default_branch_from_preferences
- empty_repo? ? Gitlab::CurrentSettings.default_branch_name : nil
+ return unless empty_repo?
+
+ group_branch_default_name = group&.default_branch_name if respond_to?(:group)
+
+ group_branch_default_name || Gitlab::CurrentSettings.default_branch_name
end
def reload_default_branch
diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb
index 8a238dc736c..468387115e5 100644
--- a/app/models/concerns/has_user_type.rb
+++ b/app/models/concerns/has_user_type.rb
@@ -11,16 +11,18 @@ module HasUserType
service_user: 4,
ghost: 5,
project_bot: 6,
- migration_bot: 7
+ migration_bot: 7,
+ security_bot: 8
}.with_indifferent_access.freeze
- BOT_USER_TYPES = %w[alert_bot project_bot support_bot visual_review_bot migration_bot].freeze
+ BOT_USER_TYPES = %w[alert_bot project_bot support_bot visual_review_bot migration_bot security_bot].freeze
NON_INTERNAL_USER_TYPES = %w[human project_bot service_user].freeze
INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze
included do
scope :humans, -> { where(user_type: :human) }
scope :bots, -> { where(user_type: BOT_USER_TYPES) }
+ scope :without_bots, -> { humans.or(where.not(user_type: BOT_USER_TYPES)) }
scope :bots_without_project_bot, -> { where(user_type: BOT_USER_TYPES - ['project_bot']) }
scope :non_internal, -> { humans.or(where(user_type: NON_INTERNAL_USER_TYPES)) }
scope :without_ghosts, -> { humans.or(where.not(user_type: :ghost)) }
diff --git a/app/models/concerns/integration.rb b/app/models/concerns/integration.rb
index 34ff5bb1195..9d446841a9f 100644
--- a/app/models/concerns/integration.rb
+++ b/app/models/concerns/integration.rb
@@ -16,7 +16,7 @@ module Integration
Project.where(id: custom_integration_project_ids)
end
- def ids_without_integration(integration, limit)
+ def without_integration(integration)
services = Service
.select('1')
.where('services.project_id = projects.id')
@@ -26,8 +26,6 @@ module Integration
.where('NOT EXISTS (?)', services)
.where(pending_delete: false)
.where(archived: false)
- .limit(limit)
- .pluck(:id)
end
end
end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 888e1b384a2..7624a1a4e80 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -182,7 +182,7 @@ module Issuable
end
def supports_time_tracking?
- is_a?(TimeTrackable) && !incident?
+ is_a?(TimeTrackable)
end
def supports_severity?
@@ -203,15 +203,6 @@ module Issuable
issuable_severity&.severity || IssuableSeverity::DEFAULT
end
- def update_severity(severity)
- return unless incident?
-
- severity = severity.to_s.downcase
- severity = IssuableSeverity::DEFAULT unless IssuableSeverity.severities.key?(severity)
-
- (issuable_severity || build_issuable_severity(issue_id: id)).update(severity: severity)
- end
-
private
def description_max_length_for_new_records_is_valid
diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb
new file mode 100644
index 00000000000..6efb8103b7b
--- /dev/null
+++ b/app/models/concerns/issue_available_features.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+# Verifies features availability based on issue type.
+# This can be used, for example, for hiding UI elements or blocking specific
+# quick actions for particular issue types;
+module IssueAvailableFeatures
+ extend ActiveSupport::Concern
+
+ # EE only features are listed on EE::IssueAvailableFeatures
+ def available_features_for_issue_types
+ {}.with_indifferent_access
+ end
+
+ def issue_type_supports?(feature)
+ unless available_features_for_issue_types.has_key?(feature)
+ raise ArgumentError, 'invalid feature'
+ end
+
+ available_features_for_issue_types[feature].include?(issue_type)
+ end
+end
+
+IssueAvailableFeatures.prepend_if_ee('EE::IssueAvailableFeatures')
diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb
index 7b4485376d4..b10e8547e86 100644
--- a/app/models/concerns/mentionable.rb
+++ b/app/models/concerns/mentionable.rb
@@ -81,13 +81,6 @@ module Mentionable
end
def store_mentions!
- # if store_mentioned_users_to_db feature flag is not enabled then consider storing operation as succeeded
- # because we wrap this method in transaction with with_transaction_returning_status, and we need the status to be
- # successful if mentionable.save is successful.
- #
- # This line will get removed when we remove the feature flag.
- return true unless store_mentioned_users_to_db_enabled?
-
refs = all_references(self.author)
references = {}
@@ -253,15 +246,6 @@ module Mentionable
def model_user_mention
user_mentions.where(note_id: nil).first_or_initialize
end
-
- # We need this method to be checking that store_mentioned_users_to_db feature flag is enabled at the group level
- # and not the project level as epics are defined at group level and we want to have epics store user mentions as well
- # for the test period.
- # During the test period the flag should be enabled at the group level.
- def store_mentioned_users_to_db_enabled?
- return Feature.enabled?(:store_mentioned_users_to_db, self.project&.group, default_enabled: true) if self.respond_to?(:project)
- return Feature.enabled?(:store_mentioned_users_to_db, self.group, default_enabled: true) if self.respond_to?(:group)
- end
end
Mentionable.prepend_if_ee('EE::Mentionable')
diff --git a/app/models/concerns/presentable.rb b/app/models/concerns/presentable.rb
index 06c300c2e41..1f05abff2f4 100644
--- a/app/models/concerns/presentable.rb
+++ b/app/models/concerns/presentable.rb
@@ -5,13 +5,13 @@ module Presentable
class_methods do
def present(attributes)
- all.map { |klass_object| klass_object.present(attributes) }
+ all.map { |klass_object| klass_object.present(**attributes) }
end
end
def present(**attributes)
Gitlab::View::Presenter::Factory
- .new(self, attributes)
+ .new(self, **attributes)
.fabricate!
end
end
diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb
index 5f30fc0c36c..3470bdab5fb 100644
--- a/app/models/concerns/reactive_caching.rb
+++ b/app/models/concerns/reactive_caching.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# The usage of the ReactiveCaching module is documented here:
-# https://docs.gitlab.com/ee/development/utilities.html#reactivecaching
+# https://docs.gitlab.com/ee/development/reactive_caching.md
module ReactiveCaching
extend ActiveSupport::Concern
@@ -9,7 +9,7 @@ module ReactiveCaching
ExceededReactiveCacheLimit = Class.new(StandardError)
WORK_TYPE = {
- default: ReactiveCachingWorker,
+ no_dependency: ReactiveCachingWorker,
external_dependency: ExternalServiceReactiveCachingWorker
}.freeze
@@ -30,7 +30,6 @@ module ReactiveCaching
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 10.minutes
self.reactive_cache_hard_limit = nil # this value should be set in megabytes. E.g: 1.megabyte
- self.reactive_cache_work_type = :default
self.reactive_cache_worker_finder = ->(id, *_args) do
find_by(primary_key => id)
end
diff --git a/app/models/concerns/reactive_service.rb b/app/models/concerns/reactive_service.rb
index af69da24994..c444f238944 100644
--- a/app/models/concerns/reactive_service.rb
+++ b/app/models/concerns/reactive_service.rb
@@ -8,5 +8,6 @@ module ReactiveService
# Default cache key: class name + project_id
self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] }
+ self.reactive_cache_work_type = :external_dependency
end
end
diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb
index 40edd3b3ead..9a17131c91c 100644
--- a/app/models/concerns/referable.rb
+++ b/app/models/concerns/referable.rb
@@ -85,7 +85,7 @@ module Referable
\/#{route.is_a?(Regexp) ? route : Regexp.escape(route)}
\/#{pattern}
(?<path>
- (\/[a-z0-9_=-]+)*
+ (\/[a-z0-9_=-]+)*\/*
)?
(?<query>
\?[a-z0-9_=-]+
diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb
index 3cbc174536c..7f559f0a7ed 100644
--- a/app/models/concerns/relative_positioning.rb
+++ b/app/models/concerns/relative_positioning.rb
@@ -102,33 +102,16 @@ module RelativePositioning
delta = at_end ? gap : -gap
indexed = (at_end ? objects : objects.reverse).each_with_index
- # Some classes are polymorphic, and not all siblings are in the same table.
- by_model = indexed.group_by { |pair| pair.first.class }
lower_bound, upper_bound = at_end ? [position, MAX_POSITION] : [MIN_POSITION, position]
- by_model.each do |model, pairs|
- model.transaction do
- pairs.each_slice(100) do |batch|
- # These are known to be integers, one from the DB, and the other
- # calculated by us, and thus safe to interpolate
- values = batch.map do |obj, i|
- desired_pos = position + delta * (i + 1)
- pos = desired_pos.clamp(lower_bound, upper_bound)
- obj.relative_position = pos
- "(#{obj.id}, #{pos})"
- end.join(', ')
-
- model.connection.exec_query(<<~SQL, "UPDATE #{model.table_name} positions")
- WITH cte(cte_id, new_pos) AS (
- SELECT *
- FROM (VALUES #{values}) as t (id, pos)
- )
- UPDATE #{model.table_name}
- SET relative_position = cte.new_pos
- FROM cte
- WHERE cte_id = id
- SQL
+ representative.model_class.transaction do
+ indexed.each_slice(100) do |batch|
+ mapping = batch.to_h.transform_values! do |i|
+ desired_pos = position + delta * (i + 1)
+ { relative_position: desired_pos.clamp(lower_bound, upper_bound) }
end
+
+ ::Gitlab::Database::BulkUpdate.execute([:relative_position], mapping, &:model_class)
end
end
@@ -200,4 +183,16 @@ module RelativePositioning
# Override if you want to be notified of failures to move
def could_not_move(exception)
end
+
+ # Override if the implementing class is not a simple application record, for
+ # example if the record is loaded from a union.
+ def reset_relative_position
+ reset.relative_position
+ end
+
+ # Override if the model class needs a more complicated computation (e.g. the
+ # object is a member of a union).
+ def model_class
+ self.class
+ end
end
diff --git a/app/models/concerns/shardable.rb b/app/models/concerns/shardable.rb
index 57cd77b44b4..c0883c08289 100644
--- a/app/models/concerns/shardable.rb
+++ b/app/models/concerns/shardable.rb
@@ -5,6 +5,10 @@ module Shardable
included do
belongs_to :shard
+
+ scope :for_repository_storage, -> (repository_storage) { joins(:shard).where(shards: { name: repository_storage }) }
+ scope :excluding_repository_storage, -> (repository_storage) { joins(:shard).where.not(shards: { name: repository_storage }) }
+
validates :shard, presence: true
end
diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb
index 3e2cf9031d0..23fd73f2904 100644
--- a/app/models/concerns/timebox.rb
+++ b/app/models/concerns/timebox.rb
@@ -73,6 +73,32 @@ module Timebox
end
end
+ # A timebox is within the timeframe (start_date, end_date) if it overlaps
+ # with that timeframe:
+ #
+ # [ timeframe ]
+ # ----| ................ # Not overlapping
+ # |--| ................ # Not overlapping
+ # ------|............... # Overlapping
+ # -----------------------| # Overlapping
+ # ---------|............ # Overlapping
+ # |-----|............ # Overlapping
+ # |--------------| # Overlapping
+ # |--------------------| # Overlapping
+ # ...|-----|...... # Overlapping
+ # .........|-----| # Overlapping
+ # .........|--------- # Overlapping
+ # |-------------------- # Overlapping
+ # .........|--------| # Overlapping
+ # ...............|--| # Overlapping
+ # ............... |-| # Not Overlapping
+ # ............... |-- # Not Overlapping
+ #
+ # where: . = in timeframe
+ # ---| no start
+ # |--- no end
+ # |--| defined start and end
+ #
scope :within_timeframe, -> (start_date, end_date) do
where('start_date is not NULL or due_date is not NULL')
.where('start_date is NULL or start_date <= ?', end_date)
diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb
index a7028e18451..586f1dbb65c 100644
--- a/app/models/concerns/update_project_statistics.rb
+++ b/app/models/concerns/update_project_statistics.rb
@@ -80,10 +80,7 @@ module UpdateProjectStatistics
run_after_commit do
ProjectStatistics.increment_statistic(
- project_id, self.class.project_statistics_name, delta)
-
- Namespaces::ScheduleAggregationWorker.perform_async(
- project.namespace_id)
+ project, self.class.project_statistics_name, delta)
end
end
end
diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb
index b1dd720d908..641d244b665 100644
--- a/app/models/container_expiration_policy.rb
+++ b/app/models/container_expiration_policy.rb
@@ -3,6 +3,7 @@
class ContainerExpirationPolicy < ApplicationRecord
include Schedulable
include UsageStatistics
+ include EachBatch
belongs_to :project, inverse_of: :container_expiration_policy
@@ -19,6 +20,16 @@ class ContainerExpirationPolicy < ApplicationRecord
scope :active, -> { where(enabled: true) }
scope :preloaded, -> { preload(project: [:route]) }
+ def self.executable
+ runnable_schedules.where(
+ 'EXISTS (?)',
+ ContainerRepository.select(1)
+ .where(
+ 'container_repositories.project_id = container_expiration_policies.project_id'
+ )
+ )
+ end
+
def self.keep_n_options
{
1 => _('%{tags} tag per image name') % { tags: 1 },
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index b0f7edac2f3..d97b8776085 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -107,6 +107,14 @@ class ContainerRepository < ApplicationRecord
client.delete_repository_tag_by_name(self.path, name)
end
+ def reset_expiration_policy_started_at!
+ update!(expiration_policy_started_at: nil)
+ end
+
+ def start_expiration_policy!
+ update!(expiration_policy_started_at: Time.zone.now)
+ end
+
def self.build_from_path(path)
self.new(project: path.repository_project,
name: path.repository_name)
diff --git a/app/models/data_list.rb b/app/models/data_list.rb
index 2cee3447886..adad8e3013e 100644
--- a/app/models/data_list.rb
+++ b/app/models/data_list.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
class DataList
- def initialize(batch_ids, data_fields_hash, klass)
- @batch_ids = batch_ids
+ def initialize(batch, data_fields_hash, klass)
+ @batch = batch
@data_fields_hash = data_fields_hash
@klass = klass
end
@@ -13,15 +13,15 @@ class DataList
private
- attr_reader :batch_ids, :data_fields_hash, :klass
+ attr_reader :batch, :data_fields_hash, :klass
def columns
data_fields_hash.keys << 'service_id'
end
def values
- batch_ids.map do |row|
- data_fields_hash.values << row['id']
+ batch.map do |record|
+ data_fields_hash.values << record['id']
end
end
end
diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb
index 395260b5201..9355d73fae9 100644
--- a/app/models/deploy_token.rb
+++ b/app/models/deploy_token.rb
@@ -78,6 +78,20 @@ class DeployToken < ApplicationRecord
end
end
+ def group
+ strong_memoize(:group) do
+ groups.first
+ end
+ end
+
+ def accessible_projects
+ if project_type?
+ projects
+ elsif group_type?
+ group.all_projects
+ end
+ end
+
def holder
strong_memoize(:holder) do
if project_type?
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 3978620c74d..2d0d98136ec 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -46,6 +46,8 @@ class Deployment < ApplicationRecord
scope :older_than, -> (deployment) { where('id < ?', deployment.id) }
scope :with_deployable, -> { includes(:deployable).where('deployable_id IS NOT NULL') }
+ FINISHED_STATUSES = %i[success failed canceled].freeze
+
state_machine :status, initial: :created do
event :run do
transition created: :running
@@ -63,27 +65,41 @@ class Deployment < ApplicationRecord
transition any - [:canceled] => :canceled
end
- before_transition any => [:success, :failed, :canceled] do |deployment|
+ before_transition any => FINISHED_STATUSES do |deployment|
deployment.finished_at = Time.current
end
- after_transition any => :success do |deployment|
+ after_transition any => :running do |deployment|
+ next unless deployment.project.ci_forward_deployment_enabled?
+
deployment.run_after_commit do
- Deployments::SuccessWorker.perform_async(id)
+ Deployments::DropOlderDeploymentsWorker.perform_async(id)
end
end
- after_transition any => [:success, :failed, :canceled] do |deployment|
+ after_transition any => :running do |deployment|
deployment.run_after_commit do
- Deployments::FinishedWorker.perform_async(id)
+ next unless Feature.enabled?(:ci_send_deployment_hook_when_start, deployment.project)
+
+ Deployments::ExecuteHooksWorker.perform_async(id)
end
end
- after_transition any => :running do |deployment|
- next unless deployment.project.forward_deployment_enabled?
+ after_transition any => :success do |deployment|
+ deployment.run_after_commit do
+ Deployments::UpdateEnvironmentWorker.perform_async(id)
+ end
+ end
+
+ after_transition any => FINISHED_STATUSES do |deployment|
+ deployment.run_after_commit do
+ Deployments::LinkMergeRequestWorker.perform_async(id)
+ end
+ end
+ after_transition any => FINISHED_STATUSES do |deployment|
deployment.run_after_commit do
- Deployments::ForwardDeploymentWorker.perform_async(id)
+ Deployments::ExecuteHooksWorker.perform_async(id)
end
end
end
@@ -273,7 +289,7 @@ class Deployment < ApplicationRecord
SQL
end
- # Changes the status of a deployment and triggers the correspinding state
+ # Changes the status of a deployment and triggers the corresponding state
# machine events.
def update_status(status)
case status
diff --git a/app/models/deployment_merge_request.rb b/app/models/deployment_merge_request.rb
index ff4d9f66202..b67f96906f5 100644
--- a/app/models/deployment_merge_request.rb
+++ b/app/models/deployment_merge_request.rb
@@ -3,4 +3,25 @@
class DeploymentMergeRequest < ApplicationRecord
belongs_to :deployment, optional: false
belongs_to :merge_request, optional: false
+
+ def self.join_deployments_for_merge_requests
+ joins(deployment: :environment)
+ .where('deployment_merge_requests.merge_request_id = merge_requests.id')
+ end
+
+ def self.by_deployment_id(id)
+ where('deployments.id = ?', id)
+ end
+
+ def self.deployed_to(name)
+ where('environments.name = ?', name)
+ end
+
+ def self.deployed_after(time)
+ where('deployments.finished_at > ?', time)
+ end
+
+ def self.deployed_before(time)
+ where('deployments.finished_at < ?', time)
+ end
end
diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb
index 57bb250829d..62e4bd6cebc 100644
--- a/app/models/design_management/design.rb
+++ b/app/models/design_management/design.rb
@@ -167,6 +167,10 @@ module DesignManagement
end
end
+ def self.build_full_path(issue, design)
+ File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", design.filename)
+ end
+
def to_ability_name
'design'
end
@@ -180,7 +184,7 @@ module DesignManagement
end
def full_path
- @full_path ||= File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", filename)
+ @full_path ||= self.class.build_full_path(issue, self)
end
def diff_refs
@@ -224,6 +228,10 @@ module DesignManagement
!interloper.exists?
end
+ def notes_with_associations
+ notes.includes(:author)
+ end
+
private
def head_version
diff --git a/app/models/design_management/design_at_version.rb b/app/models/design_management/design_at_version.rb
index b4cafb93c2c..211211144f4 100644
--- a/app/models/design_management/design_at_version.rb
+++ b/app/models/design_management/design_at_version.rb
@@ -21,10 +21,6 @@ module DesignManagement
@design, @version = design, version
end
- def self.instantiate(attrs)
- new(attrs).tap { |obj| obj.validate! }
- end
-
# The ID, needed by GraphQL types and as part of the Lazy-fetch
# protocol, includes information about both the design and the version.
#
diff --git a/app/models/design_management/design_collection.rb b/app/models/design_management/design_collection.rb
index c48b36588c9..6deba14a6ba 100644
--- a/app/models/design_management/design_collection.rb
+++ b/app/models/design_management/design_collection.rb
@@ -5,6 +5,7 @@ module DesignManagement
attr_reader :issue
delegate :designs, :project, to: :issue
+ delegate :empty?, to: :designs
state_machine :copy_state, initial: :ready, namespace: :copy do
after_transition any => any, do: :update_stored_copy_state!
diff --git a/app/models/diff_viewer/rich.rb b/app/models/diff_viewer/rich.rb
index 5caefa2031c..0d94d8f773b 100644
--- a/app/models/diff_viewer/rich.rb
+++ b/app/models/diff_viewer/rich.rb
@@ -6,7 +6,7 @@ module DiffViewer
included do
self.type = :rich
- self.switcher_icon = 'file-text-o'
+ self.switcher_icon = 'doc-text'
self.switcher_title = _('rendered diff')
end
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index cfdcb0499e6..66613869915 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -4,12 +4,15 @@ class Environment < ApplicationRecord
include Gitlab::Utils::StrongMemoize
include ReactiveCaching
include FastDestroyAll::Helpers
+ include Presentable
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 55.seconds
self.reactive_cache_hard_limit = 10.megabytes
self.reactive_cache_work_type = :external_dependency
+ PRODUCTION_ENVIRONMENT_IDENTIFIERS = %w[prod production].freeze
+
belongs_to :project, required: true
use_fast_destroy :all_deployments
@@ -67,6 +70,7 @@ class Environment < ApplicationRecord
scope :order_by_last_deployed_at_desc, -> do
order(Gitlab::Database.nulls_last_order("(#{max_deployment_id_sql})", 'DESC'))
end
+ scope :order_by_name, -> { order('environments.name ASC') }
scope :in_review_folder, -> { where(environment_type: "review") }
scope :for_name, -> (name) { where(name: name) }
@@ -86,6 +90,7 @@ class Environment < ApplicationRecord
scope :with_rank, -> do
select('environments.*, rank() OVER (PARTITION BY project_id ORDER BY id DESC)')
end
+ scope :for_id, -> (id) { where(id: id) }
state_machine :state, initial: :available do
event :start do
@@ -118,6 +123,10 @@ class Environment < ApplicationRecord
pluck(:name)
end
+ def self.pluck_unique_names
+ pluck('DISTINCT(environments.name)')
+ end
+
def self.find_or_create_by_name(name)
find_or_create_by(name: name)
end
@@ -211,7 +220,7 @@ class Environment < ApplicationRecord
end
def update_merge_request_metrics?
- folder_name == "production"
+ PRODUCTION_ENVIRONMENT_IDENTIFIERS.include?(folder_name.downcase)
end
def ref_path
diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb
index 46e41c22139..55ea4e2fe18 100644
--- a/app/models/environment_status.rb
+++ b/app/models/environment_status.rb
@@ -72,14 +72,6 @@ class EnvironmentStatus
.merge_request_diff_files.where(deleted_file: false)
end
- def changed_paths
- changes.map { |change| change[:path] }
- end
-
- def changed_urls
- changes.map { |change| change[:external_url] }
- end
-
def has_route_map?
project.route_map_for(sha).present?
end
diff --git a/app/models/event.rb b/app/models/event.rb
index 92609144576..671def16151 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -242,6 +242,8 @@ class Event < ApplicationRecord
target if note?
end
+ # rubocop: disable Metrics/CyclomaticComplexity
+ # rubocop: disable Metrics/PerceivedComplexity
def action_name
if push_action?
push_action_name
@@ -267,10 +269,14 @@ class Event < ApplicationRecord
'updated'
elsif created_project_action?
created_project_action_name
+ elsif approved_action?
+ 'approved'
else
"opened"
end
end
+ # rubocop: enable Metrics/CyclomaticComplexity
+ # rubocop: enable Metrics/PerceivedComplexity
def target_iid
target.respond_to?(:iid) ? target.iid : target_id
@@ -323,14 +329,6 @@ class Event < ApplicationRecord
end
end
- def note_target_type
- if target.noteable_type.present?
- target.noteable_type.titleize
- else
- "Wall"
- end.downcase
- end
-
def body?
if push_action?
push_with_commits?
diff --git a/app/models/global_label.rb b/app/models/global_label.rb
deleted file mode 100644
index 7c020dd3b3d..00000000000
--- a/app/models/global_label.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-
-class GlobalLabel
- include Presentable
-
- attr_accessor :title, :labels
- alias_attribute :name, :title
-
- delegate :color, :text_color, :description, :scoped_label?, to: :@first_label
-
- def for_display
- @first_label
- end
-
- def self.build_collection(labels)
- labels = labels.group_by(&:title)
-
- labels.map do |title, labels|
- new(title, labels)
- end
- end
-
- def initialize(title, labels)
- @title = title
- @labels = labels
- @first_label = labels.find { |lbl| lbl.description.present? } || labels.first
- end
-
- def present(attributes)
- super(attributes.merge(presenter_class: ::LabelPresenter))
- end
-end
diff --git a/app/models/group.rb b/app/models/group.rb
index c0f145997cc..74f7efd253d 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -15,11 +15,10 @@ class Group < Namespace
include WithUploads
include Gitlab::Utils::StrongMemoize
include GroupAPICompatibility
+ include EachBatch
ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10
- UpdateSharedRunnersError = Class.new(StandardError)
-
has_many :all_group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent
has_many :group_members, -> { where(requested_at: nil).where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :group_members
@@ -77,6 +76,7 @@ class Group < Namespace
validate :visibility_level_allowed_by_projects
validate :visibility_level_allowed_by_sub_groups
validate :visibility_level_allowed_by_parent
+ validate :two_factor_authentication_allowed
validates :variables, variable_duplicates: true
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
@@ -140,6 +140,15 @@ class Group < Namespace
end
end
+ def without_integration(integration)
+ services = Service
+ .select('1')
+ .where('services.group_id = namespaces.id')
+ .where(type: integration.type)
+
+ where('NOT EXISTS (?)', services)
+ end
+
private
def public_to_user_arel(user)
@@ -348,6 +357,7 @@ class Group < Namespace
end
group_hierarchy_members = GroupMember.active_without_invites_and_requests
+ .non_minimal_access
.where(source_id: source_ids)
GroupMember.from_union([group_hierarchy_members,
@@ -528,57 +538,37 @@ class Group < Namespace
preloader.preload(self, shared_with_group_links: [shared_with_group: :route])
end
- def shared_runners_allowed?
- shared_runners_enabled? || allow_descendants_override_disabled_shared_runners?
- end
-
- def parent_allows_shared_runners?
- return true unless has_parent?
+ def update_shared_runners_setting!(state)
+ raise ArgumentError unless SHARED_RUNNERS_SETTINGS.include?(state)
- parent.shared_runners_allowed?
+ case state
+ when 'disabled_and_unoverridable' then disable_shared_runners! # also disallows override
+ when 'disabled_with_override' then disable_shared_runners_and_allow_override!
+ when 'enabled' then enable_shared_runners! # set both to true
+ end
end
- def parent_enabled_shared_runners?
- return true unless has_parent?
-
- parent.shared_runners_enabled?
+ def default_owner
+ owners.first || parent&.default_owner || owner
end
- def enable_shared_runners!
- raise UpdateSharedRunnersError, 'Shared Runners disabled for the parent group' unless parent_enabled_shared_runners?
-
- update_column(:shared_runners_enabled, true)
+ def default_branch_name
+ namespace_settings&.default_branch_name
end
- def disable_shared_runners!
- group_ids = self_and_descendants
- return if group_ids.empty?
-
- Group.by_id(group_ids).update_all(shared_runners_enabled: false)
-
- all_projects.update_all(shared_runners_enabled: false)
+ def access_level_roles
+ GroupMember.access_level_roles
end
- def allow_descendants_override_disabled_shared_runners!
- raise UpdateSharedRunnersError, 'Shared Runners enabled' if shared_runners_enabled?
- raise UpdateSharedRunnersError, 'Group level shared Runners not allowed' unless parent_allows_shared_runners?
-
- update_column(:allow_descendants_override_disabled_shared_runners, true)
+ def access_level_values
+ access_level_roles.values
end
- def disallow_descendants_override_disabled_shared_runners!
- raise UpdateSharedRunnersError, 'Shared Runners enabled' if shared_runners_enabled?
-
- group_ids = self_and_descendants
- return if group_ids.empty?
-
- Group.by_id(group_ids).update_all(allow_descendants_override_disabled_shared_runners: false)
-
- all_projects.update_all(shared_runners_enabled: false)
- end
+ def parent_allows_two_factor_authentication?
+ return true unless has_parent?
- def default_owner
- owners.first || parent&.default_owner || owner
+ ancestor_settings = ancestors.find_by(parent_id: nil).namespace_settings
+ ancestor_settings.allow_mfa_for_subgroups
end
private
@@ -611,6 +601,15 @@ class Group < Namespace
errors.add(:visibility_level, "#{visibility} is not allowed since there are sub-groups with higher visibility.")
end
+ def two_factor_authentication_allowed
+ return unless has_parent?
+ return unless require_two_factor_authentication
+
+ return if parent_allows_two_factor_authentication?
+
+ errors.add(:require_two_factor_authentication, _('is forbidden by a top-level group'))
+ end
+
def members_from_self_and_ancestor_group_shares
group_group_link_table = GroupGroupLink.arel_table
group_member_table = GroupMember.arel_table
@@ -658,6 +657,45 @@ class Group < Namespace
.new(Group.where(id: group_ids))
.base_and_descendants
end
+
+ def disable_shared_runners!
+ update!(
+ shared_runners_enabled: false,
+ allow_descendants_override_disabled_shared_runners: false)
+
+ group_ids = descendants
+ unless group_ids.empty?
+ Group.by_id(group_ids).update_all(
+ shared_runners_enabled: false,
+ allow_descendants_override_disabled_shared_runners: false)
+ end
+
+ all_projects.update_all(shared_runners_enabled: false)
+ end
+
+ def disable_shared_runners_and_allow_override!
+ # enabled -> disabled_with_override
+ if shared_runners_enabled?
+ update!(
+ shared_runners_enabled: false,
+ allow_descendants_override_disabled_shared_runners: true)
+
+ group_ids = descendants
+ unless group_ids.empty?
+ Group.by_id(group_ids).update_all(shared_runners_enabled: false)
+ end
+
+ all_projects.update_all(shared_runners_enabled: false)
+
+ # disabled_and_unoverridable -> disabled_with_override
+ else
+ update!(allow_descendants_override_disabled_shared_runners: true)
+ end
+ end
+
+ def enable_shared_runners!
+ update!(shared_runners_enabled: true)
+ end
end
Group.prepend_if_ee('EE::Group')
diff --git a/app/models/group_import_state.rb b/app/models/group_import_state.rb
index d22c1ac5550..89602e40357 100644
--- a/app/models/group_import_state.rb
+++ b/app/models/group_import_state.rb
@@ -4,8 +4,9 @@ class GroupImportState < ApplicationRecord
self.primary_key = :group_id
belongs_to :group, inverse_of: :import_state
+ belongs_to :user, optional: false
- validates :group, :status, presence: true
+ validates :group, :status, :user, presence: true
validates :jid, presence: true, if: -> { started? || finished? }
state_machine :status, initial: :created do
diff --git a/app/models/incident_management/project_incident_management_setting.rb b/app/models/incident_management/project_incident_management_setting.rb
index c79acdb685f..4887265be88 100644
--- a/app/models/incident_management/project_incident_management_setting.rb
+++ b/app/models/incident_management/project_incident_management_setting.rb
@@ -51,3 +51,5 @@ module IncidentManagement
end
end
end
+
+IncidentManagement::ProjectIncidentManagementSetting.prepend_if_ee('EE::IncidentManagement::ProjectIncidentManagementSetting')
diff --git a/app/models/issuable_severity.rb b/app/models/issuable_severity.rb
index d68b3dc48ee..35d03a544bd 100644
--- a/app/models/issuable_severity.rb
+++ b/app/models/issuable_severity.rb
@@ -2,6 +2,13 @@
class IssuableSeverity < ApplicationRecord
DEFAULT = 'unknown'
+ SEVERITY_LABELS = {
+ unknown: 'Unknown',
+ low: 'Low - S4',
+ medium: 'Medium - S3',
+ high: 'High - S2',
+ critical: 'Critical - S1'
+ }.freeze
belongs_to :issue
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 5a5de371301..5291b7890b6 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -19,6 +19,8 @@ class Issue < ApplicationRecord
include WhereComposite
include StateEventable
include IdInOrdered
+ include Presentable
+ include IssueAvailableFeatures
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
@@ -54,6 +56,7 @@ class Issue < ApplicationRecord
dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :issue_assignees
+ has_many :issue_email_participants
has_many :assignees, class_name: "User", through: :issue_assignees
has_many :zoom_meetings
has_many :user_mentions, class_name: "IssueUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
@@ -87,6 +90,7 @@ class Issue < ApplicationRecord
alias_method :issuing_parent, :project
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
+ scope :not_in_projects, ->(project_ids) { where.not(project_id: project_ids) }
scope :with_due_date, -> { where.not(due_date: nil) }
scope :without_due_date, -> { where(due_date: nil) }
@@ -101,6 +105,8 @@ class Issue < ApplicationRecord
scope :order_relative_position_asc, -> { reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')) }
scope :order_closed_date_desc, -> { reorder(closed_at: :desc) }
scope :order_created_at_desc, -> { reorder(created_at: :desc) }
+ scope :order_severity_asc, -> { includes(:issuable_severity).order('issuable_severities.severity ASC NULLS FIRST') }
+ scope :order_severity_desc, -> { includes(:issuable_severity).order('issuable_severities.severity DESC NULLS LAST') }
scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) }
scope :with_web_entity_associations, -> { preload(:author, :project) }
@@ -122,6 +128,7 @@ class Issue < ApplicationRecord
scope :counts_by_state, -> { reorder(nil).group(:state_id).count }
scope :service_desk, -> { where(author: ::User.support_bot) }
+ scope :inc_relations_for_view, -> { includes(author: :status) }
# An issue can be uniquely identified by project_id and iid
# Takes one or more sets of composite IDs, expressed as hash-like records of
@@ -145,6 +152,7 @@ class Issue < ApplicationRecord
after_commit :expire_etag_cache, unless: :importing?
after_save :ensure_metrics, unless: :importing?
+ after_create_commit :record_create_action, unless: :importing?
attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true
@@ -232,6 +240,8 @@ class Issue < ApplicationRecord
when 'due_date', 'due_date_asc' then order_due_date_asc.with_order_id_desc
when 'due_date_desc' then order_due_date_desc.with_order_id_desc
when 'relative_position', 'relative_position_asc' then order_relative_position_asc.with_order_id_desc
+ when 'severity_asc' then order_severity_asc.with_order_id_desc
+ when 'severity_desc' then order_severity_desc.with_order_id_desc
else
super
end
@@ -413,6 +423,10 @@ class Issue < ApplicationRecord
IssueLink.inverse_link_type(type)
end
+ def relocation_target
+ moved_to || duplicated_to
+ end
+
private
def ensure_metrics
@@ -420,6 +434,10 @@ class Issue < ApplicationRecord
metrics.record!
end
+ def record_create_action
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_created_action(author: author)
+ end
+
# Returns `true` if the given User can read the current Issue.
#
# This method duplicates the same check of issue_policy.rb
diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb
index e57acbae546..7f3d552b3d9 100644
--- a/app/models/issue_assignee.rb
+++ b/app/models/issue_assignee.rb
@@ -8,6 +8,7 @@ class IssueAssignee < ApplicationRecord
scope :in_projects, ->(project_ids) { joins(:issue).where("issues.project_id in (?)", project_ids) }
scope :on_issues, ->(issue_ids) { where(issue_id: issue_ids) }
+ scope :for_assignee, ->(user) { where(assignee: user) }
end
IssueAssignee.prepend_if_ee('EE::IssueAssignee')
diff --git a/app/models/issue_email_participant.rb b/app/models/issue_email_participant.rb
new file mode 100644
index 00000000000..8eb9b6a8152
--- /dev/null
+++ b/app/models/issue_email_participant.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class IssueEmailParticipant < ApplicationRecord
+ belongs_to :issue
+
+ validates :email, presence: true, uniqueness: { scope: [:issue_id] }
+ validates :issue, presence: true
+ validate :validate_email_format
+
+ def validate_email_format
+ self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email)
+ end
+end
diff --git a/app/models/iteration.rb b/app/models/iteration.rb
index d223c80fca0..bd245de411c 100644
--- a/app/models/iteration.rb
+++ b/app/models/iteration.rb
@@ -94,13 +94,25 @@ class Iteration < ApplicationRecord
private
+ def parent_group
+ group || project.group
+ end
+
def start_or_due_dates_changed?
start_date_changed? || due_date_changed?
end
- # ensure dates do not overlap with other Iterations in the same group/project
+ # ensure dates do not overlap with other Iterations in the same group/project tree
def dates_do_not_overlap
- return unless resource_parent.iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists?
+ iterations = if parent_group.present? && resource_parent.is_a?(Project)
+ Iteration.where(group: parent_group.self_and_ancestors).or(project.iterations)
+ elsif parent_group.present?
+ Iteration.where(group: parent_group.self_and_ancestors)
+ else
+ project.iterations
+ end
+
+ return unless iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists?
errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations"))
end
diff --git a/app/models/member.rb b/app/models/member.rb
index 7ea9caa45d3..498e03b2c1a 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -80,7 +80,10 @@ class Member < ApplicationRecord
scope :request, -> { where.not(requested_at: nil) }
scope :non_request, -> { where(requested_at: nil) }
- scope :not_accepted_invitations_by_user, -> (user) { invite.where(invite_accepted_at: nil, created_by: user) }
+ scope :not_accepted_invitations, -> { invite.where(invite_accepted_at: nil) }
+ scope :not_accepted_invitations_by_user, -> (user) { not_accepted_invitations.where(created_by: user) }
+ scope :not_expired, -> (today = Date.current) { where(arel_table[:expires_at].gt(today).or(arel_table[:expires_at].eq(nil))) }
+ scope :last_ten_days_excluding_today, -> (today = Date.current) { where(created_at: (today - 10).beginning_of_day..(today - 1).end_of_day) }
scope :has_access, -> { active.where('access_level > 0') }
@@ -372,6 +375,14 @@ class Member < ApplicationRecord
send_invite
end
+ def send_invitation_reminder(reminder_index)
+ return unless invite?
+
+ generate_invite_token! unless @raw_invite_token
+
+ run_after_commit_or_now { notification_service.invite_member_reminder(self, @raw_invite_token, reminder_index) }
+ end
+
def create_notification_setting
user.notification_settings.find_or_create_for(source)
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 3fdc501644d..24541ba3218 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -31,6 +31,7 @@ class MergeRequest < ApplicationRecord
self.reactive_cache_key = ->(model) { [model.project.id, model.iid] }
self.reactive_cache_refresh_interval = 10.minutes
self.reactive_cache_lifetime = 10.minutes
+ self.reactive_cache_work_type = :no_dependency
SORTING_PREFERENCE_FIELD = :merge_requests_sort
@@ -121,6 +122,8 @@ class MergeRequest < ApplicationRecord
# when creating new merge request
attr_accessor :can_be_created, :compare_commits, :diff_options, :compare
+ participant :reviewers
+
# Keep states definition to be evaluated before the state_machine block to avoid spec failures.
# If this gets evaluated after, the `merged` and `locked` states which are overrided can be nil.
def self.available_state_names
@@ -255,11 +258,7 @@ class MergeRequest < ApplicationRecord
scope :join_project, -> { joins(:target_project) }
scope :join_metrics, -> do
query = joins(:metrics)
-
- if Feature.enabled?(:improved_mr_merged_at_queries, default_enabled: true)
- query = query.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id]))
- end
-
+ query = query.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id]))
query
end
scope :references_project, -> { references(:target_project) }
@@ -271,6 +270,8 @@ class MergeRequest < ApplicationRecord
metrics: [:latest_closed_by, :merged_by])
}
+ scope :with_csv_entity_associations, -> { preload(:assignees, :approved_by_users, :author, :milestone, metrics: [:merged_by]) }
+
scope :by_target_branch_wildcard, ->(wildcard_branch_name) do
where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%'))
end
@@ -629,7 +630,7 @@ class MergeRequest < ApplicationRecord
def diff_size
# Calling `merge_request_diff.diffs.real_size` will also perform
# highlighting, which we don't need here.
- merge_request_diff&.real_size || diff_stats&.real_size || diffs.real_size
+ merge_request_diff&.real_size || diff_stats&.real_size(project: project) || diffs.real_size
end
def modified_paths(past_merge_request_diff: nil, fallback_on_overflow: false)
@@ -928,7 +929,7 @@ class MergeRequest < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
def diffable_merge_ref?
- can_be_merged? && merge_ref_head.present?
+ merge_ref_head.present? && (Feature.enabled?(:display_merge_conflicts_in_diff, project) || can_be_merged?)
end
# Returns boolean indicating the merge_status should be rechecked in order to
@@ -1301,6 +1302,14 @@ class MergeRequest < ApplicationRecord
unlock_mr
end
+ def update_and_mark_in_progress_merge_commit_sha(commit_id)
+ self.update(in_progress_merge_commit_sha: commit_id)
+ # Since another process checks for matching merge request, we need
+ # to make it possible to detect whether the query should go to the
+ # primary.
+ target_project.mark_primary_write_location
+ end
+
def diverged_commits_count
cache = Rails.cache.read(:"merge_request_#{id}_diverged_commits")
@@ -1375,8 +1384,6 @@ class MergeRequest < ApplicationRecord
end
def has_coverage_reports?
- return false unless Feature.enabled?(:coverage_report_view, project, default_enabled: true)
-
actual_head_pipeline&.has_coverage_reports?
end
@@ -1511,6 +1518,7 @@ class MergeRequest < ApplicationRecord
metrics&.merged_at ||
merge_event&.created_at ||
+ resource_state_events.find_by(state: :merged)&.created_at ||
notes.system.reorder(nil).find_by(note: 'merged')&.created_at
end
end
@@ -1591,6 +1599,12 @@ class MergeRequest < ApplicationRecord
.find_by(sha: diff_base_sha, ref: target_branch)
end
+ def merge_base_pipeline
+ @merge_base_pipeline ||= project.ci_pipelines
+ .order(id: :desc)
+ .find_by(sha: actual_head_pipeline.target_sha, ref: target_branch)
+ end
+
def discussions_rendered_on_frontend?
true
end
@@ -1680,6 +1694,10 @@ class MergeRequest < ApplicationRecord
Feature.enabled?(:merge_request_reviewers, project)
end
+ def allows_multiple_reviewers?
+ false
+ end
+
private
def with_rebase_lock
diff --git a/app/models/merge_request_context_commit.rb b/app/models/merge_request_context_commit.rb
index a2982a5dd73..59cc82cfaf5 100644
--- a/app/models/merge_request_context_commit.rb
+++ b/app/models/merge_request_context_commit.rb
@@ -22,8 +22,8 @@ class MergeRequestContextCommit < ApplicationRecord
end
# create MergeRequestContextCommit by given commit sha and it's diff file record
- def self.bulk_insert(*args)
- Gitlab::Database.bulk_insert('merge_request_context_commits', *args) # rubocop:disable Gitlab/BulkInsert
+ def self.bulk_insert(rows, **args)
+ Gitlab::Database.bulk_insert('merge_request_context_commits', rows, **args) # rubocop:disable Gitlab/BulkInsert
end
def to_commit
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 880e3cc1ba5..24809141570 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -106,6 +106,17 @@ class MergeRequestDiff < ApplicationRecord
joins(merge_request: :metrics).where(condition)
end
+ scope :latest_diff_for_merge_requests, -> (merge_requests) do
+ inner_select = MergeRequestDiff
+ .default_scoped
+ .distinct
+ .select("FIRST_VALUE(id) OVER (PARTITION BY merge_request_id ORDER BY created_at DESC) as id")
+ .where(merge_request: merge_requests)
+
+ joins("INNER JOIN (#{inner_select.to_sql}) latest_diffs ON latest_diffs.id = merge_request_diffs.id")
+ .includes(:merge_request_diff_commits)
+ end
+
class << self
def ids_for_external_storage_migration(limit:)
return [] unless Gitlab.config.external_diffs.enabled
@@ -280,7 +291,13 @@ class MergeRequestDiff < ApplicationRecord
end
def commit_shas(limit: nil)
- merge_request_diff_commits.limit(limit).pluck(:sha)
+ if association(:merge_request_diff_commits).loaded?
+ sorted_diff_commits = merge_request_diff_commits.sort_by { |diff_commit| [diff_commit.id, diff_commit.relative_order] }
+ sorted_diff_commits = sorted_diff_commits.take(limit) if limit
+ sorted_diff_commits.map(&:sha)
+ else
+ merge_request_diff_commits.limit(limit).pluck(:sha)
+ end
end
def includes_any_commits?(shas)
@@ -509,6 +526,8 @@ class MergeRequestDiff < ApplicationRecord
end
def encode_in_base64?(diff_text)
+ return false if diff_text.nil?
+
(diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?) ||
diff_text.include?("\0")
end
@@ -536,7 +555,7 @@ class MergeRequestDiff < ApplicationRecord
rows.each do |row|
data = row.delete(:diff)
row[:external_diff_offset] = file.pos
- row[:external_diff_size] = data.bytesize
+ row[:external_diff_size] = data&.bytesize || 0
file.write(data)
end
@@ -651,7 +670,7 @@ class MergeRequestDiff < ApplicationRecord
if compare.commits.empty?
new_attributes[:state] = :empty
else
- diff_collection = compare.diffs(Commit.max_diff_options)
+ diff_collection = compare.diffs(Commit.max_diff_options(project: merge_request.project))
new_attributes[:real_size] = diff_collection.real_size
if diff_collection.any?
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 55326b9a282..0a315ba8db2 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -46,6 +46,10 @@ class Milestone < ApplicationRecord
state :active
end
+ def self.min_chars_for_partial_matching
+ 2
+ end
+
def self.reference_prefix
'%'
end
diff --git a/app/models/milestone_release.rb b/app/models/milestone_release.rb
index 0a6165c8254..2f2bf91e436 100644
--- a/app/models/milestone_release.rb
+++ b/app/models/milestone_release.rb
@@ -11,6 +11,10 @@ class MilestoneRelease < ApplicationRecord
def same_project_between_milestone_and_release
return if milestone&.project_id == release&.project_id
+ return if milestone&.group_id
+
errors.add(:base, _('Release does not have the same project as the milestone'))
end
end
+
+MilestoneRelease.prepend_if_ee('EE::MilestoneRelease')
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 527fa9d52d0..fd31042c2f6 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -18,6 +18,8 @@ class Namespace < ApplicationRecord
# Android repo (15) + some extra backup.
NUMBER_OF_ANCESTORS_ALLOWED = 20
+ SHARED_RUNNERS_SETTINGS = %w[disabled_and_unoverridable disabled_with_override enabled].freeze
+
cache_markdown_field :description, pipeline: :description
has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -59,6 +61,8 @@ class Namespace < ApplicationRecord
validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
validate :nesting_level_allowed
+ validate :changing_shared_runners_enabled_is_allowed
+ validate :changing_allow_descendants_override_disabled_shared_runners_is_allowed
validates_associated :runners
@@ -79,6 +83,7 @@ class Namespace < ApplicationRecord
scope :for_user, -> { where('type IS NULL') }
scope :sort_by_type, -> { order(Gitlab::Database.nulls_first_order(:type)) }
+ scope :include_route, -> { includes(:route) }
scope :with_statistics, -> do
joins('LEFT JOIN project_statistics ps ON ps.namespace_id = namespaces.id')
@@ -278,7 +283,11 @@ class Namespace < ApplicationRecord
# Includes projects from this namespace and projects from all subgroups
# that belongs to this namespace
def all_projects
- Project.inside_path(full_path)
+ if Feature.enabled?(:recursive_approach_for_all_projects)
+ Project.where(namespace: self_and_descendants)
+ else
+ Project.inside_path(full_path)
+ end
end
# Includes pipelines from this namespace and pipelines from all subgroups
@@ -378,6 +387,52 @@ class Namespace < ApplicationRecord
actual_plan.name
end
+ def changing_shared_runners_enabled_is_allowed
+ return unless Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true)
+ return unless new_record? || changes.has_key?(:shared_runners_enabled)
+
+ if shared_runners_enabled && has_parent? && parent.shared_runners_setting == 'disabled_and_unoverridable'
+ errors.add(:shared_runners_enabled, _('cannot be enabled because parent group has shared Runners disabled'))
+ end
+ end
+
+ def changing_allow_descendants_override_disabled_shared_runners_is_allowed
+ return unless Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true)
+ return unless new_record? || changes.has_key?(:allow_descendants_override_disabled_shared_runners)
+
+ if shared_runners_enabled && !new_record?
+ errors.add(:allow_descendants_override_disabled_shared_runners, _('cannot be changed if shared runners are enabled'))
+ end
+
+ if allow_descendants_override_disabled_shared_runners && has_parent? && parent.shared_runners_setting == 'disabled_and_unoverridable'
+ errors.add(:allow_descendants_override_disabled_shared_runners, _('cannot be enabled because parent group does not allow it'))
+ end
+ end
+
+ def shared_runners_setting
+ if shared_runners_enabled
+ 'enabled'
+ else
+ if allow_descendants_override_disabled_shared_runners
+ 'disabled_with_override'
+ else
+ 'disabled_and_unoverridable'
+ end
+ end
+ end
+
+ def shared_runners_setting_higher_than?(other_setting)
+ if other_setting == 'enabled'
+ false
+ elsif other_setting == 'disabled_with_override'
+ shared_runners_setting == 'enabled'
+ elsif other_setting == 'disabled_and_unoverridable'
+ shared_runners_setting == 'enabled' || shared_runners_setting == 'disabled_with_override'
+ else
+ raise ArgumentError
+ end
+ end
+
private
def all_projects_with_pages
diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb
index 53bfa3d979e..6f31208f28b 100644
--- a/app/models/namespace_setting.rb
+++ b/app/models/namespace_setting.rb
@@ -3,7 +3,26 @@
class NamespaceSetting < ApplicationRecord
belongs_to :namespace, inverse_of: :namespace_settings
+ validate :default_branch_name_content
+ validate :allow_mfa_for_group
+
+ NAMESPACE_SETTINGS_PARAMS = [:default_branch_name].freeze
+
self.primary_key = :namespace_id
+
+ def default_branch_name_content
+ return if default_branch_name.nil?
+
+ if default_branch_name.blank?
+ errors.add(:default_branch_name, "can not be an empty string")
+ end
+ end
+
+ def allow_mfa_for_group
+ if namespace&.subgroup? && allow_mfa_for_subgroups == false
+ errors.add(:allow_mfa_for_subgroups, _('is not allowed since the group is not top-level group.'))
+ end
+ end
end
NamespaceSetting.prepend_if_ee('EE::NamespaceSetting')
diff --git a/app/models/note.rb b/app/models/note.rb
index 812d77d5f86..954843505d4 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -322,8 +322,6 @@ class Note < ApplicationRecord
end
def contributor?
- return false unless ::Feature.enabled?(:show_contributor_on_note, project)
-
project&.team&.contributor?(self.author_id)
end
diff --git a/app/models/notification_reason.rb b/app/models/notification_reason.rb
index a7967239417..c227626af9e 100644
--- a/app/models/notification_reason.rb
+++ b/app/models/notification_reason.rb
@@ -5,6 +5,7 @@
class NotificationReason
OWN_ACTIVITY = 'own_activity'
ASSIGNED = 'assigned'
+ REVIEW_REQUESTED = 'review_requested'
MENTIONED = 'mentioned'
SUBSCRIBED = 'subscribed'
@@ -12,6 +13,7 @@ class NotificationReason
REASON_PRIORITY = [
OWN_ACTIVITY,
ASSIGNED,
+ REVIEW_REQUESTED,
MENTIONED,
SUBSCRIBED
].freeze
diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb
index 6a6b2bb1b58..79a84231083 100644
--- a/app/models/notification_recipient.rb
+++ b/app/models/notification_recipient.rb
@@ -5,7 +5,7 @@ class NotificationRecipient
attr_reader :user, :type, :reason
- def initialize(user, type, **opts)
+ def initialize(user, type, opts = {})
unless NotificationSetting.levels.key?(type) || type == :subscription
raise ArgumentError, "invalid type: #{type.inspect}"
end
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index c003a20f0fc..6066046a722 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -43,6 +43,7 @@ class NotificationSetting < ApplicationRecord
:reopen_merge_request,
:close_merge_request,
:reassign_merge_request,
+ :change_reviewer_merge_request,
:merge_merge_request,
:failed_pipeline,
:fixed_pipeline,
diff --git a/app/models/operations/feature_flags/strategy.rb b/app/models/operations/feature_flags/strategy.rb
index ff68af9741e..c70e10c72d5 100644
--- a/app/models/operations/feature_flags/strategy.rb
+++ b/app/models/operations/feature_flags/strategy.rb
@@ -6,14 +6,17 @@ module Operations
STRATEGY_DEFAULT = 'default'
STRATEGY_GITLABUSERLIST = 'gitlabUserList'
STRATEGY_GRADUALROLLOUTUSERID = 'gradualRolloutUserId'
+ STRATEGY_FLEXIBLEROLLOUT = 'flexibleRollout'
STRATEGY_USERWITHID = 'userWithId'
STRATEGIES = {
STRATEGY_DEFAULT => [].freeze,
STRATEGY_GITLABUSERLIST => [].freeze,
STRATEGY_GRADUALROLLOUTUSERID => %w[groupId percentage].freeze,
+ STRATEGY_FLEXIBLEROLLOUT => %w[groupId rollout stickiness].freeze,
STRATEGY_USERWITHID => ['userIds'].freeze
}.freeze
USERID_MAX_LENGTH = 256
+ STICKINESS_SETTINGS = %w[DEFAULT USERID SESSIONID RANDOM].freeze
self.table_name = 'operations_strategies'
@@ -67,16 +70,25 @@ module Operations
case name
when STRATEGY_GRADUALROLLOUTUSERID
gradual_rollout_user_id_parameters_validation
+ when STRATEGY_FLEXIBLEROLLOUT
+ flexible_rollout_parameters_validation
when STRATEGY_USERWITHID
FeatureFlagUserXidsValidator.validate_user_xids(self, :parameters, parameters['userIds'], 'userIds')
end
end
+ def within_range?(value, min, max)
+ return false unless value.is_a?(String)
+ return false unless value.match?(/\A\d+\z/)
+
+ value.to_i.between?(min, max)
+ end
+
def gradual_rollout_user_id_parameters_validation
percentage = parameters['percentage']
group_id = parameters['groupId']
- unless percentage.is_a?(String) && percentage.match(/\A[1-9]?[0-9]\z|\A100\z/)
+ unless within_range?(percentage, 0, 100)
parameters_error('percentage must be a string between 0 and 100 inclusive')
end
@@ -85,6 +97,25 @@ module Operations
end
end
+ def flexible_rollout_parameters_validation
+ stickiness = parameters['stickiness']
+ group_id = parameters['groupId']
+ rollout = parameters['rollout']
+
+ unless STICKINESS_SETTINGS.include?(stickiness)
+ options = STICKINESS_SETTINGS.to_sentence(last_word_connector: ', or ')
+ parameters_error("stickiness parameter must be #{options}")
+ end
+
+ unless group_id.is_a?(String) && group_id.match(/\A[a-z]{1,32}\z/)
+ parameters_error('groupId parameter is invalid')
+ end
+
+ unless within_range?(rollout, 0, 100)
+ parameters_error('rollout must be a string between 0 and 100 inclusive')
+ end
+ end
+
def parameters_error(message)
errors.add(:parameters, message)
false
diff --git a/app/models/packages/event.rb b/app/models/packages/event.rb
new file mode 100644
index 00000000000..f1d0af64ccd
--- /dev/null
+++ b/app/models/packages/event.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class Packages::Event < ApplicationRecord
+ belongs_to :package, optional: true
+
+ EVENT_SCOPES = ::Packages::Package.package_types.merge(container: 1000, tag: 1001).freeze
+
+ enum event_scope: EVENT_SCOPES
+
+ enum event_type: {
+ push_package: 0,
+ delete_package: 1,
+ pull_package: 2,
+ search_package: 3,
+ list_package: 4,
+ list_repositories: 5,
+ delete_repository: 6,
+ delete_tag: 7,
+ delete_tag_bulk: 8,
+ list_tags: 9,
+ cli_metadata: 10
+ }
+
+ enum originator_type: { user: 0, deploy_token: 1, guest: 2 }
+end
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index bda11160957..a57d640ddc0 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -26,7 +26,7 @@ class Packages::Package < ApplicationRecord
validates :project, presence: true
validates :name, presence: true
- validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: :conan?
+ validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: -> { conan? || generic? }
validates :name,
uniqueness: { scope: %i[project_id version package_type] }, unless: :conan?
@@ -35,20 +35,24 @@ class Packages::Package < ApplicationRecord
validate :valid_npm_package_name, if: :npm?
validate :valid_composer_global_name, if: :composer?
validate :package_already_taken, if: :npm?
- validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { npm? || nuget? }
validates :name, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan?
+ validates :name, format: { with: Gitlab::Regex.generic_package_name_regex }, if: :generic?
+ validates :version, format: { with: Gitlab::Regex.semver_regex }, if: :npm?
+ validates :version, format: { with: Gitlab::Regex.nuget_version_regex }, if: :nuget?
validates :version, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan?
validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? }
validates :version, format: { with: Gitlab::Regex.pypi_version_regex }, if: :pypi?
+ validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :golang?
validates :version,
presence: true,
format: { with: Gitlab::Regex.generic_package_version_regex },
if: :generic?
- enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, composer: 6, generic: 7 }
+ enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, composer: 6, generic: 7, golang: 8, debian: 9 }
scope :with_name, ->(name) { where(name: name) }
scope :with_name_like, ->(name) { where(arel_table[:name].matches(name)) }
+ scope :with_normalized_pypi_name, ->(name) { where("LOWER(regexp_replace(name, '[-_.]+', '-', 'g')) = ?", name.downcase) }
scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) }
scope :with_version, ->(version) { where(version: version) }
scope :without_version_like, -> (version) { where.not(arel_table[:version].matches(version)) }
@@ -119,6 +123,10 @@ class Packages::Package < ApplicationRecord
.where(packages_package_files: { file_name: file_name, file_sha256: sha256 }).last!
end
+ def self.by_name_and_version!(name, version)
+ find_by!(name: name, version: version)
+ end
+
def self.pluck_names
pluck(:name)
end
diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb
index 78e0f185a11..cd952c32046 100644
--- a/app/models/pages_deployment.rb
+++ b/app/models/pages_deployment.rb
@@ -2,10 +2,24 @@
# PagesDeployment stores a zip archive containing GitLab Pages web-site
class PagesDeployment < ApplicationRecord
+ include FileStoreMounter
+
belongs_to :project, optional: false
belongs_to :ci_build, class_name: 'Ci::Build', optional: true
validates :file, presence: true
validates :file_store, presence: true, inclusion: { in: ObjectStorage::SUPPORTED_STORES }
validates :size, presence: true, numericality: { greater_than: 0, only_integer: true }
+
+ before_validation :set_size, if: :file_changed?
+
+ default_value_for(:file_store) { ::Pages::DeploymentUploader.default_store }
+
+ mount_file_store_uploader ::Pages::DeploymentUploader
+
+ private
+
+ def set_size
+ self.size = file.size
+ end
end
diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb
index a4370eda5ba..c96786423e5 100644
--- a/app/models/postgresql/replication_slot.rb
+++ b/app/models/postgresql/replication_slot.rb
@@ -22,8 +22,8 @@ module Postgresql
def self.lag_too_great?(max = 100.megabytes)
return false unless in_use?
- lag_function = "#{Gitlab::Database.pg_wal_lsn_diff}" \
- "(#{Gitlab::Database.pg_current_wal_insert_lsn}(), restart_lsn)::bigint"
+ lag_function = "pg_wal_lsn_diff" \
+ "(pg_current_wal_insert_lsn(), restart_lsn)::bigint"
# We force the use of a transaction here so the query always goes to the
# primary, even when using the EE DB load balancer.
diff --git a/app/models/preloaders/merge_request_diff_preloader.rb b/app/models/preloaders/merge_request_diff_preloader.rb
new file mode 100644
index 00000000000..ee9995c497d
--- /dev/null
+++ b/app/models/preloaders/merge_request_diff_preloader.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Preloaders
+ # This class preloads the `merge_request_diff` association for the given merge request models.
+ #
+ # Usage:
+ # merge_requests = MergeRequest.where(...)
+ # Preloaders::MergeRequestDiffPreloader.new(merge_requests).preload_all
+ # merge_requests.first.merge_request_diff # won't fire any query
+ class MergeRequestDiffPreloader
+ def initialize(merge_requests)
+ @merge_requests = merge_requests
+ end
+
+ def preload_all
+ merge_request_diffs = MergeRequestDiff.latest_diff_for_merge_requests(@merge_requests)
+ cache = merge_request_diffs.index_by { |diff| diff.merge_request_id }
+
+ @merge_requests.each do |merge_request|
+ merge_request_diff = cache[merge_request.id]
+
+ merge_request.association(:merge_request_diff).target = merge_request_diff
+ merge_request.association(:merge_request_diff).loaded!
+ end
+ end
+ end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 4db0eaa0442..dbedd6d120c 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -33,7 +33,9 @@ class Project < ApplicationRecord
include FromUnion
include IgnorableColumns
include Integration
+ include EachBatch
extend Gitlab::Cache::RequestCache
+ extend Gitlab::Utils::Override
extend Gitlab::ConfigHelper
@@ -198,6 +200,7 @@ class Project < ApplicationRecord
has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :export_jobs, class_name: 'ProjectExportJob'
has_one :project_repository, inverse_of: :project
+ has_one :tracing_setting, class_name: 'ProjectTracingSetting'
has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting'
has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting'
has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting'
@@ -268,6 +271,7 @@ class Project < ApplicationRecord
has_many :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :project
has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :project
+ has_many :alert_management_http_integrations, class_name: 'AlertManagement::HttpIntegration', inverse_of: :project
# Container repositories need to remove data from the container registry,
# which is not managed by the DB. Hence we're still using dependent: :destroy
@@ -294,6 +298,7 @@ class Project < ApplicationRecord
# bulk that doesn't involve loading the rows into memory. As a result we're
# still using `dependent: :destroy` here.
has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :processables, class_name: 'Ci::Processable', inverse_of: :project
has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName'
has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks
has_many :build_report_results, class_name: 'Ci::BuildReportResult', inverse_of: :project
@@ -336,6 +341,8 @@ class Project < ApplicationRecord
has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project
has_many :reviews, inverse_of: :project
+ has_many :terraform_states, class_name: 'Terraform::State', inverse_of: :project
+
# GitLab Pages
has_many :pages_domains
has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', inverse_of: :project
@@ -361,6 +368,7 @@ class Project < ApplicationRecord
allow_destroy: true,
reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? }
+ accepts_nested_attributes_for :tracing_setting, update_only: true, allow_destroy: true
accepts_nested_attributes_for :incident_management_setting, update_only: true
accepts_nested_attributes_for :error_tracking_setting, update_only: true
accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true
@@ -392,7 +400,7 @@ class Project < ApplicationRecord
delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true
delegate :dashboard_timezone, to: :metrics_setting, allow_nil: true, prefix: true
delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci
- delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings
+ delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings, prefix: :ci
delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true
delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?,
:allow_merge_on_skipped_pipeline=, :has_confluence?,
@@ -432,6 +440,7 @@ class Project < ApplicationRecord
validate :visibility_level_allowed_by_group, if: :should_validate_visibility_level?
validate :visibility_level_allowed_as_fork, if: :should_validate_visibility_level?
validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) }
+ validate :changing_shared_runners_enabled_is_allowed
validates :repository_storage,
presence: true,
inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } }
@@ -560,6 +569,7 @@ class Project < ApplicationRecord
}
scope :imported_from, -> (type) { where(import_type: type) }
+ scope :with_tracing_enabled, -> { joins(:tracing_setting) }
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
@@ -595,7 +605,7 @@ class Project < ApplicationRecord
return public_to_user unless user
if user.is_a?(DeployToken)
- user.projects
+ user.accessible_projects
else
where('EXISTS (?) OR projects.visibility_level IN (?)',
user.authorizations_for_projects(min_access_level: min_access_level),
@@ -667,8 +677,6 @@ class Project < ApplicationRecord
scope :joins_import_state, -> { joins("INNER JOIN project_mirror_data import_state ON import_state.project_id = projects.id") }
scope :for_group, -> (group) { where(group: group) }
scope :for_group_and_its_subgroups, ->(group) { where(namespace_id: group.self_and_descendants.select(:id)) }
- scope :for_repository_storage, -> (repository_storage) { where(repository_storage: repository_storage) }
- scope :excluding_repository_storage, -> (repository_storage) { where.not(repository_storage: repository_storage) }
class << self
# Searches for a list of projects based on the query given in `query`.
@@ -842,6 +850,7 @@ class Project < ApplicationRecord
end
end
+ override :lfs_enabled?
def lfs_enabled?
return namespace.lfs_enabled? if self[:lfs_enabled].nil?
@@ -942,7 +951,7 @@ class Project < ApplicationRecord
latest_pipeline = ci_pipelines.latest_successful_for_ref(ref)
return unless latest_pipeline
- latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name)
+ latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name)
end
def latest_successful_build_for_sha(job_name, sha)
@@ -951,7 +960,7 @@ class Project < ApplicationRecord
latest_pipeline = ci_pipelines.latest_successful_for_sha(sha)
return unless latest_pipeline
- latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name)
+ latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name)
end
def latest_successful_build_for_ref!(job_name, ref = default_branch)
@@ -991,9 +1000,6 @@ class Project < ApplicationRecord
job_id =
if forked?
RepositoryForkWorker.perform_async(id)
- elsif gitlab_project_import?
- # Do not retry on Import/Export until https://gitlab.com/gitlab-org/gitlab-foss/issues/26189 is solved.
- RepositoryImportWorker.set(retry: false).perform_async(self.id)
else
RepositoryImportWorker.perform_async(self.id)
end
@@ -1186,6 +1192,15 @@ class Project < ApplicationRecord
end
end
+ def changing_shared_runners_enabled_is_allowed
+ return unless Feature.enabled?(:disable_shared_runners_on_group, default_enabled: true)
+ return unless new_record? || changes.has_key?(:shared_runners_enabled)
+
+ if shared_runners_enabled && group && group.shared_runners_setting == 'disabled_and_unoverridable'
+ errors.add(:shared_runners_enabled, _('cannot be enabled because parent group does not allow it'))
+ end
+ end
+
def to_param
if persisted? && errors.include?(:path)
path_was
@@ -1325,7 +1340,8 @@ class Project < ApplicationRecord
end
def find_or_initialize_services
- available_services_names = Service.available_services_names - disabled_services
+ available_services_names =
+ Service.available_services_names + Service.project_specific_services_names - disabled_services
available_services_names.map do |service_name|
find_or_initialize_service(service_name)
@@ -2292,6 +2308,10 @@ class Project < ApplicationRecord
[]
end
+ def mark_primary_write_location
+ # Overriden in EE
+ end
+
def toggle_ci_cd_settings!(settings_attribute)
ci_cd_settings.toggle!(settings_attribute)
end
@@ -2495,12 +2515,25 @@ class Project < ApplicationRecord
ci_config_path.presence || Ci::Pipeline::DEFAULT_CONFIG_PATH
end
+ def ci_config_for(sha)
+ repository.gitlab_ci_yml_for(sha, ci_config_path_or_default)
+ end
+
def enabled_group_deploy_keys
return GroupDeployKey.none unless group
GroupDeployKey.for_groups(group.self_and_ancestors_ids)
end
+ def feature_flags_client_token
+ instance = operations_feature_flags_client || create_operations_feature_flags_client!
+ instance.token
+ end
+
+ def tracing_external_url
+ tracing_setting&.external_url
+ end
+
private
def find_service(services, name)
@@ -2509,10 +2542,10 @@ class Project < ApplicationRecord
def build_from_instance_or_template(name)
instance = find_service(services_instances, name)
- return Service.build_from_integration(id, instance) if instance
+ return Service.build_from_integration(instance, project_id: id) if instance
template = find_service(services_templates, name)
- return Service.build_from_integration(id, template) if template
+ return Service.build_from_integration(template, project_id: id) if template
end
def services_templates
diff --git a/app/models/project_pages_metadatum.rb b/app/models/project_pages_metadatum.rb
index 8a1db4a9acf..bd1919fe7ed 100644
--- a/app/models/project_pages_metadatum.rb
+++ b/app/models/project_pages_metadatum.rb
@@ -5,6 +5,7 @@ class ProjectPagesMetadatum < ApplicationRecord
belongs_to :project, inverse_of: :pages_metadatum
belongs_to :artifacts_archive, class_name: 'Ci::JobArtifact'
+ belongs_to :pages_deployment
scope :deployed, -> { where(deployed: true) }
end
diff --git a/app/models/project_repository_storage_move.rb b/app/models/project_repository_storage_move.rb
index 2b74d9ccd88..76f428fe925 100644
--- a/app/models/project_repository_storage_move.rb
+++ b/app/models/project_repository_storage_move.rb
@@ -20,6 +20,10 @@ class ProjectRepositoryStorageMove < ApplicationRecord
inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } }
validate :project_repository_writable, on: :create
+ default_value_for(:destination_storage_name, allows_nil: false) do
+ pick_repository_storage
+ end
+
state_machine initial: :initial do
event :schedule do
transition initial: :scheduled
@@ -77,6 +81,12 @@ class ProjectRepositoryStorageMove < ApplicationRecord
scope :order_created_at_desc, -> { order(created_at: :desc) }
scope :with_projects, -> { includes(project: :route) }
+ class << self
+ def pick_repository_storage
+ Project.pick_repository_storage
+ end
+ end
+
private
def project_repository_writable
diff --git a/app/models/project_services/chat_message/deployment_message.rb b/app/models/project_services/chat_message/deployment_message.rb
index dae3a56116e..5deb757e60f 100644
--- a/app/models/project_services/chat_message/deployment_message.rb
+++ b/app/models/project_services/chat_message/deployment_message.rb
@@ -38,7 +38,11 @@ module ChatMessage
private
def message
- "Deploy to #{environment} #{humanized_status}"
+ if running?
+ "Starting deploy to #{environment}"
+ else
+ "Deploy to #{environment} #{humanized_status}"
+ end
end
def color
@@ -73,5 +77,9 @@ module ChatMessage
def humanized_status
status == 'success' ? 'succeeded' : status
end
+
+ def running?
+ status == 'running'
+ end
end
end
diff --git a/app/models/project_services/chat_message/issue_message.rb b/app/models/project_services/chat_message/issue_message.rb
index 0cdcfcf0237..c8e90b66bae 100644
--- a/app/models/project_services/chat_message/issue_message.rb
+++ b/app/models/project_services/chat_message/issue_message.rb
@@ -41,15 +41,11 @@ module ChatMessage
private
def message
- if opened_issue?
- "[#{project_link}] Issue #{state} by #{user_combined_name}"
- else
- "[#{project_link}] Issue #{issue_link} #{state} by #{user_combined_name}"
- end
+ "[#{project_link}] Issue #{issue_link} #{state} by #{user_combined_name}"
end
def opened_issue?
- action == "open"
+ action == 'open'
end
def description_message
@@ -57,7 +53,7 @@ module ChatMessage
title: issue_title,
title_link: issue_url,
text: format(description),
- color: "#C95823"
+ color: '#C95823'
}]
end
diff --git a/app/models/project_services/confluence_service.rb b/app/models/project_services/confluence_service.rb
index dd44a0d1d56..6db446fc04c 100644
--- a/app/models/project_services/confluence_service.rb
+++ b/app/models/project_services/confluence_service.rb
@@ -27,7 +27,7 @@ class ConfluenceService < Service
end
def description
- s_('ConfluenceService|Connect a Confluence Cloud Workspace to your GitLab project')
+ s_('ConfluenceService|Connect a Confluence Cloud Workspace to GitLab')
end
def detailed_description
diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb
index 4e4955b45d8..5a49f780d46 100644
--- a/app/models/project_services/drone_ci_service.rb
+++ b/app/models/project_services/drone_ci_service.rb
@@ -42,7 +42,7 @@ class DroneCiService < CiService
def commit_status_path(sha, ref)
Gitlab::Utils.append_path(
drone_url,
- "gitlab/#{project.full_path}/commits/#{sha}?branch=#{URI.encode(ref.to_s)}&access_token=#{token}")
+ "gitlab/#{project.full_path}/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}&access_token=#{token}")
end
def commit_status(sha, ref)
@@ -75,7 +75,7 @@ class DroneCiService < CiService
def build_page(sha, ref)
Gitlab::Utils.append_path(
drone_url,
- "gitlab/#{project.full_path}/redirect/commits/#{sha}?branch=#{URI.encode(ref.to_s)}")
+ "gitlab/#{project.full_path}/redirect/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}")
end
def title
diff --git a/app/models/project_services/packagist_service.rb b/app/models/project_services/packagist_service.rb
index 35dbedd1341..21f0a2b2463 100644
--- a/app/models/project_services/packagist_service.rb
+++ b/app/models/project_services/packagist_service.rb
@@ -16,7 +16,7 @@ class PackagistService < Service
end
def description
- 'Update your project on Packagist, the main Composer repository'
+ s_('Integrations|Update your projects on Packagist, the main Composer repository')
end
def self.to_param
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 67ab2c0ce8a..0d2f89fb18d 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -2,6 +2,7 @@
class ProjectStatistics < ApplicationRecord
include AfterCommitQueue
+ include CounterAttribute
belongs_to :project
belongs_to :namespace
@@ -9,6 +10,13 @@ class ProjectStatistics < ApplicationRecord
default_value_for :wiki_size, 0
default_value_for :snippets_size, 0
+ counter_attribute :build_artifacts_size
+ counter_attribute :storage_size
+
+ counter_attribute_after_flush do |project_statistic|
+ Namespaces::ScheduleAggregationWorker.perform_async(project_statistic.namespace_id)
+ end
+
before_save :update_storage_size
COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count, :snippets_size].freeze
@@ -29,6 +37,8 @@ class ProjectStatistics < ApplicationRecord
end
def refresh!(only: [])
+ return if Gitlab::Database.read_only?
+
COLUMNS_TO_REFRESH.each do |column, generator|
if only.empty? || only.include?(column)
public_send("update_#{column}") # rubocop:disable GitlabSecurity/PublicSend
@@ -96,12 +106,27 @@ class ProjectStatistics < ApplicationRecord
# Additional columns are updated depending on key => [columns], which allows
# to update statistics which are and also those which aren't included in storage_size
# or any other additional summary column in the future.
- def self.increment_statistic(project_id, key, amount)
+ def self.increment_statistic(project, key, amount)
raise ArgumentError, "Cannot increment attribute: #{key}" unless INCREMENTABLE_COLUMNS.key?(key)
return if amount == 0
- where(project_id: project_id)
- .columns_to_increment(key, amount)
+ project.statistics.try do |project_statistics|
+ if project_statistics.counter_attribute_enabled?(key)
+ statistics_to_increment = [key] + INCREMENTABLE_COLUMNS[key].to_a
+ statistics_to_increment.each do |statistic|
+ project_statistics.delayed_increment_counter(statistic, amount)
+ end
+ else
+ legacy_increment_statistic(project, key, amount)
+ end
+ end
+ end
+
+ def self.legacy_increment_statistic(project, key, amount)
+ where(project_id: project.id).columns_to_increment(key, amount)
+
+ Namespaces::ScheduleAggregationWorker.perform_async( # rubocop: disable CodeReuse/Worker
+ project.namespace_id)
end
def self.columns_to_increment(key, amount)
diff --git a/app/models/project_tracing_setting.rb b/app/models/project_tracing_setting.rb
new file mode 100644
index 00000000000..93fa80aed67
--- /dev/null
+++ b/app/models/project_tracing_setting.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class ProjectTracingSetting < ApplicationRecord
+ belongs_to :project
+
+ validates :external_url, length: { maximum: 255 }, public_url: true
+
+ before_validation :sanitize_external_url
+
+ private
+
+ def sanitize_external_url
+ self.external_url = Rails::Html::FullSanitizer.new.sanitize(self.external_url)
+ end
+end
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index bd570cf7ead..91fb3d4e4ba 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -1,10 +1,11 @@
# frozen_string_literal: true
class ProjectWiki < Wiki
+ self.container_class = Project
alias_method :project, :container
# Project wikis are tied to the main project storage
- delegate :storage, :repository_storage, :hashed_storage?, to: :container
+ delegate :storage, :repository_storage, :hashed_storage?, :lfs_enabled?, to: :container
override :disk_path
def disk_path(*args, &block)
diff --git a/app/models/prometheus_alert.rb b/app/models/prometheus_alert.rb
index f0441d4a3cb..684f50d5f58 100644
--- a/app/models/prometheus_alert.rb
+++ b/app/models/prometheus_alert.rb
@@ -4,6 +4,7 @@ class PrometheusAlert < ApplicationRecord
include Sortable
include UsageStatistics
include Presentable
+ include EachBatch
OPERATORS_MAP = {
lt: "<",
@@ -35,6 +36,7 @@ class PrometheusAlert < ApplicationRecord
scope :for_metric, -> (metric) { where(prometheus_metric: metric) }
scope :for_project, -> (project) { where(project_id: project) }
scope :for_environment, -> (environment) { where(environment_id: environment) }
+ scope :get_environment_id, -> { select(:environment_id).pluck(:environment_id) }
def self.distinct_projects
sub_query = self.group(:project_id).select(1)
diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb
index 9ddf66cd388..590eda62c11 100644
--- a/app/models/prometheus_metric.rb
+++ b/app/models/prometheus_metric.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class PrometheusMetric < ApplicationRecord
+ include EachBatch
+
belongs_to :project, validate: true, inverse_of: :prometheus_metrics
has_many :prometheus_alerts, inverse_of: :prometheus_metric
diff --git a/app/models/release.rb b/app/models/release.rb
index 4c9d89105d7..f2162a0f674 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -30,6 +30,12 @@ class Release < ApplicationRecord
scope :with_project_and_namespace, -> { includes(project: :namespace) }
scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) }
+ # Sorting
+ scope :order_created, -> { reorder('created_at ASC') }
+ scope :order_created_desc, -> { reorder('created_at DESC') }
+ scope :order_released, -> { reorder('released_at ASC') }
+ scope :order_released_desc, -> { reorder('released_at DESC') }
+
delegate :repository, to: :project
MAX_NUMBER_TO_DISPLAY = 3
@@ -92,6 +98,17 @@ class Release < ApplicationRecord
def set_released_at
self.released_at ||= created_at
end
+
+ def self.sort_by_attribute(method)
+ case method.to_s
+ when 'created_at_asc' then order_created
+ when 'created_at_desc' then order_created_desc
+ when 'released_at_asc' then order_released
+ when 'released_at_desc' then order_released_desc
+ else
+ order_created_desc
+ end
+ end
end
Release.prepend_if_ee('EE::Release')
diff --git a/app/models/repository.rb b/app/models/repository.rb
index ef17e010ba8..d4fd202b966 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -26,6 +26,7 @@ class Repository
delegate :ref_name_for_sha, to: :raw_repository
delegate :bundle_to_disk, to: :raw_repository
+ delegate :lfs_enabled?, to: :container
CreateTreeError = Class.new(StandardError)
AmbiguousRefError = Class.new(StandardError)
@@ -853,16 +854,16 @@ class Repository
def merge(user, source_sha, merge_request, message)
with_cache_hooks do
raw_repository.merge(user, source_sha, merge_request.target_branch, message) do |commit_id|
- merge_request.update(in_progress_merge_commit_sha: commit_id)
+ merge_request.update_and_mark_in_progress_merge_commit_sha(commit_id)
nil # Return value does not matter.
end
end
end
- def merge_to_ref(user, source_sha, merge_request, target_ref, message, first_parent_ref)
+ def merge_to_ref(user, source_sha, merge_request, target_ref, message, first_parent_ref, allow_conflicts = false)
branch = merge_request.target_branch
- raw.merge_to_ref(user, source_sha, branch, target_ref, message, first_parent_ref)
+ raw.merge_to_ref(user, source_sha, branch, target_ref, message, first_parent_ref, allow_conflicts)
end
def delete_refs(*ref_names)
@@ -873,7 +874,7 @@ class Repository
their_commit_id = commit(source)&.id
raise 'Invalid merge source' if their_commit_id.nil?
- merge_request&.update(in_progress_merge_commit_sha: their_commit_id)
+ merge_request&.update_and_mark_in_progress_merge_commit_sha(their_commit_id)
with_cache_hooks { raw.ff_merge(user, their_commit_id, target_branch) }
end
@@ -1142,21 +1143,10 @@ class Repository
end
def project
- if repo_type.snippet?
- container.project
- elsif container.is_a?(Project)
- container
- end
- end
-
- # TODO: pass this in directly to `Blob` rather than delegating it to here
- #
- # https://gitlab.com/gitlab-org/gitlab/-/issues/201886
- def lfs_enabled?
if container.is_a?(Project)
- container.lfs_enabled?
+ container
else
- false # LFS is not supported for snippet or group repositories
+ container.try(:project)
end
end
diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb
index cc96698be09..18e2944a9ca 100644
--- a/app/models/resource_label_event.rb
+++ b/app/models/resource_label_event.rb
@@ -15,6 +15,7 @@ class ResourceLabelEvent < ResourceEvent
validate :exactly_one_issuable
after_save :expire_etag_cache
+ after_save :usage_metrics
after_destroy :expire_etag_cache
enum action: {
@@ -113,6 +114,16 @@ class ResourceLabelEvent < ResourceEvent
def discussion_id_key
[self.class.name, created_at, user_id]
end
+
+ def for_issue?
+ issue_id.present?
+ end
+
+ def usage_metrics
+ return unless for_issue?
+
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user)
+ end
end
ResourceLabelEvent.prepend_if_ee('EE::ResourceLabelEvent')
diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb
index 1ce4e14d289..6475633868a 100644
--- a/app/models/resource_state_event.rb
+++ b/app/models/resource_state_event.rb
@@ -11,6 +11,8 @@ class ResourceStateEvent < ResourceEvent
# state is used for issue and merge request states.
enum state: Issue.available_states.merge(MergeRequest.available_states).merge(reopened: 5)
+ after_save :usage_metrics
+
def self.issuable_attrs
%i(issue merge_request).freeze
end
@@ -18,6 +20,29 @@ class ResourceStateEvent < ResourceEvent
def issuable
issue || merge_request
end
+
+ def for_issue?
+ issue_id.present?
+ end
+
+ private
+
+ def usage_metrics
+ return unless for_issue?
+
+ case state
+ when 'closed'
+ issue_usage_counter.track_issue_closed_action(author: user)
+ when 'reopened'
+ issue_usage_counter.track_issue_reopened_action(author: user)
+ else
+ # no-op, nothing to do, not a state we're tracking
+ end
+ end
+
+ def issue_usage_counter
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter
+ end
end
ResourceStateEvent.prepend_if_ee('EE::ResourceStateEvent')
diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb
index 44f48915425..dbb2b428c7b 100644
--- a/app/models/resource_timebox_event.rb
+++ b/app/models/resource_timebox_event.rb
@@ -13,6 +13,8 @@ class ResourceTimeboxEvent < ResourceEvent
remove: 2
}
+ after_save :usage_metrics
+
def self.issuable_attrs
%i(issue merge_request).freeze
end
@@ -20,4 +22,17 @@ class ResourceTimeboxEvent < ResourceEvent
def issuable
issue || merge_request
end
+
+ private
+
+ def usage_metrics
+ case self
+ when ResourceMilestoneEvent
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_milestone_changed_action(author: user)
+ when ResourceIterationEvent
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_iteration_changed_action(author: user)
+ else
+ # no-op
+ end
+ end
end
diff --git a/app/models/resource_weight_event.rb b/app/models/resource_weight_event.rb
deleted file mode 100644
index bbabd54325e..00000000000
--- a/app/models/resource_weight_event.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-class ResourceWeightEvent < ResourceEvent
- validates :issue, presence: true
-
- include IssueResourceEvent
-end
diff --git a/app/models/service.rb b/app/models/service.rb
index e63e06bf46f..764f417362f 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -7,9 +7,7 @@ class Service < ApplicationRecord
include Importable
include ProjectServicesLoggable
include DataFields
- include IgnorableColumns
-
- ignore_columns %i[default], remove_with: '13.5', remove_after: '2020-10-22'
+ include FromUnion
SERVICE_NAMES = %w[
alerts asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord
@@ -65,6 +63,7 @@ class Service < ApplicationRecord
scope :active, -> { where(active: true) }
scope :by_type, -> (type) { where(type: type) }
scope :by_active_flag, -> (flag) { where(active: flag) }
+ scope :inherit_from_id, -> (id) { where(inherit_from_id: id) }
scope :for_group, -> (group) { where(group_id: group, type: available_services_types) }
scope :for_template, -> { where(template: true, type: available_services_types) }
scope :for_instance, -> { where(instance: true, type: available_services_types) }
@@ -209,6 +208,10 @@ class Service < ApplicationRecord
DEV_SERVICE_NAMES
end
+ def self.project_specific_services_names
+ []
+ end
+
def self.available_services_types
available_services_names.map { |service_name| "#{service_name}_service".camelize }
end
@@ -217,7 +220,7 @@ class Service < ApplicationRecord
services_names.map { |service_name| "#{service_name}_service".camelize }
end
- def self.build_from_integration(project_id, integration)
+ def self.build_from_integration(integration, project_id: nil, group_id: nil)
service = integration.dup
if integration.supports_data_fields?
@@ -227,8 +230,9 @@ class Service < ApplicationRecord
service.template = false
service.instance = false
- service.inherit_from_id = integration.id if integration.instance?
service.project_id = project_id
+ service.group_id = group_id
+ service.inherit_from_id = integration.id if integration.instance? || integration.group
service.active = false if service.invalid?
service
end
@@ -245,7 +249,7 @@ class Service < ApplicationRecord
group_ids = scope.ancestors.select(:id)
array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]'
- where(type: type, group_id: group_ids)
+ where(type: type, group_id: group_ids, inherit_from_id: nil)
.order(Arel.sql("array_position(#{array}::bigint[], services.group_id)"))
.first
end
@@ -256,6 +260,19 @@ class Service < ApplicationRecord
end
private_class_method :instance_level_integration
+ def self.create_from_active_default_integrations(scope, association, with_templates: false)
+ group_ids = scope.ancestors.select(:id)
+ array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]'
+
+ from_union([
+ with_templates ? active.where(template: true) : none,
+ active.where(instance: true),
+ active.where(group_id: group_ids, inherit_from_id: nil)
+ ]).order(Arel.sql("type ASC, array_position(#{array}::bigint[], services.group_id), instance DESC")).group_by(&:type).each do |type, records|
+ build_from_integration(records.first, association => scope.id).save!
+ end
+ end
+
def activated?
active
end
diff --git a/app/models/service_list.rb b/app/models/service_list.rb
index 9cbc5e68059..5eca5f2bda1 100644
--- a/app/models/service_list.rb
+++ b/app/models/service_list.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
class ServiceList
- def initialize(batch_ids, service_hash, association)
- @batch_ids = batch_ids
+ def initialize(batch, service_hash, association)
+ @batch = batch
@service_hash = service_hash
@association = association
end
@@ -13,15 +13,15 @@ class ServiceList
private
- attr_reader :batch_ids, :service_hash, :association
+ attr_reader :batch, :service_hash, :association
def columns
- (service_hash.keys << "#{association}_id")
+ service_hash.keys << "#{association}_id"
end
def values
- batch_ids.map do |id|
- (service_hash.values << id)
+ batch.select(:id).map do |record|
+ service_hash.values << record.id
end
end
end
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 1cf3097861c..d71853e11cf 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -19,7 +19,6 @@ class Snippet < ApplicationRecord
extend ::Gitlab::Utils::Override
MAX_FILE_COUNT = 10
- MAX_SINGLE_FILE_COUNT = 1
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
@@ -175,8 +174,8 @@ class Snippet < ApplicationRecord
Snippet.find_by(id: id, project: project)
end
- def self.max_file_limit(user)
- Feature.enabled?(:snippet_multiple_files, user) ? MAX_FILE_COUNT : MAX_SINGLE_FILE_COUNT
+ def self.max_file_limit
+ MAX_FILE_COUNT
end
def initialize(attributes = {})
@@ -283,7 +282,8 @@ class Snippet < ApplicationRecord
strong_memoize(:repository_size_checker) do
::Gitlab::RepositorySizeChecker.new(
current_size_proc: -> { repository.size.megabytes },
- limit: Gitlab::CurrentSettings.snippet_size_limit
+ limit: Gitlab::CurrentSettings.snippet_size_limit,
+ namespace: nil
)
end
end
diff --git a/app/models/snippet_input_action_collection.rb b/app/models/snippet_input_action_collection.rb
index 38313e3a980..1e886e98083 100644
--- a/app/models/snippet_input_action_collection.rb
+++ b/app/models/snippet_input_action_collection.rb
@@ -8,7 +8,11 @@ class SnippetInputActionCollection
delegate :empty?, :any?, :[], to: :actions
def initialize(actions = [], allowed_actions: nil)
- @actions = actions.map { |action| SnippetInputAction.new(action.merge(allowed_actions: allowed_actions)) }
+ @actions = actions.map do |action|
+ params = action.merge(allowed_actions: allowed_actions)
+
+ SnippetInputAction.new(**params)
+ end
end
def to_commit_actions
diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb
index 2cfb201191d..fa25a6f8441 100644
--- a/app/models/snippet_repository.rb
+++ b/app/models/snippet_repository.rb
@@ -12,7 +12,7 @@ class SnippetRepository < ApplicationRecord
belongs_to :snippet, inverse_of: :snippet_repository
- delegate :repository, to: :snippet
+ delegate :repository, :repository_storage, to: :snippet
class << self
def find_snippet(disk_path)
diff --git a/app/models/snippet_statistics.rb b/app/models/snippet_statistics.rb
index 8545296d076..6fb6f0ef713 100644
--- a/app/models/snippet_statistics.rb
+++ b/app/models/snippet_statistics.rb
@@ -34,6 +34,8 @@ class SnippetStatistics < ApplicationRecord
end
def refresh!
+ return if Gitlab::Database.read_only?
+
update_commit_count
update_repository_size
update_file_count
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index 961212d0295..0ddf2c5fbcd 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -18,9 +18,9 @@ class SystemNoteMetadata < ApplicationRecord
commit description merge confidential visible label assignee cross_reference
designs_added designs_modified designs_removed designs_discussion_added
title time_tracking branch milestone discussion task moved
- opened closed merged duplicate locked unlocked outdated
+ opened closed merged duplicate locked unlocked outdated reviewer
tag due_date pinned_embed cherry_pick health_status approved unapproved
- status alert_issue_added relate unrelate new_alert_added
+ status alert_issue_added relate unrelate new_alert_added severity
].freeze
validates :note, presence: true
diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb
index 419fffcb666..9d88db27449 100644
--- a/app/models/terraform/state.rb
+++ b/app/models/terraform/state.rb
@@ -4,6 +4,12 @@ module Terraform
class State < ApplicationRecord
include UsageStatistics
include FileStoreMounter
+ include IgnorableColumns
+ # These columns are being removed since geo replication falls to the versioned state
+ # Tracking in https://gitlab.com/gitlab-org/gitlab/-/issues/258262
+ ignore_columns %i[verification_failure verification_retry_at verified_at verification_retry_count verification_checksum],
+ remove_with: '13.7',
+ remove_after: '2020-12-22'
HEX_REGEXP = %r{\A\h+\z}.freeze
UUID_LENGTH = 32
@@ -15,6 +21,7 @@ module Terraform
has_one :latest_version, -> { ordered_by_version_desc }, class_name: 'Terraform::StateVersion', foreign_key: :terraform_state_id
scope :versioning_not_enabled, -> { where(versioning_enabled: false) }
+ scope :ordered_by_name, -> { order(:name) }
validates :project_id, presence: true
validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH },
@@ -30,11 +37,11 @@ module Terraform
end
def latest_file
- versioning_enabled ? latest_version&.file : file
- end
-
- def local?
- file_store == ObjectStorage::Store::LOCAL
+ if versioning_enabled?
+ latest_version&.file
+ else
+ latest_version&.file || file
+ end
end
def locked?
@@ -43,15 +50,56 @@ module Terraform
def update_file!(data, version:)
if versioning_enabled?
- new_version = versions.build(version: version)
- new_version.assign_attributes(created_by_user: locked_by_user, file: data)
- new_version.save!
+ create_new_version!(data: data, version: version)
+ elsif latest_version.present?
+ migrate_legacy_version!(data: data, version: version)
else
self.file = data
save!
end
end
+
+ private
+
+ ##
+ # If a Terraform state was created before versioning support was
+ # introduced, it will have a single version record whose file
+ # uses a legacy naming scheme in object storage. To update
+ # these states and versions to use the new behaviour, we must do
+ # the following when creating the next version:
+ #
+ # * Read the current, non-versioned file from the old location.
+ # * Update the :versioning_enabled flag, which determines the
+ # naming scheme
+ # * Resave the existing file with the updated name and location,
+ # using a version number one prior to the new version
+ # * Create the new version as normal
+ #
+ # This migration only needs to happen once for each state, from
+ # then on the state will behave as if it was always versioned.
+ #
+ # The code can be removed in the next major version (14.0), after
+ # which any states that haven't been migrated will need to be
+ # recreated: https://gitlab.com/gitlab-org/gitlab/-/issues/258960
+ def migrate_legacy_version!(data:, version:)
+ current_file = latest_version.file.read
+ current_version = parse_serial(current_file) || version - 1
+
+ update!(versioning_enabled: true)
+
+ reload_latest_version.update!(version: current_version, file: CarrierWaveStringFile.new(current_file))
+ create_new_version!(data: data, version: version)
+ end
+
+ def create_new_version!(data:, version:)
+ new_version = versions.build(version: version, created_by_user: locked_by_user)
+ new_version.assign_attributes(file: data)
+ new_version.save!
+ end
+
+ def parse_serial(file)
+ Gitlab::Json.parse(file)["serial"]
+ rescue JSON::ParserError
+ end
end
end
-
-Terraform::State.prepend_if_ee('EE::Terraform::State')
diff --git a/app/models/terraform/state_version.rb b/app/models/terraform/state_version.rb
index d5e315d18a1..eff44485401 100644
--- a/app/models/terraform/state_version.rb
+++ b/app/models/terraform/state_version.rb
@@ -14,5 +14,11 @@ module Terraform
mount_file_store_uploader VersionedStateUploader
delegate :project_id, :uuid, to: :terraform_state, allow_nil: true
+
+ def local?
+ file_store == ObjectStorage::Store::LOCAL
+ end
end
end
+
+Terraform::StateVersion.prepend_if_ee('EE::Terraform::StateVersion')
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 6c8e085762d..0d893b25253 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -227,7 +227,7 @@ class Todo < ApplicationRecord
end
def self_assigned?
- assigned? && self_added?
+ self_added? && (assigned? || review_requested?)
end
private
diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb
index 81415eb383b..1a389081913 100644
--- a/app/models/u2f_registration.rb
+++ b/app/models/u2f_registration.rb
@@ -4,6 +4,19 @@
class U2fRegistration < ApplicationRecord
belongs_to :user
+ after_commit :schedule_webauthn_migration, on: :create
+ after_commit :update_webauthn_registration, on: :update, if: :counter_changed?
+
+ def schedule_webauthn_migration
+ BackgroundMigrationWorker.perform_async('MigrateU2fWebauthn', [id, id])
+ end
+
+ def update_webauthn_registration
+ # When we update the sign count of this registration
+ # we need to update the sign count of the corresponding webauthn registration
+ # as well if it exists already
+ WebauthnRegistration.find_by_credential_xid(webauthn_credential_xid)&.update_attribute(:counter, counter)
+ end
def self.register(user, app_id, params, challenges)
u2f = U2F::U2F.new(app_id)
@@ -40,4 +53,13 @@ class U2fRegistration < ApplicationRecord
rescue JSON::ParserError, NoMethodError, ArgumentError, U2F::Error
false
end
+
+ private
+
+ def webauthn_credential_xid
+ # To find the corresponding webauthn registration, we use that
+ # the key handle of the u2f reg corresponds to the credential xid of the webauthn reg
+ # (with some base64 back and forth)
+ Base64.strict_encode64(Base64.urlsafe_decode64(key_handle))
+ end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 0a784b30d8f..ef77e207215 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -64,14 +64,7 @@ class User < ApplicationRecord
# and should be added after Devise modules are initialized.
include AsyncDeviseEmail
- BLOCKED_MESSAGE = "Your account has been blocked. Please contact your GitLab " \
- "administrator if you think this is an error."
- LOGIN_FORBIDDEN = "Your account does not have the required permission to login. Please contact your GitLab " \
- "administrator if you think this is an error."
-
- MINIMUM_INACTIVE_DAYS = 180
-
- ignore_column :bio, remove_with: '13.4', remove_after: '2020-09-22'
+ MINIMUM_INACTIVE_DAYS = 90
# Override Devise::Models::Trackable#update_tracked_fields!
# to limit database writes to at most once every hour
@@ -134,6 +127,8 @@ class User < ApplicationRecord
-> { where(members: { access_level: [Gitlab::Access::REPORTER, Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) },
through: :group_members,
source: :group
+ has_many :minimal_access_group_members, -> { where(access_level: [Gitlab::Access::MINIMAL_ACCESS]) }, source: 'GroupMember', class_name: 'GroupMember'
+ has_many :minimal_access_groups, through: :minimal_access_group_members, source: :group
# Projects
has_many :groups_projects, through: :groups, source: :projects
@@ -172,6 +167,8 @@ class User < ApplicationRecord
has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
has_many :assigned_merge_requests, class_name: "MergeRequest", through: :merge_request_assignees, source: :merge_request
+ has_many :bulk_imports
+
has_many :custom_attributes, class_name: 'UserCustomAttribute'
has_many :callouts, class_name: 'UserCallout'
has_many :term_agreements
@@ -298,6 +295,7 @@ class User < ApplicationRecord
transition active: :blocked
transition deactivated: :blocked
transition ldap_blocked: :blocked
+ transition blocked_pending_approval: :blocked
end
event :ldap_block do
@@ -309,13 +307,18 @@ class User < ApplicationRecord
transition deactivated: :active
transition blocked: :active
transition ldap_blocked: :active
+ transition blocked_pending_approval: :active
+ end
+
+ event :block_pending_approval do
+ transition active: :blocked_pending_approval
end
event :deactivate do
transition active: :deactivated
end
- state :blocked, :ldap_blocked do
+ state :blocked, :ldap_blocked, :blocked_pending_approval do
def blocked?
true
end
@@ -339,6 +342,7 @@ class User < ApplicationRecord
# Scopes
scope :admins, -> { where(admin: true) }
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
+ scope :blocked_pending_approval, -> { with_states(:blocked_pending_approval) }
scope :external, -> { where(external: true) }
scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :active, -> { with_state(:active).non_internal }
@@ -381,11 +385,14 @@ class User < ApplicationRecord
super && can?(:log_in)
end
+ # The messages for these keys are defined in `devise.en.yml`
def inactive_message
- if blocked?
- BLOCKED_MESSAGE
+ if blocked_pending_approval?
+ :blocked_pending_approval
+ elsif blocked?
+ :blocked
elsif internal?
- LOGIN_FORBIDDEN
+ :forbidden
else
super
end
@@ -535,6 +542,8 @@ class User < ApplicationRecord
admins
when 'blocked'
blocked
+ when 'blocked_pending_approval'
+ blocked_pending_approval
when 'two_factor_disabled'
without_two_factor
when 'two_factor_enabled'
@@ -687,6 +696,17 @@ class User < ApplicationRecord
end
end
+ def security_bot
+ email_pattern = "security-bot%s@#{Settings.gitlab.host}"
+
+ unique_internal(where(user_type: :security_bot), 'GitLab-Security-Bot', email_pattern) do |u|
+ u.bio = 'System bot that monitors detected vulnerabilities for solutions and creates merge requests with the fixes.'
+ u.name = 'GitLab Security Bot'
+ u.website_url = Gitlab::Routing.url_helpers.help_page_url('user/application_security/security_bot/index.md')
+ u.avatar = bot_avatar(image: 'security-bot.png')
+ end
+ end
+
def support_bot
email_pattern = "support%s@#{Settings.gitlab.host}"
@@ -773,7 +793,7 @@ class User < ApplicationRecord
end
def two_factor_otp_enabled?
- otp_required_for_login?
+ otp_required_for_login? || Feature.enabled?(:forti_authenticator, self)
end
def two_factor_u2f_enabled?
@@ -1676,6 +1696,8 @@ class User < ApplicationRecord
end
def terms_accepted?
+ return true if project_bot?
+
accepted_term_id.present?
end
@@ -1706,7 +1728,7 @@ class User < ApplicationRecord
end
def can_be_deactivated?
- active? && no_recent_activity?
+ active? && no_recent_activity? && !internal?
end
def last_active_at
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
index 0ba319aa444..e39ff8712fc 100644
--- a/app/models/user_callout.rb
+++ b/app/models/user_callout.rb
@@ -19,7 +19,7 @@ class UserCallout < ApplicationRecord
webhooks_moved: 13,
service_templates_deprecated: 14,
admin_integrations_moved: 15,
- web_ide_alert_dismissed: 16,
+ web_ide_alert_dismissed: 16, # no longer in use
active_user_count_threshold: 18, # EE-only
buy_pipeline_minutes_notification_dot: 19, # EE-only
personal_access_token_expiry: 21, # EE-only
diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb
index 1c615777018..7e7a387d3d4 100644
--- a/app/models/user_interacted_project.rb
+++ b/app/models/user_interacted_project.rb
@@ -21,7 +21,7 @@ class UserInteractedProject < ApplicationRecord
user_id: event.author_id
}
- cached_exists?(attributes) do
+ cached_exists?(**attributes) do
transaction(requires_new: true) do
where(attributes).select(1).first || create!(attributes)
true # not caching the whole record here for now
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index d3b3a46bf74..c05bc80415a 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -8,6 +8,9 @@ class UserPreference < ApplicationRecord
belongs_to :user
+ scope :with_user, -> { joins(:user) }
+ scope :gitpod_enabled, -> { where(gitpod_enabled: true) }
+
validates :issue_notes_filter, :merge_request_notes_filter, inclusion: { in: NOTES_FILTERS.values }, presence: true
validates :tab_width, numericality: {
only_integer: true,
diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb
index 71d0b1db410..a4338c4e2bd 100644
--- a/app/models/vulnerability.rb
+++ b/app/models/vulnerability.rb
@@ -1,17 +1,7 @@
# frozen_string_literal: true
# Placeholder class for model that is implemented in EE
-# It reserves '+' as a reference prefix, but the table does not exist in FOSS
class Vulnerability < ApplicationRecord
- include IgnorableColumns
-
- def self.reference_prefix
- '+'
- end
-
- def self.reference_prefix_escaped
- '&plus;'
- end
end
Vulnerability.prepend_if_ee('EE::Vulnerability')
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
index 9462f7401c4..e329a094319 100644
--- a/app/models/wiki.rb
+++ b/app/models/wiki.rb
@@ -4,6 +4,7 @@ class Wiki
extend ::Gitlab::Utils::Override
include HasRepository
include Gitlab::Utils::StrongMemoize
+ include GlobalID::Identification
MARKUPS = { # rubocop:disable Style/MultilineIfModifier
'Markdown' => :markdown,
@@ -28,14 +29,46 @@ class Wiki
# an operation fails.
attr_reader :error_message
- def self.for_container(container, user = nil)
- "#{container.class.name}Wiki".constantize.new(container, user)
+ # Support run_after_commit callbacks, since we don't have a DB record
+ # we delegate to the container.
+ delegate :run_after_commit, to: :container
+
+ class << self
+ attr_accessor :container_class
+
+ def for_container(container, user = nil)
+ "#{container.class.name}Wiki".constantize.new(container, user)
+ end
+
+ # This is needed to support repository lookup through Gitlab::GlRepository::Identifier
+ def find_by_id(container_id)
+ container_class.find_by_id(container_id)&.wiki
+ end
end
def initialize(container, user = nil)
+ raise ArgumentError, "user must be a User, got #{user.class}" if user && !user.is_a?(User)
+
@container = container
@user = user
- raise ArgumentError, "user must be a User, got #{user.class}" if user && !user.is_a?(User)
+ end
+
+ def ==(other)
+ other.is_a?(self.class) && container == other.container
+ end
+
+ # This is needed in:
+ # - Storage::Hashed
+ # - Gitlab::GlRepository::RepoType#identifier_for_container
+ #
+ # We also need an `#id` to support `build_stubbed` in tests, where the
+ # value doesn't matter.
+ #
+ # NOTE: Wikis don't have a DB record, so this ID can be the same
+ # for two wikis in different containers and should not be expected to
+ # be unique. Use `to_global_id` instead if you need a unique ID.
+ def id
+ container.id
end
def path
@@ -103,10 +136,10 @@ class Wiki
limited = pages.size > limit
pages = pages.first(limit) if limited
- [WikiPage.group_by_directory(pages), limited]
+ [WikiDirectory.group_pages(pages), limited]
end
- # Finds a page within the repository based on a tile
+ # Finds a page within the repository based on a title
# or slug.
#
# title - The human readable or parameterized title of
@@ -183,7 +216,7 @@ class Wiki
override :repository
def repository
- @repository ||= Gitlab::GlRepository::WIKI.repository_for(container)
+ @repository ||= Gitlab::GlRepository::WIKI.repository_for(self)
end
def repository_storage
@@ -198,7 +231,6 @@ class Wiki
def full_path
container.full_path + '.wiki'
end
- alias_method :id, :full_path
# @deprecated use full_path when you need it for an URL route or disk_path when you want to point to the filesystem
alias_method :path_with_namespace, :full_path
diff --git a/app/models/wiki_directory.rb b/app/models/wiki_directory.rb
index df2fe25b08b..3a2613e15d9 100644
--- a/app/models/wiki_directory.rb
+++ b/app/models/wiki_directory.rb
@@ -3,13 +3,46 @@
class WikiDirectory
include ActiveModel::Validations
- attr_accessor :slug, :pages
+ attr_accessor :slug, :entries
validates :slug, presence: true
- def initialize(slug, pages = [])
+ # Groups a list of wiki pages into a nested collection of WikiPage and WikiDirectory objects,
+ # preserving the order of the passed pages.
+ #
+ # Returns an array with all entries for the toplevel directory.
+ #
+ # @param [Array<WikiPage>] pages
+ # @return [Array<WikiPage, WikiDirectory>]
+ #
+ def self.group_pages(pages)
+ # Build a hash to map paths to created WikiDirectory objects,
+ # and recursively create them for each level of the path.
+ # For the toplevel directory we use '' as path, as that's what WikiPage#directory returns.
+ directories = Hash.new do |_, path|
+ directories[path] = new(path).tap do |directory|
+ if path.present?
+ parent = File.dirname(path)
+ parent = '' if parent == '.'
+ directories[parent].entries << directory
+ end
+ end
+ end
+
+ pages.each do |page|
+ directories[page.directory].entries << page
+ end
+
+ directories[''].entries
+ end
+
+ def initialize(slug, entries = [])
@slug = slug
- @pages = pages
+ @entries = entries
+ end
+
+ def title
+ WikiPage.unhyphenize(File.basename(slug))
end
# Relative path to the partial to be used when rendering collections
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index faf3d19d936..989128987d5 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -31,29 +31,6 @@ class WikiPage
alias_method :==, :eql?
- # Sorts and groups pages by directory.
- #
- # pages - an array of WikiPage objects.
- #
- # Returns an array of WikiPage and WikiDirectory objects. The entries are
- # sorted by alphabetical order (directories and pages inside each directory).
- # Pages at the root level come before everything.
- def self.group_by_directory(pages)
- return [] if pages.blank?
-
- pages.each_with_object([]) do |page, grouped_pages|
- next grouped_pages << page unless page.directory.present?
-
- directory = grouped_pages.find do |obj|
- obj.is_a?(WikiDirectory) && obj.slug == page.directory
- end
-
- next directory.pages << page if directory
-
- grouped_pages << WikiDirectory.new(page.directory, [page])
- end
- end
-
def self.unhyphenize(name)
name.gsub(/-+/, ' ')
end
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 13d732e4edd..1c93073025d 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -27,10 +27,7 @@ class BasePolicy < DeclarativePolicy::Base
desc "User email is unconfirmed or user account is locked"
with_options scope: :user, score: 0
- condition(:inactive) do
- Feature.enabled?(:inactive_policy_condition, default_enabled: true) &&
- @user&.confirmation_required_on_sign_in? || @user&.access_locked?
- end
+ condition(:inactive) { @user&.confirmation_required_on_sign_in? || @user&.access_locked? }
with_options scope: :user, score: 0
condition(:external_user) { @user.nil? || @user.external? }
diff --git a/app/policies/ci/bridge_policy.rb b/app/policies/ci/bridge_policy.rb
new file mode 100644
index 00000000000..37a07ea8aaf
--- /dev/null
+++ b/app/policies/ci/bridge_policy.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Ci
+ class BridgePolicy < CommitStatusPolicy
+ condition(:can_update_downstream_branch) do
+ ::Gitlab::UserAccess.new(@user, container: @subject.downstream_project)
+ .can_update_branch?(@subject.target_revision_ref)
+ end
+
+ rule { can_update_downstream_branch }.enable :play_job
+ end
+end
diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb
index b3950c6a0e3..3efc07421e4 100644
--- a/app/policies/ci/build_policy.rb
+++ b/app/policies/ci/build_policy.rb
@@ -60,6 +60,8 @@ module Ci
rule { can?(:update_build) & terminal }.enable :create_build_terminal
+ rule { can?(:update_build) }.enable :play_job
+
rule { is_web_ide_terminal & can?(:create_web_ide_terminal) & (admin | owner_of_job) }.policy do
enable :read_web_ide_terminal
enable :update_web_ide_terminal
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index de69636b078..c1ea4dddb51 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -98,6 +98,7 @@ class GlobalPolicy < BasePolicy
rule { admin }.policy do
enable :read_custom_attribute
enable :update_custom_attribute
+ enable :approve_user
end
# We can't use `read_statistics` because the user may have different permissions for different projects
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index c98e82efef7..f9ec026a6d2 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -46,6 +46,19 @@ class GroupPolicy < BasePolicy
group_projects_for(user: @user, group: @subject, only_owned: false).any? { |p| p.design_management_enabled? }
end
+ desc "Deploy token with read_package_registry scope"
+ condition(:read_package_registry_deploy_token) do
+ @user.is_a?(DeployToken) && @user.groups.include?(@subject) && @user.read_package_registry
+ end
+
+ desc "Deploy token with write_package_registry scope"
+ condition(:write_package_registry_deploy_token) do
+ @user.is_a?(DeployToken) && @user.groups.include?(@subject) && @user.write_package_registry
+ end
+
+ with_scope :subject
+ condition(:resource_access_token_available) { resource_access_token_available? }
+
rule { design_management_enabled }.policy do
enable :read_design_activity
end
@@ -91,7 +104,6 @@ class GroupPolicy < BasePolicy
rule { developer }.policy do
enable :admin_milestone
- enable :read_package
enable :create_metrics_dashboard_annotation
enable :delete_metrics_dashboard_annotation
enable :update_metrics_dashboard_annotation
@@ -105,6 +117,7 @@ class GroupPolicy < BasePolicy
enable :admin_issue
enable :read_metrics_dashboard_annotation
enable :read_prometheus
+ enable :read_package
end
rule { maintainer }.policy do
@@ -167,6 +180,20 @@ class GroupPolicy < BasePolicy
rule { maintainer & can?(:create_projects) }.enable :transfer_projects
+ rule { read_package_registry_deploy_token }.policy do
+ enable :read_package
+ enable :read_group
+ end
+
+ rule { write_package_registry_deploy_token }.policy do
+ enable :create_package
+ enable :read_group
+ end
+
+ rule { resource_access_token_available & can?(:admin_group) }.policy do
+ enable :admin_resource_access_tokens
+ end
+
def access_level
return GroupMember::NO_ACCESS if @user.nil?
return GroupMember::NO_ACCESS unless user_is_user?
@@ -183,6 +210,14 @@ class GroupPolicy < BasePolicy
def user_is_user?
user.is_a?(User)
end
+
+ def group
+ @subject
+ end
+
+ def resource_access_token_available?
+ true
+ end
end
GroupPolicy.prepend_if_ee('EE::GroupPolicy')
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index b02bb8621ed..44c448eb601 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -15,9 +15,6 @@ class IssuePolicy < IssuablePolicy
desc "Issue is confidential"
condition(:confidential, scope: :subject) { @subject.confidential? }
- desc "Issue has moved"
- condition(:moved) { @subject.moved? }
-
rule { confidential & ~can_read_confidential }.policy do
prevent(*create_read_update_admin_destroy(:issue))
prevent :read_issue_iid
@@ -38,12 +35,6 @@ class IssuePolicy < IssuablePolicy
rule { ~can?(:read_design) }.policy do
prevent :move_design
end
-
- rule { locked | moved }.policy do
- prevent :create_design
- prevent :move_design
- prevent :destroy_design
- end
end
IssuePolicy.prepend_if_ee('EE::IssuePolicy')
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 87ee7d201e4..59e2d617bf7 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -104,6 +104,9 @@ class ProjectPolicy < BasePolicy
with_scope :subject
condition(:service_desk_enabled) { @subject.service_desk_enabled? }
+ with_scope :subject
+ condition(:resource_access_token_available) { resource_access_token_available? }
+
# We aren't checking `:read_issue` or `:read_merge_request` in this case
# because it could be possible for a user to see an issuable-iid
# (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be
@@ -237,7 +240,6 @@ class ProjectPolicy < BasePolicy
enable :read_merge_request
enable :read_sentry_issue
enable :update_sentry_issue
- enable :read_incidents
enable :read_prometheus
enable :read_metrics_dashboard_annotation
enable :metrics_dashboard
@@ -589,6 +591,10 @@ class ProjectPolicy < BasePolicy
prevent :read_project
end
+ rule { resource_access_token_available & can?(:admin_project) }.policy do
+ enable :admin_resource_access_tokens
+ end
+
private
def user_is_user?
@@ -663,6 +669,10 @@ class ProjectPolicy < BasePolicy
end
end
+ def resource_access_token_available?
+ true
+ end
+
def project
@subject
end
diff --git a/app/policies/releases/evidence_policy.rb b/app/policies/releases/evidence_policy.rb
index 701913e6fe4..3e35f2f5e87 100644
--- a/app/policies/releases/evidence_policy.rb
+++ b/app/policies/releases/evidence_policy.rb
@@ -15,6 +15,7 @@ module Releases
# - Project
# - Milestones
# - Issues
+ # TODO: remove issues from this check: https://gitlab.com/gitlab-org/gitlab/-/issues/259674
condition(:allowed_to_read_evidence) do
can?(:read_release) &&
can?(:download_code) &&
diff --git a/app/policies/terraform/state_policy.rb b/app/policies/terraform/state_policy.rb
new file mode 100644
index 00000000000..ba6109e5975
--- /dev/null
+++ b/app/policies/terraform/state_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Terraform
+ class StatePolicy < BasePolicy
+ alias_method :terraform_state, :subject
+
+ delegate { terraform_state.project }
+ end
+end
diff --git a/app/policies/wiki_policy.rb b/app/policies/wiki_policy.rb
new file mode 100644
index 00000000000..a551439d0d4
--- /dev/null
+++ b/app/policies/wiki_policy.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class WikiPolicy < ::BasePolicy
+ # Wiki policies are delegated to their container objects (Project or Group)
+ delegate { subject.container }
+end
diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb
index 5debe6d5dbd..4bfa3dc9a13 100644
--- a/app/presenters/alert_management/alert_presenter.rb
+++ b/app/presenters/alert_management/alert_presenter.rb
@@ -8,10 +8,11 @@ module AlertManagement
MARKDOWN_LINE_BREAK = " \n"
HORIZONTAL_LINE = "\n\n---\n\n"
+ INCIDENT_LABEL_NAME = ::IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES[:title]
delegate :metrics_dashboard_url, :runbook, to: :parsed_payload
- def initialize(alert, _attributes = {})
+ def initialize(alert, **attributes)
super
@alert = alert
@@ -38,6 +39,30 @@ module AlertManagement
Gitlab::Utils::InlineHash.merge_keys(payload)
end
+ def show_incident_issues_link?
+ project.incident_management_setting&.create_issue?
+ end
+
+ def show_performance_dashboard_link?
+ prometheus_alert.present?
+ end
+
+ def incident_issues_link
+ project_issues_url(project, label_name: INCIDENT_LABEL_NAME)
+ end
+
+ def performance_dashboard_link
+ if environment
+ metrics_project_environment_url(project, environment)
+ else
+ metrics_project_environments_url(project)
+ end
+ end
+
+ def email_title
+ [environment&.name, query_title].compact.join(': ')
+ end
+
private
attr_reader :alert, :project
@@ -80,5 +105,11 @@ module AlertManagement
def host_links
hosts.join(' ')
end
+
+ def query_title
+ return title unless prometheus_alert
+
+ "#{prometheus_alert.title} #{prometheus_alert.computed_operator} #{prometheus_alert.threshold} for 5 minutes"
+ end
end
end
diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb
index efb3cf7f348..62488465c24 100644
--- a/app/presenters/clusterable_presenter.rb
+++ b/app/presenters/clusterable_presenter.rb
@@ -8,7 +8,7 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated
attributes_with_presenter_class = attributes.merge(presenter_class: presenter_class)
Gitlab::View::Presenter::Factory
- .new(clusterable, attributes_with_presenter_class)
+ .new(clusterable, **attributes_with_presenter_class)
.fabricate!
end
diff --git a/app/presenters/environment_presenter.rb b/app/presenters/environment_presenter.rb
new file mode 100644
index 00000000000..3fa31eb69a2
--- /dev/null
+++ b/app/presenters/environment_presenter.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class EnvironmentPresenter < Gitlab::View::Presenter::Delegated
+ include ActionView::Helpers::UrlHelper
+
+ presents :environment
+
+ def path
+ if Feature.enabled?(:expose_environment_path_in_alert_details, project)
+ project_environment_path(project, self)
+ end
+ end
+end
diff --git a/app/presenters/event_presenter.rb b/app/presenters/event_presenter.rb
index 8f2388c2c31..c37721f7213 100644
--- a/app/presenters/event_presenter.rb
+++ b/app/presenters/event_presenter.rb
@@ -29,4 +29,26 @@ class EventPresenter < Gitlab::View::Presenter::Delegated
''
end
end
+
+ def target_type_name
+ if design?
+ 'Design'
+ elsif wiki_page?
+ 'Wiki Page'
+ elsif target_type.present?
+ target_type.titleize
+ else
+ "Project"
+ end.downcase
+ end
+
+ def note_target_type_name
+ return unless note?
+
+ if design_note?
+ 'Design'
+ else
+ target.noteable_type.titleize
+ end.downcase
+ end
end
diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb
index 7704e6b59c1..94c1195ed6a 100644
--- a/app/presenters/instance_clusterable_presenter.rb
+++ b/app/presenters/instance_clusterable_presenter.rb
@@ -8,7 +8,7 @@ class InstanceClusterablePresenter < ClusterablePresenter
attributes_with_presenter_class = attributes.merge(presenter_class: InstanceClusterablePresenter)
Gitlab::View::Presenter::Factory
- .new(clusterable, attributes_with_presenter_class)
+ .new(clusterable, **attributes_with_presenter_class)
.fabricate!
end
diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb
index 185fcd3e934..0b498ce97d8 100644
--- a/app/presenters/issue_presenter.rb
+++ b/app/presenters/issue_presenter.rb
@@ -11,3 +11,5 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated
issue.subscribed?(current_user, issue.project)
end
end
+
+IssuePresenter.prepend_if_ee('EE::IssuePresenter')
diff --git a/app/presenters/label_presenter.rb b/app/presenters/label_presenter.rb
index 68aa05ada8e..c23d6ce2218 100644
--- a/app/presenters/label_presenter.rb
+++ b/app/presenters/label_presenter.rb
@@ -2,6 +2,7 @@
class LabelPresenter < Gitlab::View::Presenter::Delegated
presents :label
+ delegate :name, :full_name, to: :label_subject, prefix: :subject
def edit_path
case label
@@ -39,8 +40,8 @@ class LabelPresenter < Gitlab::View::Presenter::Delegated
label.is_a?(ProjectLabel)
end
- def subject_name
- label.subject.name
+ def label_subject
+ @label_subject ||= label.subject
end
private
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index 1ff02412994..a22138011ae 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -37,7 +37,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
def remove_wip_path
- if work_in_progress? && can?(current_user, :update_merge_request, merge_request.project)
+ if can?(current_user, :update_merge_request, merge_request.project)
remove_wip_project_merge_request_path(project, merge_request)
end
end
diff --git a/app/presenters/packages/detail/package_presenter.rb b/app/presenters/packages/detail/package_presenter.rb
index bdb2e34854e..e8223d6498b 100644
--- a/app/presenters/packages/detail/package_presenter.rb
+++ b/app/presenters/packages/detail/package_presenter.rb
@@ -8,10 +8,13 @@ module Packages
end
def detail_view
+ name = @package.name
+ name = @package.conan_recipe if @package.conan?
+
package_detail = {
id: @package.id,
created_at: @package.created_at,
- name: @package.name,
+ name: name,
package_files: @package.package_files.map { |pf| build_package_file_view(pf) },
package_type: @package.package_type,
project_id: @package.project_id,
@@ -20,6 +23,7 @@ module Packages
version: @package.version
}
+ package_detail[:conan_package_name] = @package.name if @package.conan?
package_detail[:maven_metadatum] = @package.maven_metadatum if @package.maven_metadatum
package_detail[:nuget_metadatum] = @package.nuget_metadatum if @package.nuget_metadatum
package_detail[:composer_metadatum] = @package.composer_metadatum if @package.composer_metadatum
diff --git a/app/presenters/packages/pypi/package_presenter.rb b/app/presenters/packages/pypi/package_presenter.rb
index 4192e974645..1cb11c7be1a 100644
--- a/app/presenters/packages/pypi/package_presenter.rb
+++ b/app/presenters/packages/pypi/package_presenter.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Display package version data acording to PyPi
+# Display package version data acording to PyPI
# Simple API: https://warehouse.pypa.io/api-reference/legacy/#simple-project-api
module Packages
module Pypi
@@ -12,7 +12,7 @@ module Packages
@project = project
end
- # Returns the HTML body for PyPi simple API.
+ # Returns the HTML body for PyPI simple API.
# Basically a list of package download links for a specific
# package
def body
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index ef75c160b2d..392eeafb2b4 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -65,14 +65,14 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
if can?(current_user, :download_code, project)
user_view
- elsif user_view == "activity"
- "activity"
- elsif can?(current_user, :read_wiki, project)
- "wiki"
- elsif feature_available?(:issues, current_user)
- "projects/issues/issues"
+ elsif user_view == 'activity'
+ 'activity'
+ elsif project.wiki_repository_exists? && can?(current_user, :read_wiki, project)
+ 'wiki'
+ elsif can?(current_user, :read_issue, project)
+ 'projects/issues/issues'
else
- "customize_workflow"
+ 'activity'
end
end
@@ -106,26 +106,38 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
add_special_file_path(file_name: 'LICENSE')
end
+ def add_license_ide_path
+ ide_edit_path(project, default_branch_or_master, 'LICENSE')
+ end
+
def add_changelog_path
add_special_file_path(file_name: 'CHANGELOG')
end
+ def add_changelog_ide_path
+ ide_edit_path(project, default_branch_or_master, 'CHANGELOG')
+ end
+
def add_contribution_guide_path
add_special_file_path(file_name: 'CONTRIBUTING.md', commit_message: 'Add CONTRIBUTING')
end
- def add_ci_yml_path
- add_special_file_path(file_name: ci_config_path_or_default)
+ def add_contribution_guide_ide_path
+ ide_edit_path(project, default_branch_or_master, 'CONTRIBUTING.md')
end
- def add_ci_yml_ide_path
- ide_edit_path(project, default_branch_or_master, ci_config_path_or_default)
+ def add_ci_yml_path
+ add_special_file_path(file_name: ci_config_path_or_default)
end
def add_readme_path
add_special_file_path(file_name: 'README.md')
end
+ def add_readme_ide_path
+ ide_edit_path(project, default_branch_or_master, 'README.md')
+ end
+
def license_short_name
license = repository.license
license&.nickname || license&.name || 'LICENSE'
@@ -222,9 +234,11 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
def new_file_anchor_data
if current_user && can_current_user_push_to_default_branch?
+ new_file_path = empty_repo? ? ide_edit_path(project, default_branch_or_master) : project_new_blob_path(project, default_branch_or_master)
+
AnchorData.new(false,
statistic_icon + _('New file'),
- project_new_blob_path(project, default_branch_or_master),
+ new_file_path,
'missing')
end
end
@@ -233,7 +247,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
if current_user && can_current_user_push_to_default_branch? && repository.readme.nil?
AnchorData.new(false,
statistic_icon + _('Add README'),
- add_readme_path)
+ empty_repo? ? add_readme_ide_path : add_readme_path)
elsif repository.readme
AnchorData.new(false,
statistic_icon('doc-text') + _('README'),
@@ -247,7 +261,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
if current_user && can_current_user_push_to_default_branch? && repository.changelog.blank?
AnchorData.new(false,
statistic_icon + _('Add CHANGELOG'),
- add_changelog_path)
+ empty_repo? ? add_changelog_ide_path : add_changelog_path)
elsif repository.changelog.present?
AnchorData.new(false,
statistic_icon('doc-text') + _('CHANGELOG'),
@@ -268,7 +282,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
if current_user && can_current_user_push_to_default_branch?
AnchorData.new(false,
content_tag(:span, statistic_icon + _('Add LICENSE'), class: 'add-license-link d-flex'),
- add_license_path)
+ empty_repo? ? add_license_ide_path : add_license_path)
else
AnchorData.new(false,
icon + content_tag(:span, _('No license. All rights reserved'), class: 'project-stat-value'),
@@ -281,7 +295,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
if current_user && can_current_user_push_to_default_branch? && repository.contribution_guide.blank?
AnchorData.new(false,
statistic_icon + _('Add CONTRIBUTING'),
- add_contribution_guide_path)
+ empty_repo? ? add_contribution_guide_ide_path : add_contribution_guide_path)
elsif repository.contribution_guide.present?
AnchorData.new(false,
statistic_icon('doc-text') + _('CONTRIBUTING'),
@@ -330,7 +344,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
if cicd_missing?
AnchorData.new(false,
statistic_icon + _('Set up CI/CD'),
- add_ci_yml_ide_path)
+ add_ci_yml_path)
elsif repository.gitlab_ci_yml.present?
AnchorData.new(false,
statistic_icon('doc-text') + _('CI/CD configuration'),
@@ -393,6 +407,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
def anonymous_project_view
if !project.empty_repo? && can?(current_user, :download_code, project)
'files'
+ elsif project.wiki_repository_exists? && can?(current_user, :read_wiki, project)
+ 'wiki'
+ elsif can?(current_user, :read_issue, project)
+ 'projects/issues/issues'
else
'activity'
end
diff --git a/app/presenters/projects/prometheus/alert_presenter.rb b/app/presenters/projects/prometheus/alert_presenter.rb
deleted file mode 100644
index 783b2b2b1e0..00000000000
--- a/app/presenters/projects/prometheus/alert_presenter.rb
+++ /dev/null
@@ -1,179 +0,0 @@
-# frozen_string_literal: true
-
-module Projects
- module Prometheus
- class AlertPresenter < Gitlab::View::Presenter::Delegated
- GENERIC_ALERT_SUMMARY_ANNOTATIONS = %w(monitoring_tool service hosts).freeze
- MARKDOWN_LINE_BREAK = " \n".freeze
- INCIDENT_LABEL_NAME = ::IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES[:title].freeze
- METRIC_TIME_WINDOW = 30.minutes
-
- def full_title
- [environment_name, alert_title].compact.join(': ')
- end
-
- def project_full_path
- project.full_path
- end
-
- def metric_query
- gitlab_alert&.full_query
- end
-
- def environment_name
- environment&.name
- end
-
- def performance_dashboard_link
- if environment
- metrics_project_environment_url(project, environment)
- else
- metrics_project_environments_url(project)
- end
- end
-
- def show_performance_dashboard_link?
- gitlab_alert.present?
- end
-
- def show_incident_issues_link?
- project.incident_management_setting&.create_issue?
- end
-
- def incident_issues_link
- project_issues_url(project, label_name: INCIDENT_LABEL_NAME)
- end
-
- def start_time
- starts_at&.strftime('%d %B %Y, %-l:%M%p (%Z)')
- end
-
- def issue_summary_markdown
- <<~MARKDOWN.chomp
- #{metadata_list}
- #{metric_embed_for_alert}
- MARKDOWN
- end
-
- def metric_embed_for_alert
- "\n[](#{metrics_dashboard_url})" if metrics_dashboard_url
- end
-
- def metrics_dashboard_url
- strong_memoize(:metrics_dashboard_url) do
- embed_url_for_gitlab_alert || embed_url_for_self_managed_alert
- end
- end
-
- def details_url
- return unless am_alert
-
- ::Gitlab::Routing.url_helpers.details_project_alert_management_url(
- project,
- am_alert.iid
- )
- end
-
- private
-
- def alert_title
- query_title || title
- end
-
- def query_title
- return unless gitlab_alert
-
- "#{gitlab_alert.title} #{gitlab_alert.computed_operator} #{gitlab_alert.threshold} for 5 minutes"
- end
-
- def metadata_list
- metadata = []
-
- metadata << list_item('Start time', start_time) if start_time
- metadata << list_item('full_query', backtick(full_query)) if full_query
- metadata << list_item(service.label.humanize, service.value) if service
- metadata << list_item(monitoring_tool.label.humanize, monitoring_tool.value) if monitoring_tool
- metadata << list_item(hosts.label.humanize, host_links) if hosts
- metadata << list_item('GitLab alert', details_url) if details_url
-
- metadata.join(MARKDOWN_LINE_BREAK)
- end
-
- def details
- Gitlab::Utils::InlineHash.merge_keys(payload)
- end
-
- def list_item(key, value)
- "**#{key}:** #{value}".strip
- end
-
- def backtick(value)
- "`#{value}`"
- end
-
- GENERIC_ALERT_SUMMARY_ANNOTATIONS.each do |annotation_name|
- define_method(annotation_name) do
- annotations.find { |a| a.label == annotation_name }
- end
- end
-
- def host_links
- Array(hosts.value).join(' ')
- end
-
- def embed_url_for_gitlab_alert
- return unless gitlab_alert
-
- metrics_dashboard_project_prometheus_alert_url(
- project,
- gitlab_alert.prometheus_metric_id,
- environment_id: environment.id,
- embedded: true,
- **alert_embed_window_params(embed_time)
- )
- end
-
- def embed_url_for_self_managed_alert
- return unless environment && full_query && title
-
- metrics_dashboard_project_environment_url(
- project,
- environment,
- embed_json: dashboard_for_self_managed_alert.to_json,
- embedded: true,
- **alert_embed_window_params(embed_time)
- )
- end
-
- def embed_time
- starts_at || Time.current
- end
-
- def alert_embed_window_params(time)
- {
- start: format_embed_timestamp(time - METRIC_TIME_WINDOW),
- end: format_embed_timestamp(time + METRIC_TIME_WINDOW)
- }
- end
-
- def format_embed_timestamp(time)
- time.utc.strftime('%FT%TZ')
- end
-
- def dashboard_for_self_managed_alert
- {
- panel_groups: [{
- panels: [{
- type: 'area-chart',
- title: title,
- y_label: y_label,
- metrics: [{
- query_range: full_query
- }]
- }]
- }]
- }
- end
- end
- end
-end
diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb
index 4393ca05f48..c27059c6d63 100644
--- a/app/presenters/release_presenter.rb
+++ b/app/presenters/release_presenter.rb
@@ -20,8 +20,6 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
end
def self_url
- return unless ::Feature.enabled?(:release_show_page, project, default_enabled: true)
-
project_release_url(project, release)
end
diff --git a/app/presenters/sentry_error_presenter.rb b/app/presenters/sentry_error_presenter.rb
index ba724b0f8be..669bcb68b7c 100644
--- a/app/presenters/sentry_error_presenter.rb
+++ b/app/presenters/sentry_error_presenter.rb
@@ -14,7 +14,7 @@ class SentryErrorPresenter < Gitlab::View::Presenter::Delegated
end
def project_id
- Gitlab::GlobalId.build(model_name: 'Project', id: error.project_id).to_s
+ Gitlab::GlobalId.build(model_name: 'SentryProject', id: error.project_id).to_s
end
def frequency
diff --git a/app/presenters/snippet_blob_presenter.rb b/app/presenters/snippet_blob_presenter.rb
index abe95f5c44d..597ef6ebc39 100644
--- a/app/presenters/snippet_blob_presenter.rb
+++ b/app/presenters/snippet_blob_presenter.rb
@@ -25,10 +25,6 @@ class SnippetBlobPresenter < BlobPresenter
private
- def snippet_multiple_files?
- blob.container.repository_exists? && Feature.enabled?(:snippet_multiple_files, current_user)
- end
-
def snippet
blob.container
end
@@ -52,8 +48,8 @@ class SnippetBlobPresenter < BlobPresenter
end
def snippet_blob_raw_route(only_path: false)
- return gitlab_raw_snippet_blob_url(snippet, blob.path, only_path: only_path) if snippet_multiple_files?
+ return gitlab_raw_snippet_url(snippet, only_path: only_path) unless snippet.repository_exists?
- gitlab_raw_snippet_url(snippet, only_path: only_path)
+ gitlab_raw_snippet_blob_url(snippet, blob.path, only_path: only_path)
end
end
diff --git a/app/presenters/snippet_presenter.rb b/app/presenters/snippet_presenter.rb
index d814c4404b6..695aa266e2c 100644
--- a/app/presenters/snippet_presenter.rb
+++ b/app/presenters/snippet_presenter.rb
@@ -32,15 +32,9 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated
end
def blob
- blobs.first
- end
+ return snippet.blob if snippet.empty_repo?
- def blobs
- if snippet.empty_repo?
- [snippet.blob]
- else
- snippet.blobs
- end
+ blobs.first
end
private
diff --git a/app/serializers/README.md b/app/serializers/README.md
index 2cbe6f9d263..89721f572e0 100644
--- a/app/serializers/README.md
+++ b/app/serializers/README.md
@@ -47,11 +47,11 @@ representation. It rarely happens that a serialization entity exists without
a corresponding domain model class. As an example, we have an `Issue` class and
a corresponding `IssueSerializer`.
-Serialization entites are designed to reuse other serialization entities, which
+Serialization entities are designed to reuse other serialization entities, which
is a convenient way to create a multi-level JSON representation of a piece of
a domain model you want to serialize.
-See [documentation for Grape Entites][grape-entity-readme] for more details.
+See [documentation for Grape Entities][grape-entity-readme] for more details.
## How to implement a serializer?
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index 2b8522539b4..109213ab729 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -35,7 +35,7 @@ class BuildDetailsEntity < JobEntity
browse_project_job_artifacts_path(project, build)
end
- expose :keep_path, if: -> (*) { (build.locked_artifacts? || build.has_expiring_archive_artifacts?) && can?(current_user, :update_build, build) } do |build|
+ expose :keep_path, if: -> (*) { (build.has_expired_locked_archive_artifacts? || build.has_expiring_archive_artifacts?) && can?(current_user, :update_build, build) } do |build|
keep_project_job_artifacts_path(project, build)
end
@@ -133,7 +133,7 @@ class BuildDetailsEntity < JobEntity
def callout_message
return super unless build.failure_reason.to_sym == :missing_dependency_failure
- docs_url = "https://docs.gitlab.com/ce/ci/yaml/README.html#dependencies"
+ docs_url = "https://docs.gitlab.com/ee/ci/yaml/README.html#dependencies"
[
failure_message.html_safe,
diff --git a/app/serializers/ci/trigger_entity.rb b/app/serializers/ci/trigger_entity.rb
new file mode 100644
index 00000000000..005a9b752ed
--- /dev/null
+++ b/app/serializers/ci/trigger_entity.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Ci
+ class TriggerEntity < Grape::Entity
+ include Gitlab::Routing
+ include Gitlab::Allowable
+
+ expose :description
+ expose :owner, using: UserEntity
+ expose :last_used
+
+ expose :token do |trigger|
+ can_admin_trigger?(trigger) ? trigger.token : trigger.short_token
+ end
+
+ expose :has_token_exposed do |trigger|
+ can_admin_trigger?(trigger)
+ end
+
+ expose :can_access_project do |trigger|
+ trigger.can_access_project?
+ end
+
+ expose :project_trigger_path, if: -> (trigger) { can_manage_trigger?(trigger) } do |trigger|
+ project_trigger_path(options[:project], trigger)
+ end
+
+ expose :edit_project_trigger_path, if: -> (trigger) { can_admin_trigger?(trigger) } do |trigger|
+ edit_project_trigger_path(options[:project], trigger)
+ end
+
+ private
+
+ def can_manage_trigger?(trigger)
+ can?(options[:current_user], :manage_trigger, trigger)
+ end
+
+ def can_admin_trigger?(trigger)
+ can?(options[:current_user], :admin_trigger, trigger)
+ end
+ end
+end
diff --git a/app/serializers/ci/trigger_serializer.rb b/app/serializers/ci/trigger_serializer.rb
new file mode 100644
index 00000000000..8e42ec12c3f
--- /dev/null
+++ b/app/serializers/ci/trigger_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Ci
+ class TriggerSerializer < BaseSerializer
+ entity ::Ci::TriggerEntity
+ end
+end
diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb
index eea0acdc11b..b904666971e 100644
--- a/app/serializers/cluster_entity.rb
+++ b/app/serializers/cluster_entity.rb
@@ -6,6 +6,8 @@ class ClusterEntity < Grape::Entity
expose :cluster_type
expose :enabled
expose :environment_scope
+ expose :id
+ expose :namespace_per_environment
expose :name
expose :nodes
expose :provider_type
diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb
index 700a46040e3..f71591612a6 100644
--- a/app/serializers/cluster_serializer.rb
+++ b/app/serializers/cluster_serializer.rb
@@ -12,6 +12,7 @@ class ClusterSerializer < BaseSerializer
:environment_scope,
:gitlab_managed_apps_logs_path,
:enable_advanced_logs_querying,
+ :id,
:kubernetes_errors,
:name,
:nodes,
diff --git a/app/serializers/container_repository_entity.rb b/app/serializers/container_repository_entity.rb
index 4c87d1438b0..9a002971bef 100644
--- a/app/serializers/container_repository_entity.rb
+++ b/app/serializers/container_repository_entity.rb
@@ -4,6 +4,7 @@ class ContainerRepositoryEntity < Grape::Entity
include RequestAwareEntity
expose :id, :name, :path, :location, :created_at, :status, :tags_count
+ expose :expiration_policy_started_at, as: :cleanup_policy_started_at
expose :tags_path do |repository|
project_registry_repository_tags_path(project, repository, format: :json)
diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb
index dc7c4654208..a37011d0100 100644
--- a/app/serializers/deployment_entity.rb
+++ b/app/serializers/deployment_entity.rb
@@ -17,6 +17,7 @@ class DeploymentEntity < Grape::Entity
end
end
+ expose :status
expose :created_at
expose :deployed_at
expose :tag
diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb
index 9f27191c3c8..596f5d686da 100644
--- a/app/serializers/diff_file_base_entity.rb
+++ b/app/serializers/diff_file_base_entity.rb
@@ -34,7 +34,7 @@ class DiffFileBaseEntity < Grape::Entity
expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file|
merge_request = options[:merge_request]
- next unless merge_request.merged? || merge_request.source_branch_exists?
+ next unless has_edit_path?(merge_request)
target_project, target_branch = edit_project_branch_options(merge_request)
@@ -43,6 +43,14 @@ class DiffFileBaseEntity < Grape::Entity
project_edit_blob_path(target_project, tree_join(target_branch, diff_file.new_path), options)
end
+ expose :ide_edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file|
+ merge_request = options[:merge_request]
+
+ next unless has_edit_path?(merge_request)
+
+ gitlab_ide_merge_request_path(merge_request)
+ end
+
expose :old_path_html do |diff_file|
old_path, _ = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
old_path
@@ -125,4 +133,8 @@ class DiffFileBaseEntity < Grape::Entity
[merge_request.target_project, merge_request.target_branch]
end
end
+
+ def has_edit_path?(merge_request)
+ merge_request.merged? || merge_request.source_branch_exists?
+ end
end
diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb
index 6ef524b5bec..0b4f21c55f4 100644
--- a/app/serializers/diffs_entity.rb
+++ b/app/serializers/diffs_entity.rb
@@ -78,7 +78,7 @@ class DiffsEntity < Grape::Entity
options[:merge_request_diffs]
end
- expose :definition_path_prefix, if: -> (diff_file) { Feature.enabled?(:code_navigation, merge_request.project, default_enabled: true) } do |diffs|
+ expose :definition_path_prefix do |diffs|
project_blob_path(merge_request.project, diffs.diff_refs&.head_sha)
end
@@ -89,8 +89,6 @@ class DiffsEntity < Grape::Entity
private
def code_navigation_path(diffs)
- return unless Feature.enabled?(:code_navigation, merge_request.project, default_enabled: true)
-
Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs&.head_sha)
end
diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb
index 2957205a81c..497471699b2 100644
--- a/app/serializers/discussion_entity.rb
+++ b/app/serializers/discussion_entity.rb
@@ -69,9 +69,6 @@ class DiscussionEntity < Grape::Entity
end
def display_merge_ref_discussions?(discussion)
- return unless discussion.diff_discussion?
- return if discussion.legacy_diff_discussion?
-
- Feature.enabled?(:merge_ref_head_comments, discussion.project, default_enabled: true)
+ discussion.diff_discussion? && !discussion.legacy_diff_discussion?
end
end
diff --git a/app/serializers/feature_flag_entity.rb b/app/serializers/feature_flag_entity.rb
new file mode 100644
index 00000000000..80cf869a389
--- /dev/null
+++ b/app/serializers/feature_flag_entity.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class FeatureFlagEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id
+ expose :iid
+ expose :active
+ expose :created_at
+ expose :updated_at
+ expose :name
+ expose :description
+ expose :version
+
+ expose :edit_path, if: -> (feature_flag, _) { can_update?(feature_flag) } do |feature_flag|
+ edit_project_feature_flag_path(feature_flag.project, feature_flag)
+ end
+
+ expose :update_path, if: -> (feature_flag, _) { can_update?(feature_flag) } do |feature_flag|
+ project_feature_flag_path(feature_flag.project, feature_flag)
+ end
+
+ expose :destroy_path, if: -> (feature_flag, _) { can_destroy?(feature_flag) } do |feature_flag|
+ project_feature_flag_path(feature_flag.project, feature_flag)
+ end
+
+ expose :scopes, with: FeatureFlagScopeEntity do |feature_flag|
+ feature_flag.scopes.sort_by(&:id)
+ end
+
+ expose :strategies, with: FeatureFlags::StrategyEntity do |feature_flag|
+ feature_flag.strategies.sort_by(&:id)
+ end
+
+ private
+
+ def can_update?(feature_flag)
+ can?(current_user, :update_feature_flag, feature_flag)
+ end
+
+ def can_destroy?(feature_flag)
+ can?(current_user, :destroy_feature_flag, feature_flag)
+ end
+
+ def current_user
+ request.current_user
+ end
+end
diff --git a/app/serializers/feature_flag_scope_entity.rb b/app/serializers/feature_flag_scope_entity.rb
new file mode 100644
index 00000000000..0450797a545
--- /dev/null
+++ b/app/serializers/feature_flag_scope_entity.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class FeatureFlagScopeEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id
+ expose :active
+ expose :environment_scope
+ expose :created_at
+ expose :updated_at
+ expose :strategies
+end
diff --git a/app/serializers/feature_flag_serializer.rb b/app/serializers/feature_flag_serializer.rb
new file mode 100644
index 00000000000..e0ff33cc61a
--- /dev/null
+++ b/app/serializers/feature_flag_serializer.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class FeatureFlagSerializer < BaseSerializer
+ include WithPagination
+ entity FeatureFlagEntity
+
+ def represent(resource, opts = {})
+ super(resource, opts)
+ end
+end
diff --git a/app/serializers/feature_flag_summary_entity.rb b/app/serializers/feature_flag_summary_entity.rb
new file mode 100644
index 00000000000..be4f02dabca
--- /dev/null
+++ b/app/serializers/feature_flag_summary_entity.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class FeatureFlagSummaryEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :count do
+ expose :all do |project|
+ project.operations_feature_flags.count
+ end
+
+ expose :enabled do |project|
+ project.operations_feature_flags.enabled.count
+ end
+
+ expose :disabled do |project|
+ project.operations_feature_flags.disabled.count
+ end
+ end
+end
diff --git a/app/serializers/feature_flag_summary_serializer.rb b/app/serializers/feature_flag_summary_serializer.rb
new file mode 100644
index 00000000000..46f70666e40
--- /dev/null
+++ b/app/serializers/feature_flag_summary_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class FeatureFlagSummarySerializer < BaseSerializer
+ entity FeatureFlagSummaryEntity
+end
diff --git a/app/serializers/feature_flags/scope_entity.rb b/app/serializers/feature_flags/scope_entity.rb
new file mode 100644
index 00000000000..1c9dd491652
--- /dev/null
+++ b/app/serializers/feature_flags/scope_entity.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class ScopeEntity < Grape::Entity
+ expose :id
+ expose :environment_scope
+ end
+end
diff --git a/app/serializers/feature_flags/strategy_entity.rb b/app/serializers/feature_flags/strategy_entity.rb
new file mode 100644
index 00000000000..73450476869
--- /dev/null
+++ b/app/serializers/feature_flags/strategy_entity.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class StrategyEntity < Grape::Entity
+ expose :id
+ expose :name
+ expose :parameters
+ expose :scopes, with: FeatureFlags::ScopeEntity
+ expose :user_list, with: FeatureFlags::UserListEntity, expose_nil: false
+ end
+end
diff --git a/app/serializers/feature_flags/user_list_entity.rb b/app/serializers/feature_flags/user_list_entity.rb
new file mode 100644
index 00000000000..d3fddb4fa7a
--- /dev/null
+++ b/app/serializers/feature_flags/user_list_entity.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class UserListEntity < Grape::Entity
+ expose :id
+ expose :iid
+ expose :name
+ expose :user_xids
+ end
+end
diff --git a/app/serializers/feature_flags_client_entity.rb b/app/serializers/feature_flags_client_entity.rb
new file mode 100644
index 00000000000..4a195c7d759
--- /dev/null
+++ b/app/serializers/feature_flags_client_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class FeatureFlagsClientEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :token
+end
diff --git a/app/serializers/feature_flags_client_serializer.rb b/app/serializers/feature_flags_client_serializer.rb
new file mode 100644
index 00000000000..104729b6668
--- /dev/null
+++ b/app/serializers/feature_flags_client_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class FeatureFlagsClientSerializer < BaseSerializer
+ entity FeatureFlagsClientEntity
+
+ def represent_token(resource, opts = {})
+ represent(resource, only: [:token])
+ end
+end
diff --git a/app/serializers/group_group_link_entity.rb b/app/serializers/group_group_link_entity.rb
index 7a51e1a9316..1e736214f54 100644
--- a/app/serializers/group_group_link_entity.rb
+++ b/app/serializers/group_group_link_entity.rb
@@ -1,17 +1,31 @@
# frozen_string_literal: true
class GroupGroupLinkEntity < Grape::Entity
+ include RequestAwareEntity
+
expose :id
expose :created_at
expose :expires_at do |group_link|
group_link.expires_at&.to_time
end
+ expose :can_update do |group_link|
+ can_manage?(group_link)
+ end
+
+ expose :can_remove do |group_link|
+ can_manage?(group_link)
+ end
+
expose :access_level do
expose :human_access, as: :string_value
expose :group_access, as: :integer_value
end
+ expose :valid_roles do |group_link|
+ group_link.class.access_options
+ end
+
expose :shared_with_group do
expose :avatar_url do |group_link|
group_link.shared_with_group.avatar_url(only_path: false)
@@ -23,4 +37,14 @@ class GroupGroupLinkEntity < Grape::Entity
expose :shared_with_group, merge: true, using: GroupBasicEntity
end
+
+ private
+
+ def current_user
+ options[:current_user]
+ end
+
+ def can_manage?(group_link)
+ can?(current_user, :admin_group_member, group_link.shared_group)
+ end
end
diff --git a/app/serializers/import/bulk_import_entity.rb b/app/serializers/import/bulk_import_entity.rb
new file mode 100644
index 00000000000..8f0a9dd4428
--- /dev/null
+++ b/app/serializers/import/bulk_import_entity.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Import::BulkImportEntity < Grape::Entity
+ expose :id do |entity|
+ entity['id']
+ end
+
+ expose :full_name do |entity|
+ entity['full_name']
+ end
+
+ expose :full_path do |entity|
+ entity['full_path']
+ end
+end
diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb
index 5082245dda9..e586d7f8407 100644
--- a/app/serializers/label_entity.rb
+++ b/app/serializers/label_entity.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class LabelEntity < Grape::Entity
- expose :id, if: ->(label, _) { !label.is_a?(GlobalLabel) }
+ expose :id
expose :title
expose :color
diff --git a/app/serializers/label_serializer.rb b/app/serializers/label_serializer.rb
index 25b9f7de243..b09592da67f 100644
--- a/app/serializers/label_serializer.rb
+++ b/app/serializers/label_serializer.rb
@@ -4,6 +4,6 @@ class LabelSerializer < BaseSerializer
entity LabelEntity
def represent_appearance(resource)
- represent(resource, { only: [:id, :title, :color, :text_color] })
+ represent(resource, { only: [:id, :title, :color, :text_color, :project_id] })
end
end
diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb
index ef1177e9967..9e2bce53c8a 100644
--- a/app/serializers/merge_request_basic_entity.rb
+++ b/app/serializers/merge_request_basic_entity.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class MergeRequestBasicEntity < Grape::Entity
+ expose :title
expose :public_merge_status, as: :merge_status
expose :merge_error
expose :state
diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb
index 002be8be729..080b6554de1 100644
--- a/app/serializers/merge_request_poll_cached_widget_entity.rb
+++ b/app/serializers/merge_request_poll_cached_widget_entity.rb
@@ -15,7 +15,7 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
expose :target_project_id
expose :squash
expose :rebase_in_progress?, as: :rebase_in_progress
- expose :default_squash_commit_message, if: -> (merge_request, _) { merge_request.mergeable? }
+ expose :default_squash_commit_message
expose :commits_count
expose :merge_ongoing?, as: :merge_ongoing
expose :work_in_progress?, as: :work_in_progress
@@ -25,10 +25,10 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
expose :source_branch_exists?, as: :source_branch_exists
expose :branch_missing?, as: :branch_missing
- expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity,
- if: -> (merge_request, _) { merge_request.mergeable? } do |merge_request|
+ expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity do |merge_request|
merge_request.recent_commits.without_merge_commits
end
+
expose :diff_head_sha do |merge_request|
merge_request.diff_head_sha.presence
end
@@ -46,6 +46,12 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
end
end
+ expose :actual_head_pipeline, as: :pipeline, if: -> (mr, _) {
+ Feature.enabled?(:merge_request_cached_pipeline_serializer, mr.project) && presenter(mr).can_read_pipeline?
+ } do |merge_request, options|
+ MergeRequests::PipelineEntity.represent(merge_request.actual_head_pipeline, options)
+ end
+
# Paths
#
expose :target_branch_commits_path do |merge_request|
diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb
index 41ab5005091..9609a894e6d 100644
--- a/app/serializers/merge_request_poll_widget_entity.rb
+++ b/app/serializers/merge_request_poll_widget_entity.rb
@@ -19,20 +19,14 @@ class MergeRequestPollWidgetEntity < Grape::Entity
# User entities
expose :merge_user, using: UserEntity
- expose :actual_head_pipeline, as: :pipeline, if: -> (mr, _) { presenter(mr).can_read_pipeline? } do |merge_request, options|
- if Feature.enabled?(:merge_request_short_pipeline_serializer, merge_request.project, default_enabled: true)
- MergeRequests::PipelineEntity.represent(merge_request.actual_head_pipeline, options)
- else
- PipelineDetailsEntity.represent(merge_request.actual_head_pipeline, options)
- end
+ expose :actual_head_pipeline, as: :pipeline, if: -> (mr, _) {
+ Feature.disabled?(:merge_request_cached_pipeline_serializer, mr.project) && presenter(mr).can_read_pipeline?
+ } do |merge_request, options|
+ MergeRequests::PipelineEntity.represent(merge_request.actual_head_pipeline, options)
end
expose :merge_pipeline, if: ->(mr, _) { mr.merged? && can?(request.current_user, :read_pipeline, mr.target_project)} do |merge_request, options|
- if Feature.enabled?(:merge_request_short_pipeline_serializer, merge_request.project, default_enabled: true)
- MergeRequests::PipelineEntity.represent(merge_request.merge_pipeline, options)
- else
- PipelineDetailsEntity.represent(merge_request.merge_pipeline, options)
- end
+ MergeRequests::PipelineEntity.represent(merge_request.merge_pipeline, options)
end
expose :default_merge_commit_message
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index 494192c8dbb..44cbcfc5044 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -67,15 +67,15 @@ class MergeRequestWidgetEntity < Grape::Entity
)
end
- expose :user_callouts_path, if: -> (*) { Feature.enabled?(:suggest_pipeline) } do |merge_request|
+ expose :user_callouts_path, if: -> (_, opts) { opts[:experiment_enabled] == :suggest_pipeline } do |_merge_request|
user_callouts_path
end
- expose :suggest_pipeline_feature_id, if: -> (*) { Feature.enabled?(:suggest_pipeline) } do |merge_request|
+ expose :suggest_pipeline_feature_id, if: -> (_, opts) { opts[:experiment_enabled] == :suggest_pipeline } do |_merge_request|
SUGGEST_PIPELINE
end
- expose :is_dismissed_suggest_pipeline, if: -> (*) { Feature.enabled?(:suggest_pipeline) } do |merge_request|
+ expose :is_dismissed_suggest_pipeline, if: -> (_, opts) { opts[:experiment_enabled] == :suggest_pipeline } do |_merge_request|
current_user && current_user.dismissed_callout?(feature_name: SUGGEST_PIPELINE)
end
@@ -120,10 +120,18 @@ class MergeRequestWidgetEntity < Grape::Entity
end
expose :base_path do |merge_request|
- base_pipeline_downloadable_path_for_report_type(:codequality)
+ if use_merge_base_with_merged_results?
+ merge_base_pipeline_downloadable_path_for_report_type(:codequality)
+ else
+ base_pipeline_downloadable_path_for_report_type(:codequality)
+ end
end
end
+ expose :security_reports_docs_path do |merge_request|
+ help_page_path('user/application_security/sast/index.md', anchor: 'reports-json-format')
+ end
+
private
delegate :current_user, to: :request
@@ -152,6 +160,16 @@ class MergeRequestWidgetEntity < Grape::Entity
object.base_pipeline&.present(current_user: current_user)
&.downloadable_path_for_report_type(file_type)
end
+
+ def use_merge_base_with_merged_results?
+ Feature.enabled?(:merge_base_pipelines, object.target_project) &&
+ object.actual_head_pipeline&.merge_request_event_type == :merged_result
+ end
+
+ def merge_base_pipeline_downloadable_path_for_report_type(file_type)
+ object.merge_base_pipeline&.present(current_user: current_user)
+ &.downloadable_path_for_report_type(file_type)
+ end
end
MergeRequestWidgetEntity.prepend_if_ee('EE::MergeRequestWidgetEntity')
diff --git a/app/serializers/paginated_diff_entity.rb b/app/serializers/paginated_diff_entity.rb
index 37c48338e55..f24571f7d7d 100644
--- a/app/serializers/paginated_diff_entity.rb
+++ b/app/serializers/paginated_diff_entity.rb
@@ -37,8 +37,6 @@ class PaginatedDiffEntity < Grape::Entity
private
def code_navigation_path(diffs)
- return unless Feature.enabled?(:code_navigation, merge_request.project, default_enabled: true)
-
Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs&.head_sha)
end
diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb
index 64958b06f1d..2d278f0e30d 100644
--- a/app/serializers/pipeline_entity.rb
+++ b/app/serializers/pipeline_entity.rb
@@ -82,7 +82,9 @@ class PipelineEntity < Grape::Entity
end
expose :failed_builds, if: -> (*) { can_retry? }, using: JobEntity do |pipeline|
- pipeline.failed_builds
+ pipeline.failed_builds.each do |build|
+ build.project = pipeline.project
+ end
end
private
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index 45c5a1d3e1c..a45214670fa 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -47,6 +47,7 @@ class PipelineSerializer < BaseSerializer
:retryable_builds,
:scheduled_actions,
:stages,
+ :latest_statuses,
:trigger_requests,
:user,
{
@@ -62,7 +63,14 @@ class PipelineSerializer < BaseSerializer
pending_builds: :project,
project: [:route, { namespace: :route }],
triggered_by_pipeline: [{ project: [:route, { namespace: :route }] }, :user],
- triggered_pipelines: [{ project: [:route, { namespace: :route }] }, :user, :source_job]
+ triggered_pipelines: [
+ {
+ project: [:route, { namespace: :route }]
+ },
+ :source_job,
+ :latest_statuses,
+ :user
+ ]
}
]
end
diff --git a/app/serializers/test_case_entity.rb b/app/serializers/test_case_entity.rb
index d2e08590ef0..b44aa62ad73 100644
--- a/app/serializers/test_case_entity.rb
+++ b/app/serializers/test_case_entity.rb
@@ -6,6 +6,7 @@ class TestCaseEntity < Grape::Entity
expose :status
expose :name
expose :classname
+ expose :file
expose :execution_time
expose :system_output
expose :stack_trace
diff --git a/app/services/admin/propagate_integration_service.rb b/app/services/admin/propagate_integration_service.rb
index 34d6008cb6a..96a6d861e47 100644
--- a/app/services/admin/propagate_integration_service.rb
+++ b/app/services/admin/propagate_integration_service.rb
@@ -7,66 +7,45 @@ module Admin
def propagate
update_inherited_integrations
- create_integration_for_groups_without_integration if Feature.enabled?(:group_level_integrations)
- create_integration_for_projects_without_integration
+ if integration.instance?
+ create_integration_for_groups_without_integration if Feature.enabled?(:group_level_integrations)
+ create_integration_for_projects_without_integration
+ else
+ create_integration_for_groups_without_integration_belonging_to_group
+ create_integration_for_projects_without_integration_belonging_to_group
+ end
end
private
# rubocop: disable Cop/InBatches
- # rubocop: disable CodeReuse/ActiveRecord
def update_inherited_integrations
- Service.where(type: integration.type, inherit_from_id: integration.id).in_batches(of: BATCH_SIZE) do |batch|
- bulk_update_from_integration(batch)
+ Service.by_type(integration.type).inherit_from_id(integration.id).in_batches(of: BATCH_SIZE) do |services|
+ min_id, max_id = services.pick("MIN(services.id), MAX(services.id)")
+ PropagateIntegrationInheritWorker.perform_async(integration.id, min_id, max_id)
end
end
# rubocop: enable Cop/InBatches
- # rubocop: enable CodeReuse/ActiveRecord
-
- # rubocop: disable CodeReuse/ActiveRecord
- def bulk_update_from_integration(batch)
- # Retrieving the IDs instantiates the ActiveRecord relation (batch)
- # into concrete models, otherwise update_all will clear the relation.
- # https://stackoverflow.com/q/34811646/462015
- batch_ids = batch.pluck(:id)
-
- Service.transaction do
- batch.update_all(service_hash)
-
- if data_fields_present?
- integration.data_fields.class.where(service_id: batch_ids).update_all(data_fields_hash)
- end
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
def create_integration_for_groups_without_integration
- loop do
- batch = Group.uncached { group_ids_without_integration(integration, BATCH_SIZE) }
-
- bulk_create_from_integration(batch, 'group') unless batch.empty?
-
- break if batch.size < BATCH_SIZE
+ Group.without_integration(integration).each_batch(of: BATCH_SIZE) do |groups|
+ min_id, max_id = groups.pick("MIN(namespaces.id), MAX(namespaces.id)")
+ PropagateIntegrationGroupWorker.perform_async(integration.id, min_id, max_id)
end
end
- def service_hash
- @service_hash ||= integration.to_service_hash
- .tap { |json| json['inherit_from_id'] = integration.id }
+ def create_integration_for_groups_without_integration_belonging_to_group
+ integration.group.descendants.without_integration(integration).each_batch(of: BATCH_SIZE) do |groups|
+ min_id, max_id = groups.pick("MIN(namespaces.id), MAX(namespaces.id)")
+ PropagateIntegrationGroupWorker.perform_async(integration.id, min_id, max_id)
+ end
end
- # rubocop:disable CodeReuse/ActiveRecord
- def group_ids_without_integration(integration, limit)
- services = Service
- .select('1')
- .where('services.group_id = namespaces.id')
- .where(type: integration.type)
-
- Group
- .where('NOT EXISTS (?)', services)
- .limit(limit)
- .pluck(:id)
+ def create_integration_for_projects_without_integration_belonging_to_group
+ Project.without_integration(integration).in_namespace(integration.group.self_and_descendants).each_batch(of: BATCH_SIZE) do |projects|
+ min_id, max_id = projects.pick("MIN(projects.id), MAX(projects.id)")
+ PropagateIntegrationProjectWorker.perform_async(integration.id, min_id, max_id)
+ end
end
- # rubocop:enable CodeReuse/ActiveRecord
end
end
diff --git a/app/services/admin/propagate_service_template.rb b/app/services/admin/propagate_service_template.rb
index cd0d2d5d03f..07be3c1027d 100644
--- a/app/services/admin/propagate_service_template.rb
+++ b/app/services/admin/propagate_service_template.rb
@@ -9,11 +9,5 @@ module Admin
create_integration_for_projects_without_integration
end
-
- private
-
- def service_hash
- @service_hash ||= integration.to_service_hash
- end
end
end
diff --git a/app/services/alert_management/alerts/update_service.rb b/app/services/alert_management/alerts/update_service.rb
index 18d615aa7e7..464d5f2ecea 100644
--- a/app/services/alert_management/alerts/update_service.rb
+++ b/app/services/alert_management/alerts/update_service.rb
@@ -13,6 +13,7 @@ module AlertManagement
@current_user = current_user
@params = params
@param_errors = []
+ @status = params.delete(:status)
end
def execute
@@ -35,7 +36,7 @@ module AlertManagement
private
- attr_reader :alert, :current_user, :params, :param_errors
+ attr_reader :alert, :current_user, :params, :param_errors, :status
delegate :resolved?, to: :alert
def allowed?
@@ -68,8 +69,12 @@ module AlertManagement
param_errors << message
end
+ def param_errors?
+ params.empty? && status.blank?
+ end
+
def filter_params
- param_errors << _('Please provide attributes to update') if params.empty?
+ param_errors << _('Please provide attributes to update') if param_errors?
filter_status
filter_assignees
@@ -110,9 +115,9 @@ module AlertManagement
# ------ Status-related behavior -------
def filter_status
- return unless params[:status]
+ return unless status
- status_event = AlertManagement::Alert::STATUS_EVENTS[status_key]
+ status_event = alert.status_event_for(status)
unless status_event
param_errors << _('Invalid status')
@@ -122,13 +127,6 @@ module AlertManagement
params[:status_event] = status_event
end
- def status_key
- strong_memoize(:status_key) do
- status = params.delete(:status)
- AlertManagement::Alert::STATUSES.key(status)
- end
- end
-
def handle_status_change
add_status_change_system_note
resolve_todos if resolved?
@@ -144,7 +142,7 @@ module AlertManagement
def filter_duplicate
# Only need to check if changing to an open status
- return unless params[:status_event] && AlertManagement::Alert::OPEN_STATUSES.include?(status_key)
+ return unless params[:status_event] && AlertManagement::Alert.open_status?(status)
param_errors << unresolved_alert_error if duplicate_alert?
end
diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb
index 95ae84a85a4..5c7698f724a 100644
--- a/app/services/alert_management/process_prometheus_alert_service.rb
+++ b/app/services/alert_management/process_prometheus_alert_service.rb
@@ -47,7 +47,7 @@ module AlertManagement
def create_alert_management_alert
if alert.save
alert.execute_services
- SystemNoteService.create_new_alert(alert, Gitlab::AlertManagement::AlertParams::MONITORING_TOOLS[:prometheus])
+ SystemNoteService.create_new_alert(alert, Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus])
return
end
diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb
index d7630dbdac9..3c21844ec62 100644
--- a/app/services/audit_event_service.rb
+++ b/app/services/audit_event_service.rb
@@ -53,7 +53,6 @@ class AuditEventService
private
- attr_accessor :authentication_event
attr_reader :ip_address
def build_author(author)
@@ -99,23 +98,35 @@ class AuditEventService
end
def mark_as_authentication_event!
- self.authentication_event = true
+ @authentication_event = true
end
def authentication_event?
- authentication_event
+ @authentication_event
end
def log_security_event_to_database
return if Gitlab::Database.read_only?
- AuditEvent.create(base_payload.merge(details: @details))
+ event = AuditEvent.new(base_payload.merge(details: @details))
+ save_or_track event
+
+ event
end
def log_authentication_event_to_database
return unless Gitlab::Database.read_write? && authentication_event?
- AuthenticationEvent.create(authentication_event_payload)
+ event = AuthenticationEvent.new(authentication_event_payload)
+ save_or_track event
+
+ event
+ end
+
+ def save_or_track(event)
+ event.save!
+ rescue => e
+ Gitlab::ErrorTracking.track_exception(e, audit_event_type: event.class.to_s)
end
end
diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb
index 1a5dc790c41..2ccaea64d14 100644
--- a/app/services/boards/create_service.rb
+++ b/app/services/boards/create_service.rb
@@ -3,7 +3,11 @@
module Boards
class CreateService < Boards::BaseService
def execute
- create_board! if can_create_board?
+ unless can_create_board?
+ return ServiceResponse.error(message: "You don't have the permission to create a board for this resource.")
+ end
+
+ create_board!
end
private
@@ -15,12 +19,16 @@ module Boards
def create_board!
board = parent.boards.create(params)
- if board.persisted?
- board.lists.create(list_type: :backlog)
- board.lists.create(list_type: :closed)
+ unless board.persisted?
+ return ServiceResponse.error(message: "There was an error when creating a board.", payload: board)
+ end
+
+ board.tap do |created_board|
+ created_board.lists.create(list_type: :backlog)
+ created_board.lists.create(list_type: :closed)
end
- board
+ ServiceResponse.success(payload: board)
end
end
end
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index 140420a32bd..ab9d11abe98 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -80,6 +80,7 @@ module Boards
set_scope
set_non_archived
set_attempt_search_optimizations
+ set_issue_types
params
end
@@ -116,6 +117,10 @@ module Boards
end
end
+ def set_issue_types
+ params[:issue_types] = Issue::TYPES_FOR_LIST
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def board_label_ids
@board_label_ids ||= board.lists.movable.pluck(:label_id)
diff --git a/app/services/boards/lists/destroy_service.rb b/app/services/boards/lists/destroy_service.rb
index e20805d0405..ebac0f07fe1 100644
--- a/app/services/boards/lists/destroy_service.rb
+++ b/app/services/boards/lists/destroy_service.rb
@@ -4,7 +4,9 @@ module Boards
module Lists
class DestroyService < Boards::BaseService
def execute(list)
- return false unless list.destroyable?
+ unless list.destroyable?
+ return ServiceResponse.error(message: "The list cannot be destroyed. Only label lists can be destroyed.")
+ end
@board = list.board
@@ -12,6 +14,8 @@ module Boards
decrement_higher_lists(list)
remove_list(list)
end
+
+ ServiceResponse.success
end
private
@@ -26,7 +30,7 @@ module Boards
# rubocop: enable CodeReuse/ActiveRecord
def remove_list(list)
- list.destroy
+ list.destroy!
end
end
end
diff --git a/app/services/bulk_create_integration_service.rb b/app/services/bulk_create_integration_service.rb
new file mode 100644
index 00000000000..23b89b0d8a9
--- /dev/null
+++ b/app/services/bulk_create_integration_service.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+class BulkCreateIntegrationService
+ def initialize(integration, batch, association)
+ @integration = integration
+ @batch = batch
+ @association = association
+ end
+
+ def execute
+ service_list = ServiceList.new(batch, service_hash, association).to_array
+
+ Service.transaction do
+ results = bulk_insert(*service_list)
+
+ if integration.data_fields_present?
+ data_list = DataList.new(results, data_fields_hash, integration.data_fields.class).to_array
+
+ bulk_insert(*data_list)
+ end
+
+ run_callbacks(batch) if association == 'project'
+ end
+ end
+
+ private
+
+ attr_reader :integration, :batch, :association
+
+ def bulk_insert(klass, columns, values_array)
+ items_to_insert = values_array.map { |array| Hash[columns.zip(array)] }
+
+ klass.insert_all(items_to_insert, returning: [:id])
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def run_callbacks(batch)
+ if integration.issue_tracker?
+ Project.where(id: batch.select(:id)).update_all(has_external_issue_tracker: true)
+ end
+
+ if integration.type == 'ExternalWikiService'
+ Project.where(id: batch.select(:id)).update_all(has_external_wiki: true)
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def service_hash
+ if integration.template?
+ integration.to_service_hash
+ else
+ integration.to_service_hash.tap { |json| json['inherit_from_id'] = integration.id }
+ end
+ end
+
+ def data_fields_hash
+ integration.to_data_fields_hash
+ end
+end
diff --git a/app/services/bulk_update_integration_service.rb b/app/services/bulk_update_integration_service.rb
new file mode 100644
index 00000000000..74d77618f2c
--- /dev/null
+++ b/app/services/bulk_update_integration_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class BulkUpdateIntegrationService
+ def initialize(integration, batch)
+ @integration = integration
+ @batch = batch
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def execute
+ Service.transaction do
+ batch.update_all(service_hash)
+
+ if integration.data_fields_present?
+ integration.data_fields.class.where(service_id: batch.select(:id)).update_all(data_fields_hash)
+ end
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ attr_reader :integration, :batch
+
+ def service_hash
+ integration.to_service_hash.tap { |json| json['inherit_from_id'] = integration.id }
+ end
+
+ def data_fields_hash
+ integration.to_data_fields_hash
+ end
+end
diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb
index 073ef465e13..9b2c7788897 100644
--- a/app/services/ci/archive_trace_service.rb
+++ b/app/services/ci/archive_trace_service.rb
@@ -27,7 +27,7 @@ module Ci
rescue => e
# Tracks this error with application logs, Sentry, and Prometheus.
# If `archive!` keeps failing for over a week, that could incur data loss.
- # (See more https://docs.gitlab.com/ee/administration/job_traces.html#new-live-trace-architecture)
+ # (See more https://docs.gitlab.com/ee/administration/job_logs.html#new-incremental-logging-architecture)
# In order to avoid interrupting the system, we do not raise an exception here.
archive_error(e, job, worker_name)
end
diff --git a/app/services/ci/build_report_result_service.rb b/app/services/ci/build_report_result_service.rb
index ca66ad8249d..76ecf428f11 100644
--- a/app/services/ci/build_report_result_service.rb
+++ b/app/services/ci/build_report_result_service.rb
@@ -2,12 +2,20 @@
module Ci
class BuildReportResultService
+ include Gitlab::Utils::UsageData
+
+ EVENT_NAME = 'i_testing_test_case_parsed'
+
def execute(build)
return unless build.has_test_reports?
+ test_suite = generate_test_suite_report(build)
+
+ track_test_cases(build, test_suite)
+
build.report_results.create!(
project_id: build.project_id,
- data: tests_params(build)
+ data: tests_params(test_suite)
)
end
@@ -17,9 +25,7 @@ module Ci
build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new)
end
- def tests_params(build)
- test_suite = generate_test_suite_report(build)
-
+ def tests_params(test_suite)
{
tests: {
name: test_suite.name,
@@ -31,5 +37,20 @@ module Ci
}
}
end
+
+ def track_test_cases(build, test_suite)
+ return if Feature.disabled?(:track_unique_test_cases_parsed, build.project)
+
+ track_usage_event(EVENT_NAME, test_case_hashes(build, test_suite))
+ end
+
+ def test_case_hashes(build, test_suite)
+ [].tap do |hashes|
+ test_suite.each_test_case do |test_case|
+ key = "#{build.project_id}-#{test_case.key}"
+ hashes << Digest::SHA256.hexdigest(key)
+ end
+ end
+ end
end
end
diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb
index 0394cfb6119..86d0cf079fc 100644
--- a/app/services/ci/create_downstream_pipeline_service.rb
+++ b/app/services/ci/create_downstream_pipeline_service.rb
@@ -33,7 +33,7 @@ module Ci
pipeline_params.fetch(:target_revision))
downstream_pipeline = service.execute(
- pipeline_params.fetch(:source), pipeline_params[:execute_params]) do |pipeline|
+ pipeline_params.fetch(:source), **pipeline_params[:execute_params]) do |pipeline|
pipeline.variables.build(@bridge.downstream_variables)
end
@@ -77,16 +77,9 @@ module Ci
# TODO: Remove this condition if favour of model validation
# https://gitlab.com/gitlab-org/gitlab/issues/38338
- if ::Gitlab::Ci::Features.child_of_child_pipeline_enabled?(project)
- if has_max_descendants_depth?
- @bridge.drop!(:reached_max_descendant_pipelines_depth)
- return false
- end
- else
- if @bridge.triggers_child_pipeline? && @bridge.pipeline.parent_pipeline.present?
- @bridge.drop!(:bridge_pipeline_is_child_pipeline)
- return false
- end
+ if has_max_descendants_depth?
+ @bridge.drop!(:reached_max_descendant_pipelines_depth)
+ return false
end
unless can_create_downstream_pipeline?(target_ref)
diff --git a/app/services/ci/create_job_artifacts_service.rb b/app/services/ci/create_job_artifacts_service.rb
index 1fe65898d55..5efb3805bf7 100644
--- a/app/services/ci/create_job_artifacts_service.rb
+++ b/app/services/ci/create_job_artifacts_service.rb
@@ -52,24 +52,15 @@ module Ci
attr_reader :job, :project
def validate_requirements(artifact_type:, filesize:)
- return forbidden_type_error(artifact_type) if forbidden_type?(artifact_type)
return too_large_error if too_large?(artifact_type, filesize)
success
end
- def forbidden_type?(type)
- lsif?(type) && !code_navigation_enabled?
- end
-
def too_large?(type, size)
size > max_size(type) if size
end
- def code_navigation_enabled?
- Feature.enabled?(:code_navigation, project, default_enabled: true)
- end
-
def lsif?(type)
type == LSIF_ARTIFACT_TYPE
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 70ad18e80eb..e7ede98fea4 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -70,7 +70,7 @@ module Ci
push_options: params[:push_options] || {},
chat_data: params[:chat_data],
bridge: bridge,
- **extra_options(options))
+ **extra_options(**options))
# Ensure we never persist the pipeline when dry_run: true
@pipeline.readonly! if command.dry_run?
@@ -82,8 +82,7 @@ module Ci
schedule_head_pipeline_update if pipeline.persisted?
# If pipeline is not persisted, try to recover IID
- pipeline.reset_project_iid unless pipeline.persisted? ||
- Feature.disabled?(:ci_pipeline_rewind_iid, project, default_enabled: true)
+ pipeline.reset_project_iid unless pipeline.persisted?
pipeline
end
diff --git a/app/services/ci/daily_build_group_report_result_service.rb b/app/services/ci/daily_build_group_report_result_service.rb
index 6cdf3c88f8c..c32fc27c274 100644
--- a/app/services/ci/daily_build_group_report_result_service.rb
+++ b/app/services/ci/daily_build_group_report_result_service.rb
@@ -3,8 +3,6 @@
module Ci
class DailyBuildGroupReportResultService
def execute(pipeline)
- return unless Feature.enabled?(:ci_daily_code_coverage, pipeline.project, default_enabled: true)
-
DailyBuildGroupReportResult.upsert_reports(coverage_reports(pipeline))
end
diff --git a/app/services/ci/delete_objects_service.rb b/app/services/ci/delete_objects_service.rb
new file mode 100644
index 00000000000..bac99abadc9
--- /dev/null
+++ b/app/services/ci/delete_objects_service.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module Ci
+ class DeleteObjectsService
+ TransactionInProgressError = Class.new(StandardError)
+ TRANSACTION_MESSAGE = "can't perform network calls inside a database transaction"
+ BATCH_SIZE = 100
+ RETRY_IN = 10.minutes
+
+ def execute
+ objects = load_next_batch
+ destroy_everything(objects)
+ end
+
+ def remaining_batches_count(max_batch_count:)
+ Ci::DeletedObject
+ .ready_for_destruction(max_batch_count * BATCH_SIZE)
+ .size
+ .fdiv(BATCH_SIZE)
+ .ceil
+ end
+
+ private
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def load_next_batch
+ # `find_by_sql` performs a write in this case and we need to wrap it in
+ # a transaction to stick to the primary database.
+ Ci::DeletedObject.transaction do
+ Ci::DeletedObject.find_by_sql([
+ next_batch_sql, new_pick_up_at: RETRY_IN.from_now
+ ])
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def next_batch_sql
+ <<~SQL.squish
+ UPDATE "ci_deleted_objects"
+ SET "pick_up_at" = :new_pick_up_at
+ WHERE "ci_deleted_objects"."id" IN (#{locked_object_ids_sql})
+ RETURNING *
+ SQL
+ end
+
+ def locked_object_ids_sql
+ Ci::DeletedObject.lock_for_destruction(BATCH_SIZE).to_sql
+ end
+
+ def destroy_everything(objects)
+ raise TransactionInProgressError, TRANSACTION_MESSAGE if transaction_open?
+ return unless objects.any?
+
+ deleted = objects.select(&:delete_file_from_storage)
+ Ci::DeletedObject.id_in(deleted.map(&:id)).delete_all
+ end
+
+ def transaction_open?
+ Ci::DeletedObject.connection.transaction_open?
+ end
+ end
+end
diff --git a/app/services/ci/destroy_expired_job_artifacts_service.rb b/app/services/ci/destroy_expired_job_artifacts_service.rb
index ca6e60f819a..438b5c7496d 100644
--- a/app/services/ci/destroy_expired_job_artifacts_service.rb
+++ b/app/services/ci/destroy_expired_job_artifacts_service.rb
@@ -39,6 +39,13 @@ module Ci
return false if artifacts.empty?
artifacts.each(&:destroy!)
+ run_after_destroy(artifacts)
+
+ true # This is required because of the design of `loop_until` method.
end
+
+ def run_after_destroy(artifacts); end
end
end
+
+Ci::DestroyExpiredJobArtifactsService.prepend_if_ee('EE::Ci::DestroyExpiredJobArtifactsService')
diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb
index 32abd1a7626..8343e0f8cd0 100644
--- a/app/services/ci/expire_pipeline_cache_service.rb
+++ b/app/services/ci/expire_pipeline_cache_service.rb
@@ -32,11 +32,18 @@ module Ci
Gitlab::Routing.url_helpers.project_new_merge_request_path(project, format: :json)
end
+ def pipelines_project_merge_request_path(merge_request)
+ Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(merge_request.target_project, merge_request, format: :json)
+ end
+
+ def merge_request_widget_path(merge_request)
+ Gitlab::Routing.url_helpers.cached_widget_project_json_merge_request_path(merge_request.project, merge_request, format: :json)
+ end
+
def each_pipelines_merge_request_path(pipeline)
pipeline.all_merge_requests.each do |merge_request|
- path = Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(merge_request.target_project, merge_request, format: :json)
-
- yield(path)
+ yield(pipelines_project_merge_request_path(merge_request))
+ yield(merge_request_widget_path(merge_request))
end
end
diff --git a/app/services/ci/list_config_variables_service.rb b/app/services/ci/list_config_variables_service.rb
new file mode 100644
index 00000000000..b5dc192b512
--- /dev/null
+++ b/app/services/ci/list_config_variables_service.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Ci
+ class ListConfigVariablesService < ::BaseService
+ def execute(sha)
+ config = project.ci_config_for(sha)
+ return {} unless config
+
+ result = Gitlab::Ci::YamlProcessor.new(config).execute
+ result.valid? ? result.variables_with_data : {}
+ end
+ end
+end
diff --git a/app/services/ci/pipelines/create_artifact_service.rb b/app/services/ci/pipelines/create_artifact_service.rb
index b7d334e436d..bfaf317241a 100644
--- a/app/services/ci/pipelines/create_artifact_service.rb
+++ b/app/services/ci/pipelines/create_artifact_service.rb
@@ -3,7 +3,6 @@ module Ci
module Pipelines
class CreateArtifactService
def execute(pipeline)
- return unless ::Gitlab::Ci::Features.coverage_report_view?(pipeline.project)
return unless pipeline.can_generate_coverage_reports?
return if pipeline.has_coverage_reports?
diff --git a/app/services/ci/play_bridge_service.rb b/app/services/ci/play_bridge_service.rb
new file mode 100644
index 00000000000..70c4a8e6136
--- /dev/null
+++ b/app/services/ci/play_bridge_service.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Ci
+ class PlayBridgeService < ::BaseService
+ def execute(bridge)
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, :play_job, bridge)
+
+ bridge.tap do |bridge|
+ bridge.user = current_user
+ bridge.enqueue!
+ end
+ end
+ end
+end
diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb
index 9f922ffde81..6adeca624a8 100644
--- a/app/services/ci/play_build_service.rb
+++ b/app/services/ci/play_build_service.rb
@@ -3,9 +3,7 @@
module Ci
class PlayBuildService < ::BaseService
def execute(build, job_variables_attributes = nil)
- unless can?(current_user, :update_build, build)
- raise Gitlab::Access::AccessDeniedError
- end
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, :play_job, build)
# Try to enqueue the build, otherwise create a duplicate.
#
diff --git a/app/services/ci/play_manual_stage_service.rb b/app/services/ci/play_manual_stage_service.rb
index 2497fc52e6b..c6fa7803e52 100644
--- a/app/services/ci/play_manual_stage_service.rb
+++ b/app/services/ci/play_manual_stage_service.rb
@@ -9,12 +9,12 @@ module Ci
end
def execute(stage)
- stage.builds.manual.each do |build|
- next unless build.playable?
+ stage.processables.manual.each do |processable|
+ next unless processable.playable?
- build.play(current_user)
+ processable.play(current_user)
rescue Gitlab::Access::AccessDeniedError
- logger.error(message: 'Unable to play manual action', build_id: build.id)
+ logger.error(message: 'Unable to play manual action', processable_id: processable.id)
end
end
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
index 18bae26613f..e511e26adfe 100644
--- a/app/services/ci/process_pipeline_service.rb
+++ b/app/services/ci/process_pipeline_service.rb
@@ -31,14 +31,14 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def update_retried
# find the latest builds for each name
- latest_statuses = pipeline.statuses.latest
+ latest_statuses = pipeline.latest_statuses
.group(:name)
.having('count(*) > 1')
.pluck(Arel.sql('MAX(id)'), 'name')
# mark builds that are retried
if latest_statuses.any?
- pipeline.statuses.latest
+ pipeline.latest_statuses
.where(name: latest_statuses.map(&:second))
.where.not(id: latest_statuses.map(&:first))
.update_all(retried: true)
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index 6b2e6c245f3..f397ada0696 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -58,7 +58,7 @@ module Ci
build = project.builds.new(attributes)
build.assign_attributes(::Gitlab::Ci::Pipeline::Seed::Build.environment_attributes_for(build))
build.retried = false
- BulkInsertableAssociations.with_bulk_insert(enabled: ::Gitlab::Ci::Features.bulk_insert_on_create?(project)) do
+ BulkInsertableAssociations.with_bulk_insert do
build.save!
end
build
diff --git a/app/services/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb
index 31c7178c9e7..241eba733ea 100644
--- a/app/services/ci/update_build_queue_service.rb
+++ b/app/services/ci/update_build_queue_service.rb
@@ -9,9 +9,7 @@ module Ci
private
def tick_for(build, runners)
- if Feature.enabled?(:ci_update_queues_for_online_runners, build.project, default_enabled: true)
- runners = runners.with_recent_runner_queue
- end
+ runners = runners.with_recent_runner_queue
runners.each do |runner|
runner.pick_build!(build)
diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb
index 61e4c77c1e5..22a27906700 100644
--- a/app/services/ci/update_build_state_service.rb
+++ b/app/services/ci/update_build_state_service.rb
@@ -2,7 +2,11 @@
module Ci
class UpdateBuildStateService
- Result = Struct.new(:status, keyword_init: true)
+ include ::Gitlab::Utils::StrongMemoize
+ include ::Gitlab::ExclusiveLeaseHelpers
+
+ Result = Struct.new(:status, :backoff, keyword_init: true)
+ InvalidTraceError = Class.new(StandardError)
ACCEPT_TIMEOUT = 5.minutes.freeze
@@ -17,43 +21,77 @@ module Ci
def execute
overwrite_trace! if has_trace?
- if accept_request?
- accept_build_state!
- else
- check_migration_state
- update_build_state!
+ unless accept_available?
+ return update_build_state!
+ end
+
+ ensure_pending_state!
+
+ in_build_trace_lock do
+ process_build_state!
end
end
private
- def accept_build_state!
- if Time.current - ensure_pending_state.created_at > ACCEPT_TIMEOUT
- metrics.increment_trace_operation(operation: :discarded)
+ def overwrite_trace!
+ metrics.increment_trace_operation(operation: :overwrite)
- return update_build_state!
+ build.trace.set(params[:trace]) if Gitlab::Ci::Features.trace_overwrite?
+ end
+
+ def ensure_pending_state!
+ pending_state.created_at
+ end
+
+ def process_build_state!
+ if live_chunks_pending?
+ if pending_state_outdated?
+ discard_build_trace!
+ update_build_state!
+ else
+ accept_build_state!
+ end
+ else
+ validate_build_trace!
+ update_build_state!
end
+ end
+ def accept_build_state!
build.trace_chunks.live.find_each do |chunk|
chunk.schedule_to_persist!
end
metrics.increment_trace_operation(operation: :accepted)
- Result.new(status: 202)
+ ::Gitlab::Ci::Runner::Backoff.new(pending_state.created_at).then do |backoff|
+ Result.new(status: 202, backoff: backoff.to_seconds)
+ end
end
- def overwrite_trace!
- metrics.increment_trace_operation(operation: :overwrite)
+ def validate_build_trace!
+ return unless has_chunks?
- build.trace.set(params[:trace]) if Gitlab::Ci::Features.trace_overwrite?
- end
+ unless live_chunks_pending?
+ metrics.increment_trace_operation(operation: :finalized)
+ metrics.observe_migration_duration(pending_state_seconds)
+ end
- def check_migration_state
- return unless accept_available?
+ ::Gitlab::Ci::Trace::Checksum.new(build).then do |checksum|
+ unless checksum.valid?
+ metrics.increment_trace_operation(operation: :invalid)
- if has_chunks? && !live_chunks_pending?
- metrics.increment_trace_operation(operation: :finalized)
+ next unless log_invalid_chunks?
+
+ ::Gitlab::ErrorTracking.log_exception(InvalidTraceError.new,
+ project_path: build.project.full_path,
+ build_id: build.id,
+ state_crc32: checksum.state_crc32,
+ chunks_crc32: checksum.chunks_crc32,
+ chunks_count: checksum.chunks_count
+ )
+ end
end
end
@@ -76,12 +114,32 @@ module Ci
end
end
+ def discard_build_trace!
+ metrics.increment_trace_operation(operation: :discarded)
+ end
+
def accept_available?
!build_running? && has_checksum? && chunks_migration_enabled?
end
- def accept_request?
- accept_available? && live_chunks_pending?
+ def live_chunks_pending?
+ build.trace_chunks.live.any?
+ end
+
+ def has_chunks?
+ build.trace_chunks.any?
+ end
+
+ def pending_state_outdated?
+ pending_state_duration > ACCEPT_TIMEOUT
+ end
+
+ def pending_state_duration
+ Time.current - pending_state.created_at
+ end
+
+ def pending_state_seconds
+ pending_state_duration.seconds
end
def build_state
@@ -96,18 +154,14 @@ module Ci
params.dig(:checksum).present?
end
- def has_chunks?
- build.trace_chunks.any?
- end
-
- def live_chunks_pending?
- build.trace_chunks.live.any?
- end
-
def build_running?
build_state == 'running'
end
+ def pending_state
+ strong_memoize(:pending_state) { ensure_pending_state }
+ end
+
def ensure_pending_state
Ci::BuildPendingState.create_or_find_by!(
build_id: build.id,
@@ -121,8 +175,38 @@ module Ci
build.pending_state
end
+ ##
+ # This method is releasing an exclusive lock on a build trace the moment we
+ # conclude that build status has been written and the build state update
+ # has been committed to the database.
+ #
+ # Because a build state machine schedules a bunch of workers to run after
+ # build status transition to complete, we do not want to keep the lease
+ # until all the workers are scheduled because it opens a possibility of
+ # race conditions happening.
+ #
+ # Instead of keeping the lease until the transition is fully done and
+ # workers are scheduled, we immediately release the lock after the database
+ # commit happens.
+ #
+ def in_build_trace_lock(&block)
+ build.trace.lock do |_, lease| # rubocop:disable CodeReuse/ActiveRecord
+ build.run_on_status_commit { lease.cancel }
+
+ yield
+ end
+ rescue ::Gitlab::Ci::Trace::LockedError
+ metrics.increment_trace_operation(operation: :locked)
+
+ accept_build_state!
+ end
+
def chunks_migration_enabled?
::Gitlab::Ci::Features.accept_trace?(build.project)
end
+
+ def log_invalid_chunks?
+ ::Gitlab::Ci::Features.log_invalid_trace_chunks?(build.project)
+ end
end
end
diff --git a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb
index b1820474c9d..2725a3aeaa5 100644
--- a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb
+++ b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb
@@ -117,9 +117,11 @@ module Clusters
end
def role_binding_resource
+ role_name = Feature.enabled?(:kubernetes_cluster_namespace_role_admin) ? 'admin' : Clusters::Kubernetes::PROJECT_CLUSTER_ROLE_NAME
+
Gitlab::Kubernetes::RoleBinding.new(
name: role_binding_name,
- role_name: Clusters::Kubernetes::PROJECT_CLUSTER_ROLE_NAME,
+ role_name: role_name,
role_kind: :ClusterRole,
namespace: service_account_namespace,
service_account_name: service_account_name
diff --git a/app/services/concerns/admin/propagate_service.rb b/app/services/concerns/admin/propagate_service.rb
index 974408f678c..065ab6f7ff9 100644
--- a/app/services/concerns/admin/propagate_service.rb
+++ b/app/services/concerns/admin/propagate_service.rb
@@ -4,9 +4,7 @@ module Admin
module PropagateService
extend ActiveSupport::Concern
- BATCH_SIZE = 100
-
- delegate :data_fields_present?, to: :integration
+ BATCH_SIZE = 10_000
class_methods do
def propagate(integration)
@@ -23,51 +21,10 @@ module Admin
attr_reader :integration
def create_integration_for_projects_without_integration
- loop do
- batch_ids = Project.uncached { Project.ids_without_integration(integration, BATCH_SIZE) }
-
- bulk_create_from_integration(batch_ids, 'project') unless batch_ids.empty?
-
- break if batch_ids.size < BATCH_SIZE
- end
- end
-
- def bulk_create_from_integration(batch_ids, association)
- service_list = ServiceList.new(batch_ids, service_hash, association).to_array
-
- Service.transaction do
- results = bulk_insert(*service_list)
-
- if data_fields_present?
- data_list = DataList.new(results, data_fields_hash, integration.data_fields.class).to_array
-
- bulk_insert(*data_list)
- end
-
- run_callbacks(batch_ids) if association == 'project'
+ Project.without_integration(integration).each_batch(of: BATCH_SIZE) do |projects|
+ min_id, max_id = projects.pick("MIN(projects.id), MAX(projects.id)")
+ PropagateIntegrationProjectWorker.perform_async(integration.id, min_id, max_id)
end
end
-
- def bulk_insert(klass, columns, values_array)
- items_to_insert = values_array.map { |array| Hash[columns.zip(array)] }
-
- klass.insert_all(items_to_insert, returning: [:id])
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def run_callbacks(batch_ids)
- if integration.issue_tracker?
- Project.where(id: batch_ids).update_all(has_external_issue_tracker: true)
- end
-
- if integration.type == 'ExternalWikiService'
- Project.where(id: batch_ids).update_all(has_external_wiki: true)
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def data_fields_hash
- @data_fields_hash ||= integration.to_data_fields_hash
- end
end
end
diff --git a/app/services/deployments/after_create_service.rb b/app/services/deployments/update_environment_service.rb
index 3560f9c983b..e9c2f41f626 100644
--- a/app/services/deployments/after_create_service.rb
+++ b/app/services/deployments/update_environment_service.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Deployments
- class AfterCreateService
+ class UpdateEnvironmentService
attr_reader :deployment
attr_reader :deployable
@@ -64,4 +64,4 @@ module Deployments
end
end
-Deployments::AfterCreateService.prepend_if_ee('EE::Deployments::AfterCreateService')
+Deployments::UpdateEnvironmentService.prepend_if_ee('EE::Deployments::UpdateEnvironmentService')
diff --git a/app/services/design_management/copy_design_collection.rb b/app/services/design_management/copy_design_collection.rb
new file mode 100644
index 00000000000..66cf6112062
--- /dev/null
+++ b/app/services/design_management/copy_design_collection.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ module CopyDesignCollection
+ end
+end
diff --git a/app/services/design_management/copy_design_collection/copy_service.rb b/app/services/design_management/copy_design_collection/copy_service.rb
new file mode 100644
index 00000000000..5099c2c5704
--- /dev/null
+++ b/app/services/design_management/copy_design_collection/copy_service.rb
@@ -0,0 +1,309 @@
+# frozen_string_literal: true
+
+# Service to copy a DesignCollection from one Issue to another.
+# Copies the DesignCollection's Designs, Versions, and Notes on Designs.
+module DesignManagement
+ module CopyDesignCollection
+ class CopyService < DesignService
+ # rubocop: disable CodeReuse/ActiveRecord
+ def initialize(project, user, params = {})
+ super
+
+ @target_issue = params.fetch(:target_issue)
+ @target_project = @target_issue.project
+ @target_repository = @target_project.design_repository
+ @target_design_collection = @target_issue.design_collection
+ @temporary_branch = "CopyDesignCollectionService_#{SecureRandom.hex}"
+ # The user who triggered the copy may not have permissions to push
+ # to the design repository.
+ @git_user = @target_project.default_owner
+
+ @designs = DesignManagement::Design.unscoped.where(issue: issue).order(:id).load
+ @versions = DesignManagement::Version.unscoped.where(issue: issue).order(:id).includes(:designs).load
+
+ @sha_attribute = Gitlab::Database::ShaAttribute.new
+ @shas = []
+ @event_enum_map = DesignManagement::DesignAction::EVENT_FOR_GITALY_ACTION.invert
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def execute
+ return error('User cannot copy design collection to issue') unless user_can_copy?
+ return error('Target design collection must first be queued') unless target_design_collection.copy_in_progress?
+ return error('Design collection has no designs') if designs.empty?
+ return error('Target design collection already has designs') unless target_design_collection.empty?
+
+ with_temporary_branch do
+ copy_commits!
+
+ ActiveRecord::Base.transaction do
+ design_ids = copy_designs!
+ version_ids = copy_versions!
+ copy_actions!(design_ids, version_ids)
+ link_lfs_files!
+ copy_notes!(design_ids)
+ finalize!
+ end
+ end
+
+ ServiceResponse.success
+ rescue => error
+ log_exception(error)
+
+ target_design_collection.error_copy!
+
+ error('Designs were unable to be copied successfully')
+ end
+
+ private
+
+ attr_reader :designs, :event_enum_map, :git_user, :sha_attribute, :shas,
+ :temporary_branch, :target_design_collection, :target_issue,
+ :target_repository, :target_project, :versions
+
+ alias_method :merge_branch, :target_branch
+
+ def log_exception(exception)
+ payload = {
+ issue_id: issue.id,
+ project_id: project.id,
+ target_issue_id: target_issue.id,
+ target_project: target_project.id
+ }
+
+ Gitlab::ErrorTracking.track_exception(exception, payload)
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def user_can_copy?
+ current_user.can?(:read_design, design_collection) &&
+ current_user.can?(:admin_issue, target_issue)
+ end
+
+ def with_temporary_branch(&block)
+ target_repository.create_if_not_exists
+
+ create_master_branch! if target_repository.empty?
+ create_temporary_branch!
+
+ yield
+ ensure
+ remove_temporary_branch!
+ end
+
+ # A project that does not have any designs will have a blank design
+ # repository. To create a temporary branch from `master` we need
+ # create `master` first by adding a file to it.
+ def create_master_branch!
+ target_repository.create_file(
+ git_user,
+ ".CopyDesignCollectionService_#{Time.now.to_i}",
+ '.gitlab',
+ message: "Commit to create #{merge_branch} branch in CopyDesignCollectionService",
+ branch_name: merge_branch
+ )
+ end
+
+ def create_temporary_branch!
+ target_repository.add_branch(
+ git_user,
+ temporary_branch,
+ target_repository.root_ref
+ )
+ end
+
+ def remove_temporary_branch!
+ return unless target_repository.branch_exists?(temporary_branch)
+
+ target_repository.rm_branch(git_user, temporary_branch)
+ end
+
+ # Merge the temporary branch containing the commits to `master`
+ # and update the state of the target_design_collection.
+ def finalize!
+ source_sha = shas.last
+
+ target_repository.raw.merge(
+ git_user,
+ source_sha,
+ merge_branch,
+ 'CopyDesignCollectionService finalize merge'
+ ) { nil }
+
+ target_design_collection.end_copy!
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def copy_commits!
+ # Execute another query to include actions and their designs
+ DesignManagement::Version.unscoped.where(id: versions).order(:id).includes(actions: :design).find_each(batch_size: 100) do |version|
+ gitaly_actions = version.actions.map do |action|
+ design = action.design
+ # Map the raw Action#event enum value to a Gitaly "action" for the
+ # `Repository#multi_action` call.
+ gitaly_action_name = @event_enum_map[action.event_before_type_cast]
+ # `content` will be the LfsPointer file and not the design file,
+ # and can be nil for deletions.
+ content = blobs.dig(version.sha, design.filename)&.data
+ file_path = DesignManagement::Design.build_full_path(target_issue, design)
+
+ {
+ action: gitaly_action_name,
+ file_path: file_path,
+ content: content
+ }.compact
+ end
+
+ sha = target_repository.multi_action(
+ git_user,
+ branch_name: temporary_branch,
+ message: commit_message(version),
+ actions: gitaly_actions
+ )
+
+ shas << sha
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def copy_designs!
+ design_attributes = attributes_config[:design_attributes]
+
+ new_rows = designs.map do |design|
+ design.attributes.slice(*design_attributes).merge(
+ issue_id: target_issue.id,
+ project_id: target_project.id
+ )
+ end
+
+ # TODO Replace `Gitlab::Database.bulk_insert` with `BulkInsertSafe`
+ # once https://gitlab.com/gitlab-org/gitlab/-/issues/247718 is fixed.
+ ::Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert
+ DesignManagement::Design.table_name,
+ new_rows,
+ return_ids: true
+ )
+ end
+
+ def copy_versions!
+ version_attributes = attributes_config[:version_attributes]
+ # `shas` are the list of Git commits made during the Git copy phase,
+ # and will be ordered 1:1 with old versions
+ shas_enum = shas.to_enum
+
+ new_rows = versions.map do |version|
+ version.attributes.slice(*version_attributes).merge(
+ issue_id: target_issue.id,
+ sha: sha_attribute.serialize(shas_enum.next)
+ )
+ end
+
+ # TODO Replace `Gitlab::Database.bulk_insert` with `BulkInsertSafe`
+ # once https://gitlab.com/gitlab-org/gitlab/-/issues/247718 is fixed.
+ ::Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert
+ DesignManagement::Version.table_name,
+ new_rows,
+ return_ids: true
+ )
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def copy_actions!(new_design_ids, new_version_ids)
+ # Create a map of <Old design id> => <New design id>
+ design_id_map = new_design_ids.each_with_index.to_h do |design_id, i|
+ [designs[i].id, design_id]
+ end
+
+ # Create a map of <Old version id> => <New version id>
+ version_id_map = new_version_ids.each_with_index.to_h do |version_id, i|
+ [versions[i].id, version_id]
+ end
+
+ actions = DesignManagement::Action.unscoped.select(:design_id, :version_id, :event).where(design: designs, version: versions)
+
+ new_rows = actions.map do |action|
+ {
+ design_id: design_id_map[action.design_id],
+ version_id: version_id_map[action.version_id],
+ event: action.event_before_type_cast
+ }
+ end
+
+ # We cannot use `BulkInsertSafe` because of the uploader mounted in `Action`.
+ ::Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert
+ DesignManagement::Action.table_name,
+ new_rows
+ )
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def commit_message(version)
+ "Copy commit #{version.sha} from issue #{issue.to_reference(full: true)}"
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def copy_notes!(design_ids)
+ new_designs = DesignManagement::Design.unscoped.find(design_ids)
+
+ # Execute another query to filter only designs with notes
+ DesignManagement::Design.unscoped.where(id: designs).joins(:notes).distinct.find_each(batch_size: 100) do |old_design|
+ new_design = new_designs.find { |d| d.filename == old_design.filename }
+
+ Notes::CopyService.new(current_user, old_design, new_design).execute
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def link_lfs_files!
+ oids = blobs.values.flat_map(&:values).map(&:lfs_oid)
+ repository_type = LfsObjectsProject.repository_types[:design]
+
+ new_rows = LfsObject.where(oid: oids).find_each(batch_size: 1000).map do |lfs_object|
+ {
+ project_id: target_project.id,
+ lfs_object_id: lfs_object.id,
+ repository_type: repository_type
+ }
+ end
+
+ # We cannot use `BulkInsertSafe` due to the LfsObjectsProject#update_project_statistics
+ # callback that fires after_commit.
+ ::Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert
+ LfsObjectsProject.table_name,
+ new_rows,
+ on_conflict: :do_nothing # Upsert
+ )
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # Blob data is used to find the oids for LfsObjects and to copy to Git.
+ # Blobs are reasonably small in memory, as their data are LFS Pointer files.
+ #
+ # Returns all blobs for the designs as a Hash of `{ Blob#commit_id => { Design#filename => Blob } }`
+ def blobs
+ @blobs ||= begin
+ items = versions.flat_map { |v| v.designs.map { |d| [v.sha, DesignManagement::Design.build_full_path(issue, d)] } }
+
+ repository.blobs_at(items).each_with_object({}) do |blob, h|
+ design = designs.find { |d| DesignManagement::Design.build_full_path(issue, d) == blob.path }
+
+ h[blob.commit_id] ||= {}
+ h[blob.commit_id][design.filename] = blob
+ end
+ end
+ end
+
+ def attributes_config
+ @attributes_config ||= YAML.load_file(attributes_config_file).symbolize_keys
+ end
+
+ def attributes_config_file
+ Rails.root.join('lib/gitlab/design_management/copy_design_collection_model_attributes.yml')
+ end
+ end
+ end
+end
diff --git a/app/services/design_management/copy_design_collection/queue_service.rb b/app/services/design_management/copy_design_collection/queue_service.rb
new file mode 100644
index 00000000000..f76917dbe47
--- /dev/null
+++ b/app/services/design_management/copy_design_collection/queue_service.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+# Service for setting the initial copy_state on the target DesignCollection
+# and queuing a CopyDesignCollectionWorker.
+module DesignManagement
+ module CopyDesignCollection
+ class QueueService
+ def initialize(current_user, issue, target_issue)
+ @current_user = current_user
+ @issue = issue
+ @target_issue = target_issue
+ @target_design_collection = target_issue.design_collection
+ end
+
+ def execute
+ return error('User cannot copy designs to issue') unless user_can_copy?
+ return error('Target design collection copy state must be `ready`') unless target_design_collection.can_start_copy?
+
+ target_design_collection.start_copy!
+
+ DesignManagement::CopyDesignCollectionWorker.perform_async(current_user.id, issue.id, target_issue.id)
+
+ ServiceResponse.success
+ end
+
+ private
+
+ delegate :design_collection, to: :issue
+
+ attr_reader :current_user, :issue, :target_design_collection, :target_issue
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def user_can_copy?
+ current_user.can?(:read_design, issue) &&
+ current_user.can?(:admin_issue, target_issue)
+ end
+ end
+ end
+end
diff --git a/app/services/design_management/delete_designs_service.rb b/app/services/design_management/delete_designs_service.rb
index 5d875c630a0..a90c34d4e34 100644
--- a/app/services/design_management/delete_designs_service.rb
+++ b/app/services/design_management/delete_designs_service.rb
@@ -16,6 +16,7 @@ module DesignManagement
version = delete_designs!
EventCreateService.new.destroy_designs(designs, current_user)
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_removed_action(author: current_user)
success(version: version)
end
diff --git a/app/services/design_management/design_service.rb b/app/services/design_management/design_service.rb
index 54e53609646..5aa2a2f73bc 100644
--- a/app/services/design_management/design_service.rb
+++ b/app/services/design_management/design_service.rb
@@ -19,6 +19,7 @@ module DesignManagement
def collection
issue.design_collection
end
+ alias_method :design_collection, :collection
def repository
collection.repository
diff --git a/app/services/design_management/generate_image_versions_service.rb b/app/services/design_management/generate_image_versions_service.rb
index 213aac164ff..e56d163c461 100644
--- a/app/services/design_management/generate_image_versions_service.rb
+++ b/app/services/design_management/generate_image_versions_service.rb
@@ -48,6 +48,9 @@ module DesignManagement
# Store and process the file
action.image_v432x230.store!(raw_file)
action.save!
+ rescue CarrierWave::IntegrityError => e
+ Gitlab::ErrorTracking.log_exception(e, project_id: project.id, design_id: action.design_id, version_id: action.version_id)
+ log_error(e.message)
rescue CarrierWave::UploadError => e
Gitlab::ErrorTracking.track_exception(e, project_id: project.id, design_id: action.design_id, version_id: action.version_id)
log_error(e.message)
diff --git a/app/services/design_management/runs_design_actions.rb b/app/services/design_management/runs_design_actions.rb
index 4bd6bb45658..ee6aa9286d3 100644
--- a/app/services/design_management/runs_design_actions.rb
+++ b/app/services/design_management/runs_design_actions.rb
@@ -4,14 +4,15 @@ module DesignManagement
module RunsDesignActions
NoActions = Class.new(StandardError)
- # this concern requires the following methods to be implemented:
+ # This concern requires the following methods to be implemented:
# current_user, target_branch, repository, commit_message
#
# Before calling `run_actions`, you should ensure the repository exists, by
# calling `repository.create_if_not_exists`.
#
# @raise [NoActions] if actions are empty
- def run_actions(actions)
+ # @return [DesignManagement::Version]
+ def run_actions(actions, skip_system_notes: false)
raise NoActions if actions.empty?
sha = repository.multi_action(current_user,
@@ -21,14 +22,14 @@ module DesignManagement
::DesignManagement::Version
.create_for_designs(actions, sha, current_user)
- .tap { |version| post_process(version) }
+ .tap { |version| post_process(version, skip_system_notes) }
end
private
- def post_process(version)
+ def post_process(version, skip_system_notes)
version.run_after_commit_or_now do
- ::DesignManagement::NewVersionWorker.perform_async(id)
+ ::DesignManagement::NewVersionWorker.perform_async(id, skip_system_notes)
end
end
end
diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb
index 0446d2f1ee8..c26d2e7ab47 100644
--- a/app/services/design_management/save_designs_service.rb
+++ b/app/services/design_management/save_designs_service.rb
@@ -16,11 +16,15 @@ module DesignManagement
def execute
return error("Not allowed!") unless can_create_designs?
return error("Only #{MAX_FILES} files are allowed simultaneously") if files.size > MAX_FILES
+ return error("Duplicate filenames are not allowed!") if files.map(&:original_filename).uniq.length != files.length
+ return error("Design copy is in progress") if design_collection.copy_in_progress?
uploaded_designs, version = upload_designs!
skipped_designs = designs - uploaded_designs
create_events
+ design_collection.reset_copy!
+
success({ designs: uploaded_designs, version: version, skipped_designs: skipped_designs })
rescue ::ActiveRecord::RecordInvalid => e
error(e.message)
@@ -34,7 +38,10 @@ module DesignManagement
::DesignManagement::Version.with_lock(project.id, repository) do
actions = build_actions
- [actions.map(&:design), actions.presence && run_actions(actions)]
+ [
+ actions.map(&:design),
+ actions.presence && run_actions(actions)
+ ]
end
end
@@ -59,7 +66,7 @@ module DesignManagement
action = new_file?(design) ? :create : :update
on_success do
- ::Gitlab::UsageDataCounters::DesignsCounter.count(action)
+ track_usage_metrics(action)
end
DesignManagement::DesignAction.new(design, action, content)
@@ -121,6 +128,16 @@ module DesignManagement
end
end
end
+
+ def track_usage_metrics(action)
+ if action == :update
+ ::Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_modified_action(author: current_user)
+ else
+ ::Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_added_action(author: current_user)
+ end
+
+ ::Gitlab::UsageDataCounters::DesignsCounter.count(action)
+ end
end
end
diff --git a/app/services/feature_flags/base_service.rb b/app/services/feature_flags/base_service.rb
new file mode 100644
index 00000000000..9b27df90992
--- /dev/null
+++ b/app/services/feature_flags/base_service.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class BaseService < ::BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ AUDITABLE_ATTRIBUTES = %w(name description active).freeze
+
+ protected
+
+ def audit_event(feature_flag)
+ message = audit_message(feature_flag)
+
+ return if message.blank?
+
+ details =
+ {
+ custom_message: message,
+ target_id: feature_flag.id,
+ target_type: feature_flag.class.name,
+ target_details: feature_flag.name
+ }
+
+ ::AuditEventService.new(
+ current_user,
+ feature_flag.project,
+ details
+ )
+ end
+
+ def save_audit_event(audit_event)
+ return unless audit_event
+
+ audit_event.security_event
+ end
+
+ def created_scope_message(scope)
+ "Created rule <strong>#{scope.environment_scope}</strong> "\
+ "and set it as <strong>#{scope.active ? "active" : "inactive"}</strong> "\
+ "with strategies <strong>#{scope.strategies}</strong>."
+ end
+
+ def feature_flag_by_name
+ strong_memoize(:feature_flag_by_name) do
+ project.operations_feature_flags.find_by_name(params[:name])
+ end
+ end
+
+ def feature_flag_scope_by_environment_scope
+ strong_memoize(:feature_flag_scope_by_environment_scope) do
+ feature_flag_by_name.scopes.find_by_environment_scope(params[:environment_scope])
+ end
+ end
+ end
+end
diff --git a/app/services/feature_flags/create_service.rb b/app/services/feature_flags/create_service.rb
new file mode 100644
index 00000000000..b4ca90f7aae
--- /dev/null
+++ b/app/services/feature_flags/create_service.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class CreateService < FeatureFlags::BaseService
+ def execute
+ return error('Access Denied', 403) unless can_create?
+ return error('Version is invalid', :bad_request) unless valid_version?
+ return error('New version feature flags are not enabled for this project', :bad_request) unless flag_version_enabled?
+
+ ActiveRecord::Base.transaction do
+ feature_flag = project.operations_feature_flags.new(params)
+
+ if feature_flag.save
+ save_audit_event(audit_event(feature_flag))
+
+ success(feature_flag: feature_flag)
+ else
+ error(feature_flag.errors.full_messages, 400)
+ end
+ end
+ end
+
+ private
+
+ def audit_message(feature_flag)
+ message_parts = ["Created feature flag <strong>#{feature_flag.name}</strong>",
+ "with description <strong>\"#{feature_flag.description}\"</strong>."]
+
+ message_parts += feature_flag.scopes.map do |scope|
+ created_scope_message(scope)
+ end
+
+ message_parts.join(" ")
+ end
+
+ def can_create?
+ Ability.allowed?(current_user, :create_feature_flag, project)
+ end
+
+ def valid_version?
+ !params.key?(:version) || Operations::FeatureFlag.versions.key?(params[:version])
+ end
+
+ def flag_version_enabled?
+ params[:version] != 'new_version_flag' || new_version_feature_flags_enabled?
+ end
+
+ def new_version_feature_flags_enabled?
+ ::Feature.enabled?(:feature_flags_new_version, project, default_enabled: true)
+ end
+ end
+end
diff --git a/app/services/feature_flags/destroy_service.rb b/app/services/feature_flags/destroy_service.rb
new file mode 100644
index 00000000000..c77e3e03ec3
--- /dev/null
+++ b/app/services/feature_flags/destroy_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class DestroyService < FeatureFlags::BaseService
+ def execute(feature_flag)
+ destroy_feature_flag(feature_flag)
+ end
+
+ private
+
+ def destroy_feature_flag(feature_flag)
+ return error('Access Denied', 403) unless can_destroy?(feature_flag)
+
+ ActiveRecord::Base.transaction do
+ if feature_flag.destroy
+ save_audit_event(audit_event(feature_flag))
+
+ success(feature_flag: feature_flag)
+ else
+ error(feature_flag.errors.full_messages)
+ end
+ end
+ end
+
+ def audit_message(feature_flag)
+ "Deleted feature flag <strong>#{feature_flag.name}</strong>."
+ end
+
+ def can_destroy?(feature_flag)
+ Ability.allowed?(current_user, :destroy_feature_flag, feature_flag)
+ end
+ end
+end
diff --git a/app/services/feature_flags/disable_service.rb b/app/services/feature_flags/disable_service.rb
new file mode 100644
index 00000000000..8a443ac1795
--- /dev/null
+++ b/app/services/feature_flags/disable_service.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class DisableService < BaseService
+ def execute
+ return error('Feature Flag not found', 404) unless feature_flag_by_name
+ return error('Feature Flag Scope not found', 404) unless feature_flag_scope_by_environment_scope
+ return error('Strategy not found', 404) unless strategy_exist_in_persisted_data?
+
+ ::FeatureFlags::UpdateService
+ .new(project, current_user, update_params)
+ .execute(feature_flag_by_name)
+ end
+
+ private
+
+ def update_params
+ if remaining_strategies.empty?
+ params_to_destroy_scope
+ else
+ params_to_update_scope
+ end
+ end
+
+ def remaining_strategies
+ strong_memoize(:remaining_strategies) do
+ feature_flag_scope_by_environment_scope.strategies.reject do |strategy|
+ strategy['name'] == params[:strategy]['name'] &&
+ strategy['parameters'] == params[:strategy]['parameters']
+ end
+ end
+ end
+
+ def strategy_exist_in_persisted_data?
+ feature_flag_scope_by_environment_scope.strategies != remaining_strategies
+ end
+
+ def params_to_destroy_scope
+ { scopes_attributes: [{ id: feature_flag_scope_by_environment_scope.id, _destroy: true }] }
+ end
+
+ def params_to_update_scope
+ { scopes_attributes: [{ id: feature_flag_scope_by_environment_scope.id, strategies: remaining_strategies }] }
+ end
+ end
+end
diff --git a/app/services/feature_flags/enable_service.rb b/app/services/feature_flags/enable_service.rb
new file mode 100644
index 00000000000..b4cbb32e003
--- /dev/null
+++ b/app/services/feature_flags/enable_service.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class EnableService < BaseService
+ def execute
+ if feature_flag_by_name
+ update_feature_flag
+ else
+ create_feature_flag
+ end
+ end
+
+ private
+
+ def create_feature_flag
+ ::FeatureFlags::CreateService
+ .new(project, current_user, create_params)
+ .execute
+ end
+
+ def update_feature_flag
+ ::FeatureFlags::UpdateService
+ .new(project, current_user, update_params)
+ .execute(feature_flag_by_name)
+ end
+
+ def create_params
+ if params[:environment_scope] == '*'
+ params_to_create_flag_with_default_scope
+ else
+ params_to_create_flag_with_additional_scope
+ end
+ end
+
+ def update_params
+ if feature_flag_scope_by_environment_scope
+ params_to_update_scope
+ else
+ params_to_create_scope
+ end
+ end
+
+ def params_to_create_flag_with_default_scope
+ {
+ name: params[:name],
+ scopes_attributes: [
+ {
+ active: true,
+ environment_scope: '*',
+ strategies: [params[:strategy]]
+ }
+ ]
+ }
+ end
+
+ def params_to_create_flag_with_additional_scope
+ {
+ name: params[:name],
+ scopes_attributes: [
+ {
+ active: false,
+ environment_scope: '*'
+ },
+ {
+ active: true,
+ environment_scope: params[:environment_scope],
+ strategies: [params[:strategy]]
+ }
+ ]
+ }
+ end
+
+ def params_to_create_scope
+ {
+ scopes_attributes: [{
+ active: true,
+ environment_scope: params[:environment_scope],
+ strategies: [params[:strategy]]
+ }]
+ }
+ end
+
+ def params_to_update_scope
+ {
+ scopes_attributes: [{
+ id: feature_flag_scope_by_environment_scope.id,
+ active: true,
+ strategies: feature_flag_scope_by_environment_scope.strategies | [params[:strategy]]
+ }]
+ }
+ end
+ end
+end
diff --git a/app/services/feature_flags/update_service.rb b/app/services/feature_flags/update_service.rb
new file mode 100644
index 00000000000..c837e50b104
--- /dev/null
+++ b/app/services/feature_flags/update_service.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+module FeatureFlags
+ class UpdateService < FeatureFlags::BaseService
+ AUDITABLE_SCOPE_ATTRIBUTES_HUMAN_NAMES = {
+ 'active' => 'active state',
+ 'environment_scope' => 'environment scope',
+ 'strategies' => 'strategies'
+ }.freeze
+
+ def execute(feature_flag)
+ return error('Access Denied', 403) unless can_update?(feature_flag)
+
+ ActiveRecord::Base.transaction do
+ feature_flag.assign_attributes(params)
+
+ feature_flag.strategies.each do |strategy|
+ if strategy.name_changed? && strategy.name_was == ::Operations::FeatureFlags::Strategy::STRATEGY_GITLABUSERLIST
+ strategy.user_list = nil
+ end
+ end
+
+ audit_event = audit_event(feature_flag)
+
+ if feature_flag.save
+ save_audit_event(audit_event)
+
+ success(feature_flag: feature_flag)
+ else
+ error(feature_flag.errors.full_messages, :bad_request)
+ end
+ end
+ end
+
+ private
+
+ def audit_message(feature_flag)
+ changes = changed_attributes_messages(feature_flag)
+ changes += changed_scopes_messages(feature_flag)
+
+ return if changes.empty?
+
+ "Updated feature flag <strong>#{feature_flag.name}</strong>. " + changes.join(" ")
+ end
+
+ def changed_attributes_messages(feature_flag)
+ feature_flag.changes.slice(*AUDITABLE_ATTRIBUTES).map do |attribute_name, changes|
+ "Updated #{attribute_name} "\
+ "from <strong>\"#{changes.first}\"</strong> to "\
+ "<strong>\"#{changes.second}\"</strong>."
+ end
+ end
+
+ def changed_scopes_messages(feature_flag)
+ feature_flag.scopes.map do |scope|
+ if scope.new_record?
+ created_scope_message(scope)
+ elsif scope.marked_for_destruction?
+ deleted_scope_message(scope)
+ else
+ updated_scope_message(scope)
+ end
+ end.compact # updated_scope_message can return nil if nothing has been changed
+ end
+
+ def deleted_scope_message(scope)
+ "Deleted rule <strong>#{scope.environment_scope}</strong>."
+ end
+
+ def updated_scope_message(scope)
+ changes = scope.changes.slice(*AUDITABLE_SCOPE_ATTRIBUTES_HUMAN_NAMES.keys)
+ return if changes.empty?
+
+ message = "Updated rule <strong>#{scope.environment_scope}</strong> "
+ message += changes.map do |attribute_name, change|
+ name = AUDITABLE_SCOPE_ATTRIBUTES_HUMAN_NAMES[attribute_name]
+ "#{name} from <strong>#{change.first}</strong> to <strong>#{change.second}</strong>"
+ end.join(' ')
+
+ message + '.'
+ end
+
+ def can_update?(feature_flag)
+ Ability.allowed?(current_user, :update_feature_flag, feature_flag)
+ end
+ end
+end
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
index dcb32b4c84b..93a0d139001 100644
--- a/app/services/git/branch_hooks_service.rb
+++ b/app/services/git/branch_hooks_service.rb
@@ -76,12 +76,20 @@ module Git
def branch_change_hooks
enqueue_process_commit_messages
enqueue_jira_connect_sync_messages
+ enqueue_metrics_dashboard_sync
end
def branch_remove_hooks
project.repository.after_remove_branch(expire_cache: false)
end
+ def enqueue_metrics_dashboard_sync
+ return unless Feature.enabled?(:sync_metrics_dashboards, project)
+ return unless default_branch?
+
+ ::Metrics::Dashboard::SyncDashboardsWorker.perform_async(project.id)
+ end
+
# Schedules processing of commit messages
def enqueue_process_commit_messages
referencing_commits = limited_commits.select(&:matches_cross_reference_regex?)
diff --git a/app/services/git/wiki_push_service.rb b/app/services/git/wiki_push_service.rb
index fa3019ee9d6..87e2be858c0 100644
--- a/app/services/git/wiki_push_service.rb
+++ b/app/services/git/wiki_push_service.rb
@@ -34,9 +34,7 @@ module Git
def can_process_wiki_events?
# TODO: Support activity events for group wikis
# https://gitlab.com/gitlab-org/gitlab/-/issues/209306
- return false unless wiki.is_a?(ProjectWiki)
-
- Feature.enabled?(:wiki_events_on_git_push, wiki.container)
+ wiki.is_a?(ProjectWiki)
end
def push_changes
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index ce583095168..cf843d92862 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -15,6 +15,8 @@ module Groups
after_build_hook(@group, params)
+ inherit_group_shared_runners_settings
+
unless can_use_visibility_level? && can_create_group?
return @group
end
@@ -28,9 +30,12 @@ module Groups
@group.build_chat_team(name: response['name'], team_id: response['id'])
end
- if @group.save
- @group.add_owner(current_user)
- add_settings_record
+ Group.transaction do
+ if @group.save
+ @group.add_owner(current_user)
+ @group.create_namespace_settings
+ Service.create_from_active_default_integrations(@group, :group_id) if Feature.enabled?(:group_level_integrations)
+ end
end
@group
@@ -44,6 +49,7 @@ module Groups
def remove_unallowed_params
params.delete(:default_branch_protection) unless can?(current_user, :create_group_with_default_branch_protection)
+ params.delete(:allow_mfa_for_subgroups)
end
def create_chat_team?
@@ -84,8 +90,11 @@ module Groups
params[:visibility_level] = Gitlab::CurrentSettings.current_application_settings.default_group_visibility
end
- def add_settings_record
- @group.create_namespace_settings
+ def inherit_group_shared_runners_settings
+ return unless @group.parent
+
+ @group.shared_runners_enabled = @group.parent.shared_runners_enabled
+ @group.allow_descendants_override_disabled_shared_runners = @group.parent.allow_descendants_override_disabled_shared_runners
end
end
end
diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb
index a5c776f8fc2..a0ddc50e5e0 100644
--- a/app/services/groups/import_export/import_service.rb
+++ b/app/services/groups/import_export/import_service.rb
@@ -13,7 +13,7 @@ module Groups
end
def async_execute
- group_import_state = GroupImportState.safe_find_or_create_by!(group: group)
+ group_import_state = GroupImportState.safe_find_or_create_by!(group: group, user: current_user)
jid = GroupImportWorker.perform_async(current_user.id, group.id)
if jid.present?
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index 2bd571f60af..aad574aeaf5 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -38,6 +38,7 @@ module Groups
# Overridden in EE
def post_update_hooks(updated_project_ids)
refresh_project_authorizations
+ refresh_descendant_groups if @new_parent_group
end
def ensure_allowed_transfer
@@ -101,8 +102,13 @@ module Groups
@group.visibility_level = @new_parent_group.visibility_level
end
+ update_two_factor_authentication if @new_parent_group
+
@group.parent = @new_parent_group
@group.clear_memoization(:self_and_ancestors_ids)
+
+ inherit_group_shared_runners_settings
+
@group.save!
end
@@ -126,8 +132,26 @@ module Groups
projects_to_update
.update_all(visibility_level: @new_parent_group.visibility_level)
end
+
+ def update_two_factor_authentication
+ return if namespace_parent_allows_two_factor_auth
+
+ @group.require_two_factor_authentication = false
+ end
+
+ def refresh_descendant_groups
+ return if namespace_parent_allows_two_factor_auth
+
+ if @group.descendants.where(require_two_factor_authentication: true).any?
+ DisallowTwoFactorForSubgroupsWorker.perform_async(@group.id)
+ end
+ end
# rubocop: enable CodeReuse/ActiveRecord
+ def namespace_parent_allows_two_factor_auth
+ @new_parent_group.namespace_settings.allow_mfa_for_subgroups
+ end
+
def ensure_ownership
return if @new_parent_group
return unless @group.owners.empty?
@@ -161,6 +185,17 @@ module Groups
group_contains_npm_packages: s_('TransferGroup|Group contains projects with NPM packages.')
}.freeze
end
+
+ def inherit_group_shared_runners_settings
+ parent_setting = @group.parent&.shared_runners_setting
+ return unless parent_setting
+
+ if @group.shared_runners_setting_higher_than?(parent_setting)
+ result = Groups::UpdateSharedRunnersService.new(@group, current_user, shared_runners_setting: parent_setting).execute
+
+ raise TransferError, result[:message] unless result[:status] == :success
+ end
+ end
end
end
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index 81393681dc0..84385f5da25 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -4,6 +4,8 @@ module Groups
class UpdateService < Groups::BaseService
include UpdateVisibilityLevel
+ SETTINGS_PARAMS = [:allow_mfa_for_subgroups].freeze
+
def execute
reject_parent_id!
remove_unallowed_params
@@ -19,8 +21,14 @@ module Groups
return false unless valid_path_change_with_npm_packages?
+ return false unless update_shared_runners
+
+ handle_changes
+
before_assignment_hook(group, params)
+ handle_namespace_settings
+
group.assign_attributes(params)
begin
@@ -38,6 +46,18 @@ module Groups
private
+ def handle_namespace_settings
+ settings_params = params.slice(*::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS)
+
+ return if settings_params.empty?
+
+ ::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS.each do |nsp|
+ params.delete(nsp)
+ end
+
+ ::NamespaceSettings::UpdateService.new(current_user, group, settings_params).execute
+ end
+
def valid_path_change_with_npm_packages?
return true unless group.packages_feature_enabled?
return true if params[:path].blank?
@@ -73,6 +93,18 @@ module Groups
# don't enqueue immediately to prevent todos removal in case of a mistake
TodosDestroyer::GroupPrivateWorker.perform_in(Todo::WAIT_FOR_DELETE, group.id)
end
+
+ update_two_factor_requirement_for_subgroups
+ end
+
+ def update_two_factor_requirement_for_subgroups
+ settings = group.namespace_settings
+ return if settings.allow_mfa_for_subgroups
+
+ if settings.previous_changes.include?(:allow_mfa_for_subgroups)
+ # enque in batches members update
+ DisallowTwoFactorForSubgroupsWorker.perform_async(group.id)
+ end
end
def reject_parent_id!
@@ -85,6 +117,21 @@ module Groups
params.delete(:default_branch_protection) unless can?(current_user, :update_default_branch_protection, group)
end
+ def handle_changes
+ handle_settings_update
+ end
+
+ def handle_settings_update
+ settings_params = params.slice(*allowed_settings_params)
+ allowed_settings_params.each { |param| params.delete(param) }
+
+ ::NamespaceSettings::UpdateService.new(current_user, group, settings_params).execute
+ end
+
+ def allowed_settings_params
+ SETTINGS_PARAMS
+ end
+
def valid_share_with_group_lock_change?
return true unless changing_share_with_group_lock?
return true if can?(current_user, :change_share_with_group_lock, group)
@@ -98,6 +145,17 @@ module Groups
params[:share_with_group_lock] != group.share_with_group_lock
end
+
+ def update_shared_runners
+ return true if params[:shared_runners_setting].nil?
+
+ result = Groups::UpdateSharedRunnersService.new(group, current_user, shared_runners_setting: params.delete(:shared_runners_setting)).execute
+
+ return true if result[:status] == :success
+
+ group.errors.add(:update_shared_runners, result[:message])
+ false
+ end
end
end
diff --git a/app/services/groups/update_shared_runners_service.rb b/app/services/groups/update_shared_runners_service.rb
index 63f57104510..639c5bf6ae0 100644
--- a/app/services/groups/update_shared_runners_service.rb
+++ b/app/services/groups/update_shared_runners_service.rb
@@ -7,44 +7,24 @@ module Groups
validate_params
- enable_or_disable_shared_runners!
- allow_or_disallow_descendants_override_disabled_shared_runners!
+ update_shared_runners
success
- rescue Group::UpdateSharedRunnersError => error
+ rescue ActiveRecord::RecordInvalid, ArgumentError => error
error(error.message)
end
private
def validate_params
- if Gitlab::Utils.to_boolean(params[:shared_runners_enabled]) && !params[:allow_descendants_override_disabled_shared_runners].nil?
- raise Group::UpdateSharedRunnersError, 'Cannot set shared_runners_enabled to true and allow_descendants_override_disabled_shared_runners'
+ unless Namespace::SHARED_RUNNERS_SETTINGS.include?(params[:shared_runners_setting])
+ raise ArgumentError, "state must be one of: #{Namespace::SHARED_RUNNERS_SETTINGS.join(', ')}"
end
end
- def enable_or_disable_shared_runners!
- return if params[:shared_runners_enabled].nil?
-
- if Gitlab::Utils.to_boolean(params[:shared_runners_enabled])
- group.enable_shared_runners!
- else
- group.disable_shared_runners!
- end
- end
-
- def allow_or_disallow_descendants_override_disabled_shared_runners!
- return if params[:allow_descendants_override_disabled_shared_runners].nil?
-
- # Needs to reset group because if both params are present could result in error
- group.reset
-
- if Gitlab::Utils.to_boolean(params[:allow_descendants_override_disabled_shared_runners])
- group.allow_descendants_override_disabled_shared_runners!
- else
- group.disallow_descendants_override_disabled_shared_runners!
- end
+ def update_shared_runners
+ group.update_shared_runners_setting!(params[:shared_runners_setting])
end
end
end
diff --git a/app/services/incident_management/incidents/create_service.rb b/app/services/incident_management/incidents/create_service.rb
index 5b925e0f440..cff288d602b 100644
--- a/app/services/incident_management/incidents/create_service.rb
+++ b/app/services/incident_management/incidents/create_service.rb
@@ -24,7 +24,7 @@ module IncidentManagement
return error(issue.errors.full_messages.to_sentence, issue) unless issue.valid?
- issue.update_severity(severity)
+ update_severity_for(issue)
success(issue)
end
@@ -40,6 +40,10 @@ module IncidentManagement
def error(message, issue = nil)
ServiceResponse.error(payload: { issue: issue }, message: message)
end
+
+ def update_severity_for(issue)
+ ::IncidentManagement::Incidents::UpdateSeverityService.new(issue, current_user, severity).execute
+ end
end
end
end
diff --git a/app/services/incident_management/incidents/update_severity_service.rb b/app/services/incident_management/incidents/update_severity_service.rb
new file mode 100644
index 00000000000..5b150f3f02e
--- /dev/null
+++ b/app/services/incident_management/incidents/update_severity_service.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ module Incidents
+ class UpdateSeverityService < BaseService
+ def initialize(issuable, current_user, severity)
+ super(issuable.project, current_user)
+
+ @issuable = issuable
+ @severity = severity.to_s.downcase
+ @severity = IssuableSeverity::DEFAULT unless IssuableSeverity.severities.key?(@severity)
+ end
+
+ def execute
+ return unless issuable.incident?
+
+ update_severity!
+ add_system_note
+ end
+
+ private
+
+ attr_reader :issuable, :severity
+
+ def issuable_severity
+ issuable.issuable_severity || issuable.build_issuable_severity(issue_id: issuable.id)
+ end
+
+ def update_severity!
+ issuable_severity.update!(severity: severity)
+ end
+
+ def add_system_note
+ ::IncidentManagement::AddSeveritySystemNoteWorker.perform_async(issuable.id, current_user.id)
+ end
+ end
+ end
+end
diff --git a/app/services/incident_management/pager_duty/process_webhook_service.rb b/app/services/incident_management/pager_duty/process_webhook_service.rb
index fd8252f75fb..027425e4aaa 100644
--- a/app/services/incident_management/pager_duty/process_webhook_service.rb
+++ b/app/services/incident_management/pager_duty/process_webhook_service.rb
@@ -34,7 +34,7 @@ module IncidentManagement
strong_memoize(:pager_duty_processable_events) do
::PagerDuty::WebhookPayloadParser
.call(params.to_h)
- .filter { |msg| msg['event'].in?(PAGER_DUTY_PROCESSABLE_EVENT_TYPES) }
+ .filter { |msg| msg['event'].to_s.in?(PAGER_DUTY_PROCESSABLE_EVENT_TYPES) }
end
end
diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb
index c84074039ea..3861d88bce9 100644
--- a/app/services/issuable/clone/attributes_rewriter.rb
+++ b/app/services/issuable/clone/attributes_rewriter.rb
@@ -18,7 +18,6 @@ module Issuable
new_entity.update(update_attributes)
copy_resource_label_events
- copy_resource_weight_events
copy_resource_milestone_events
copy_resource_state_events
end
@@ -55,16 +54,6 @@ module Issuable
end
end
- def copy_resource_weight_events
- return unless both_respond_to?(:resource_weight_events)
-
- copy_events(ResourceWeightEvent.table_name, original_entity.resource_weight_events) do |event|
- event.attributes
- .except('id', 'reference', 'reference_html')
- .merge('issue_id' => new_entity.id)
- end
- end
-
def copy_resource_milestone_events
return unless milestone_events_supported?
@@ -128,3 +117,5 @@ module Issuable
end
end
end
+
+Issuable::Clone::AttributesRewriter.prepend_if_ee('EE::Issuable::Clone::AttributesRewriter')
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 1672ba2830a..60e5293e218 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -322,7 +322,7 @@ class IssuableBaseService < BaseService
def change_severity(issuable)
if severity = params.delete(:severity)
- issuable.update_severity(severity)
+ ::IncidentManagement::Incidents::UpdateSeverityService.new(issuable, current_user, severity).execute
end
end
@@ -366,6 +366,7 @@ class IssuableBaseService < BaseService
}
associations[:total_time_spent] = issuable.total_time_spent if issuable.respond_to?(:total_time_spent)
associations[:description] = issuable.description
+ associations[:reviewers] = issuable.reviewers.to_a if issuable.allows_reviewers?
associations
end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index 0ed2b08b7b1..978ea6fe9bc 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -34,6 +34,18 @@ module Issues
private
+ def filter_params(merge_request)
+ super
+
+ moved_issue = params.delete(:moved_issue)
+
+ # Setting created_at, updated_at and iid is allowed only for admins and owners or
+ # when moving an issue as we preserve the original issue attributes except id and iid.
+ params.delete(:iid) unless current_user.can?(:set_issue_iid, project)
+ params.delete(:created_at) unless moved_issue || current_user.can?(:set_issue_created_at, project)
+ params.delete(:updated_at) unless moved_issue || current_user.can?(:set_issue_updated_at, project)
+ end
+
def create_assignee_note(issue, old_assignees)
SystemNoteService.change_issuable_assignees(
issue, issue.project, current_user, old_assignees)
diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb
index 2de6ed9fa1c..3145739fe91 100644
--- a/app/services/issues/build_service.rb
+++ b/app/services/issues/build_service.rb
@@ -64,20 +64,26 @@ module Issues
private
- def whitelisted_issue_params
- base_params = [:title, :description, :confidential]
- admin_params = [:milestone_id, :issue_type]
+ def allowed_issue_base_params
+ [:title, :description, :confidential, :issue_type]
+ end
+
+ def allowed_issue_admin_params
+ [:milestone_id]
+ end
+ def allowed_issue_params
if can?(current_user, :admin_issue, project)
- params.slice(*(base_params + admin_params))
+ params.slice(*(allowed_issue_base_params + allowed_issue_admin_params))
else
- params.slice(*base_params)
+ params.slice(*allowed_issue_base_params)
end
end
def build_issue_params
- { author: current_user }.merge(issue_params_with_info_from_discussions)
- .merge(whitelisted_issue_params)
+ { author: current_user }
+ .merge(issue_params_with_info_from_discussions)
+ .merge(allowed_issue_params)
end
end
end
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index 60e0d1eec3d..90ccbd8ed21 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -23,11 +23,15 @@ module Issues
# to receive service desk emails on the new moved issue.
update_service_desk_sent_notifications
+ queue_copy_designs
+
new_entity
end
private
+ attr_reader :target_project
+
def update_service_desk_sent_notifications
return unless original_entity.from_service_desk?
@@ -46,9 +50,10 @@ module Issues
new_params = {
id: nil,
iid: nil,
- project: @target_project,
+ project: target_project,
author: original_entity.author,
- assignee_ids: original_entity.assignee_ids
+ assignee_ids: original_entity.assignee_ids,
+ moved_issue: true
}
new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params)
@@ -58,6 +63,18 @@ module Issues
CreateService.new(@target_project, @current_user, new_params).execute(skip_system_notes: true)
end
+ def queue_copy_designs
+ return unless original_entity.designs.present?
+
+ response = DesignManagement::CopyDesignCollection::QueueService.new(
+ current_user,
+ original_entity,
+ new_entity
+ ).execute
+
+ log_error(response.message) if response.error?
+ end
+
def mark_as_moved
original_entity.update(moved_to: new_entity)
end
@@ -75,7 +92,7 @@ module Issues
end
def add_note_from
- SystemNoteService.noteable_moved(new_entity, @target_project,
+ SystemNoteService.noteable_moved(new_entity, target_project,
original_entity, current_user,
direction: :from)
end
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index e2b1b5400c7..12dbff57ec5 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -5,6 +5,8 @@ module Issues
def execute(issue)
return issue unless can?(current_user, :reopen_issue, issue)
+ before_reopen(issue)
+
if issue.reopen
event_service.reopen_issue(issue, current_user)
create_note(issue, 'reopened')
@@ -21,8 +23,14 @@ module Issues
private
+ def before_reopen(issue)
+ # Overriden in EE
+ end
+
def create_note(issue, state = issue.state)
SystemNoteService.change_status(issue, issue.project, current_user, state, nil)
end
end
end
+
+Issues::ReopenService.prepend_if_ee('EE::Issues::ReopenService')
diff --git a/app/services/jira/requests/base.rb b/app/services/jira/requests/base.rb
index 7c6db372257..4ed8df0f235 100644
--- a/app/services/jira/requests/base.rb
+++ b/app/services/jira/requests/base.rb
@@ -40,7 +40,12 @@ module Jira
build_service_response(response)
rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => error
error_message = "Jira request error: #{error.message}"
- log_error("Error sending message", client_url: client.options[:site], error: error_message)
+ log_error("Error sending message", client_url: client.options[:site],
+ error: {
+ exception_class: error.class.name,
+ exception_message: error.message,
+ exception_backtrace: Gitlab::BacktraceCleaner.clean_backtrace(error.backtrace)
+ })
ServiceResponse.error(message: error_message)
end
diff --git a/app/services/lfs/push_service.rb b/app/services/lfs/push_service.rb
index 6e1a11ebff8..9b947fbed07 100644
--- a/app/services/lfs/push_service.rb
+++ b/app/services/lfs/push_service.rb
@@ -12,7 +12,7 @@ module Lfs
def execute
lfs_objects_relation.each_batch(of: BATCH_SIZE) do |objects|
- push_objects(objects)
+ push_objects!(objects)
end
success
@@ -30,8 +30,8 @@ module Lfs
project.lfs_objects_for_repository_types(nil, :project)
end
- def push_objects(objects)
- rsp = lfs_client.batch('upload', objects)
+ def push_objects!(objects)
+ rsp = lfs_client.batch!('upload', objects)
objects = objects.index_by(&:oid)
rsp.fetch('objects', []).each do |spec|
@@ -53,14 +53,14 @@ module Lfs
return
end
- lfs_client.upload(object, upload, authenticated: authenticated)
+ lfs_client.upload!(object, upload, authenticated: authenticated)
end
def verify_object!(object, spec)
- # TODO: the remote has requested that we make another call to verify that
- # the object has been sent correctly.
- # https://gitlab.com/gitlab-org/gitlab/-/issues/250654
- log_error("LFS upload verification requested, but not supported for #{object.oid}")
+ authenticated = spec['authenticated']
+ verify = spec.dig('actions', 'verify')
+
+ lfs_client.verify!(object, verify, authenticated: authenticated)
end
def url
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index 610288c5e76..088e6f031c8 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -7,7 +7,7 @@ module Members
def execute(source)
return error(s_('AddMember|No users specified.')) if params[:user_ids].blank?
- user_ids = params[:user_ids].split(',').uniq
+ user_ids = params[:user_ids].split(',').uniq.flatten
return error(s_("AddMember|Too many users specified (limit is %{user_limit})") % { user_limit: user_limit }) if
user_limit && user_ids.size > user_limit
diff --git a/app/services/members/invitation_reminder_email_service.rb b/app/services/members/invitation_reminder_email_service.rb
new file mode 100644
index 00000000000..e589cdc2fa3
--- /dev/null
+++ b/app/services/members/invitation_reminder_email_service.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Members
+ class InvitationReminderEmailService
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :invitation
+
+ MAX_INVITATION_LIFESPAN = 14.0
+ REMINDER_RATIO = [2, 5, 10].freeze
+
+ def initialize(invitation)
+ @invitation = invitation
+ end
+
+ def execute
+ return unless experiment_enabled?
+
+ reminder_index = days_on_which_to_send_reminders.index(days_after_invitation_sent)
+ return unless reminder_index
+
+ invitation.send_invitation_reminder(reminder_index)
+ end
+
+ private
+
+ def experiment_enabled?
+ Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, invitation.invite_email)
+ end
+
+ def days_after_invitation_sent
+ (Date.today - invitation.created_at.to_date).to_i
+ end
+
+ def days_on_which_to_send_reminders
+ # Don't send any reminders if the invitation has expired or expires today
+ return [] if invitation.expires_at && invitation.expires_at <= Date.today
+
+ # Calculate the number of days on which to send reminders based on the MAX_INVITATION_LIFESPAN and the REMINDER_RATIO
+ REMINDER_RATIO.map { |number_of_days| ((number_of_days * invitation_lifespan_in_days) / MAX_INVITATION_LIFESPAN).ceil }.uniq
+ end
+
+ def invitation_lifespan_in_days
+ # When the invitation lifespan is more than 14 days or does not expire, send the reminders within 14 days
+ strong_memoize(:invitation_lifespan_in_days) do
+ if invitation.expires_at
+ [(invitation.expires_at - invitation.created_at.to_date).to_i, MAX_INVITATION_LIFESPAN].min
+ else
+ MAX_INVITATION_LIFESPAN
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index abc3f99797d..aa591312c6a 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -110,6 +110,10 @@ module MergeRequests
return
end
+ unless merge_request.allows_multiple_reviewers?
+ params[:reviewer_ids] = params[:reviewer_ids].first(1)
+ end
+
reviewer_ids = params[:reviewer_ids].select { |reviewer_id| user_can_read?(merge_request, reviewer_id) }
if params[:reviewer_ids].map(&:to_s) == [IssuableFinder::Params::NONE]
@@ -130,6 +134,11 @@ module MergeRequests
merge_request, merge_request.project, current_user, old_assignees)
end
+ def create_reviewer_note(merge_request, old_reviewers)
+ SystemNoteService.change_issuable_reviewers(
+ merge_request, merge_request.project, current_user, old_reviewers)
+ end
+
def create_pipeline_for(merge_request, user)
MergeRequests::CreatePipelineService.new(project, user).execute(merge_request)
end
diff --git a/app/services/merge_requests/cleanup_refs_service.rb b/app/services/merge_requests/cleanup_refs_service.rb
index 0f03f5f09b4..d003124a112 100644
--- a/app/services/merge_requests/cleanup_refs_service.rb
+++ b/app/services/merge_requests/cleanup_refs_service.rb
@@ -17,7 +17,7 @@ module MergeRequests
@repository = merge_request.project.repository
@ref_path = merge_request.ref_path
@merge_ref_path = merge_request.merge_ref_path
- @ref_head_sha = @repository.commit(merge_request.ref_path).id
+ @ref_head_sha = @repository.commit(merge_request.ref_path)&.id
@merge_ref_sha = merge_request.merge_ref_head&.id
end
diff --git a/app/services/merge_requests/export_csv_service.rb b/app/services/merge_requests/export_csv_service.rb
new file mode 100644
index 00000000000..1e7f0c8e722
--- /dev/null
+++ b/app/services/merge_requests/export_csv_service.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class ExportCsvService
+ include Gitlab::Routing.url_helpers
+ include GitlabRoutingHelper
+
+ # Target attachment size before base64 encoding
+ TARGET_FILESIZE = 15.megabytes
+
+ def initialize(merge_requests, project)
+ @project = project
+ @merge_requests = merge_requests
+ end
+
+ def csv_data
+ csv_builder.render(TARGET_FILESIZE)
+ end
+
+ def email(user)
+ Notify.merge_requests_csv_email(user, @project, csv_data, csv_builder.status).deliver_now
+ end
+
+ private
+
+ def csv_builder
+ @csv_builder ||= CsvBuilder.new(@merge_requests.with_csv_entity_associations, header_to_value_hash)
+ end
+
+ def header_to_value_hash
+ {
+ 'MR IID' => 'iid',
+ 'URL' => -> (merge_request) { merge_request_url(merge_request) },
+ 'Title' => 'title',
+ 'State' => 'state',
+ 'Description' => 'description',
+ 'Source Branch' => 'source_branch',
+ 'Target Branch' => 'target_branch',
+ 'Source Project ID' => 'source_project_id',
+ 'Target Project ID' => 'target_project_id',
+ 'Author' => -> (merge_request) { merge_request.author.name },
+ 'Author Username' => -> (merge_request) { merge_request.author.username },
+ 'Assignees' => -> (merge_request) { merge_request.assignees.map(&:name).join(', ') },
+ 'Assignee Usernames' => -> (merge_request) { merge_request.assignees.map(&:username).join(', ') },
+ 'Approvers' => -> (merge_request) { merge_request.approved_by_users.map(&:name).join(', ') },
+ 'Approver Usernames' => -> (merge_request) { merge_request.approved_by_users.map(&:username).join(', ') },
+ 'Merged User' => -> (merge_request) { merge_request.metrics&.merged_by&.name.to_s },
+ 'Merged Username' => -> (merge_request) { merge_request.metrics&.merged_by&.username.to_s },
+ 'Milestone ID' => -> (merge_request) { merge_request&.milestone&.id || '' },
+ 'Created At (UTC)' => -> (merge_request) { merge_request.created_at.utc },
+ 'Updated At (UTC)' => -> (merge_request) { merge_request.updated_at.utc }
+ }
+ end
+ end
+end
diff --git a/app/services/merge_requests/ff_merge_service.rb b/app/services/merge_requests/ff_merge_service.rb
index 79011094e88..c5640047899 100644
--- a/app/services/merge_requests/ff_merge_service.rb
+++ b/app/services/merge_requests/ff_merge_service.rb
@@ -27,7 +27,7 @@ module MergeRequests
rescue StandardError => e
raise MergeError, "Something went wrong during merge: #{e.message}"
ensure
- merge_request.update(in_progress_merge_commit_sha: nil)
+ merge_request.update_and_mark_in_progress_merge_commit_sha(nil)
end
end
end
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index 437e87dadf7..ba22b458777 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -84,7 +84,7 @@ module MergeRequests
merge_request.update!(merge_commit_sha: commit_id)
ensure
- merge_request.update_column(:in_progress_merge_commit_sha, nil)
+ merge_request.update_and_mark_in_progress_merge_commit_sha(nil)
end
def try_merge
diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb
index 1876b1096fe..c0115e94903 100644
--- a/app/services/merge_requests/merge_to_ref_service.rb
+++ b/app/services/merge_requests/merge_to_ref_service.rb
@@ -58,8 +58,15 @@ module MergeRequests
params[:first_parent_ref] || merge_request.target_branch_ref
end
+ ##
+ # The parameter `allow_conflicts` is a flag whether merge conflicts should be merged into diff
+ # Default is false
+ def allow_conflicts
+ params[:allow_conflicts] || false
+ end
+
def commit
- repository.merge_to_ref(current_user, source, merge_request, target_ref, commit_message, first_parent_ref)
+ repository.merge_to_ref(current_user, source, merge_request, target_ref, commit_message, first_parent_ref, allow_conflicts)
rescue Gitlab::Git::PreReceiveError, Gitlab::Git::CommandError => error
raise MergeError, error.message
end
diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb
index a3c39fa2e32..627c747203c 100644
--- a/app/services/merge_requests/mergeability_check_service.rb
+++ b/app/services/merge_requests/mergeability_check_service.rb
@@ -88,7 +88,7 @@ module MergeRequests
sleep_sec: retry_lease ? 1.second : 0
}
- in_lock(lease_key, lease_opts, &block)
+ in_lock(lease_key, **lease_opts, &block)
end
def payload
@@ -115,18 +115,20 @@ module MergeRequests
def update_merge_status
return unless merge_request.recheck_merge_status?
+ return merge_request.mark_as_unmergeable if merge_request.broken?
- if can_git_merge? && merge_to_ref
+ merge_to_ref_success = merge_to_ref
+
+ update_diff_discussion_positions! if merge_to_ref_success
+
+ if merge_to_ref_success && can_git_merge?
merge_request.mark_as_mergeable
- update_diff_discussion_positions!
else
merge_request.mark_as_unmergeable
end
end
def update_diff_discussion_positions!
- return if Feature.disabled?(:merge_ref_head_comments, merge_request.target_project, default_enabled: true)
-
Discussions::CaptureDiffNotePositionsService.new(merge_request).execute
end
@@ -151,13 +153,14 @@ module MergeRequests
end
def can_git_merge?
- !merge_request.broken? && repository.can_be_merged?(merge_request.diff_head_sha, merge_request.target_branch)
+ repository.can_be_merged?(merge_request.diff_head_sha, merge_request.target_branch)
end
def merge_to_ref
return true unless merge_ref_auto_sync_enabled?
- result = MergeRequests::MergeToRefService.new(project, merge_request.author).execute(merge_request)
+ params = { allow_conflicts: Feature.enabled?(:display_merge_conflicts_in_diff, project) }
+ result = MergeRequests::MergeToRefService.new(project, merge_request.author, params).execute(merge_request)
result[:status] == :success
end
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index 405b8fe9c9e..e5d0b216d6c 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -85,18 +85,13 @@ module MergeRequests
return if merge_requests.empty?
- commit_analyze_enabled = Feature.enabled?(:branch_push_merge_commit_analyze, @project, default_enabled: true)
- if commit_analyze_enabled
- analyzer = Gitlab::BranchPushMergeCommitAnalyzer.new(
- @commits.reverse,
- relevant_commit_ids: merge_requests.map(&:diff_head_sha)
- )
- end
+ analyzer = Gitlab::BranchPushMergeCommitAnalyzer.new(
+ @commits.reverse,
+ relevant_commit_ids: merge_requests.map(&:diff_head_sha)
+ )
merge_requests.each do |merge_request|
- if commit_analyze_enabled
- merge_request.merge_commit_sha = analyzer.get_merge_commit(merge_request.diff_head_sha)
- end
+ merge_request.merge_commit_sha = analyzer.get_merge_commit(merge_request.diff_head_sha)
MergeRequests::PostMergeService
.new(merge_request.target_project, @current_user)
@@ -184,7 +179,7 @@ module MergeRequests
def abort_auto_merge_with_todo(merge_request, reason)
response = abort_auto_merge(merge_request, reason)
- response = ServiceResponse.new(response)
+ response = ServiceResponse.new(**response)
return unless response.success?
todo_service.merge_request_became_unmergeable(merge_request)
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 1468bfd6bb6..8c069ea5bb0 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -112,6 +112,8 @@ module MergeRequests
end
def handle_reviewers_change(merge_request, old_reviewers)
+ create_reviewer_note(merge_request, old_reviewers)
+ notification_service.async.changed_reviewer_of_merge_request(merge_request, current_user, old_reviewers)
todo_service.reassigned_reviewable(merge_request, current_user, old_reviewers)
end
diff --git a/app/services/metrics/dashboard/custom_dashboard_service.rb b/app/services/metrics/dashboard/custom_dashboard_service.rb
index f0f19bf2ba3..bde8e86851a 100644
--- a/app/services/metrics/dashboard/custom_dashboard_service.rb
+++ b/app/services/metrics/dashboard/custom_dashboard_service.rb
@@ -42,6 +42,12 @@ module Metrics
def cache_key
"project_#{project.id}_metrics_dashboard_#{dashboard_path}"
end
+
+ def sequence
+ [
+ ::Gitlab::Metrics::Dashboard::Stages::CustomDashboardMetricsInserter
+ ] + super
+ end
end
end
end
diff --git a/app/services/metrics/dashboard/dynamic_embed_service.rb b/app/services/metrics/dashboard/dynamic_embed_service.rb
index 0b198ecbbe9..a94538668c1 100644
--- a/app/services/metrics/dashboard/dynamic_embed_service.rb
+++ b/app/services/metrics/dashboard/dynamic_embed_service.rb
@@ -18,7 +18,7 @@ module Metrics
# Determines whether the provided params are sufficient
# to uniquely identify a panel from a yml-defined dashboard.
#
- # See https://docs.gitlab.com/ee/operations/metrics/dashboards/index.html#defining-custom-dashboards-per-project
+ # See https://docs.gitlab.com/ee/operations/metrics/dashboards/index.html
# for additional info on defining custom dashboards.
def valid_params?(params)
[
diff --git a/app/services/namespace_settings/update_service.rb b/app/services/namespace_settings/update_service.rb
new file mode 100644
index 00000000000..3c9b7b637ac
--- /dev/null
+++ b/app/services/namespace_settings/update_service.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module NamespaceSettings
+ class UpdateService
+ include ::Gitlab::Allowable
+
+ attr_reader :current_user, :group, :settings_params
+
+ def initialize(current_user, group, settings)
+ @current_user = current_user
+ @group = group
+ @settings_params = settings
+ end
+
+ def execute
+ if group.namespace_settings
+ group.namespace_settings.attributes = settings_params
+ else
+ group.build_namespace_settings(settings_params)
+ end
+ end
+ end
+end
+
+NamespaceSettings::UpdateService.prepend_if_ee('EE::NamespaceSettings::UpdateService')
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index e26f662a697..48f44affb23 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -70,7 +70,7 @@ module Notes
Gitlab::Tracking.event('Notes::CreateService', 'execute', tracking_data_for(note))
end
- if Feature.enabled?(:merge_ref_head_comments, project, default_enabled: true) && note.for_merge_request? && note.diff_note? && note.start_of_discussion?
+ if note.for_merge_request? && note.diff_note? && note.start_of_discussion?
Discussions::CaptureDiffNotePositionService.new(note.noteable, note.diff_file&.paths).execute(note.discussion)
end
end
diff --git a/app/services/notification_recipients/build_service.rb b/app/services/notification_recipients/build_service.rb
index 0fe0d26d7b2..040ecc29d3a 100644
--- a/app/services/notification_recipients/build_service.rb
+++ b/app/services/notification_recipients/build_service.rb
@@ -13,8 +13,8 @@ module NotificationRecipients
NotificationRecipient.new(user, *args).notifiable?
end
- def self.build_recipients(*args)
- ::NotificationRecipients::Builder::Default.new(*args).notification_recipients
+ def self.build_recipients(target, current_user, **args)
+ ::NotificationRecipients::Builder::Default.new(target, current_user, **args).notification_recipients
end
def self.build_new_note_recipients(*args)
@@ -25,8 +25,8 @@ module NotificationRecipients
::NotificationRecipients::Builder::MergeRequestUnmergeable.new(*args).notification_recipients
end
- def self.build_project_maintainers_recipients(*args)
- ::NotificationRecipients::Builder::ProjectMaintainers.new(*args).notification_recipients
+ def self.build_project_maintainers_recipients(target, **args)
+ ::NotificationRecipients::Builder::ProjectMaintainers.new(target, **args).notification_recipients
end
def self.build_new_release_recipients(*args)
diff --git a/app/services/notification_recipients/builder/default.rb b/app/services/notification_recipients/builder/default.rb
index 790ce57452c..19527ba84e6 100644
--- a/app/services/notification_recipients/builder/default.rb
+++ b/app/services/notification_recipients/builder/default.rb
@@ -34,6 +34,9 @@ module NotificationRecipients
when :reassign_merge_request, :reassign_issue
add_recipients(previous_assignees, :mention, nil)
add_recipients(target.assignees, :mention, NotificationReason::ASSIGNED)
+ when :change_reviewer_merge_request
+ add_recipients(previous_assignees, :mention, nil)
+ add_recipients(target.reviewers, :mention, NotificationReason::REVIEW_REQUESTED)
end
add_subscribed_users
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 731d72c41d4..7853ad11c64 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -238,6 +238,33 @@ class NotificationService
end
end
+ # When we change reviewer in a merge_request we should send an email to:
+ #
+ # * merge_request old reviewers if their notification level is not Disabled
+ # * merge_request new reviewers if their notification level is not Disabled
+ # * users with custom level checked with "change reviewer merge request"
+ #
+ def changed_reviewer_of_merge_request(merge_request, current_user, previous_reviewers = [])
+ recipients = NotificationRecipients::BuildService.build_recipients(
+ merge_request,
+ current_user,
+ action: "change_reviewer",
+ previous_assignees: previous_reviewers
+ )
+
+ previous_reviewer_ids = previous_reviewers.map(&:id)
+
+ recipients.each do |recipient|
+ mailer.changed_reviewer_of_merge_request_email(
+ recipient.user.id,
+ merge_request.id,
+ previous_reviewer_ids,
+ current_user.id,
+ recipient.reason
+ ).deliver_later
+ end
+ end
+
# When we add labels to a merge request we should send an email to:
#
# * watchers of the mr's labels
@@ -408,6 +435,10 @@ class NotificationService
mailer.member_invited_email(group_member.real_source_type, group_member.id, token).deliver_later
end
+ def invite_member_reminder(group_member, token, reminder_index)
+ mailer.member_invited_reminder_email(group_member.real_source_type, group_member.id, token, reminder_index).deliver_later
+ end
+
def accept_group_invite(group_member)
mailer.member_invite_accepted_email(group_member.real_source_type, group_member.id).deliver_later
end
diff --git a/app/services/packages/composer/composer_json_service.rb b/app/services/packages/composer/composer_json_service.rb
index 6ffb5a77da3..98aabd84d3d 100644
--- a/app/services/packages/composer/composer_json_service.rb
+++ b/app/services/packages/composer/composer_json_service.rb
@@ -3,6 +3,8 @@
module Packages
module Composer
class ComposerJsonService
+ InvalidJson = Class.new(StandardError)
+
def initialize(project, target)
@project, @target = project, target
end
@@ -20,11 +22,11 @@ module Packages
Gitlab::Json.parse(composer_file.data)
rescue JSON::ParserError
- raise 'Could not parse composer.json file. Invalid JSON.'
+ raise InvalidJson, 'Could not parse composer.json file. Invalid JSON.'
end
def composer_file_not_found!
- raise 'The file composer.json was not found.'
+ raise InvalidJson, 'The file composer.json was not found.'
end
end
end
diff --git a/app/services/packages/create_event_service.rb b/app/services/packages/create_event_service.rb
new file mode 100644
index 00000000000..d009cba2812
--- /dev/null
+++ b/app/services/packages/create_event_service.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Packages
+ class CreateEventService < BaseService
+ def execute
+ event_scope = scope.is_a?(::Packages::Package) ? scope.package_type : scope
+
+ ::Packages::Event.create!(
+ event_type: event_name,
+ originator: current_user&.id,
+ originator_type: originator_type,
+ event_scope: event_scope
+ )
+ end
+
+ private
+
+ def scope
+ params[:scope]
+ end
+
+ def event_name
+ params[:event_name]
+ end
+
+ def originator_type
+ case current_user
+ when User
+ :user
+ when DeployToken
+ :deploy_token
+ else
+ :guest
+ end
+ end
+ end
+end
diff --git a/app/services/packages/create_package_service.rb b/app/services/packages/create_package_service.rb
index 397a5f74e0a..e3b0ad218e2 100644
--- a/app/services/packages/create_package_service.rb
+++ b/app/services/packages/create_package_service.rb
@@ -10,6 +10,7 @@ module Packages
.with_package_type(package_type)
.safe_find_or_create_by!(name: name, version: version) do |pkg|
pkg.creator = package_creator
+ yield pkg if block_given?
end
end
diff --git a/app/services/packages/generic/create_package_file_service.rb b/app/services/packages/generic/create_package_file_service.rb
new file mode 100644
index 00000000000..4d49c63799f
--- /dev/null
+++ b/app/services/packages/generic/create_package_file_service.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Packages
+ module Generic
+ class CreatePackageFileService < BaseService
+ def execute
+ ::Packages::Package.transaction do
+ create_package_file(find_or_create_package)
+ end
+ end
+
+ private
+
+ def find_or_create_package
+ package_params = {
+ name: params[:package_name],
+ version: params[:package_version],
+ build: params[:build]
+ }
+
+ ::Packages::Generic::FindOrCreatePackageService
+ .new(project, current_user, package_params)
+ .execute
+ end
+
+ def create_package_file(package)
+ file_params = {
+ file: params[:file],
+ size: params[:file].size,
+ file_sha256: params[:file].sha256,
+ file_name: params[:file_name]
+ }
+
+ ::Packages::CreatePackageFileService.new(package, file_params).execute
+ end
+ end
+ end
+end
diff --git a/app/services/packages/generic/find_or_create_package_service.rb b/app/services/packages/generic/find_or_create_package_service.rb
new file mode 100644
index 00000000000..8a8459d167e
--- /dev/null
+++ b/app/services/packages/generic/find_or_create_package_service.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Packages
+ module Generic
+ class FindOrCreatePackageService < ::Packages::CreatePackageService
+ def execute
+ find_or_create_package!(::Packages::Package.package_types['generic']) do |package|
+ if params[:build].present?
+ package.build_info = Packages::BuildInfo.new(pipeline: params[:build].pipeline)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/personal_access_tokens/revoke_service.rb b/app/services/personal_access_tokens/revoke_service.rb
index 16ba42bd317..17405002d8d 100644
--- a/app/services/personal_access_tokens/revoke_service.rb
+++ b/app/services/personal_access_tokens/revoke_service.rb
@@ -2,11 +2,12 @@
module PersonalAccessTokens
class RevokeService
- attr_reader :token, :current_user
+ attr_reader :token, :current_user, :group
- def initialize(current_user = nil, params = { token: nil })
+ def initialize(current_user = nil, params = { token: nil, group: nil })
@current_user = current_user
@token = params[:token]
+ @group = params[:group]
end
def execute
@@ -34,3 +35,5 @@ module PersonalAccessTokens
end
end
end
+
+PersonalAccessTokens::RevokeService.prepend_if_ee('EE::PersonalAccessTokens::RevokeService')
diff --git a/app/services/pod_logs/base_service.rb b/app/services/pod_logs/base_service.rb
index 8936f9b67a5..e4b6ad31e33 100644
--- a/app/services/pod_logs/base_service.rb
+++ b/app/services/pod_logs/base_service.rb
@@ -10,6 +10,8 @@ module PodLogs
CACHE_KEY_GET_POD_LOG = 'get_pod_log'
K8S_NAME_MAX_LENGTH = 253
+ self.reactive_cache_work_type = :external_dependency
+
def id
cluster.id
end
diff --git a/app/services/pod_logs/elasticsearch_service.rb b/app/services/pod_logs/elasticsearch_service.rb
index f79562c8ab3..58d1bfbf835 100644
--- a/app/services/pod_logs/elasticsearch_service.rb
+++ b/app/services/pod_logs/elasticsearch_service.rb
@@ -11,7 +11,6 @@ module PodLogs
:pod_logs,
:filter_return_keys
- self.reactive_cache_work_type = :external_dependency
self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) }
private
diff --git a/app/services/pod_logs/kubernetes_service.rb b/app/services/pod_logs/kubernetes_service.rb
index b573ceae1aa..03b84f98973 100644
--- a/app/services/pod_logs/kubernetes_service.rb
+++ b/app/services/pod_logs/kubernetes_service.rb
@@ -17,7 +17,6 @@ module PodLogs
:split_logs,
:filter_return_keys
- self.reactive_cache_work_type = :external_dependency
self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) }
private
diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb
index bfce5f1ad63..affac45fc3d 100644
--- a/app/services/projects/alerting/notify_service.rb
+++ b/app/services/projects/alerting/notify_service.rb
@@ -7,43 +7,34 @@ module Projects
include ::IncidentManagement::Settings
def execute(token)
+ return bad_request unless valid_payload_size?
return forbidden unless alerts_service_activated?
return unauthorized unless valid_token?(token)
- alert = process_alert
+ process_alert
return bad_request unless alert.persisted?
- process_incident_issues(alert) if process_issues?
+ process_incident_issues if process_issues?
send_alert_email if send_email?
ServiceResponse.success
- rescue Gitlab::Alerting::NotificationPayloadParser::BadPayloadError
- bad_request
end
private
delegate :alerts_service, :alerts_service_activated?, to: :project
- def am_alert_params
- strong_memoize(:am_alert_params) do
- Gitlab::AlertManagement::AlertParams.from_generic_alert(project: project, payload: params.to_h)
- end
- end
-
def process_alert
- existing_alert = find_alert_by_fingerprint(am_alert_params[:fingerprint])
-
- if existing_alert
- process_existing_alert(existing_alert)
+ if alert.persisted?
+ process_existing_alert
else
create_alert
end
end
- def process_existing_alert(alert)
- if am_alert_params[:ended_at].present?
- process_resolved_alert(alert)
+ def process_existing_alert
+ if incoming_payload.ends_at.present?
+ process_resolved_alert
else
alert.register_new_event!
end
@@ -51,10 +42,10 @@ module Projects
alert
end
- def process_resolved_alert(alert)
+ def process_resolved_alert
return unless auto_close_incident?
- if alert.resolve(am_alert_params[:ended_at])
+ if alert.resolve(incoming_payload.ends_at)
close_issue(alert.issue)
end
@@ -72,20 +63,16 @@ module Projects
end
def create_alert
- alert = AlertManagement::Alert.create(am_alert_params.except(:ended_at))
- alert.execute_services if alert.persisted?
- SystemNoteService.create_new_alert(alert, 'Generic Alert Endpoint')
-
- alert
- end
-
- def find_alert_by_fingerprint(fingerprint)
- return unless fingerprint
+ return unless alert.save
- AlertManagement::Alert.not_resolved.for_fingerprint(project, fingerprint).first
+ alert.execute_services
+ SystemNoteService.create_new_alert(
+ alert,
+ alert.monitoring_tool || 'Generic Alert Endpoint'
+ )
end
- def process_incident_issues(alert)
+ def process_incident_issues
return if alert.issue
::IncidentManagement::ProcessAlertWorker.perform_async(nil, nil, alert.id)
@@ -94,11 +81,33 @@ module Projects
def send_alert_email
notification_service
.async
- .prometheus_alerts_fired(project, [parsed_payload])
+ .prometheus_alerts_fired(project, [alert.attributes])
+ end
+
+ def alert
+ strong_memoize(:alert) do
+ existing_alert || new_alert
+ end
+ end
+
+ def existing_alert
+ return unless incoming_payload.gitlab_fingerprint
+
+ AlertManagement::Alert.not_resolved.for_fingerprint(project, incoming_payload.gitlab_fingerprint).first
+ end
+
+ def new_alert
+ AlertManagement::Alert.new(**incoming_payload.alert_params, ended_at: nil)
+ end
+
+ def incoming_payload
+ strong_memoize(:incoming_payload) do
+ Gitlab::AlertManagement::Payload.parse(project, params.to_h)
+ end
end
- def parsed_payload
- Gitlab::Alerting::NotificationPayloadParser.call(params.to_h, project)
+ def valid_payload_size?
+ Gitlab::Utils::DeepSize.new(params).valid?
end
def valid_token?(token)
diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb
index 31500043544..b8047a1ad71 100644
--- a/app/services/projects/container_repository/cleanup_tags_service.rb
+++ b/app/services/projects/container_repository/cleanup_tags_service.rb
@@ -4,7 +4,6 @@ module Projects
module ContainerRepository
class CleanupTagsService < BaseService
def execute(container_repository)
- return error('feature disabled') unless can_use?
return error('access denied') unless can_destroy?
return error('invalid regex') unless valid_regex?
@@ -74,10 +73,6 @@ module Projects
can?(current_user, :destroy_container_image, project)
end
- def can_use?
- Feature.enabled?(:container_registry_cleanup, project, default_enabled: true)
- end
-
def valid_regex?
%w(name_regex_delete name_regex name_regex_keep).each do |param_name|
regex = params[param_name]
diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb
index fa3de4ae2ac..9fc3ec0aafb 100644
--- a/app/services/projects/container_repository/delete_tags_service.rb
+++ b/app/services/projects/container_repository/delete_tags_service.rb
@@ -26,9 +26,7 @@ module Projects
end
def delete_service
- fast_delete_enabled = Feature.enabled?(:container_registry_fast_tag_delete, default_enabled: true)
-
- if fast_delete_enabled && @container_repository.client.supports_tag_delete?
+ if @container_repository.client.supports_tag_delete?
::Projects::ContainerRepository::Gitlab::DeleteTagsService.new(@container_repository, @tag_names)
else
::Projects::ContainerRepository::ThirdParty::DeleteTagsService.new(@container_repository, @tag_names)
diff --git a/app/services/projects/create_from_template_service.rb b/app/services/projects/create_from_template_service.rb
index a207fd2c574..45b52a1861c 100644
--- a/app/services/projects/create_from_template_service.rb
+++ b/app/services/projects/create_from_template_service.rb
@@ -14,10 +14,16 @@ module Projects
def execute
return project unless validate_template!
- file = built_in_template&.file
+ file = built_in_template&.file || sample_data_template&.file
override_params = params.dup
- params[:file] = file
+
+ if built_in_template
+ params[:file] = built_in_template.file
+ elsif sample_data_template
+ params[:file] = sample_data_template.file
+ params[:sample_data] = true
+ end
GitlabProjectsImportService.new(current_user, params, override_params).execute
ensure
@@ -27,7 +33,7 @@ module Projects
private
def validate_template!
- return true if built_in_template
+ return true if built_in_template || sample_data_template
project.errors.add(:template_name, _("'%{template_name}' is unknown or invalid" % { template_name: template_name }))
false
@@ -39,6 +45,12 @@ module Projects
end
end
+ def sample_data_template
+ strong_memoize(:sample_data_template) do
+ Gitlab::SampleDataTemplate.find(template_name)
+ end
+ end
+
def project
@project ||= ::Project.new(namespace_id: params[:namespace_id])
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 68b40fdd8f1..8f18a23aa0f 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -19,6 +19,10 @@ module Projects
@project = Project.new(params)
+ # If a project is newly created it should have shared runners settings
+ # based on its group having it enabled. This is like the "default value"
+ @project.shared_runners_enabled = false if !params.key?(:shared_runners_enabled) && @project.group && @project.group.shared_runners_setting != 'enabled'
+
# Make sure that the user is allowed to use the specified visibility level
if project_visibility.restricted?
deny_visibility_level(@project, project_visibility.visibility_level)
@@ -143,7 +147,7 @@ module Projects
def create_readme
commit_attrs = {
- branch_name: Gitlab::CurrentSettings.default_branch_name.presence || 'master',
+ branch_name: @project.default_branch || 'master',
commit_message: 'Initial commit',
file_path: 'README.md',
file_content: "# #{@project.name}\n\n#{@project.description}"
@@ -161,10 +165,9 @@ module Projects
@project.create_or_update_import_data(data: @import_data[:data], credentials: @import_data[:credentials]) if @import_data
if @project.save
- unless @project.gitlab_project_import?
- create_services_from_active_instances_or_templates(@project)
- @project.create_labels
- end
+ Service.create_from_active_default_integrations(@project, :project_id, with_templates: true)
+
+ @project.create_labels unless @project.gitlab_project_import?
unless @project.import?
raise 'Failed to create repository' unless @project.create_repository
@@ -228,15 +231,6 @@ module Projects
private
- # rubocop: disable CodeReuse/ActiveRecord
- def create_services_from_active_instances_or_templates(project)
- Service.active.where(instance: true).or(Service.active.where(template: true)).group_by(&:type).each do |type, records|
- service = records.find(&:instance?) || records.find(&:template?)
- Service.build_from_integration(project.id, service).save!
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def project_namespace
@project_namespace ||= Namespace.find_by_id(@params[:namespace_id]) || current_user.namespace
end
diff --git a/app/services/projects/gitlab_projects_import_service.rb b/app/services/projects/gitlab_projects_import_service.rb
index 2e192942b9c..27cce15f97d 100644
--- a/app/services/projects/gitlab_projects_import_service.rb
+++ b/app/services/projects/gitlab_projects_import_service.rb
@@ -66,6 +66,7 @@ module Projects
end
if template_file
+ data[:sample_data] = params.delete(:sample_data) if params.key?(:sample_data)
params[:import_type] = 'gitlab_project'
end
diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb
index 7af489c3751..7dfe7fffa1b 100644
--- a/app/services/projects/operations/update_service.rb
+++ b/app/services/projects/operations/update_service.rb
@@ -18,6 +18,7 @@ module Projects
.merge(grafana_integration_params)
.merge(prometheus_integration_params)
.merge(incident_management_setting_params)
+ .merge(tracing_setting_params)
end
def alerting_setting_params
@@ -121,6 +122,15 @@ module Projects
{ incident_management_setting_attributes: attrs }
end
+
+ def tracing_setting_params
+ attr = params[:tracing_setting_attributes]
+ return {} unless attr
+
+ destroy = attr[:external_url].blank?
+
+ { tracing_setting_attributes: attr.merge(_destroy: destroy) }
+ end
end
end
end
diff --git a/app/services/projects/overwrite_project_service.rb b/app/services/projects/overwrite_project_service.rb
index 958a00afbb8..e681b5643ee 100644
--- a/app/services/projects/overwrite_project_service.rb
+++ b/app/services/projects/overwrite_project_service.rb
@@ -32,12 +32,12 @@ module Projects
def move_before_destroy_relationships(source_project)
options = { remove_remaining_elements: false }
- ::Projects::MoveUsersStarProjectsService.new(@project, @current_user).execute(source_project, options)
- ::Projects::MoveAccessService.new(@project, @current_user).execute(source_project, options)
- ::Projects::MoveDeployKeysProjectsService.new(@project, @current_user).execute(source_project, options)
- ::Projects::MoveNotificationSettingsService.new(@project, @current_user).execute(source_project, options)
- ::Projects::MoveForksService.new(@project, @current_user).execute(source_project, options)
- ::Projects::MoveLfsObjectsProjectsService.new(@project, @current_user).execute(source_project, options)
+ ::Projects::MoveUsersStarProjectsService.new(@project, @current_user).execute(source_project, **options)
+ ::Projects::MoveAccessService.new(@project, @current_user).execute(source_project, **options)
+ ::Projects::MoveDeployKeysProjectsService.new(@project, @current_user).execute(source_project, **options)
+ ::Projects::MoveNotificationSettingsService.new(@project, @current_user).execute(source_project, **options)
+ ::Projects::MoveForksService.new(@project, @current_user).execute(source_project, **options)
+ ::Projects::MoveLfsObjectsProjectsService.new(@project, @current_user).execute(source_project, **options)
add_source_project_to_fork_network(source_project)
end
diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb
index d32ead76d00..c002aca32db 100644
--- a/app/services/projects/prometheus/alerts/notify_service.rb
+++ b/app/services/projects/prometheus/alerts/notify_service.rb
@@ -125,7 +125,7 @@ module Projects
notification_service
.async
- .prometheus_alerts_fired(project, firings)
+ .prometheus_alerts_fired(project, alerts_attributes)
end
def process_prometheus_alerts
@@ -136,6 +136,18 @@ module Projects
end
end
+ def alerts_attributes
+ firings.map do |payload|
+ alert_params = Gitlab::AlertManagement::Payload.parse(
+ project,
+ payload,
+ monitoring_tool: Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus]
+ ).alert_params
+
+ AlertManagement::Alert.new(alert_params).attributes
+ end
+ end
+
def bad_request
ServiceResponse.error(message: 'Bad Request', http_status: :bad_request)
end
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index dba5177718d..013861631a1 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -88,6 +88,10 @@ module Projects
# Move uploads
move_project_uploads(project)
+ # If a project is being transferred to another group it means it can already
+ # have shared runners enabled but we need to check whether the new group allows that.
+ project.shared_runners_enabled = false if project.group && project.group.shared_runners_setting == 'disabled_and_unoverridable'
+
project.old_path_with_namespace = @old_path
update_repository_configuration(@new_path)
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index ea37f2e4ec0..64b9eca9014 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -97,6 +97,7 @@ module Projects
build.artifacts_file.use_file do |artifacts_path|
SafeZip::Extract.new(artifacts_path)
.extract(directories: [PUBLIC_DIR], to: temp_path)
+ create_pages_deployment(artifacts_path)
end
rescue SafeZip::Extract::Error => e
raise FailedToExtractError, e.message
@@ -118,6 +119,21 @@ module Projects
FileUtils.rm_r(previous_public_path, force: true)
end
+ def create_pages_deployment(artifacts_path)
+ return unless Feature.enabled?(:zip_pages_deployments, project)
+
+ File.open(artifacts_path) do |file|
+ deployment = project.pages_deployments.create!(file: file)
+ project.pages_metadatum.update!(pages_deployment: deployment)
+ end
+
+ # TODO: schedule old deployment removal https://gitlab.com/gitlab-org/gitlab/-/issues/235730
+ rescue => e
+ # we don't want to break current pages deployment process if something goes wrong
+ # TODO: remove this rescue as part of https://gitlab.com/gitlab-org/gitlab/-/issues/245308
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
+ end
+
def latest?
# check if sha for the ref is still the most recent one
# this helps in case when multiple deployments happens
diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb
index 5c41f00aac2..6115db54829 100644
--- a/app/services/projects/update_remote_mirror_service.rb
+++ b/app/services/projects/update_remote_mirror_service.rb
@@ -2,12 +2,14 @@
module Projects
class UpdateRemoteMirrorService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
MAX_TRIES = 3
def execute(remote_mirror, tries)
return success unless remote_mirror.enabled?
- if Gitlab::UrlBlocker.blocked_url?(CGI.unescape(Gitlab::UrlSanitizer.sanitize(remote_mirror.url)))
+ if Gitlab::UrlBlocker.blocked_url?(normalized_url(remote_mirror.url))
return error("The remote mirror URL is invalid.")
end
@@ -27,6 +29,12 @@ module Projects
private
+ def normalized_url(url)
+ strong_memoize(:normalized_url) do
+ CGI.unescape(Gitlab::UrlSanitizer.sanitize(url))
+ end
+ end
+
def update_mirror(remote_mirror)
remote_mirror.update_start!
remote_mirror.ensure_remote!
@@ -47,7 +55,6 @@ module Projects
end
def send_lfs_objects!(remote_mirror)
- return unless Feature.enabled?(:push_mirror_syncs_lfs, project)
return unless project.lfs_enabled?
# TODO: Support LFS sync over SSH
diff --git a/app/services/quick_actions/target_service.rb b/app/services/quick_actions/target_service.rb
index 4273acfbf8b..a465632ccfb 100644
--- a/app/services/quick_actions/target_service.rb
+++ b/app/services/quick_actions/target_service.rb
@@ -17,12 +17,16 @@ module QuickActions
# rubocop: disable CodeReuse/ActiveRecord
def issue(type_id)
+ return project.issues.build if type_id.nil?
+
IssuesFinder.new(current_user, project_id: project.id).find_by(iid: type_id) || project.issues.build
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def merge_request(type_id)
+ return project.merge_requests.build if type_id.nil?
+
MergeRequestsFinder.new(current_user, project_id: project.id).find_by(iid: type_id) || project.merge_requests.build
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/releases/base_service.rb b/app/services/releases/base_service.rb
new file mode 100644
index 00000000000..15d040287a3
--- /dev/null
+++ b/app/services/releases/base_service.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+module Releases
+ class BaseService
+ include BaseServiceUtility
+ include Gitlab::Utils::StrongMemoize
+
+ attr_accessor :project, :current_user, :params
+
+ def initialize(project, user = nil, params = {})
+ @project, @current_user, @params = project, user, params.dup
+ end
+
+ delegate :repository, to: :project
+
+ def tag_name
+ params[:tag]
+ end
+
+ def ref
+ params[:ref]
+ end
+
+ def name
+ params[:name] || tag_name
+ end
+
+ def description
+ params[:description]
+ end
+
+ def released_at
+ params[:released_at]
+ end
+
+ def release
+ strong_memoize(:release) do
+ project.releases.find_by_tag(tag_name)
+ end
+ end
+
+ def existing_tag
+ strong_memoize(:existing_tag) do
+ repository.find_tag(tag_name)
+ end
+ end
+
+ def tag_exist?
+ existing_tag.present?
+ end
+
+ def repository
+ strong_memoize(:repository) do
+ project.repository
+ end
+ end
+
+ def milestones
+ return [] unless param_for_milestone_titles_provided?
+
+ strong_memoize(:milestones) do
+ MilestonesFinder.new(
+ project: project,
+ current_user: current_user,
+ project_ids: Array(project.id),
+ group_ids: Array(project_group_id),
+ state: 'all',
+ title: params[:milestones]
+ ).execute
+ end
+ end
+
+ def inexistent_milestones
+ return [] unless param_for_milestone_titles_provided?
+
+ existing_milestone_titles = milestones.map(&:title)
+ Array(params[:milestones]) - existing_milestone_titles
+ end
+
+ def param_for_milestone_titles_provided?
+ params.key?(:milestones)
+ end
+
+ # overridden in EE
+ def project_group_id; end
+ end
+end
+
+Releases::BaseService.prepend_if_ee('EE::Releases::BaseService')
diff --git a/app/services/releases/concerns.rb b/app/services/releases/concerns.rb
deleted file mode 100644
index a0ebaea77c8..00000000000
--- a/app/services/releases/concerns.rb
+++ /dev/null
@@ -1,77 +0,0 @@
-# frozen_string_literal: true
-
-module Releases
- module Concerns
- extend ActiveSupport::Concern
- include Gitlab::Utils::StrongMemoize
-
- included do
- def tag_name
- params[:tag]
- end
-
- def ref
- params[:ref]
- end
-
- def name
- params[:name] || tag_name
- end
-
- def description
- params[:description]
- end
-
- def released_at
- params[:released_at]
- end
-
- def release
- strong_memoize(:release) do
- project.releases.find_by_tag(tag_name)
- end
- end
-
- def existing_tag
- strong_memoize(:existing_tag) do
- repository.find_tag(tag_name)
- end
- end
-
- def tag_exist?
- existing_tag.present?
- end
-
- def repository
- strong_memoize(:repository) do
- project.repository
- end
- end
-
- def milestones
- return [] unless param_for_milestone_titles_provided?
-
- strong_memoize(:milestones) do
- MilestonesFinder.new(
- project: project,
- current_user: current_user,
- project_ids: Array(project.id),
- state: 'all',
- title: params[:milestones]
- ).execute
- end
- end
-
- def inexistent_milestones
- return [] unless param_for_milestone_titles_provided?
-
- existing_milestone_titles = milestones.map(&:title)
- Array(params[:milestones]) - existing_milestone_titles
- end
-
- def param_for_milestone_titles_provided?
- params.key?(:milestones)
- end
- end
- end
-end
diff --git a/app/services/releases/create_evidence_service.rb b/app/services/releases/create_evidence_service.rb
index 9c370722d2c..78b6d77c2cb 100644
--- a/app/services/releases/create_evidence_service.rb
+++ b/app/services/releases/create_evidence_service.rb
@@ -12,7 +12,7 @@ module Releases
summary = ::Evidences::EvidenceSerializer.new.represent(evidence, evidence_options) # rubocop: disable CodeReuse/Serializer
evidence.summary = summary
- # TODO: fix the sha generating https://gitlab.com/gitlab-org/gitlab/-/issues/209000
+ # TODO: fix the sha generation https://gitlab.com/groups/gitlab-org/-/epics/3683
evidence.summary_sha = Gitlab::CryptoHelper.sha256(summary)
evidence.save!
diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb
index 92a0b875dd4..887c2d355ee 100644
--- a/app/services/releases/create_service.rb
+++ b/app/services/releases/create_service.rb
@@ -1,9 +1,7 @@
# frozen_string_literal: true
module Releases
- class CreateService < BaseService
- include Releases::Concerns
-
+ class CreateService < Releases::BaseService
def execute
return error('Access Denied', 403) unless allowed?
return error('Release already exists', 409) if release
diff --git a/app/services/releases/destroy_service.rb b/app/services/releases/destroy_service.rb
index f9f6101abdd..8abf9308689 100644
--- a/app/services/releases/destroy_service.rb
+++ b/app/services/releases/destroy_service.rb
@@ -1,9 +1,7 @@
# frozen_string_literal: true
module Releases
- class DestroyService < BaseService
- include Releases::Concerns
-
+ class DestroyService < Releases::BaseService
def execute
return error('Release does not exist', 404) unless release
return error('Access Denied', 403) unless allowed?
diff --git a/app/services/releases/update_service.rb b/app/services/releases/update_service.rb
index a452f7aa17a..4786d35f31e 100644
--- a/app/services/releases/update_service.rb
+++ b/app/services/releases/update_service.rb
@@ -1,9 +1,7 @@
# frozen_string_literal: true
module Releases
- class UpdateService < BaseService
- include Releases::Concerns
-
+ class UpdateService < Releases::BaseService
def execute
return error('Tag does not exist', 404) unless existing_tag
return error('Release does not exist', 404) unless release
@@ -16,10 +14,18 @@ module Releases
params[:milestones] = milestones
end
- if release.update(params)
- success(tag: existing_tag, release: release, milestones_updated: milestones_updated?(previous_milestones))
- else
- error(release.errors.messages || '400 Bad request', 400)
+ # transaction needed as Rails applies `save!` to milestone_releases
+ # when it does assign_attributes instead of actual saving
+ # this leads to the validation error being raised
+ # see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43385
+ ActiveRecord::Base.transaction do
+ if release.update(params)
+ success(tag: existing_tag, release: release, milestones_updated: milestones_updated?(previous_milestones))
+ else
+ error(release.errors.messages || '400 Bad request', 400)
+ end
+ rescue ActiveRecord::RecordInvalid => e
+ error(e.message || '400 Bad request', 400)
end
end
diff --git a/app/services/repository_archive_clean_up_service.rb b/app/services/repository_archive_clean_up_service.rb
index 99a9c834352..0fb7dfdb85f 100644
--- a/app/services/repository_archive_clean_up_service.rb
+++ b/app/services/repository_archive_clean_up_service.rb
@@ -1,8 +1,23 @@
# frozen_string_literal: true
+# RepositoryArchiveCleanUpService removes cached repository archives
+# that are generated on-the-fly by Gitaly. These files are stored in the
+# following form (as defined in lib/gitlab/git/repository.rb) and served
+# by GitLab Workhorse:
+#
+# /path/to/repository/downloads/project-N/sha/@v2/archive.format
+#
+# Legacy paths omit the @v2 prefix.
+#
+# For example:
+#
+# /var/opt/gitlab/gitlab-rails/shared/cache/archive/project-1/master/@v2/archive.zip
class RepositoryArchiveCleanUpService
LAST_MODIFIED_TIME_IN_MINUTES = 120
+ # For `/path/project-N/sha/@v2/archive.zip`, `find /path -maxdepth 4` will find this file
+ MAX_ARCHIVE_DEPTH = 4
+
attr_reader :mmin, :path
def initialize(mmin = LAST_MODIFIED_TIME_IN_MINUTES)
@@ -22,12 +37,15 @@ class RepositoryArchiveCleanUpService
private
def clean_up_old_archives
- run(%W(find #{path} -mindepth 1 -maxdepth 3 -type f \( -name \*.tar -o -name \*.bz2 -o -name \*.tar.gz -o -name \*.zip \) -mmin +#{mmin} -delete))
+ run(%W(find #{path} -mindepth 1 -maxdepth #{MAX_ARCHIVE_DEPTH} -type f \( -name \*.tar -o -name \*.bz2 -o -name \*.tar.gz -o -name \*.zip \) -mmin +#{mmin} -delete))
end
def clean_up_empty_directories
- run(%W(find #{path} -mindepth 2 -maxdepth 2 -type d -empty -delete))
- run(%W(find #{path} -mindepth 1 -maxdepth 1 -type d -empty -delete))
+ (1...MAX_ARCHIVE_DEPTH).reverse_each { |depth| clean_up_empty_directories_with_depth(depth) }
+ end
+
+ def clean_up_empty_directories_with_depth(depth)
+ run(%W(find #{path} -mindepth #{depth} -maxdepth #{depth} -type d -empty -delete))
end
def run(cmd)
diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb
index c253154c1b7..cdeb57627a8 100644
--- a/app/services/resource_access_tokens/create_service.rb
+++ b/app/services/resource_access_tokens/create_service.rb
@@ -10,7 +10,6 @@ module ResourceAccessTokens
end
def execute
- return unless feature_enabled?
return error("User does not have permission to create #{resource_type} Access Token") unless has_permission_to_create?
user = create_user
@@ -31,21 +30,8 @@ module ResourceAccessTokens
attr_reader :resource_type, :resource
- def feature_enabled?
- return false if ::Gitlab.com?
-
- ::Feature.enabled?(:resource_access_token, resource, default_enabled: true)
- end
-
def has_permission_to_create?
- case resource_type
- when 'project'
- can?(current_user, :admin_project, resource)
- when 'group'
- can?(current_user, :admin_group, resource)
- else
- false
- end
+ %w(project group).include?(resource_type) && can?(current_user, :admin_resource_access_tokens, resource)
end
def create_user
@@ -103,7 +89,7 @@ module ResourceAccessTokens
end
def provision_access(resource, user)
- resource.add_maintainer(user)
+ resource.add_user(user, :maintainer, expires_at: params[:expires_at])
end
def error(message)
diff --git a/app/services/resource_access_tokens/revoke_service.rb b/app/services/resource_access_tokens/revoke_service.rb
index efeb0bfb8d5..ece928dac31 100644
--- a/app/services/resource_access_tokens/revoke_service.rb
+++ b/app/services/resource_access_tokens/revoke_service.rb
@@ -14,18 +14,15 @@ module ResourceAccessTokens
end
def execute
+ return error("#{current_user.name} cannot delete #{bot_user.name}") unless can_destroy_bot_member?
return error("Failed to find bot user") unless find_member
- PersonalAccessToken.transaction do
- access_token.revoke!
+ access_token.revoke!
- raise RevokeAccessTokenError, "Failed to remove #{bot_user.name} member from: #{resource.name}" unless remove_member
+ destroy_bot_user
- raise RevokeAccessTokenError, "Migration to ghost user failed" unless migrate_to_ghost_user
- end
-
- success("Revoked access token: #{access_token.name}")
- rescue ActiveRecord::ActiveRecordError, RevokeAccessTokenError => error
+ success("Access token #{access_token.name} has been revoked and the bot user has been scheduled for deletion.")
+ rescue StandardError => error
log_error("Failed to revoke access token for #{bot_user.name}: #{error.message}")
error(error.message)
end
@@ -34,12 +31,18 @@ module ResourceAccessTokens
attr_reader :current_user, :access_token, :bot_user, :resource
- def remove_member
- ::Members::DestroyService.new(current_user).execute(find_member, destroy_bot: true)
+ def destroy_bot_user
+ DeleteUserWorker.perform_async(current_user.id, bot_user.id, skip_authorization: true)
end
- def migrate_to_ghost_user
- ::Users::MigrateToGhostUserService.new(bot_user).execute
+ def can_destroy_bot_member?
+ if resource.is_a?(Project)
+ can?(current_user, :admin_project_member, @resource)
+ elsif resource.is_a?(Group)
+ can?(current_user, :admin_group_member, @resource)
+ else
+ false
+ end
end
def find_member
diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb
index fab02697cf0..5f80b07aa59 100644
--- a/app/services/search/global_service.rb
+++ b/app/services/search/global_service.rb
@@ -4,6 +4,8 @@ module Search
class GlobalService
include Gitlab::Utils::StrongMemoize
+ ALLOWED_SCOPES = %w(issues merge_requests milestones users).freeze
+
attr_accessor :current_user, :params
def initialize(user, params)
@@ -14,7 +16,8 @@ module Search
Gitlab::SearchResults.new(current_user,
params[:search],
projects,
- filters: { state: params[:state] })
+ sort: params[:sort],
+ filters: { state: params[:state], confidential: params[:confidential] })
end
def projects
@@ -22,10 +25,7 @@ module Search
end
def allowed_scopes
- strong_memoize(:allowed_scopes) do
- allowed_scopes = %w[issues merge_requests milestones]
- allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true)
- end
+ ALLOWED_SCOPES
end
def scope
diff --git a/app/services/search/group_service.rb b/app/services/search/group_service.rb
index 68778aa2768..e17522dcd68 100644
--- a/app/services/search/group_service.rb
+++ b/app/services/search/group_service.rb
@@ -16,7 +16,8 @@ module Search
params[:search],
projects,
group: group,
- filters: { state: params[:state] }
+ sort: params[:sort],
+ filters: { state: params[:state], confidential: params[:confidential] }
)
end
diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb
index 5eba909c23b..d32534303be 100644
--- a/app/services/search/project_service.rb
+++ b/app/services/search/project_service.rb
@@ -2,6 +2,10 @@
module Search
class ProjectService
+ include Gitlab::Utils::StrongMemoize
+
+ ALLOWED_SCOPES = %w(notes issues merge_requests milestones wiki_blobs commits users).freeze
+
attr_accessor :project, :current_user, :params
def initialize(project, user, params)
@@ -13,15 +17,18 @@ module Search
params[:search],
project: project,
repository_ref: params[:repository_ref],
- filters: { state: params[:state] })
+ sort: params[:sort],
+ filters: { confidential: params[:confidential], state: params[:state] }
+ )
end
- def scope
- @scope ||= begin
- allowed_scopes = %w[notes issues merge_requests milestones wiki_blobs commits]
- allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true)
+ def allowed_scopes
+ ALLOWED_SCOPES
+ end
- allowed_scopes.delete(params[:scope]) { 'blobs' }
+ def scope
+ strong_memoize(:scope) do
+ allowed_scopes.include?(params[:scope]) ? params[:scope] : 'blobs'
end
end
end
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 278cf389e07..3ccd67c8d30 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -65,6 +65,10 @@ class SearchService
@search_objects ||= redact_unauthorized_results(search_results.objects(scope, page: params[:page], per_page: per_page, preload_method: preload_method))
end
+ def search_highlight
+ search_results.highlight_map(scope)
+ end
+
private
def per_page
diff --git a/app/services/snippets/base_service.rb b/app/services/snippets/base_service.rb
index 53a04e5a398..278857b7933 100644
--- a/app/services/snippets/base_service.rb
+++ b/app/services/snippets/base_service.rb
@@ -4,6 +4,9 @@ module Snippets
class BaseService < ::BaseService
include SpamCheckMethods
+ UPDATE_COMMIT_MSG = 'Update snippet'
+ INITIAL_COMMIT_MSG = 'Initial commit'
+
CreateRepositoryError = Class.new(StandardError)
attr_reader :uploaded_assets, :snippet_actions
@@ -85,5 +88,20 @@ module Snippets
def restricted_files_actions
nil
end
+
+ def commit_attrs(snippet, msg)
+ {
+ branch_name: snippet.default_branch,
+ message: msg
+ }
+ end
+
+ def delete_repository(snippet)
+ snippet.repository.remove
+ snippet.snippet_repository&.delete
+
+ # Purge any existing value for repository_exists?
+ snippet.repository.expire_exists_cache
+ end
end
end
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index 5c9b2eb1aea..d7181883c39 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -59,7 +59,7 @@ module Snippets
log_error(e.message)
# If the commit action failed we need to remove the repository if exists
- @snippet.repository.remove if @snippet.repository_exists?
+ delete_repository(@snippet) if @snippet.repository_exists?
# If the snippet was created, we need to remove it as we
# would do like if it had had any validation error
@@ -81,12 +81,9 @@ module Snippets
end
def create_commit
- commit_attrs = {
- branch_name: @snippet.default_branch,
- message: 'Initial commit'
- }
+ attrs = commit_attrs(@snippet, INITIAL_COMMIT_MSG)
- @snippet.snippet_repository.multi_files_action(current_user, files_to_commit(@snippet), commit_attrs)
+ @snippet.snippet_repository.multi_files_action(current_user, files_to_commit(@snippet), **attrs)
end
def move_temporary_files
diff --git a/app/services/snippets/repository_validation_service.rb b/app/services/snippets/repository_validation_service.rb
index 5bf5e692ef4..7e9b2eded16 100644
--- a/app/services/snippets/repository_validation_service.rb
+++ b/app/services/snippets/repository_validation_service.rb
@@ -52,7 +52,7 @@ module Snippets
def check_file_count!
file_count = repository.ls_files(snippet.default_branch).size
- limit = Snippet.max_file_limit(current_user)
+ limit = Snippet.max_file_limit
if file_count > limit
raise RepositoryValidationError, _('Repository files count over the limit')
diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb
index a0e9ab6ffda..0115cd19287 100644
--- a/app/services/snippets/update_service.rb
+++ b/app/services/snippets/update_service.rb
@@ -37,7 +37,10 @@ module Snippets
# is implemented.
# Once we can perform different operations through this service
# we won't need to keep track of the `content` and `file_name` fields
- if snippet_actions.any?
+ #
+ # If the repository does not exist we don't need to update `params`
+ # because we need to commit the information from the database
+ if snippet_actions.any? && snippet.repository_exists?
params[:content] = snippet_actions[0].content if snippet_actions[0].content
params[:file_name] = snippet_actions[0].file_path
end
@@ -52,7 +55,11 @@ module Snippets
# the repository we can just return
return true unless committable_attributes?
- create_repository_for(snippet)
+ unless snippet.repository_exists?
+ create_repository_for(snippet)
+ create_first_commit_using_db_data(snippet)
+ end
+
create_commit(snippet)
true
@@ -72,13 +79,7 @@ module Snippets
# If the commit action failed we remove it because
# we don't want to leave empty repositories
# around, to allow cloning them.
- if repository_empty?(snippet)
- snippet.repository.remove
- snippet.snippet_repository&.delete
- end
-
- # Purge any existing value for repository_exists?
- snippet.repository.expire_exists_cache
+ delete_repository(snippet) if repository_empty?(snippet)
false
end
@@ -89,15 +90,25 @@ module Snippets
raise CreateRepositoryError, 'Repository could not be created' unless snippet.repository_exists?
end
+ # If the user provides `snippet_actions` and the repository
+ # does not exist, we need to commit first the snippet info stored
+ # in the database. Mostly because the content inside `snippet_actions`
+ # would assume that the file is already in the repository.
+ def create_first_commit_using_db_data(snippet)
+ return if snippet_actions.empty?
+
+ attrs = commit_attrs(snippet, INITIAL_COMMIT_MSG)
+ actions = [{ file_path: snippet.file_name, content: snippet.content }]
+
+ snippet.snippet_repository.multi_files_action(current_user, actions, **attrs)
+ end
+
def create_commit(snippet)
raise UpdateError unless snippet.snippet_repository
- commit_attrs = {
- branch_name: snippet.default_branch,
- message: 'Update snippet'
- }
+ attrs = commit_attrs(snippet, UPDATE_COMMIT_MSG)
- snippet.snippet_repository.multi_files_action(current_user, files_to_commit(snippet), commit_attrs)
+ snippet.snippet_repository.multi_files_action(current_user, files_to_commit(snippet), **attrs)
end
# Because we are removing repositories we don't want to remove
diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb
index b745b67f566..b3d617256ff 100644
--- a/app/services/spam/spam_action_service.rb
+++ b/app/services/spam/spam_action_service.rb
@@ -45,7 +45,7 @@ module Spam
attr_reader :user, :context
def allowlisted?(user)
- user.respond_to?(:gitlab_employee) && user.gitlab_employee?
+ user.try(:gitlab_employee?) || user.try(:gitlab_bot?) || user.try(:gitlab_service_user?)
end
def perform_spam_service_check(api)
diff --git a/app/services/static_site_editor/config_service.rb b/app/services/static_site_editor/config_service.rb
index 987ee071976..7b3115468a5 100644
--- a/app/services/static_site_editor/config_service.rb
+++ b/app/services/static_site_editor/config_service.rb
@@ -4,18 +4,38 @@ module StaticSiteEditor
class ConfigService < ::BaseContainerService
ValidationError = Class.new(StandardError)
- def execute
+ def initialize(container:, current_user: nil, params: {})
+ super
+
@project = container
+ @repository = project.repository
+ @ref = params.fetch(:ref)
+ end
+
+ def execute
check_access!
+ file_config = load_file_config!
+ file_data = file_config.to_hash_with_defaults
+ generated_data = load_generated_config.data
+
+ check_for_duplicate_keys!(generated_data, file_data)
+ data = merged_data(generated_data, file_data)
+
ServiceResponse.success(payload: data)
rescue ValidationError => e
ServiceResponse.error(message: e.message)
+ rescue => e
+ Gitlab::ErrorTracking.track_and_raise_exception(e)
end
private
- attr_reader :project
+ attr_reader :project, :repository, :ref
+
+ def static_site_editor_config_file
+ '.gitlab/static-site-editor.yml'
+ end
def check_access!
unless can?(current_user, :download_code, project)
@@ -23,27 +43,43 @@ module StaticSiteEditor
end
end
- def data
- check_for_duplicate_keys!
- generated_data.merge(file_data)
+ def load_file_config!
+ yaml = yaml_from_repo.presence || '{}'
+ file_config = Gitlab::StaticSiteEditor::Config::FileConfig.new(yaml)
+
+ unless file_config.valid?
+ raise ValidationError, file_config.errors.first
+ end
+
+ file_config
+ rescue Gitlab::StaticSiteEditor::Config::FileConfig::ConfigError => e
+ raise ValidationError, e.message
end
- def generated_data
- @generated_data ||= Gitlab::StaticSiteEditor::Config::GeneratedConfig.new(
- project.repository,
- params.fetch(:ref),
+ def load_generated_config
+ Gitlab::StaticSiteEditor::Config::GeneratedConfig.new(
+ repository,
+ ref,
params.fetch(:path),
params[:return_url]
- ).data
- end
-
- def file_data
- @file_data ||= Gitlab::StaticSiteEditor::Config::FileConfig.new.data
+ )
end
- def check_for_duplicate_keys!
+ def check_for_duplicate_keys!(generated_data, file_data)
duplicate_keys = generated_data.keys & file_data.keys
raise ValidationError.new("Duplicate key(s) '#{duplicate_keys}' found.") if duplicate_keys.present?
end
+
+ def merged_data(generated_data, file_data)
+ generated_data.merge(file_data)
+ end
+
+ def yaml_from_repo
+ repository.blob_data_at(ref, static_site_editor_config_file)
+ rescue GRPC::NotFound
+ # Return nil in the case of a GRPC::NotFound exception, so the default config will be used.
+ # Allow any other unexpected exception will be tracked and re-raised.
+ nil
+ end
end
end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index df042fdc393..1a4374f2e94 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -41,6 +41,10 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_assignees(old_assignees)
end
+ def change_issuable_reviewers(issuable, project, author, old_reviewers)
+ ::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_reviewers(old_reviewers)
+ end
+
def relate_issue(noteable, noteable_ref, user)
::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).relate_issue(noteable_ref)
end
@@ -308,6 +312,10 @@ module SystemNoteService
::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project).create_new_alert(monitoring_tool)
end
+ def change_incident_severity(incident, author)
+ ::SystemNotes::IncidentService.new(noteable: incident, project: incident.project, author: author).change_incident_severity
+ end
+
private
def merge_requests_service(noteable, project, author)
diff --git a/app/services/system_notes/incident_service.rb b/app/services/system_notes/incident_service.rb
new file mode 100644
index 00000000000..4628662f0e9
--- /dev/null
+++ b/app/services/system_notes/incident_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module SystemNotes
+ class IncidentService < ::SystemNotes::BaseService
+ # Called when the severity of an Incident has changed
+ #
+ # Example Note text:
+ #
+ # "changed the severity to Medium - S3"
+ #
+ # Returns the created Note object
+ def change_incident_severity
+ severity = noteable.severity
+
+ if severity_label = IssuableSeverity::SEVERITY_LABELS[severity.to_sym]
+ body = "changed the severity to **#{severity_label}**"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'severity'))
+ else
+ Gitlab::AppLogger.error(
+ message: 'Cannot create a system note for severity change',
+ noteable_class: noteable.class.to_s,
+ noteable_id: noteable.id,
+ severity: severity
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index 2252503d97e..7a73af0a81a 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -13,6 +13,8 @@ module SystemNotes
def relate_issue(noteable_ref)
body = "marked this issue as related to #{noteable_ref.to_reference(noteable.project)}"
+ issue_activity_counter.track_issue_related_action(author: author) if noteable.is_a?(Issue)
+
create_note(NoteSummary.new(noteable, project, author, body, action: 'relate'))
end
@@ -27,6 +29,8 @@ module SystemNotes
def unrelate_issue(noteable_ref)
body = "removed the relation with #{noteable_ref.to_reference(noteable.project)}"
+ issue_activity_counter.track_issue_unrelated_action(author: author) if noteable.is_a?(Issue)
+
create_note(NoteSummary.new(noteable, project, author, body, action: 'unrelate'))
end
@@ -81,6 +85,32 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee'))
end
+ # Called when the reviewers of an issuable is changed or removed
+ #
+ # reviewers - Users being requested to review, or nil
+ #
+ # Example Note text:
+ #
+ # "requested review from @user1 and @user2"
+ #
+ # "requested review from @user1, @user2 and @user3 and removed review request for @user4 and @user5"
+ #
+ # Returns the created Note object
+ def change_issuable_reviewers(old_reviewers)
+ unassigned_users = old_reviewers - noteable.reviewers
+ added_users = noteable.reviewers - old_reviewers
+ text_parts = []
+
+ Gitlab::I18n.with_default_locale do
+ text_parts << "requested review from #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
+ text_parts << "removed review request for #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any?
+ end
+
+ body = text_parts.join(' and ')
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'reviewer'))
+ end
+
# Called when the title of a Noteable is changed
#
# old_title - Previous String title
@@ -148,6 +178,8 @@ module SystemNotes
if noteable.is_a?(ExternalIssue)
noteable.project.external_issue_tracker.create_cross_reference_note(noteable, mentioner, author)
else
+ issue_activity_counter.track_issue_cross_referenced_action(author: author) if noteable.is_a?(Issue)
+
create_note(NoteSummary.new(noteable, noteable.project, author, body, action: 'cross_reference'))
end
end
@@ -182,6 +214,8 @@ module SystemNotes
status_label = new_task.complete? ? Taskable::COMPLETED : Taskable::INCOMPLETE
body = "marked the task **#{new_task.source}** as #{status_label}"
+ issue_activity_counter.track_issue_description_changed_action(author: author) if noteable.is_a?(Issue)
+
create_note(NoteSummary.new(noteable, project, author, body, action: 'task'))
end
@@ -203,6 +237,8 @@ module SystemNotes
cross_reference = noteable_ref.to_reference(project)
body = "moved #{direction} #{cross_reference}"
+ issue_activity_counter.track_issue_moved_action(author: author) if noteable.is_a?(Issue)
+
create_note(NoteSummary.new(noteable, project, author, body, action: 'moved'))
end
@@ -242,19 +278,7 @@ module SystemNotes
#
# Returns the created Note object
def change_status(status, source = nil)
- body = status.dup
- body << " via #{source.gfm_reference(project)}" if source
-
- action = status == 'reopened' ? 'opened' : status
-
- # A state event which results in a synthetic note will be
- # created by EventCreateService if change event tracking
- # is enabled.
- if state_change_tracking_enabled?
- create_resource_state_event(status: status, mentionable_source: source)
- else
- create_note(NoteSummary.new(noteable, project, author, body, action: action))
- end
+ create_resource_state_event(status: status, mentionable_source: source)
end
# Check if a cross reference to a noteable from a mentioner already exists
@@ -285,6 +309,9 @@ module SystemNotes
# Returns the created Note object
def mark_duplicate_issue(canonical_issue)
body = "marked this issue as a duplicate of #{canonical_issue.to_reference(project)}"
+
+ issue_activity_counter.track_issue_marked_as_duplicate_action(author: author) if noteable.is_a?(Issue)
+
create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
end
@@ -308,27 +335,23 @@ module SystemNotes
action = noteable.discussion_locked? ? 'locked' : 'unlocked'
body = "#{action} this #{noteable.class.to_s.titleize.downcase}"
+ if noteable.is_a?(Issue)
+ if action == 'locked'
+ issue_activity_counter.track_issue_locked_action(author: author)
+ else
+ issue_activity_counter.track_issue_unlocked_action(author: author)
+ end
+ end
+
create_note(NoteSummary.new(noteable, project, author, body, action: action))
end
def close_after_error_tracking_resolve
- if state_change_tracking_enabled?
- create_resource_state_event(status: 'closed', close_after_error_tracking_resolve: true)
- else
- body = 'resolved the corresponding error and closed the issue.'
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'closed'))
- end
+ create_resource_state_event(status: 'closed', close_after_error_tracking_resolve: true)
end
def auto_resolve_prometheus_alert
- if state_change_tracking_enabled?
- create_resource_state_event(status: 'closed', close_auto_resolve_prometheus_alert: true)
- else
- body = 'automatically closed this issue because the alert resolved.'
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'closed'))
- end
+ create_resource_state_event(status: 'closed', close_auto_resolve_prometheus_alert: true)
end
private
@@ -361,11 +384,6 @@ module SystemNotes
.execute(params)
end
- def state_change_tracking_enabled?
- noteable.respond_to?(:resource_state_events) &&
- ::Feature.enabled?(:track_resource_state_change_events, noteable.project, default_enabled: true)
- end
-
def issue_activity_counter
Gitlab::UsageDataCounters::IssueActivityUniqueCounter
end
diff --git a/app/services/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb
index 8de42bd3225..650e40680b1 100644
--- a/app/services/system_notes/time_tracking_service.rb
+++ b/app/services/system_notes/time_tracking_service.rb
@@ -16,6 +16,8 @@ module SystemNotes
def change_due_date(due_date)
body = due_date ? "changed due date to #{due_date.to_s(:long)}" : 'removed due date'
+ issue_activity_counter.track_issue_due_date_changed_action(author: author) if noteable.is_a?(Issue)
+
create_note(NoteSummary.new(noteable, project, author, body, action: 'due_date'))
end
@@ -38,6 +40,8 @@ module SystemNotes
"changed time estimate to #{parsed_time}"
end
+ issue_activity_counter.track_issue_time_estimate_changed_action(author: author) if noteable.is_a?(Issue)
+
create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
end
@@ -67,7 +71,15 @@ module SystemNotes
body = text_parts.join(' ')
end
+ issue_activity_counter.track_issue_time_spent_changed_action(author: author) if noteable.is_a?(Issue)
+
create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
end
+
+ private
+
+ def issue_activity_counter
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter
+ end
end
end
diff --git a/app/services/todos/destroy/entity_leave_service.rb b/app/services/todos/destroy/entity_leave_service.rb
index 0c0548a17a1..97c56b84434 100644
--- a/app/services/todos/destroy/entity_leave_service.rb
+++ b/app/services/todos/destroy/entity_leave_service.rb
@@ -7,16 +7,14 @@ module Todos
attr_reader :user, :entity
- # rubocop: disable CodeReuse/ActiveRecord
def initialize(user_id, entity_id, entity_type)
unless %w(Group Project).include?(entity_type)
raise ArgumentError.new("#{entity_type} is not an entity user can leave")
end
- @user = User.find_by(id: user_id)
- @entity = entity_type.constantize.find_by(id: entity_id)
+ @user = UserFinder.new(user_id).find_by_id
+ @entity = entity_type.constantize.find_by(id: entity_id) # rubocop: disable CodeReuse/ActiveRecord
end
- # rubocop: enable CodeReuse/ActiveRecord
def execute
return unless entity && user
@@ -42,34 +40,37 @@ module Todos
end
end
- # rubocop: disable CodeReuse/ActiveRecord
def remove_confidential_issue_todos
- Todo.where(
- target_id: confidential_issues.select(:id), target_type: Issue.name, user_id: user.id
- ).delete_all
+ Todo
+ .for_target(confidential_issues.select(:id))
+ .for_type(Issue.name)
+ .for_user(user)
+ .delete_all
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def remove_project_todos
# Issues are viewable by guests (even in private projects), so remove those todos
# from projects without guest access
- Todo.where(project_id: non_authorized_guest_projects, user_id: user.id)
+ Todo
+ .for_project(non_authorized_guest_projects)
+ .for_user(user)
.delete_all
# MRs require reporter access, so remove those todos that are not authorized
- Todo.where(project_id: non_authorized_reporter_projects, target_type: MergeRequest.name, user_id: user.id)
+ Todo
+ .for_project(non_authorized_reporter_projects)
+ .for_type(MergeRequest.name)
+ .for_user(user)
.delete_all
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def remove_group_todos
- Todo.where(group_id: non_authorized_groups, user_id: user.id).delete_all
+ Todo
+ .for_group(non_authorized_groups)
+ .for_user(user)
+ .delete_all
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def projects
condition = case entity
when Project
@@ -78,55 +79,40 @@ module Todos
{ namespace_id: non_authorized_reporter_groups }
end
- Project.where(condition)
+ Project.where(condition) # rubocop: disable CodeReuse/ActiveRecord
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def authorized_reporter_projects
user.authorized_projects(Gitlab::Access::REPORTER).select(:id)
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def authorized_guest_projects
user.authorized_projects(Gitlab::Access::GUEST).select(:id)
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def non_authorized_reporter_projects
- projects.where('id NOT IN (?)', authorized_reporter_projects)
+ projects.id_not_in(authorized_reporter_projects)
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def non_authorized_guest_projects
- projects.where('id NOT IN (?)', authorized_guest_projects)
+ projects.id_not_in(authorized_guest_projects)
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def authorized_reporter_groups
GroupsFinder.new(user, min_access_level: Gitlab::Access::REPORTER).execute.select(:id)
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def non_authorized_groups
return [] unless entity.is_a?(Namespace)
entity.self_and_descendants.select(:id)
- .where('id NOT IN (?)', GroupsFinder.new(user).execute.select(:id))
+ .id_not_in(GroupsFinder.new(user).execute.select(:id))
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def non_authorized_reporter_groups
entity.self_and_descendants.select(:id)
- .where('id NOT IN (?)', authorized_reporter_groups)
+ .id_not_in(authorized_reporter_groups)
end
- # rubocop: enable CodeReuse/ActiveRecord
def user_has_reporter_access?
return unless entity.is_a?(Namespace)
@@ -134,16 +120,16 @@ module Todos
entity.member?(User.find(user.id), Gitlab::Access::REPORTER)
end
- # rubocop: disable CodeReuse/ActiveRecord
def confidential_issues
- assigned_ids = IssueAssignee.select(:issue_id).where(user_id: user.id)
+ assigned_ids = IssueAssignee.select(:issue_id).for_assignee(user)
- Issue.where(project_id: projects, confidential: true)
- .where('project_id NOT IN(?)', authorized_reporter_projects)
- .where('author_id != ?', user.id)
- .where('id NOT IN (?)', assigned_ids)
+ Issue
+ .in_projects(projects)
+ .confidential_only
+ .not_in_projects(authorized_reporter_projects)
+ .not_authored_by(user)
+ .id_not_in(assigned_ids)
end
- # rubocop: enable CodeReuse/ActiveRecord
end
end
end
diff --git a/app/services/users/approve_service.rb b/app/services/users/approve_service.rb
new file mode 100644
index 00000000000..228cfbd6947
--- /dev/null
+++ b/app/services/users/approve_service.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Users
+ class ApproveService < BaseService
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute(user)
+ return error(_('You are not allowed to approve a user')) unless allowed?
+ return error(_('The user you are trying to approve is not pending an approval')) unless approval_required?(user)
+
+ if user.activate
+ # Resends confirmation email if the user isn't confirmed yet.
+ # Please see Devise's implementation of `resend_confirmation_instructions` for detail.
+ user.resend_confirmation_instructions
+ user.accept_pending_invitations! if user.active_for_authentication?
+
+ success
+ else
+ error(user.errors.full_messages.uniq.join('. '))
+ end
+ end
+
+ private
+
+ attr_reader :current_user
+
+ def allowed?
+ can?(current_user, :approve_user)
+ end
+
+ def approval_required?(user)
+ user.blocked_pending_approval?
+ end
+ end
+end
diff --git a/app/services/users/block_service.rb b/app/services/users/block_service.rb
index 041db731875..8513664ee85 100644
--- a/app/services/users/block_service.rb
+++ b/app/services/users/block_service.rb
@@ -7,6 +7,8 @@ module Users
end
def execute(user)
+ return error('An internal user cannot be blocked', 403) if user.internal?
+
if user.block
after_block_hook(user)
success
diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb
index 2fc46f033dd..e3f02bf85f0 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -104,7 +104,6 @@ module Users
def build_user_params(skip_authorization:)
if current_user&.admin?
user_params = params.slice(*admin_create_params)
- user_params[:created_by_id] = current_user&.id
if params[:reset_password]
user_params.merge!(force_random_password: true, password_expires_at: nil)
@@ -125,6 +124,8 @@ module Users
end
end
+ user_params[:created_by_id] = current_user&.id
+
if user_default_internal_regex_enabled? && !user_params.key?(:external)
user_params[:external] = user_external?
end
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index 436d4fb3985..613d2e4ad82 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -26,7 +26,7 @@ module Users
def execute(user, options = {})
delete_solo_owned_groups = options.fetch(:delete_solo_owned_groups, options[:hard_delete])
- unless Ability.allowed?(current_user, :destroy_user, user)
+ unless Ability.allowed?(current_user, :destroy_user, user) || options[:skip_authorization]
raise Gitlab::Access::AccessDeniedError, "#{current_user} tried to destroy user #{user}!"
end
diff --git a/app/services/users/validate_otp_service.rb b/app/services/users/validate_otp_service.rb
new file mode 100644
index 00000000000..a9ce7959aea
--- /dev/null
+++ b/app/services/users/validate_otp_service.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Users
+ class ValidateOtpService < BaseService
+ def initialize(current_user)
+ @current_user = current_user
+ @strategy = if Feature.enabled?(:forti_authenticator, current_user)
+ ::Gitlab::Auth::Otp::Strategies::FortiAuthenticator.new(current_user)
+ else
+ ::Gitlab::Auth::Otp::Strategies::Devise.new(current_user)
+ end
+ end
+
+ def execute(otp_code)
+ strategy.validate(otp_code)
+ rescue StandardError => ex
+ Gitlab::ErrorTracking.log_exception(ex)
+ error(message: ex.message)
+ end
+
+ private
+
+ attr_reader :strategy
+ end
+end
diff --git a/app/services/web_hooks/destroy_service.rb b/app/services/web_hooks/destroy_service.rb
new file mode 100644
index 00000000000..58117985b56
--- /dev/null
+++ b/app/services/web_hooks/destroy_service.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module WebHooks
+ class DestroyService
+ include BaseServiceUtility
+
+ BATCH_SIZE = 1000
+ LOG_COUNT_THRESHOLD = 10000
+
+ DestroyError = Class.new(StandardError)
+
+ attr_accessor :current_user, :web_hook
+
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute(web_hook)
+ @web_hook = web_hook
+
+ async = false
+ # For a better user experience, it's better if the Web hook is
+ # destroyed right away without waiting for Sidekiq. However, if
+ # there are a lot of Web hook logs, we will need more time to
+ # clean them up, so schedule a Sidekiq job to do this.
+ if needs_async_destroy?
+ Gitlab::AppLogger.info("User #{current_user&.id} scheduled a deletion of hook ID #{web_hook.id}")
+ async_destroy(web_hook)
+ async = true
+ else
+ sync_destroy(web_hook)
+ end
+
+ success({ async: async })
+ end
+
+ def sync_destroy(web_hook)
+ @web_hook = web_hook
+
+ delete_web_hook_logs
+ result = web_hook.destroy
+
+ if result
+ success({ async: false })
+ else
+ error("Unable to destroy #{web_hook.model_name.human}")
+ end
+ end
+
+ private
+
+ def async_destroy(web_hook)
+ WebHooks::DestroyWorker.perform_async(current_user.id, web_hook.id)
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def needs_async_destroy?
+ web_hook.web_hook_logs.limit(LOG_COUNT_THRESHOLD).count == LOG_COUNT_THRESHOLD
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def delete_web_hook_logs
+ loop do
+ count = delete_web_hook_logs_in_batches
+ break if count < BATCH_SIZE
+ end
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def delete_web_hook_logs_in_batches
+ # We can't use EachBatch because that does an ORDER BY id, which can
+ # easily time out. We don't actually care about ordering when
+ # we are deleting these rows.
+ web_hook.web_hook_logs.limit(BATCH_SIZE).delete_all
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/services/webauthn/authenticate_service.rb b/app/services/webauthn/authenticate_service.rb
index a4513c62c2d..a575a853995 100644
--- a/app/services/webauthn/authenticate_service.rb
+++ b/app/services/webauthn/authenticate_service.rb
@@ -11,12 +11,6 @@ module Webauthn
def execute
parsed_device_response = Gitlab::Json.parse(@device_response)
- # appid is set for legacy U2F devices, will be used in a future iteration
- # rp_id = @app_id
- # unless parsed_device_response['clientExtensionResults'] && parsed_device_response['clientExtensionResults']['appid']
- # rp_id = URI(@app_id).host
- # end
-
webauthn_credential = WebAuthn::Credential.from_get(parsed_device_response)
encoded_raw_id = Base64.strict_encode64(webauthn_credential.raw_id)
stored_webauthn_credential = @user.webauthn_registrations.find_by_credential_xid(encoded_raw_id)
@@ -52,10 +46,14 @@ module Webauthn
# Verifies that webauthn_credential matches stored_credential with the given challenge
#
def verify_webauthn_credential(webauthn_credential, stored_credential, challenge, encoder)
+ # We need to adjust the relaying party id (RP id) we verify against if the registration in question
+ # is a migrated U2F registration. This is beacuse the appid of U2F and the rp id of WebAuthn differ.
+ rp_id = webauthn_credential.client_extension_outputs['appid'] ? WebAuthn.configuration.origin : URI(WebAuthn.configuration.origin).host
webauthn_credential.response.verify(
encoder.decode(challenge),
public_key: encoder.decode(stored_credential.public_key),
- sign_count: stored_credential.counter)
+ sign_count: stored_credential.counter,
+ rp_id: rp_id)
end
end
end
diff --git a/app/uploaders/deleted_object_uploader.rb b/app/uploaders/deleted_object_uploader.rb
new file mode 100644
index 00000000000..fc0f62b920c
--- /dev/null
+++ b/app/uploaders/deleted_object_uploader.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class DeletedObjectUploader < GitlabUploader
+ include ObjectStorage::Concern
+
+ storage_options Gitlab.config.artifacts
+
+ def store_dir
+ model.store_dir
+ end
+end
diff --git a/app/uploaders/pages/deployment_uploader.rb b/app/uploaders/pages/deployment_uploader.rb
new file mode 100644
index 00000000000..e510025fc7d
--- /dev/null
+++ b/app/uploaders/pages/deployment_uploader.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Pages
+ class DeploymentUploader < GitlabUploader
+ include ObjectStorage::Concern
+
+ storage_options Gitlab.config.pages
+
+ alias_method :upload, :model
+
+ private
+
+ def dynamic_segment
+ Gitlab::HashedPath.new('pages_deployments', model.id, root_hash: model.project_id)
+ end
+
+ # @hashed is chosen to avoid conflict with namespace name because we use the same directory for storage
+ # @ is not valid character for namespace
+ def base_dir
+ "@hashed"
+ end
+
+ # override GitlabUploader
+ # if set to true it erases the original file when uploading
+ # and we copy from the artifacts archive, so artifacts end up
+ # without the file
+ def move_to_cache
+ false
+ end
+
+ class << self
+ # we only upload this files from the rails background job
+ # so we don't need direct upload for pages deployments
+ # this method is here to ignore any user setting
+ def direct_upload_enabled?
+ false
+ end
+
+ # we don't need background uploads because we upload files
+ # to the right store right away, and we already do that in
+ # the background job
+ def background_upload_enabled?
+ false
+ end
+
+ def default_store
+ object_store_enabled? ? ObjectStorage::Store::REMOTE : ObjectStorage::Store::LOCAL
+ end
+ end
+ end
+end
diff --git a/app/uploaders/terraform/versioned_state_uploader.rb b/app/uploaders/terraform/versioned_state_uploader.rb
index be07993da0f..e50ab6c7dc6 100644
--- a/app/uploaders/terraform/versioned_state_uploader.rb
+++ b/app/uploaders/terraform/versioned_state_uploader.rb
@@ -2,12 +2,22 @@
module Terraform
class VersionedStateUploader < StateUploader
+ delegate :terraform_state, to: :model
+
def filename
- "#{model.version}.tfstate"
+ if terraform_state.versioning_enabled?
+ "#{model.version}.tfstate"
+ else
+ "#{model.uuid}.tfstate"
+ end
end
def store_dir
- Gitlab::HashedPath.new(model.uuid, root_hash: project_id)
+ if terraform_state.versioning_enabled?
+ Gitlab::HashedPath.new(model.uuid, root_hash: project_id)
+ else
+ project_id.to_s
+ end
end
end
end
diff --git a/app/validators/addressable_url_validator.rb b/app/validators/addressable_url_validator.rb
index 9fa99903e36..c6d9bd73566 100644
--- a/app/validators/addressable_url_validator.rb
+++ b/app/validators/addressable_url_validator.rb
@@ -80,7 +80,7 @@ class AddressableUrlValidator < ActiveModel::EachValidator
value = strip_value!(record, attribute, value)
- Gitlab::UrlBlocker.validate!(value, blocker_args)
+ Gitlab::UrlBlocker.validate!(value, **blocker_args)
rescue Gitlab::UrlBlocker::BlockedUrlError => e
record.errors.add(attribute, options.fetch(:blocked_message) % { exception_message: e.message })
end
diff --git a/app/validators/ip_address_validator.rb b/app/validators/ip_address_validator.rb
new file mode 100644
index 00000000000..0acf2bdf4fc
--- /dev/null
+++ b/app/validators/ip_address_validator.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+# IpAddressValidator
+#
+# Validates that an IP address is a valid IPv4 or IPv6 address.
+# This should be coupled with a database column of type `inet`
+#
+# When using column type `inet` Rails will silently return the value
+# as `nil` when the value is not valid according to its type cast
+# using `IpAddr`. It's not very user friendly to return an error
+# "IP Address can't be blank" when a value was clearly given but
+# was not the right format. This validator will look at the value
+# before Rails type casts it when the value itself is `nil`.
+# This enables the validator to return a specific and useful error message.
+#
+# This validator allows `nil` values by default since the database
+# allows null values by default. To disallow `nil` values, use in conjunction
+# with `presence: true`.
+#
+# Do not use this validator with `allow_nil: true` or `allow_blank: true`.
+# Because of Rails type casting, when an invalid value is set the attribute
+# will return `nil` and Rails won't run this validator.
+#
+# Example:
+#
+# class Group < ActiveRecord::Base
+# validates :ip_address, presence: true, ip_address: true
+# end
+#
+class IpAddressValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, _)
+ value = record.public_send("#{attribute}_before_type_cast") # rubocop:disable GitlabSecurity/PublicSend
+ return if value.blank?
+
+ IPAddress.parse(value.to_s)
+ rescue ArgumentError
+ record.errors.add(attribute, _('must be a valid IPv4 or IPv6 address'))
+ end
+end
diff --git a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
index 8fde92d6312..08442565931 100644
--- a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
+++ b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
@@ -6,8 +6,8 @@
"type": "string",
"default_value": "",
"value": "",
- "size": "MEDIUM",
- "description": "Analyzer image's registry prefix (or Name of the registry providing the analyzers' image)"
+ "size": "LARGE",
+ "description": "Analyzer image's registry prefix (or name of the registry providing the analyzers' image)"
},
{
"field" : "SAST_EXCLUDED_PATHS",
@@ -15,7 +15,7 @@
"type": "string",
"default_value": "",
"value": "",
- "size": "LARGE",
+ "size": "MEDIUM",
"description": "Comma-separated list of paths to be excluded from analyzer output. Patterns can be globs, file paths, or folder paths."
},
{
diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml
index 5ed9a0d1adb..ae0da214fb7 100644
--- a/app/views/admin/abuse_reports/_abuse_report.html.haml
+++ b/app/views/admin/abuse_reports/_abuse_report.html.haml
@@ -1,7 +1,7 @@
- reporter = abuse_report.reporter
- user = abuse_report.user
%tr
- %th.d-block.d-sm-none.d-md-none
+ %th.d-block.d-sm-none
%strong= _('User')
%td
- if user
@@ -11,21 +11,22 @@
- else
= _('(removed)')
%td
- %strong.subheading.d-block.d-sm-none.d-md-none
+ %strong.subheading.d-block.d-sm-none
= _('Reported by %{reporter}') % { reporter: reporter ? link_to(reporter.name, reporter) : _('(removed)') }
.light.small
= time_ago_with_tooltip(abuse_report.created_at)
%td
- %strong.subheading.d-block.d-sm-none.d-md-none= _('Message')
+ %strong.subheading.d-block.d-sm-none
+ = _('Message')
.message
= markdown_field(abuse_report, :message)
%td
- if user
= link_to _('Remove user & report'), admin_abuse_report_path(abuse_report, remove_user: true),
- data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name } }, remote: true, method: :delete, class: "btn btn-sm btn-block btn-remove js-remove-tr"
+ data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name } }, remote: true, method: :delete, class: "gl-button btn btn-sm btn-block btn-danger js-remove-tr"
- if user && !user.blocked?
- = link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "btn btn-sm btn-block"
+ = link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "gl-button btn btn-sm btn-block"
- else
.btn.btn-sm.disabled.btn-block
= _('Already blocked')
- = link_to _('Remove report'), [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-sm btn-block btn-close js-remove-tr"
+ = link_to _('Remove report'), [:admin, abuse_report], remote: true, method: :delete, class: "gl-button btn btn-sm btn-block btn-close js-remove-tr"
diff --git a/app/views/admin/abuse_reports/index.html.haml b/app/views/admin/abuse_reports/index.html.haml
index e3d78b3058f..daa766429e0 100644
--- a/app/views/admin/abuse_reports/index.html.haml
+++ b/app/views/admin/abuse_reports/index.html.haml
@@ -17,7 +17,7 @@
- if @abuse_reports.present?
.table-holder
%table.table.responsive-table
- %thead.d-none.d-sm-none.d-md-table-header-group
+ %thead.d-none.d-md-table-header-group
%tr
%th User
%th Reported by
diff --git a/app/views/admin/application_settings/_abuse.html.haml b/app/views/admin/application_settings/_abuse.html.haml
index ddffec32c41..c77615f9040 100644
--- a/app/views/admin/application_settings/_abuse.html.haml
+++ b/app/views/admin/application_settings/_abuse.html.haml
@@ -3,9 +3,9 @@
%fieldset
.form-group
- = f.label :admin_notification_email, 'Abuse reports notification email', class: 'label-bold'
- = f.text_field :admin_notification_email, class: 'form-control'
+ = f.label :abuse_notification_email, 'Abuse reports notification email', class: 'label-bold'
+ = f.text_field :abuse_notification_email, class: 'form-control'
.form-text.text-muted
Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area.
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
index 184249bcaba..f46eb84ce8e 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -60,5 +60,4 @@
= render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: f
= render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: 'btn btn-success qa-save-changes-button'
+ = f.submit _('Save changes'), class: 'gl-button btn btn-success qa-save-changes-button'
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index b9cce6c8085..9f384519c3a 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -60,4 +60,4 @@
= _("The default CI configuration path for new projects.").html_safe
= link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'custom-ci-configuration-path'), target: '_blank'
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml
index 1bf25b6a558..6811c1e10d6 100644
--- a/app/views/admin/application_settings/_diff_limits.html.haml
+++ b/app/views/admin/application_settings/_diff_limits.html.haml
@@ -12,5 +12,4 @@
= link_to sprite_icon('question-o'),
help_page_path('user/admin_area/diff_limits',
anchor: 'maximum-diff-patch-size')
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: 'btn btn-success'
+ = f.submit _('Save changes'), class: 'gl-button btn btn-success'
diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml
index d74afcd3e64..68324425ef9 100644
--- a/app/views/admin/application_settings/_eks.html.haml
+++ b/app/views/admin/application_settings/_eks.html.haml
@@ -28,4 +28,4 @@
= f.label :eks_secret_access_key, 'Secret access key', class: 'label-bold'
= f.password_field :eks_secret_access_key, autocomplete: 'off', class: 'form-control'
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml
index 49747f2bfd4..dd1be876505 100644
--- a/app/views/admin/application_settings/_email.html.haml
+++ b/app/views/admin/application_settings/_email.html.haml
@@ -25,4 +25,4 @@
= render_if_exists 'admin/application_settings/email_additional_text_setting', form: f
- = f.submit _('Save changes'), class: "btn btn-success", data: { qa_selector: 'save_changes_button' }
+ = f.submit _('Save changes'), class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
index 179eb2d5f2e..c8c1f3e6214 100644
--- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml
+++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
@@ -47,5 +47,4 @@
.form-group
= f.label :external_authorization_service_default_label, _('Default classification label'), class: 'label-bold'
= f.text_field :external_authorization_service_default_label, class: 'form-control'
- .gl-display-flex.gl-justify-content-end
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml
index fac2de8811f..a0cd70b4d7c 100644
--- a/app/views/admin/application_settings/_gitaly.html.haml
+++ b/app/views/admin/application_settings/_gitaly.html.haml
@@ -24,4 +24,4 @@
.form-text.text-muted
Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout.
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml
index bbad5155ada..f0a1fd5e763 100644
--- a/app/views/admin/application_settings/_gitpod.html.haml
+++ b/app/views/admin/application_settings/_gitpod.html.haml
@@ -1,6 +1,5 @@
- return unless Gitlab::Gitpod.feature_available?
- expanded = integration_expanded?('gitpod_')
-- gitpod_link = link_to("Gitpod#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}".html_safe, 'https://gitpod.io/', target: '_blank', rel: 'noopener noreferrer')
%section.settings.no-animate#js-gitpod-settings{ class: ('expanded' if expanded) }
.settings-header
@@ -9,7 +8,7 @@
%button.btn.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- = s_('Enable %{gitpod_link} integration to launch a development environment in your browser directly from GitLab.').html_safe % { gitpod_link: gitpod_link }
+ = gitpod_enable_description
= link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information')
@@ -27,4 +26,4 @@
= f.text_field :gitpod_url, class: 'form-control', placeholder: s_('Gitpod|e.g. https://gitpod.example.com')
.form-text.text-muted
= s_('Gitpod|Add the URL to your Gitpod instance configured to read your GitLab projects.')
- = f.submit s_('Save changes'), class: 'btn btn-success'
+ = f.submit s_('Save changes'), class: 'gl-button btn btn-success'
diff --git a/app/views/admin/application_settings/_grafana.html.haml b/app/views/admin/application_settings/_grafana.html.haml
index 80ff5a298b4..bd2b2094311 100644
--- a/app/views/admin/application_settings/_grafana.html.haml
+++ b/app/views/admin/application_settings/_grafana.html.haml
@@ -14,4 +14,4 @@
= f.label :grafana_url, _('Grafana URL'), class: 'label-bold'
= f.text_field :grafana_url, class: 'form-control', placeholder: '/-/grafana'
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml
index 5e5ab1e4269..fc31f612b8c 100644
--- a/app/views/admin/application_settings/_help_page.html.haml
+++ b/app/views/admin/application_settings/_help_page.html.haml
@@ -18,4 +18,9 @@
= f.text_field :help_page_support_url, class: 'form-control', placeholder: 'http://company.example.com/getting-help', :'aria-describedby' => 'support_help_block'
%span.form-text.text-muted#support_help_block= _('Alternate support URL for help page and help dropdown')
- = f.submit _('Save changes'), class: "btn btn-success"
+ - if show_documentation_base_url_field?
+ .form-group
+ = f.label :help_page_documentation_base_url, _('Documentation pages URL'), class: 'label-bold'
+ = f.text_field :help_page_documentation_base_url, class: 'form-control', placeholder: 'https://docs.gitlab.com'
+
+ = f.submit _('Save changes'), class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_import_export_limits.html.haml b/app/views/admin/application_settings/_import_export_limits.html.haml
index d26c3376391..58218a41282 100644
--- a/app/views/admin/application_settings/_import_export_limits.html.haml
+++ b/app/views/admin/application_settings/_import_export_limits.html.haml
@@ -31,4 +31,4 @@
= f.label :group_download_export_limit, _('Max Group Export Download requests per minute per user'), class: 'label-bold'
= f.number_field :group_download_export_limit, class: 'form-control'
- = f.submit 'Save changes', class: "btn btn-success", data: { qa_selector: 'save_changes_button' }
+ = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_initial_branch_name.html.haml b/app/views/admin/application_settings/_initial_branch_name.html.haml
index acbf971e4b9..bab841fcade 100644
--- a/app/views/admin/application_settings/_initial_branch_name.html.haml
+++ b/app/views/admin/application_settings/_initial_branch_name.html.haml
@@ -10,5 +10,4 @@
%span.form-text.text-muted
= (_("Changes affect new repositories only. If not specified, Git's default name %{branch_name_default} will be used.") % { branch_name_default: fallback_branch_name } ).html_safe
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: 'gl-button btn-success'
+ = f.submit _('Save changes'), class: 'gl-button btn-success'
diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml
index 9512c1837bf..c1565cf42e1 100644
--- a/app/views/admin/application_settings/_ip_limits.html.haml
+++ b/app/views/admin/application_settings/_ip_limits.html.haml
@@ -42,4 +42,4 @@
= f.label :throttle_authenticated_web_period_in_seconds, 'Rate limit period in seconds', class: 'label-bold'
= f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control'
- = f.submit 'Save changes', class: "btn btn-success", data: { qa_selector: 'save_changes_button' }
+ = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_issue_limits.html.haml b/app/views/admin/application_settings/_issue_limits.html.haml
index b0bdc204f64..200ea3a8ec1 100644
--- a/app/views/admin/application_settings/_issue_limits.html.haml
+++ b/app/views/admin/application_settings/_issue_limits.html.haml
@@ -6,4 +6,4 @@
= f.label :issues_create_limit, 'Max requests per minute per user', class: 'label-bold'
= f.number_field :issues_create_limit, class: 'form-control'
- = f.submit 'Save changes', class: "btn btn-success", data: { qa_selector: 'save_changes_button' }
+ = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml
index e01c123d1db..5ad7080b22b 100644
--- a/app/views/admin/application_settings/_localization.html.haml
+++ b/app/views/admin/application_settings/_localization.html.haml
@@ -15,4 +15,4 @@
= f.label :time_tracking_limit_to_hours, class: 'form-check-label' do
= _('Limit display of time tracking units to hours.')
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml
index b0593b3bfa2..4f38ab3ab7a 100644
--- a/app/views/admin/application_settings/_outbound.html.haml
+++ b/app/views/admin/application_settings/_outbound.html.haml
@@ -27,4 +27,4 @@
%span.form-text.text-muted
= _('Resolves IP addresses once and uses them to submit requests')
- = f.submit 'Save changes', class: "btn btn-success", data: { qa_selector: 'save_changes_button' }
+ = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml
index 2ee7f3edc97..d42987eb7d8 100644
--- a/app/views/admin/application_settings/_pages.html.haml
+++ b/app/views/admin/application_settings/_pages.html.haml
@@ -41,4 +41,4 @@
- terms_of_service_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: lets_encrypt_terms_of_service_admin_application_settings_path }
= _("I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end} (PDF)").html_safe % { link_start: terms_of_service_link_start, link_end: '</a>'.html_safe }
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml
index 3473c185dbe..2d27bceef10 100644
--- a/app/views/admin/application_settings/_performance.html.haml
+++ b/app/views/admin/application_settings/_performance.html.haml
@@ -31,4 +31,4 @@
.form-text.text-muted
= _('Number of changes (branches or tags) in a single push to determine whether individual push events or bulk push event will be created. Bulk push event will be created if it surpasses that value.')
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml
index f8bc29048f2..1036cc94bd0 100644
--- a/app/views/admin/application_settings/_performance_bar.html.haml
+++ b/app/views/admin/application_settings/_performance_bar.html.haml
@@ -11,4 +11,4 @@
= f.label :performance_bar_allowed_group_path, 'Allowed group', class: 'label-bold'
= f.text_field :performance_bar_allowed_group_path, class: 'form-control', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path
- = f.submit 'Save changes', class: 'btn btn-success qa-save-changes-button'
+ = f.submit 'Save changes', class: 'gl-button btn btn-success qa-save-changes-button'
diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml
index f2011257b8c..324f544a108 100644
--- a/app/views/admin/application_settings/_plantuml.html.haml
+++ b/app/views/admin/application_settings/_plantuml.html.haml
@@ -24,4 +24,4 @@
= link_to "PlantUML", "http://plantuml.com"
diagrams in Asciidoc documents using an external PlantUML service.
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml
index 49f58449d29..c571ec1c1b0 100644
--- a/app/views/admin/application_settings/_prometheus.html.haml
+++ b/app/views/admin/application_settings/_prometheus.html.haml
@@ -30,4 +30,4 @@
A method call is only tracked when it takes longer to complete than
the given amount of milliseconds.
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_protected_paths.html.haml b/app/views/admin/application_settings/_protected_paths.html.haml
index 0220570daa9..fce64369f17 100644
--- a/app/views/admin/application_settings/_protected_paths.html.haml
+++ b/app/views/admin/application_settings/_protected_paths.html.haml
@@ -28,4 +28,4 @@
= _('All paths are relative to the GitLab URL. Do not include %{relative_url_link_start}relative URL%{relative_url_link_end}.').html_safe % { relative_url_link_start: relative_url_link_start, relative_url_link_end: '</a>'.html_safe }
= f.text_area :protected_paths_raw, placeholder: '/users/sign_in,/users/password', class: 'form-control', rows: 10
- = f.submit 'Save changes', class: 'btn btn-success'
+ = f.submit 'Save changes', class: 'gl-button btn btn-success'
diff --git a/app/views/admin/application_settings/_realtime.html.haml b/app/views/admin/application_settings/_realtime.html.haml
index 0e9731b1c70..cf0b2b53eff 100644
--- a/app/views/admin/application_settings/_realtime.html.haml
+++ b/app/views/admin/application_settings/_realtime.html.haml
@@ -14,4 +14,4 @@
installations. Set to 0 to completely disable polling.
= link_to sprite_icon('question-o'), help_page_path('administration/polling')
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml
index 7ff2b6e841d..dd64d0ae419 100644
--- a/app/views/admin/application_settings/_registry.html.haml
+++ b/app/views/admin/application_settings/_registry.html.haml
@@ -21,4 +21,4 @@
.form-text.text-muted
= _("Tags are deleted until the timeout is reached. Any remaining tags are included the next time the policy runs. To remove the time limit, set it to 0.")
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml
index 6f9d3a889cd..b9c2e406b78 100644
--- a/app/views/admin/application_settings/_repository_check.html.haml
+++ b/app/views/admin/application_settings/_repository_check.html.haml
@@ -18,8 +18,7 @@
If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database.
- clear_repository_checks_link = _('Clear all repository checks')
- clear_repository_checks_message = _('This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?')
- .gl-display-flex.gl-justify-content-end
- = link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message }, method: :put, class: "btn btn-sm btn-remove"
+ = link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message }, method: :put, class: "gl-button btn btn-sm btn-danger"
.sub-section
%h4 Housekeeping
@@ -56,5 +55,4 @@
.form-text.text-muted
Number of Git pushes after which 'git gc' is run.
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
index d598f173ff3..125fa48bbc3 100644
--- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml
+++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
@@ -14,5 +14,4 @@
= render_if_exists 'admin/application_settings/mirror_settings', form: f
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_repository_static_objects.html.haml b/app/views/admin/application_settings/_repository_static_objects.html.haml
index 9bc751adc8b..00b9b4b8964 100644
--- a/app/views/admin/application_settings/_repository_static_objects.html.haml
+++ b/app/views/admin/application_settings/_repository_static_objects.html.haml
@@ -15,5 +15,4 @@
%span.form-text.text-muted#static_objects_external_storage_auth_token_help_block
= _('A secure token that identifies an external storage request.')
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml
index 0dc8dc0740e..0862d1bf0b6 100644
--- a/app/views/admin/application_settings/_repository_storage.html.haml
+++ b/app/views/admin/application_settings/_repository_storage.html.haml
@@ -22,5 +22,4 @@
= f.text_field attribute[:name], class: 'form-text-input', value: attribute[:value]
= f.label attribute[:label], attribute[:label], class: 'label-bold form-check-label'
%br
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success qa-save-changes-button"
+ = f.submit _('Save changes'), class: "gl-button btn btn-success qa-save-changes-button"
diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml
index 2a26a0909fd..4a8616beff6 100644
--- a/app/views/admin/application_settings/_signin.html.haml
+++ b/app/views/admin/application_settings/_signin.html.haml
@@ -57,5 +57,4 @@
= f.label :sign_in_text, class: 'label-bold'
= f.text_area :sign_in_text, class: 'form-control', rows: 4
.form-text.text-muted Markdown enabled
- .gl-display-flex.gl-justify-content-end
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml
index 3b88696dc51..98b49a236a3 100644
--- a/app/views/admin/application_settings/_signup.html.haml
+++ b/app/views/admin/application_settings/_signup.html.haml
@@ -9,6 +9,14 @@
Sign-up enabled
.form-text.text-muted
= _("When enabled, any user visiting %{host} will be able to create an account.") % { host: "#{new_user_session_url(host: Gitlab.config.gitlab.host)}" }
+ - if Feature.enabled?(:admin_approval_for_new_user_signups, default_enabled: true)
+ .form-group
+ .form-check
+ = f.check_box :require_admin_approval_after_user_signup, class: 'form-check-input'
+ = f.label :require_admin_approval_after_user_signup, class: 'form-check-label' do
+ = _('Require admin approval for new sign-ups')
+ .form-text.text-muted
+ = _("When enabled, any user visiting %{host} and creating an account will have to be explicitly approved by an admin before they can sign in. This setting is effective only if sign-ups are enabled.") % { host: "#{new_user_session_url(host: Gitlab.config.gitlab.host)}" }
.form-group
.form-check
= f.check_box :send_user_confirmation_email, class: 'form-check-input'
@@ -67,5 +75,4 @@
= f.label :after_sign_up_text, class: 'label-bold'
= f.text_area :after_sign_up_text, class: 'form-control', rows: 4
.form-text.text-muted Markdown enabled
- .gl-display-flex.gl-justify-content-end
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml
index c339d6df363..7c2c5e0b3dc 100644
--- a/app/views/admin/application_settings/_snowplow.html.haml
+++ b/app/views/admin/application_settings/_snowplow.html.haml
@@ -26,4 +26,4 @@
= f.label :snowplow_cookie_domain, _('Cookie domain'), class: 'label-light'
= f.text_field :snowplow_cookie_domain, class: 'form-control'
- = f.submit _('Save changes'), class: 'btn btn-success'
+ = f.submit _('Save changes'), class: 'gl-button btn btn-success'
diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml
index 7650526dfc0..2a4e8f87c31 100644
--- a/app/views/admin/application_settings/_sourcegraph.html.haml
+++ b/app/views/admin/application_settings/_sourcegraph.html.haml
@@ -35,4 +35,4 @@
= f.text_field :sourcegraph_url, class: 'form-control', placeholder: s_('SourcegraphAdmin|e.g. https://sourcegraph.example.com')
.form-text.text-muted
= s_('SourcegraphAdmin|Configure the URL to a Sourcegraph instance which can read your GitLab projects.')
- = f.submit s_('SourcegraphAdmin|Save changes'), class: 'btn btn-success'
+ = f.submit s_('SourcegraphAdmin|Save changes'), class: 'gl-button btn btn-success'
diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml
index ab9368e723e..b54f1d7c829 100644
--- a/app/views/admin/application_settings/_spam.html.haml
+++ b/app/views/admin/application_settings/_spam.html.haml
@@ -71,4 +71,4 @@
= f.label :spam_check_endpoint_url, _('URL of the external Spam Check endpoint'), class: 'label-bold'
= f.text_field :spam_check_endpoint_url, class: 'form-control'
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml
index 25d23ea7a84..7bc5b2405e8 100644
--- a/app/views/admin/application_settings/_terminal.html.haml
+++ b/app/views/admin/application_settings/_terminal.html.haml
@@ -8,5 +8,4 @@
.form-text.text-muted
Maximum time for web terminal websocket connection (in seconds).
0 for unlimited.
- .gl-display-flex.gl-justify-content-end
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_terms.html.haml b/app/views/admin/application_settings/_terms.html.haml
index a6d03ac1dde..10db1e23d7b 100644
--- a/app/views/admin/application_settings/_terms.html.haml
+++ b/app/views/admin/application_settings/_terms.html.haml
@@ -15,5 +15,4 @@
= f.text_area :terms, class: 'form-control', rows: 8
.form-text.text-muted
= _("Markdown enabled")
- .gl-display-flex.gl-justify-content-end
- = f.submit _("Save changes"), class: "btn btn-success"
+ = f.submit _("Save changes"), class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_third_party_offers.html.haml b/app/views/admin/application_settings/_third_party_offers.html.haml
index 0ed7341986d..7e3e063118e 100644
--- a/app/views/admin/application_settings/_third_party_offers.html.haml
+++ b/app/views/admin/application_settings/_third_party_offers.html.haml
@@ -17,4 +17,4 @@
= f.check_box :hide_third_party_offers, class: 'form-check-input'
= f.label :hide_third_party_offers, _('Do not display offers from third parties within GitLab'), class: 'form-check-label'
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml
index 4e45db1e10a..2ba7dcefd44 100644
--- a/app/views/admin/application_settings/_usage.html.haml
+++ b/app/views/admin/application_settings/_usage.html.haml
@@ -33,8 +33,8 @@
%pre.usage-data.js-syntax-highlight.code.highlight.mt-2.d-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
- else
= _('The usage ping is disabled, and cannot be configured through this form.')
- - deactivating_usage_ping_path = help_page_path('development/telemetry/usage_ping', anchor: 'disable-usage-ping')
+ - deactivating_usage_ping_path = help_page_path('development/product_analytics/usage_ping', anchor: 'disable-usage-ping')
- deactivating_usage_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: deactivating_usage_ping_path }
= s_('For more information, see the documentation on %{deactivating_usage_ping_link_start}deactivating the usage ping%{deactivating_usage_ping_link_end}.').html_safe % { deactivating_usage_ping_link_start: deactivating_usage_ping_link_start, deactivating_usage_ping_link_end: '</a>'.html_safe }
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml
index 28208d923db..46d8a8ac9c7 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -66,5 +66,4 @@
.form-group
= f.label field_name, "#{type.upcase} SSH keys", class: 'label-bold'
= f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control'
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index 823cee09d4b..2d336bebc8d 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -101,8 +101,7 @@
= s_('IDE|Live Preview')
%span.form-text.text-muted
= s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox Live Preview.')
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-success"
- if Feature.enabled?(:maintenance_mode)
%section.settings.no-animate#js-maintenance-mode-toggle{ class: ('expanded' if expanded_by_default?) }
diff --git a/app/views/admin/applications/_delete_form.html.haml b/app/views/admin/applications/_delete_form.html.haml
index 86f09bf1cb0..d348ad507c2 100644
--- a/app/views/admin/applications/_delete_form.html.haml
+++ b/app/views/admin/applications/_delete_form.html.haml
@@ -1,4 +1,4 @@
-- submit_btn_css ||= 'btn btn-link btn-remove btn-sm'
+- submit_btn_css ||= 'gl-button btn btn-danger btn-sm'
= form_tag admin_application_path(application) do
%input{ :name => "_method", :type => "hidden", :value => "delete" }/
= submit_tag 'Destroy', class: submit_btn_css, data: { confirm: _('Are you sure?') }
diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml
index 0d01f1c57e0..0c3a4e73e30 100644
--- a/app/views/admin/applications/_form.html.haml
+++ b/app/views/admin/applications/_form.html.haml
@@ -40,5 +40,5 @@
= render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes
.form-actions
- = f.submit 'Submit', class: "btn btn-success wide"
- = link_to "Cancel", admin_applications_path, class: "btn btn-cancel"
+ = f.submit 'Submit', class: "gl-button btn btn-success wide"
+ = link_to "Cancel", admin_applications_path, class: "gl-button btn btn-cancel"
diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml
index 0119cabf1ad..c1c1c2a4cfe 100644
--- a/app/views/admin/applications/index.html.haml
+++ b/app/views/admin/applications/index.html.haml
@@ -4,7 +4,7 @@
%p.light
System OAuth applications don't belong to any user and can only be managed by admins
%hr
-%p= link_to 'New application', new_admin_application_path, class: 'btn btn-success'
+%p= link_to 'New application', new_admin_application_path, class: 'gl-button btn btn-success'
%table.table
%thead
%tr
@@ -23,6 +23,6 @@
%td= @application_counts[application.id].to_i
%td= application.trusted? ? 'Y': 'N'
%td= application.confidential? ? 'Y': 'N'
- %td= link_to 'Edit', edit_admin_application_path(application), class: 'btn btn-link'
+ %td= link_to 'Edit', edit_admin_application_path(application), class: 'gl-button btn btn-link'
%td= render 'delete_form', application: application
= paginate @applications, theme: 'gitlab'
diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml
index 5259dd56df5..f029da6b3af 100644
--- a/app/views/admin/applications/show.html.haml
+++ b/app/views/admin/applications/show.html.haml
@@ -13,7 +13,7 @@
.input-group
%input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true }
.input-group-append
- = clipboard_button(target: '#application_id', title: _("Copy ID"), class: "btn btn btn-default")
+ = clipboard_button(target: '#application_id', title: _("Copy ID"), class: "gl-button btn btn-default")
%tr
%td
= _('Secret')
@@ -22,7 +22,7 @@
.input-group
%input.label.label-monospace.monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true }
.input-group-append
- = clipboard_button(target: '#secret', title: _("Copy secret"), class: "btn btn btn-default")
+ = clipboard_button(target: '#secret', title: _("Copy secret"), class: "gl-button btn btn-default")
%tr
%td
= _('Callback URL')
@@ -45,5 +45,5 @@
= render "shared/tokens/scopes_list", token: @application
.form-actions
- = link_to 'Edit', edit_admin_application_path(@application), class: 'btn btn-primary wide float-left'
+ = link_to 'Edit', edit_admin_application_path(@application), class: 'gl-button btn btn-primary wide float-left'
= render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger gl-ml-3'
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index 8a937bd66cf..9693a97367f 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -1,4 +1,4 @@
-.broadcast-message.broadcast-banner-message.alert-warning.js-broadcast-banner-message-preview.mt-2{ style: broadcast_message_style(@broadcast_message), class: ('hidden' unless @broadcast_message.banner? ) }
+.broadcast-message.broadcast-banner-message.gl-alert-warning.js-broadcast-banner-message-preview.gl-mt-3{ style: broadcast_message_style(@broadcast_message), class: ('gl-display-none' unless @broadcast_message.banner? ) }
= sprite_icon('bullhorn', css_class:'vertical-align-text-top')
.js-broadcast-message-preview
- if @broadcast_message.message.present?
@@ -77,6 +77,6 @@
= f.datetime_select :ends_at, {}, class: 'form-control form-control-inline'
.form-actions
- if @broadcast_message.persisted?
- = f.submit "Update broadcast message", class: "btn btn-success"
+ = f.submit "Update broadcast message", class: "btn gl-button btn-success"
- else
- = f.submit "Add broadcast message", class: "btn btn-success"
+ = f.submit "Add broadcast message", class: "btn gl-button btn-success"
diff --git a/app/views/admin/dashboard/_billable_users_text.html.haml b/app/views/admin/dashboard/_billable_users_text.html.haml
new file mode 100644
index 00000000000..e9485d23228
--- /dev/null
+++ b/app/views/admin/dashboard/_billable_users_text.html.haml
@@ -0,0 +1 @@
+= s_('AdminArea|Active users')
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 4acfc96caf2..b0d4a3fd8f5 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -19,7 +19,7 @@
%h3.text-center
= s_('AdminArea|Projects: %{number_of_projects}') % { number_of_projects: approximate_count_with_delimiters(@counts, Project) }
%hr
- = link_to(s_('AdminArea|New project'), new_project_path, class: "btn btn-success gl-w-full")
+ = link_to(s_('AdminArea|New project'), new_project_path, class: "btn gl-button btn-success gl-w-full")
.col-sm-4
.info-well.dark-well
.well-segment.well-centered
@@ -28,8 +28,8 @@
= s_('AdminArea|Users: %{number_of_users}') % { number_of_users: approximate_count_with_delimiters(@counts, User) }
%hr
.btn-group.d-flex{ role: 'group' }
- = link_to s_('AdminArea|New user'), new_admin_user_path, class: "btn btn-success gl-w-full"
- = link_to s_('AdminArea|Users statistics'), admin_dashboard_stats_path, class: 'btn btn-primary gl-w-full'
+ = link_to s_('AdminArea|New user'), new_admin_user_path, class: "btn gl-button btn-success gl-w-full"
+ = link_to s_('AdminArea|Users statistics'), admin_dashboard_stats_path, class: 'btn gl-button btn-info gl-w-full'
.col-sm-4
.info-well.dark-well
.well-segment.well-centered
@@ -37,7 +37,7 @@
%h3.text-center
= s_('AdminArea|Groups: %{number_of_groups}') % { number_of_groups: approximate_count_with_delimiters(@counts, Group) }
%hr
- = link_to s_('AdminArea|New group'), new_admin_group_path, class: "btn btn-success gl-w-full"
+ = link_to s_('AdminArea|New group'), new_admin_group_path, class: "btn gl-button btn-success gl-w-full"
.row
.col-md-4
#js-admin-statistics-container
@@ -51,7 +51,7 @@
= feature_entry(_('LDAP'),
enabled: Gitlab.config.ldap.enabled,
- doc_href: help_page_path('administration/auth/ldap'))
+ doc_href: help_page_path('administration/auth/ldap/index.md'))
= feature_entry(_('Gravatar'),
href: general_admin_application_settings_path(anchor: 'js-account-settings'),
diff --git a/app/views/admin/dashboard/stats.html.haml b/app/views/admin/dashboard/stats.html.haml
index 78707235cb5..9a89bf12365 100644
--- a/app/views/admin/dashboard/stats.html.haml
+++ b/app/views/admin/dashboard/stats.html.haml
@@ -50,11 +50,9 @@
= s_('AdminArea|Bots')
%td.p-3.text-right
= @users_statistics&.bots.to_i
-
%tr.bg-gray-light.gl-text-gray-900
%td.p-3
%strong
- = s_('AdminArea|Active users')
= render_if_exists 'admin/dashboard/billable_users_text'
%td.p-3.text-right
%strong
diff --git a/app/views/admin/deploy_keys/edit.html.haml b/app/views/admin/deploy_keys/edit.html.haml
index 99d8af65068..2a0177ab997 100644
--- a/app/views/admin/deploy_keys/edit.html.haml
+++ b/app/views/admin/deploy_keys/edit.html.haml
@@ -6,5 +6,5 @@
= form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f|
= render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
.form-actions
- = f.submit _('Save changes'), class: 'btn-success btn'
- = link_to _('Cancel'), admin_deploy_keys_path, class: 'btn btn-cancel'
+ = f.submit _('Save changes'), class: 'btn gl-button btn-success'
+ = link_to _('Cancel'), admin_deploy_keys_path, class: 'btn gl-button btn-cancel'
diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml
index 3409e2ffc8a..9b6aa278906 100644
--- a/app/views/admin/deploy_keys/index.html.haml
+++ b/app/views/admin/deploy_keys/index.html.haml
@@ -2,7 +2,7 @@
- if @deploy_keys.any?
%h3.page-title.deploy-keys-title
= _('Public deploy keys (%{deploy_keys_count})') % { deploy_keys_count: @deploy_keys.load.size }
- = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'float-right btn btn-success btn-md gl-button'
+ = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'float-right btn gl-button btn-success btn-md gl-button'
.table-holder.deploy-keys-list
%table.table
%thead
@@ -27,7 +27,7 @@
= _('added %{created_at_timeago}').html_safe % { created_at_timeago: time_ago_with_tooltip(deploy_key.created_at) }
%td
.float-right
- = link_to _('Edit'), edit_admin_deploy_key_path(deploy_key), class: 'btn btn-sm'
- = link_to _('Remove'), admin_deploy_key_path(deploy_key), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-sm btn-remove delete-key'
+ = link_to _('Edit'), edit_admin_deploy_key_path(deploy_key), class: 'btn gl-button btn-sm'
+ = link_to _('Remove'), admin_deploy_key_path(deploy_key), data: { confirm: _('Are you sure?') }, method: :delete, class: 'gl-button btn btn-sm btn-danger delete-key'
- else
= render 'shared/empty_states/deploy_keys'
diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml
index f43c1447f09..5a3b880a596 100644
--- a/app/views/admin/deploy_keys/new.html.haml
+++ b/app/views/admin/deploy_keys/new.html.haml
@@ -6,5 +6,5 @@
= form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f|
= render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
.form-actions
- = f.submit 'Create', class: 'btn-success btn'
- = link_to 'Cancel', admin_deploy_keys_path, class: 'btn btn-cancel'
+ = f.submit 'Create', class: 'btn gl-button btn-success'
+ = link_to 'Cancel', admin_deploy_keys_path, class: 'btn gl-button btn-cancel'
diff --git a/app/views/admin/dev_ops_report/show.html.haml b/app/views/admin/dev_ops_report/show.html.haml
index 1892557d0d6..88105be70fb 100644
--- a/app/views/admin/dev_ops_report/show.html.haml
+++ b/app/views/admin/dev_ops_report/show.html.haml
@@ -1,5 +1,6 @@
- page_title _('DevOps Report')
- usage_ping_enabled = Gitlab::CurrentSettings.usage_ping_enabled
+- add_page_specific_style 'page_bundles/dev_ops_report'
.container
- if usage_ping_enabled && show_callout?('dev_ops_report_intro_callout_dismissed')
@@ -7,7 +8,7 @@
.gl-mt-3
- if !usage_ping_enabled
- #js-devops-empty-state{ data: { is_admin: current_user&.admin.to_s, empty_state_svg_path: image_path('illustrations/convdev/convdev_no_index.svg'), enable_usage_ping_link: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), docs_link: help_page_path('development/telemetry/usage_ping') } }
+ #js-devops-empty-state{ data: { is_admin: current_user&.admin.to_s, empty_state_svg_path: image_path('illustrations/convdev/convdev_no_index.svg'), enable_usage_ping_link: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), docs_link: help_page_path('development/product_analytics/usage_ping') } }
- elsif @metric.blank?
= render 'no_data'
- else
@@ -25,7 +26,7 @@
- @metric.cards.each do |card|
= render 'card', card: card
- .devops-steps.d-none.d-lg-block.d-xl-block
+ .devops-steps.d-none.d-lg-block
- @metric.idea_to_production_steps.each_with_index do |step, index|
.devops-step{ class: "devops-#{score_level(step.percentage_score)}-score" }
= custom_icon("i2p_step_#{index + 1}")
diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index 041b0661d37..6174da14ac0 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -29,12 +29,10 @@
.gl-alert-body
= render 'shared/group_tips'
.form-actions
- = f.submit _('Create group'), class: "btn btn-success"
- = link_to _('Cancel'), admin_groups_path, class: "btn btn-cancel"
+ = f.submit _('Create group'), class: "gl-button btn btn-success"
+ = link_to _('Cancel'), admin_groups_path, class: "gl-button btn btn-cancel"
- else
.form-actions
- = f.submit _('Save changes'), class: "btn btn-success", data: { qa_selector: 'save_changes_button' }
- = link_to _('Cancel'), admin_group_path(@group), class: "btn btn-cancel"
-
-= render_if_exists 'ldap_group_links/ldap_syncrhonizations', group: @group
+ = f.submit _('Save changes'), class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
+ = link_to _('Cancel'), admin_group_path(@group), class: "gl-button btn btn-cancel"
diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml
index 3a82f3803bd..a667fc7ca04 100644
--- a/app/views/admin/groups/_group.html.haml
+++ b/app/views/admin/groups/_group.html.haml
@@ -34,4 +34,4 @@
.controls.gl-flex-shrink-0.gl-ml-5
= link_to _('Edit'), admin_group_edit_path(group), id: "edit_#{dom_id(group)}", class: 'btn'
- = link_to _('Delete'), [:admin, group], data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name } }, method: :delete, class: 'btn btn-remove'
+ = link_to _('Delete'), [:admin, group], data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name } }, method: :delete, class: 'gl-button btn btn-danger'
diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml
index da2b2c60b15..bc4d4e489ce 100644
--- a/app/views/admin/groups/index.html.haml
+++ b/app/views/admin/groups/index.html.haml
@@ -8,9 +8,9 @@
- project_name = params[:name].present? ? params[:name] : nil
.search-field-holder
= search_field_tag :name, project_name, class: "form-control search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: 'Search by name', data: { qa_selector: 'group_search_field' }
- = icon("search", class: "search-icon")
+ = sprite_icon('search', css_class: 'search-icon')
= render "shared/groups/dropdown", options_hash: admin_groups_sort_options_hash
- = link_to new_admin_group_path, class: "btn btn-success" do
+ = link_to new_admin_group_path, class: "gl-button btn btn-success" do
= _('New group')
%ul.content-list
= render @groups
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index 6c2c0b3a488..424251f543e 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -113,9 +113,9 @@
%div
= users_select_tag(:user_ids, multiple: true, email_user: true, skip_ldap: @group.ldap_synced?, scope: :all)
.gl-mt-3
- = select_tag :access_level, options_for_select(GroupMember.access_level_roles), class: "project-access-select select2"
+ = select_tag :access_level, options_for_select(@group.access_level_roles), class: "project-access-select select2"
%hr
- = button_tag _('Add users to group'), class: "btn btn-success"
+ = button_tag _('Add users to group'), class: "gl-button btn btn-success"
= render 'shared/members/requests', membership_source: @group, requesters: @requesters, force_mobile_view: true
.card
diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml
index 65d3c78ec11..76e4fa971a3 100644
--- a/app/views/admin/health_check/show.html.haml
+++ b/app/views/admin/health_check/show.html.haml
@@ -9,7 +9,7 @@
%code#health-check-token= Gitlab::CurrentSettings.health_check_access_token
.gl-mt-3
= button_to _("Reset health check access token"), reset_health_check_token_admin_application_settings_path,
- method: :put, class: 'btn btn-default',
+ method: :put, class: 'gl-button btn btn-default',
data: { confirm: _('Are you sure you want to reset the health check token?') }
%p.light
#{ _('Health information can be retrieved from the following endpoints. More information is available') }
diff --git a/app/views/admin/hook_logs/show.html.haml b/app/views/admin/hook_logs/show.html.haml
index a8ef19dcf46..ca2737ca56f 100644
--- a/app/views/admin/hook_logs/show.html.haml
+++ b/app/views/admin/hook_logs/show.html.haml
@@ -4,6 +4,6 @@
%hr
-= link_to _("Resend Request"), retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, class: "btn btn-default float-right gl-ml-3"
+= link_to _("Resend Request"), retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, class: "btn gl-button btn-default float-right gl-ml-3"
= render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log }
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index f9faf5b11fa..74f73b4972a 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -9,9 +9,9 @@
= form_for @hook, as: :hook, url: admin_hook_path do |f|
= render partial: 'form', locals: { form: f, hook: @hook }
.form-actions
- %span>= f.submit _('Save changes'), class: 'btn btn-success gl-mr-3'
+ %span>= f.submit _('Save changes'), class: 'btn gl-button btn-success gl-mr-3'
= render 'shared/web_hooks/test_button', hook: @hook
- = link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: _('Are you sure?') }
+ = link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn gl-button btn-danger float-right', data: { confirm: _('Are you sure?') }
%hr
diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml
index d70baa592ea..c0bad6a0a63 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -7,7 +7,7 @@
.col-lg-8.gl-mb-3
= form_for @hook, as: :hook, url: admin_hooks_path do |f|
= render partial: 'form', locals: { form: f, hook: @hook }
- = f.submit _('Add system hook'), class: 'btn btn-success'
+ = f.submit _('Add system hook'), class: 'btn gl-button btn-success'
= render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class
diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml
index 40a7014e143..5c62cff27c7 100644
--- a/app/views/admin/identities/_form.html.haml
+++ b/app/views/admin/identities/_form.html.haml
@@ -14,5 +14,5 @@
= f.text_field :extern_uid, class: 'form-control', required: true
.form-actions
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-success"
diff --git a/app/views/admin/identities/_identity.html.haml b/app/views/admin/identities/_identity.html.haml
index 5ed59809db5..d8facbb780a 100644
--- a/app/views/admin/identities/_identity.html.haml
+++ b/app/views/admin/identities/_identity.html.haml
@@ -4,9 +4,9 @@
%td
= identity.extern_uid
%td
- = link_to edit_admin_user_identity_path(@user, identity), class: 'btn btn-sm btn-grouped' do
+ = link_to edit_admin_user_identity_path(@user, identity), class: 'gl-button btn btn-sm btn-grouped' do
= _("Edit")
= link_to [:admin, @user, identity], method: :delete,
- class: 'btn btn-sm btn-danger',
+ class: 'gl-button btn btn-sm btn-danger',
data: { confirm: _("Are you sure you want to remove this identity?") } do
= _('Delete')
diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml
index 9543bbcf977..a6d562dad31 100644
--- a/app/views/admin/identities/index.html.haml
+++ b/app/views/admin/identities/index.html.haml
@@ -3,7 +3,7 @@
- page_title _("Identities"), @user.name, _("Users")
= render 'admin/users/head'
-= link_to _('New identity'), new_admin_user_identity_path, class: 'float-right btn btn-success'
+= link_to _('New identity'), new_admin_user_identity_path, class: 'float-right gl-button btn btn-success'
- if @identities.present?
.table-holder
%table.table
diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml
index 32c0a801a1d..d482ae04c08 100644
--- a/app/views/admin/jobs/index.html.haml
+++ b/app/views/admin/jobs/index.html.haml
@@ -8,7 +8,7 @@
- if @all_builds.running_or_pending.any?
#stop-jobs-modal
.nav-controls
- %button#stop-jobs-button.btn.btn-danger{ data: { toggle: 'modal',
+ %button#stop-jobs-button.btn.gl-button.btn-danger{ data: { toggle: 'modal',
target: '#stop-jobs-modal',
url: cancel_all_admin_jobs_path } }
= s_('AdminArea|Stop all jobs')
diff --git a/app/views/admin/labels/_form.html.haml b/app/views/admin/labels/_form.html.haml
index 299d0a12e6c..664081339f3 100644
--- a/app/views/admin/labels/_form.html.haml
+++ b/app/views/admin/labels/_form.html.haml
@@ -27,5 +27,5 @@
= render_suggested_colors
.form-actions
- = f.submit _('Save'), class: 'btn btn-success js-save-button'
- = link_to _("Cancel"), admin_labels_path, class: 'btn btn-cancel'
+ = f.submit _('Save'), class: 'btn gl-button btn-success js-save-button'
+ = link_to _("Cancel"), admin_labels_path, class: 'btn gl-button btn-cancel'
diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml
index 6d934654c5d..b31b9bdab0a 100644
--- a/app/views/admin/labels/_label.html.haml
+++ b/app/views/admin/labels/_label.html.haml
@@ -1,7 +1,7 @@
%li.label-list-item{ id: dom_id(label) }
= render "shared/label_row", label: label.present(issuable_subject: nil)
.label-actions-list
- = link_to edit_admin_label_path(label), class: 'btn btn-transparent label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
+ = link_to edit_admin_label_path(label), class: 'btn gl-button btn-transparent label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
= sprite_icon('pencil')
- = link_to admin_label_path(label), class: 'btn btn-transparent remove-row label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: "Delete this label? Are you sure?" }, aria_label: _('Delete'), method: :delete, remote: true do
+ = link_to admin_label_path(label), class: 'btn gl-button btn-transparent remove-row label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: "Delete this label? Are you sure?" }, aria_label: _('Delete'), method: :delete, remote: true do
= sprite_icon('remove')
diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml
index 38137f360fd..76d37626fff 100644
--- a/app/views/admin/labels/index.html.haml
+++ b/app/views/admin/labels/index.html.haml
@@ -1,7 +1,7 @@
- page_title _("Labels")
%div
- = link_to new_admin_label_path, class: "float-right btn btn-nr btn-success" do
+ = link_to new_admin_label_path, class: "float-right btn gl-button btn-nr btn-success" do
= _('New label')
%h3.page-title
= _('Labels')
diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml
index 08e668e8623..bcf09dfc0d2 100644
--- a/app/views/admin/projects/index.html.haml
+++ b/app/views/admin/projects/index.html.haml
@@ -30,8 +30,8 @@
= dropdown_content
= dropdown_loading
= render 'shared/projects/dropdown'
- = link_to new_project_path, class: 'btn btn-success' do
+ = link_to new_project_path, class: 'gl-button btn btn-success' do
New Project
- = button_tag "Search", class: "btn btn-primary btn-search hide"
+ = button_tag "Search", class: "gl-button btn btn-primary btn-search hide"
= render 'projects'
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index d5af12fcd09..417fd1d60eb 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -13,8 +13,9 @@
- if @project.last_repository_check_failed?
.row
.col-md-12
- .card
- .card-header.alert.alert-danger
+ .gl-alert.gl-alert-danger.gl-mb-5
+ = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body
- last_check_message = _("Last repository check (%{last_check_timestamp}) failed. See the 'repocheck.log' file for error messages.")
- last_check_message = last_check_message % { last_check_timestamp: time_ago_with_tooltip(@project.last_repository_check_at) }
= last_check_message.html_safe
@@ -148,7 +149,7 @@
.form-group.row
.offset-sm-3.col-sm-9
- = f.submit _('Transfer'), class: 'btn btn-primary'
+ = f.submit _('Transfer'), class: 'gl-button btn btn-primary'
.card.repository-check
.card-header
@@ -168,7 +169,7 @@
= link_to sprite_icon('question-o'), help_page_path('administration/repository_checks')
.form-group
- = f.submit _('Trigger repository check'), class: 'btn btn-primary'
+ = f.submit _('Trigger repository check'), class: 'gl-button btn btn-primary'
.col-md-6
- if @group
diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml
index a2b736c332c..cc8ac6b0642 100644
--- a/app/views/admin/runners/_runner.html.haml
+++ b/app/views/admin/runners/_runner.html.haml
@@ -65,15 +65,15 @@
.table-section.table-button-footer.section-10
.btn-group.table-action-buttons
.btn-group
- = link_to admin_runner_path(runner), class: 'btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do
+ = link_to admin_runner_path(runner), class: 'gl-button btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do
= sprite_icon('pencil')
.btn-group
- if runner.active?
- = link_to [:pause, :admin, runner], method: :get, class: 'btn btn-default btn-svg has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
+ = link_to [:pause, :admin, runner], method: :get, class: 'gl-button btn btn-default btn-svg has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
= sprite_icon('pause')
- else
- = link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default btn-svg has-tooltip gl-px-3', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do
+ = link_to [:resume, :admin, runner], method: :get, class: 'gl-button btn btn-default btn-svg has-tooltip gl-px-3', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do
= sprite_icon('play')
.btn-group
- = link_to [:admin, runner], method: :delete, class: 'btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
+ = link_to [:admin, runner], method: :delete, class: 'gl-button btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
= sprite_icon('close')
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index cc218aefdb7..3d3b8c28a17 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -48,7 +48,7 @@
.filtered-search-box
= dropdown_tag(_('Recent searches'),
options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
- toggle_class: 'btn filtered-search-history-dropdown-toggle-button',
+ toggle_class: 'gl-button btn filtered-search-history-dropdown-toggle-button',
dropdown_class: 'filtered-search-history-dropdown',
content_class: 'filtered-search-history-dropdown-content' }) do
.js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } }
@@ -60,7 +60,7 @@
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
- = button_tag class: %w[btn btn-link] do
+ = button_tag class: %w[gl-button btn btn-link] do
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
@@ -78,21 +78,21 @@
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_STATUSES.each do |status|
%li.filter-dropdown-item{ data: { value: status } }
- = button_tag class: %w[btn btn-link] do
+ = button_tag class: %w[gl-button btn btn-link] do
= status.titleize
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
%li.filter-dropdown-item{ data: { value: runner_type } }
- = button_tag class: %w[btn btn-link] do
+ = button_tag class: %w[gl-button btn btn-link] do
= runner_type.titleize
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
%li.filter-dropdown-item{ data: { value: runner_type } }
- = button_tag class: %w[btn btn-link] do
+ = button_tag class: %w[gl-button btn btn-link] do
= runner_type.titleize
#js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index cecf3f137ed..2c4befb1be2 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -49,7 +49,7 @@
= project.full_name
%td
.float-right
- = link_to 'Disable', admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn btn-danger btn-sm'
+ = link_to 'Disable', admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'gl-button btn btn-danger btn-sm'
%table.table.unassigned-projects
%thead
@@ -73,7 +73,7 @@
.float-right
= form_for project.runner_projects.new, url: admin_namespace_project_runner_projects_path(project.namespace, project), method: :post do |f|
= f.hidden_field :runner_id, value: @runner.id
- = f.submit 'Enable', class: 'btn btn-sm'
+ = f.submit 'Enable', class: 'gl-button btn btn-sm'
= paginate_without_count @projects
.col-md-6
diff --git a/app/views/admin/serverless/domains/_form.html.haml b/app/views/admin/serverless/domains/_form.html.haml
index 9e7990ef8ca..8f0dd0cab8e 100644
--- a/app/views/admin/serverless/domains/_form.html.haml
+++ b/app/views/admin/serverless/domains/_form.html.haml
@@ -16,7 +16,7 @@
- text, status = @domain.unverified? ? [_('Unverified'), 'badge-danger'] : [_('Verified'), 'badge-success']
.badge{ class: status }
= text
- = link_to sprite_icon("redo"), verify_admin_serverless_domain_path(@domain.id), method: :post, class: "btn has-tooltip", title: _("Retry verification")
+ = link_to sprite_icon("redo"), verify_admin_serverless_domain_path(@domain.id), method: :post, class: "gl-button btn has-tooltip", title: _("Retry verification")
.col-sm-6
= f.label :serverless_domain_dns, _('DNS'), class: 'label-bold'
@@ -50,7 +50,7 @@
.d-flex.justify-content-between.align-items-center.p-3
%span
= @domain.subject || _('missing')
- %button.btn.btn-remove.btn-sm.js-domain-cert-replace-btn{ type: 'button' }
+ %button.gl-button.btn.btn-danger.btn-sm.js-domain-cert-replace-btn{ type: 'button' }
= _('Replace')
.js-domain-cert-inputs{ class: ('hidden' if show_certificate_card) }
@@ -65,9 +65,9 @@
%span.form-text.text-muted
= _("Upload a private key for your certificate")
- = f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "btn btn-success js-serverless-domain-submit", disabled: @domain.persisted?
+ = f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "gl-button btn btn-success js-serverless-domain-submit", disabled: @domain.persisted?
- if @domain.persisted?
- %button.btn.btn-remove{ type: 'button', data: { toggle: 'modal', target: "#modal-delete-domain" } }
+ %button.gl-button.btn.btn-danger{ type: 'button', data: { toggle: 'modal', target: "#modal-delete-domain" } }
= _('Delete domain')
-# haml-lint:disable NoPlainNodes
@@ -88,12 +88,12 @@
= _("You are about to delete %{domain} from your instance. This domain will no longer be available to any Knative application.").html_safe % { domain: "<code>#{@domain.domain}</code>".html_safe }
.modal-footer
- %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' }
+ %a{ href: '#', data: { dismiss: 'modal' }, class: 'gl-button btn btn-default' }
= _('Cancel')
= link_to _('Delete domain'),
admin_serverless_domain_path(@domain.id),
title: _('Delete'),
method: :delete,
- class: "btn btn-remove",
+ class: "gl-button btn btn-danger",
disabled: domain_attached
diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml
index 5be1c90d6aa..47ef4f26889 100644
--- a/app/views/admin/sessions/_new_base.html.haml
+++ b/app/views/admin/sessions/_new_base.html.haml
@@ -4,4 +4,4 @@
= password_field_tag 'user[password]', nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
.submit-container.move-submit-down
- = submit_tag _('Enter Admin Mode'), class: 'btn btn-success', data: { qa_selector: 'enter_admin_mode_button' }
+ = submit_tag _('Enter Admin Mode'), class: 'gl-button btn btn-success', data: { qa_selector: 'enter_admin_mode_button' }
diff --git a/app/views/admin/sessions/_two_factor_otp.html.haml b/app/views/admin/sessions/_two_factor_otp.html.haml
index 8d5588de06e..3fe6e20a367 100644
--- a/app/views/admin/sessions/_two_factor_otp.html.haml
+++ b/app/views/admin/sessions/_two_factor_otp.html.haml
@@ -6,4 +6,4 @@
= _("Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.")
.submit-container.move-submit-down
- = submit_tag 'Verify code', class: 'btn btn-success'
+ = submit_tag 'Verify code', class: 'gl-button btn btn-success'
diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml
index 9d47dc1cce5..2e7114ddab4 100644
--- a/app/views/admin/spam_logs/_spam_log.html.haml
+++ b/app/views/admin/spam_logs/_spam_log.html.haml
@@ -24,16 +24,16 @@
%td
- if user
= link_to 'Remove user', admin_spam_log_path(spam_log, remove_user: true),
- data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-sm btn-remove"
+ data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "gl-button btn btn-sm btn-danger"
%td
- if spam_log.submitted_as_ham?
.btn.btn-sm.disabled
Submitted as ham
- else
- = link_to 'Submit as ham', mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'btn btn-sm btn-warning'
+ = link_to 'Submit as ham', mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'gl-button btn btn-sm btn-warning'
- if user && !user.blocked?
- = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-sm"
+ = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "gl-button btn btn-sm"
- else
.btn.btn-sm.disabled
Already blocked
- = link_to 'Remove log', [:admin, spam_log], remote: true, method: :delete, class: "btn btn-sm btn-close js-remove-tr"
+ = link_to 'Remove log', [:admin, spam_log], remote: true, method: :delete, class: "gl-button btn btn-sm btn-close js-remove-tr"
diff --git a/app/views/admin/users/_approve_user.html.haml b/app/views/admin/users/_approve_user.html.haml
new file mode 100644
index 00000000000..b4d960d909c
--- /dev/null
+++ b/app/views/admin/users/_approve_user.html.haml
@@ -0,0 +1,7 @@
+.card.border-info
+ .card-header.gl-bg-blue-500.gl-text-white
+ = s_('AdminUsers|This user has requested access')
+ .card-body
+ = render partial: 'admin/users/user_approve_effects'
+ %br
+ = link_to s_('AdminUsers|Approve user'), approve_admin_user_path(user), method: :put, class: "btn gl-button btn-info", data: { confirm: s_('AdminUsers|Are you sure?') }
diff --git a/app/views/admin/users/_block_user.html.haml b/app/views/admin/users/_block_user.html.haml
new file mode 100644
index 00000000000..b07a72c3e28
--- /dev/null
+++ b/app/views/admin/users/_block_user.html.haml
@@ -0,0 +1,11 @@
+.card.border-warning
+ .card-header.bg-warning.text-white
+ = s_('AdminUsers|Block this user')
+ .card-body
+ = render partial: 'admin/users/user_block_effects'
+ %br
+ %button.btn.gl-button.btn-warning{ data: { 'gl-modal-action': 'block',
+ content: s_('AdminUsers|You can always unblock their account, their data will remain intact.'),
+ url: block_admin_user_path(user),
+ username: sanitize_name(user.name) } }
+ = s_('AdminUsers|Block user')
diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml
index 9e31c8d2852..61c31d2d864 100644
--- a/app/views/admin/users/_form.html.haml
+++ b/app/views/admin/users/_form.html.haml
@@ -88,7 +88,7 @@
.form-actions
- if @user.new_record?
= f.submit 'Create user', class: "btn gl-button btn-success"
- = link_to 'Cancel', admin_users_path, class: "btn btn-cancel"
+ = link_to 'Cancel', admin_users_path, class: "gl-button btn btn-cancel"
- else
= f.submit 'Save changes', class: "btn gl-button btn-success"
= link_to 'Cancel', admin_user_path(@user), class: "btn gl-button btn-cancel"
diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml
index a60dbd51935..4abcdef7e27 100644
--- a/app/views/admin/users/_head.html.haml
+++ b/app/views/admin/users/_head.html.haml
@@ -1,13 +1,20 @@
%h3.page-title
= @user.name
- - if @user.blocked?
- %span.cred (Blocked)
+ - if @user.blocked_pending_approval?
+ %span.cred
+ = s_('AdminUsers|(Pending approval)')
+ - elsif @user.blocked?
+ %span.cred
+ = s_('AdminUsers|(Blocked)')
- if @user.internal?
- %span.cred (Internal)
+ %span.cred
+ = s_('AdminUsers|(Internal)')
- if @user.admin
- %span.cred (Admin)
+ %span.cred
+ = s_('AdminUsers|(Admin)')
- if @user.deactivated?
- %span.cred (Deactivated)
+ %span.cred
+ = s_('AdminUsers|(Deactivated)')
= render_if_exists 'admin/users/audtior_user_badge'
.float-right
diff --git a/app/views/admin/users/_modals.html.haml b/app/views/admin/users/_modals.html.haml
index 6cf6dc116e3..a8e5d962e5b 100644
--- a/app/views/admin/users/_modals.html.haml
+++ b/app/views/admin/users/_modals.html.haml
@@ -25,6 +25,6 @@
'secondary-action': s_('AdminUsers|Block user') } }
= s_('AdminUsers|You are about to permanently delete the user %{username}. This will delete all of the issues,
merge requests, and groups linked to them. To avoid data loss,
- consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end},
+ consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd},
it cannot be undone or recovered.')
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index 160303890f5..70ab95bfa61 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -4,7 +4,12 @@
= _('Name')
.table-mobile-content
= render 'user_detail', user: user
- .table-section.section-25
+ .table-section.section-10
+ .table-mobile-header{ role: 'rowheader' }
+ = _('Projects')
+ .table-mobile-content.gl-str-truncated{ data: { testid: "user-project-count-#{user.id}" } }
+ = user.authorized_projects.length
+ .table-section.section-15
.table-mobile-header{ role: 'rowheader' }
= _('Created on')
.table-mobile-content
@@ -30,15 +35,22 @@
%span.small
= s_('AdminUsers|Cannot unblock LDAP blocked users')
- elsif user.blocked?
- = link_to _('Unblock'), unblock_admin_user_path(user), method: :put
+ - if user.blocked_pending_approval?
+ = link_to s_('AdminUsers|Approve'), approve_admin_user_path(user), method: :put
+ %button.btn.btn-default-tertiary{ data: { 'gl-modal-action': 'block',
+ url: block_admin_user_path(user),
+ username: sanitize_name(user.name) } }
+ = s_('AdminUsers|Block')
+ - else
+ = link_to _('Unblock'), unblock_admin_user_path(user), method: :put
- else
- %button.btn.gl-button.btn-default-tertiary{ data: { 'gl-modal-action': 'block',
+ %button.btn.btn-default-tertiary{ data: { 'gl-modal-action': 'block',
url: block_admin_user_path(user),
username: sanitize_name(user.name) } }
= s_('AdminUsers|Block')
- if user.can_be_deactivated?
%li
- %button.btn.gl-button.btn-default-tertiary{ data: { 'gl-modal-action': 'deactivate',
+ %button.btn.btn-default-tertiary{ data: { 'gl-modal-action': 'deactivate',
url: deactivate_admin_user_path(user),
username: sanitize_name(user.name) } }
= s_('AdminUsers|Deactivate')
@@ -52,13 +64,13 @@
%li.divider
- if user.can_be_removed?
%li
- %button.delete-user-button.btn.gl-button.btn-default-tertiary.text-danger{ data: { 'gl-modal-action': 'delete',
+ %button.delete-user-button.btn.btn-default-tertiary.text-danger{ data: { 'gl-modal-action': 'delete',
delete_user_url: admin_user_path(user),
block_user_url: block_admin_user_path(user),
username: sanitize_name(user.name) } }
= s_('AdminUsers|Delete user')
%li
- %button.delete-user-button.btn.gl-button.btn-default-tertiary.text-danger{ data: { 'gl-modal-action': 'delete-with-contributions',
+ %button.delete-user-button.btn.btn-default-tertiary.text-danger{ data: { 'gl-modal-action': 'delete-with-contributions',
delete_user_url: admin_user_path(user, hard_delete: true),
block_user_url: block_admin_user_path(user),
username: sanitize_name(user.name) } }
diff --git a/app/views/admin/users/_user_approve_effects.html.haml b/app/views/admin/users/_user_approve_effects.html.haml
new file mode 100644
index 00000000000..54e51bf3467
--- /dev/null
+++ b/app/views/admin/users/_user_approve_effects.html.haml
@@ -0,0 +1,11 @@
+%p
+ = s_('AdminUsers|Approved users can:')
+%ul
+ %li
+ = s_('AdminUsers|Log in')
+ %li
+ = s_('AdminUsers|Access Git repositories')
+ %li
+ = s_('AdminUsers|Access the API')
+ %li
+ = s_('AdminUsers|Be added to groups and projects')
diff --git a/app/views/admin/users/_user_detail.html.haml b/app/views/admin/users/_user_detail.html.haml
index 3839231cb95..3bafd1cb396 100644
--- a/app/views/admin/users/_user_detail.html.haml
+++ b/app/views/admin/users/_user_detail.html.haml
@@ -15,3 +15,5 @@
.row-second-line.str-truncated-100
= mail_to user.email, user.email, class: 'text-secondary'
+ - unless Feature.disabled?(:security_auto_fix) || !user.internal? || user.website_url.blank?
+ = link_to "(#{_('more information')})", user.website_url
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index 118bdf7bb17..33faef92646 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -30,6 +30,11 @@
= link_to admin_users_path(filter: "blocked") do
= s_('AdminUsers|Blocked')
%small.badge.badge-pill= limited_counter_with_delimiter(User.blocked)
+ - if Feature.enabled?(:admin_approval_for_new_user_signups, default_enabled: true)
+ = nav_link(html_options: { class: "#{active_when(params[:filter] == 'blocked_pending_approval')} filter-blocked-pending-approval" }) do
+ = link_to admin_users_path(filter: "blocked_pending_approval") do
+ = s_('AdminUsers|Pending approval')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked_pending_approval)
= nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do
= link_to admin_users_path(filter: "deactivated") do
= s_('AdminUsers|Deactivated')
@@ -51,7 +56,7 @@
= search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { qa_selector: 'user_search_field' }
- if @sort.present?
= hidden_field_tag :sort, @sort
- = icon("search", class: "search-icon")
+ = sprite_icon('search', css_class: 'search-icon')
= button_tag s_('AdminUsers|Search users') if Rails.env.test?
.dropdown.user-sort-dropdown
= label_tag 'Sort by', nil, class: 'label-bold'
@@ -72,7 +77,8 @@
.table-holder
.thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-40{ role: 'rowheader' }= _('Name')
- .table-section.section-25{ role: 'rowheader' }= _('Created on')
+ .table-section.section-10{ role: 'rowheader' }= _('Projects')
+ .table-section.section-15{ role: 'rowheader' }= _('Created on')
.table-section.section-15{ role: 'rowheader' }= _('Last activity')
= render partial: 'admin/users/user', collection: @users
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index a08c29714e0..9c6f151a6b1 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -54,7 +54,7 @@
%strong{ class: @user.two_factor_enabled? ? 'cgreen' : 'cred' }
- if @user.two_factor_enabled?
Enabled
- = link_to 'Disable', disable_two_factor_admin_user_path(@user), data: {confirm: 'Are you sure?'}, method: :patch, class: 'btn gl-button btn-sm btn-remove float-right', title: 'Disable Two-factor Authentication'
+ = link_to 'Disable', disable_two_factor_admin_user_path(@user), data: {confirm: 'Are you sure?'}, method: :patch, class: 'btn gl-button btn-sm btn-danger float-right', title: 'Disable Two-factor Authentication'
- else
Disabled
@@ -137,7 +137,7 @@
.col-md-6
- unless @user == current_user
- - unless @user.confirmed?
+ - if can_force_email_confirmation?(@user)
.card.border-info
.card-header.bg-info.text-white
Confirm user
@@ -150,50 +150,46 @@
= render 'admin/users/user_detail_note'
- - if @user.deactivated?
- .card.border-info
- .card-header.bg-info.text-white
- Reactivate this user
- .card-body
- = render partial: 'admin/users/user_activation_effects'
- %br
- = link_to 'Activate user', activate_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?' }
- - elsif @user.can_be_deactivated?
- .card.border-warning
- .card-header.bg-warning.text-white
- Deactivate this user
- .card-body
- = render partial: 'admin/users/user_deactivation_effects'
- %br
- %button.btn.gl-button.btn-warning{ data: { 'gl-modal-action': 'deactivate',
- content: 'You can always re-activate their account, their data will remain intact.',
- url: deactivate_admin_user_path(@user),
- username: sanitize_name(@user.name) } }
- = s_('AdminUsers|Deactivate user')
+ - unless @user.internal?
+ - if @user.deactivated?
+ .card.border-info
+ .card-header.bg-info.text-white
+ Reactivate this user
+ .card-body
+ = render partial: 'admin/users/user_activation_effects'
+ %br
+ = link_to 'Activate user', activate_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?' }
+ - elsif @user.can_be_deactivated?
+ .card.border-warning
+ .card-header.bg-warning.text-white
+ Deactivate this user
+ .card-body
+ = render partial: 'admin/users/user_deactivation_effects'
+ %br
+ %button.btn.gl-button.btn-warning{ data: { 'gl-modal-action': 'deactivate',
+ content: 'You can always re-activate their account, their data will remain intact.',
+ url: deactivate_admin_user_path(@user),
+ username: sanitize_name(@user.name) } }
+ = s_('AdminUsers|Deactivate user')
- if @user.blocked?
- .card.border-info
- .card-header.bg-info.text-white
- This user is blocked
- .card-body
- %p A blocked user cannot:
- %ul
- %li Log in
- %li Access Git repositories
- %br
- = link_to 'Unblock user', unblock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?' }
- - else
- .card.border-warning
- .card-header.bg-warning.text-white
- Block this user
- .card-body
- = render partial: 'admin/users/user_block_effects'
- %br
- %button.btn.gl-button.btn-warning{ data: { 'gl-modal-action': 'block',
- content: 'You can always unblock their account, their data will remain intact.',
- url: block_admin_user_path(@user),
- username: sanitize_name(@user.name) } }
- = s_('AdminUsers|Block user')
+ - if @user.blocked_pending_approval?
+ = render 'admin/users/approve_user', user: @user
+ = render 'admin/users/block_user', user: @user
+ - else
+ .card.border-info
+ .card-header.gl-bg-blue-500.gl-text-white
+ This user is blocked
+ .card-body
+ %p A blocked user cannot:
+ %ul
+ %li Log in
+ %li Access Git repositories
+ %br
+ = link_to 'Unblock user', unblock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: s_('AdminUsers|Are you sure?') }
+ - elsif !@user.internal?
+ = render 'admin/users/block_user', user: @user
+
- if @user.access_locked?
.card.border-info
.card-header.bg-info.text-white
diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml
index 4c8bb84c9ef..5e9b02b5fe2 100644
--- a/app/views/ci/status/_dropdown_graph_badge.html.haml
+++ b/app/views/ci/status/_dropdown_graph_badge.html.haml
@@ -8,12 +8,12 @@
- if status.has_details?
= link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item d-flex', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
%span{ class: klass }= sprite_icon(status.icon)
- %span.ci-build-text.text-truncate.mw-70p.gl-pl-2= subject.name
+ %span.gl-text-truncate.mw-70p.gl-pl-2= subject.name
- else
.menu-item.mini-pipeline-graph-dropdown-item.d-flex{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } }
%span{ class: klass }= sprite_icon(status.icon)
- %span.ci-build-text.text-truncate.mw-70p.gl-pl-2= subject.name
+ %span.gl-text-truncate.mw-70p.gl-pl-2= subject.name
- if status.has_action?
= link_to status.action_path, class: "gl-button ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index 8d379774719..b90e672cca9 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -24,7 +24,7 @@
- else
.row
.col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint, maskable_regex: ci_variable_maskable_regex } }
- .hide.alert.alert-danger.js-ci-variable-error-box
+ .hide.gl-alert.gl-alert-danger.js-ci-variable-error-box
%ul.ci-variable-list
= render 'ci/variables/variable_header'
diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml
index 117bdbc06a1..bbdbda40297 100644
--- a/app/views/clusters/clusters/_advanced_settings.html.haml
+++ b/app/views/clusters/clusters/_advanced_settings.html.haml
@@ -24,17 +24,18 @@
.text-muted
= html_escape(s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes %{code_open}cluster-admin%{code_close} privileges.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
= link_to _('More information'), help_page_path('user/clusters/management_project.md'), target: '_blank'
- .gl-display-flex.gl-justify-content-end
- = field.submit _('Save changes'), class: 'btn btn-success'
+ = field.submit _('Save changes'), class: 'btn gl-button btn-success'
- - if @cluster.managed?
- .sub-section.form-group
- %h4
- = s_('ClusterIntegration|Clear cluster cache')
- %p
- = s_("ClusterIntegration|Clear the local cache of namespace and service accounts. This is necessary if your integration has become out of sync. The cache is repopulated during the next CI job that requires namespace and service accounts.")
- .gl-display-flex.gl-justify-content-end
- = link_to(s_('ClusterIntegration|Clear cluster cache'), clusterable.clear_cluster_cache_path(@cluster), method: :delete, class: 'btn btn-primary')
+ .sub-section.form-group
+ %h4
+ = s_('ClusterIntegration|Clear cluster cache')
+ %p
+ = s_("ClusterIntegration|Clear the local cache of namespace and service accounts.")
+ - if @cluster.managed?
+ = s_("ClusterIntegration|This is necessary if your integration has become out of sync. The cache is repopulated during the next CI job that requires namespace and service accounts.")
+ - else
+ = s_("ClusterIntegration|This is necessary to clear existing environment-namespace associations from clusters previously managed by GitLab.")
+ = link_to(s_('ClusterIntegration|Clear cluster cache'), clusterable.clear_cluster_cache_path(@cluster), method: :delete, class: 'btn gl-button btn-info')
.sub-section.form-group
%h4.text-danger
diff --git a/app/views/clusters/clusters/_buttons.html.haml b/app/views/clusters/clusters/_buttons.html.haml
deleted file mode 100644
index c81d1d5b05a..00000000000
--- a/app/views/clusters/clusters/_buttons.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-.nav-controls
- - if clusterable.can_add_cluster?
- = link_to s_('ClusterIntegration|Add Kubernetes cluster'), clusterable.new_path, class: 'btn btn-success js-add-cluster'
- - else
- %span.btn.btn-add-cluster.disabled.js-add-cluster
- = s_("ClusterIntegration|Add Kubernetes cluster")
diff --git a/app/views/clusters/clusters/_cluster.html.haml b/app/views/clusters/clusters/_cluster.html.haml
deleted file mode 100644
index f11117ea5c4..00000000000
--- a/app/views/clusters/clusters/_cluster.html.haml
+++ /dev/null
@@ -1,19 +0,0 @@
-.card
- .card-body.gl-responsive-table-row
- .table-section.section-60
- .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster")
- .table-mobile-content.gl-display-flex.gl-align-items-center.gl-justify-content-end.gl-justify-content-md-start
- .gl-w-6.gl-h-6.gl-mr-3.gl-display-flex.gl-align-items-center= provider_icon(cluster.provider_type)
- = cluster.item_link(clusterable, html_options: { data: { qa_selector: 'cluster', qa_cluster_name: cluster.name } })
- - if cluster.status_name == :creating
- .spinner.ml-2.align-middle.has-tooltip{ title: s_("ClusterIntegration|Cluster being created") }
- - unless cluster.enabled?
- %span.badge.badge-danger Connection disabled
- .table-section.section-25
- .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Environment scope")
- .table-mobile-content= cluster.environment_scope
- .table-section.section-15.text-right
- .table-mobile-header{ role: "rowheader" }
- .table-mobile-content
- %span.badge.badge-light
- = cluster.cluster_type_description
diff --git a/app/views/clusters/clusters/_cluster_list.html.haml b/app/views/clusters/clusters/_cluster_list.html.haml
new file mode 100644
index 00000000000..9627d940126
--- /dev/null
+++ b/app/views/clusters/clusters/_cluster_list.html.haml
@@ -0,0 +1,12 @@
+- if clusters.empty?
+ = render 'empty_state'
+- else
+ .top-area.adjust
+ .gl-display-block.gl-text-right.gl-my-4.gl-w-full
+ - if clusterable.can_add_cluster?
+ = link_to s_('ClusterIntegration|Connect cluster with certificate'), clusterable.new_path, class: 'btn gl-button btn-success js-add-cluster gl-py-2', qa_selector: :integrate_kubernetes_cluster_button
+ - else
+ %span.btn.gl-button.btn-success.js-add-cluster.disabled.gl-py-2
+ = s_("ClusterIntegration|Connect cluster with certificate")
+
+ #js-clusters-list-app{ data: js_clusters_list_data(clusterable.index_path(format: :json)) }
diff --git a/app/views/clusters/clusters/_empty_state.html.haml b/app/views/clusters/clusters/_empty_state.html.haml
index cfdbfe2dea1..1798ba81075 100644
--- a/app/views/clusters/clusters/_empty_state.html.haml
+++ b/app/views/clusters/clusters/_empty_state.html.haml
@@ -3,12 +3,12 @@
.svg-content= image_tag 'illustrations/clusters_empty.svg'
.col-12
.text-content
- %h4.text-center= s_('ClusterIntegration|Integrate Kubernetes cluster automation')
- %p
+ %h4.gl-text-center= s_('ClusterIntegration|Integrate Kubernetes with a cluster certificate')
+ %p.gl-text-center
= s_('ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way.')
= clusterable.empty_state_help_text
= clusterable.learn_more_link
- if clusterable.can_add_cluster?
- .text-center
- = link_to s_('ClusterIntegration|Add Kubernetes cluster'), clusterable.new_path, class: 'btn btn-success'
+ .gl-text-center
+ = link_to s_('ClusterIntegration|Integrate with a cluster certificate'), clusterable.new_path, class: 'btn btn-success'
diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
index 54f6fa91cf1..8c23fc7c590 100644
--- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
+++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
@@ -1,11 +1,9 @@
- link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
-.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.gl-mt-3.gl-mb-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } }
- %button.close.js-close{ type: "button" } &times;
- .gcp-signup-offer--content
- .gcp-signup-offer--icon.gl-mr-3
- = sprite_icon("information")
- .gcp-signup-offer--copy
- %h4= s_('ClusterIntegration|Did you know?')
- %p= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link }
- %a.btn.btn-default{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' }
- = s_("ClusterIntegration|Apply for credit")
+.gcp-signup-offer.gl-alert.gl-alert-info.gl-my-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } }
+ %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
+ = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ %h4.gl-alert-title= s_('ClusterIntegration|Did you know?')
+ %p.gl-alert-body= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link }
+ %a.gl-button.btn-info{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' }
+ = s_("ClusterIntegration|Apply for credit")
diff --git a/app/views/clusters/clusters/_provider_details_form.html.haml b/app/views/clusters/clusters/_provider_details_form.html.haml
index e211851b939..16891c7fc21 100644
--- a/app/views/clusters/clusters/_provider_details_form.html.haml
+++ b/app/views/clusters/clusters/_provider_details_form.html.haml
@@ -42,11 +42,17 @@
class: 'js-gl-managed',
label_class: 'label-bold' }
.form-text.text-muted
- = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.')
+ = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.')
= link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
+ .form-group
+ = field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' }
+ .form-text.text-muted
+ = s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'), target: '_blank'
+
- if cluster.allow_user_defined_namespace?
- = render('clusters/clusters/namespace', platform_field: platform_field)
+ = render('clusters/clusters/namespace', platform_field: platform_field, field: field)
- .form-group.gl-display-flex.gl-justify-content-end
+ .form-group
= field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success'
diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml
index 3eab9b46fb3..b1a277faae9 100644
--- a/app/views/clusters/clusters/aws/_new.html.haml
+++ b/app/views/clusters/clusters/aws/_new.html.haml
@@ -4,6 +4,7 @@
= s_('Amazon authentication is not %{link_start}correctly configured%{link_end}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_start: documentation_link_start, link_end: '<a/>'.html_safe }
- else
.js-create-eks-cluster-form-container{ data: { 'gitlab-managed-cluster-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'),
+ 'namespace-per-environment-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'),
'create-role-path' => clusterable.authorize_aws_role_path,
'create-cluster-path' => clusterable.create_aws_clusters_path,
'account-id' => Gitlab::CurrentSettings.eks_account_id,
diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml
index 434c02a5c41..ceb6e1d46b0 100644
--- a/app/views/clusters/clusters/gcp/_form.html.haml
+++ b/app/views/clusters/clusters/gcp/_form.html.haml
@@ -75,9 +75,15 @@
= field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'),
label_class: 'label-bold' }
.form-text.text-muted
- = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.')
+ = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.')
= link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
+ .form-group
+ = field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' }
+ .form-text.text-muted
+ = s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'), target: '_blank'
+
.form-group.js-gke-cluster-creation-submit-container
= field.submit s_('ClusterIntegration|Create Kubernetes cluster'),
class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true
diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml
index 557ad1bf280..45287a01cc9 100644
--- a/app/views/clusters/clusters/index.html.haml
+++ b/app/views/clusters/clusters/index.html.haml
@@ -3,30 +3,24 @@
= render_gcp_signup_offer
-.clusters-container
- - if @clusters.empty?
- = render "empty_state"
- - else
- .top-area.adjust
- .nav-text
- = s_('ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project')
- = render 'clusters/clusters/buttons'
+.clusters-container.gl-my-2
+ - if display_cluster_agents?(clusterable)
+ .js-toggle-container
+ %ul.nav-links.nav-tabs.nav{ role: 'tablist' }
+ %li.nav-item{ role: 'presentation' }
+ %a.nav-link.active{ href: "#certificate-clusters-pane", id: "certificate-clusters-tab", data: { toggle: 'tab' }, role: 'tab' }
+ %span= s_('ClusterIntegration|Clusters connected with a certificate')
+
+ %li.nav-item{ role: 'presentation' }
+ %a.nav-link{ href: "#agent-clusters-pane", id: "agent-clusters-tab", data: { toggle: 'tab' }, role: 'tab' }
+ %span= s_('ClusterIntegration|GitLab Agent managed clusters')
+
+ .tab-content
+ .tab-pane.active{ id: 'certificate-clusters-pane', role: 'tabpanel' }
+ = render 'cluster_list', clusters: @clusters
- - if Feature.enabled?(:clusters_list_redesign)
- #js-clusters-list-app{ data: js_clusters_list_data(clusterable.index_path(format: :json)) }
- - else
- - if @has_ancestor_clusters
- .bs-callout.bs-callout-info
- = s_('ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.')
- %strong
- = link_to _('More information'), help_page_path('user/group/clusters/index', anchor: 'cluster-precedence')
- .clusters-table.js-clusters-list
- .gl-responsive-table-row.table-row-header{ role: "row" }
- .table-section.section-60{ role: "rowheader" }
- = s_("ClusterIntegration|Kubernetes cluster")
- .table-section.section-30{ role: "rowheader" }
- = s_("ClusterIntegration|Environment scope")
- .table-section.section-10{ role: "rowheader" }
- - @clusters.each do |cluster|
- = render "cluster", cluster: cluster.present(current_user: current_user)
- = paginate @clusters, theme: "gitlab"
+ .tab-pane{ id: 'agent-clusters-pane', role: 'tabpanel' }
+ #js-cluster-agents-list{ data: js_cluster_agents_list_data(clusterable) }
+
+ - else
+ = render 'cluster_list', clusters: @clusters
diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml
index 11772107135..a6097038b2e 100644
--- a/app/views/clusters/clusters/user/_form.html.haml
+++ b/app/views/clusters/clusters/user/_form.html.haml
@@ -46,9 +46,15 @@
class: 'js-gl-managed',
label_class: 'label-bold' }
.form-text.text-muted
- = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.')
+ = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.')
= link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
+ .form-group
+ = field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' }
+ .form-text.text-muted
+ = s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'), target: '_blank'
+
= field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field|
- if @user_cluster.allow_user_defined_namespace?
= render('clusters/clusters/namespace', platform_field: platform_kubernetes_field)
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index dd9fd34f284..2111b66d26e 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -14,7 +14,7 @@
.top-area
= render 'shared/issuable/nav', type: :merge_requests, display_count: !@no_filters_set
-= render 'shared/issuable/search_bar', type: :merge_requests
+= render 'shared/issuable/search_bar', type: :merge_requests, disable_target_branch: true
- if current_user && @no_filters_set
= render 'shared/dashboard/no_filter_selected'
diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml
index a0c1c314a85..923e78ad360 100644
--- a/app/views/dashboard/milestones/index.html.haml
+++ b/app/views/dashboard/milestones/index.html.haml
@@ -1,6 +1,7 @@
- @hide_top_links = true
- page_title _('Milestones')
- header_title _('Milestones'), dashboard_milestones_path
+- add_page_specific_style 'page_bundles/milestone'
.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Milestones')
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index 82abb9b3b8a..6fb387ecca3 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -2,7 +2,7 @@
.todo-avatar
= author_avatar(todo, size: 40)
- .todo-item.todo-block.align-self-center
+ .todo-item.todo-block.align-self-center{ data: { qa_selector: "todo_item_container" } }
.todo-title
- if todo_author_display?(todo)
= todo_target_state_pill(todo)
@@ -13,7 +13,7 @@
- else
(removed)
- %span.title-item.action-name
+ %span.title-item.action-name{ data: { qa_selector: "todo_action_name_content" } }
= todo_action_name(todo)
%span.title-item.todo-label.todo-target-link
@@ -22,7 +22,7 @@
- else
= _("(removed)")
- %span.title-item.todo-target-title
+ %span.title-item.todo-target-title{ data: { qa_selector: "todo_target_title_content" } }
= todo_target_title(todo)
%span.title-item.todo-project.todo-label
@@ -31,7 +31,7 @@
- if todo.self_assigned?
%span.title-item.action-name
- to yourself
+ = todo_self_addressing(todo)
%span.title-item
&middot;
@@ -57,5 +57,5 @@
- else
.todo-actions
= link_to restore_dashboard_todo_path(todo), method: :patch, class: 'btn btn-loading d-flex align-items-center js-add-todo', data: { href: restore_dashboard_todo_path(todo) } do
- Add a To Do
+ Add a to do
%span.spinner.ml-1
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index 9c6a6be1bc3..56506370ee0 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -3,7 +3,7 @@
- header_title _("To-Do List"), dashboard_todos_path
= render_dashboard_gold_trial(current_user)
-= stylesheet_link_tag 'page_bundles/todos'
+- add_page_specific_style 'page_bundles/todos'
.page-title-holder.d-flex.align-items-center
%h1.page-title= _('To-Do List')
@@ -83,7 +83,7 @@
.todos-list-container.js-todos-all
- if @todos.any?
- .js-todos-list-container
+ .js-todos-list-container{ data: { qa_selector: "todos_list_container" } }
.js-todos-options{ data: { per_page: @todos.limit_value, current_page: @todos.current_page, total_pages: @todos.total_pages } }
.card.card-without-border.card-without-margin
%ul.content-list.todos-list
diff --git a/app/views/devise/mailer/confirmation_instructions.text.erb b/app/views/devise/mailer/confirmation_instructions.text.erb
index 05fddddf415..925ad9bd22e 100644
--- a/app/views/devise/mailer/confirmation_instructions.text.erb
+++ b/app/views/devise/mailer/confirmation_instructions.text.erb
@@ -1 +1 @@
-<%= render partial: "confirmation_instructions_#{@resource.is_a?(User) ? 'account' : 'secondary'}" %> \ No newline at end of file
+<%= render partial: "confirmation_instructions_#{@resource.is_a?(User) ? 'account' : 'secondary'}" %>
diff --git a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml
index 5f7345f306d..621bbb32a13 100644
--- a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml
+++ b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml
@@ -11,10 +11,10 @@
.name.form-row
.col.form-group
= f.label :first_name, _('First name'), for: 'new_user_first_name', class: 'label-bold'
- = f.text_field :first_name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_first_name_length, :max_length_message => _("First Name is too long (maximum is %{max_length} characters).") % { max_length: max_first_name_length }, :qa_selector => 'new_user_firstname_field' }, required: true, title: _("This field is required.")
+ = f.text_field :first_name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_first_name_length, :max_length_message => _("First name is too long (maximum is %{max_length} characters).") % { max_length: max_first_name_length }, :qa_selector => 'new_user_firstname_field' }, required: true, title: _("This field is required.")
.col.form-group
= f.label :last_name, _('Last name'), for: 'new_user_last_name', class: 'label-bold'
- = f.text_field :last_name, class: "form-control top js-block-emoji js-validate-length", :data => { :max_length => max_last_name_length, :max_length_message => _("Last Name is too long (maximum is %{max_length} characters).") % { max_length: max_last_name_length }, :qa_selector => 'new_user_lastname_field' }, required: true, title: _("This field is required.")
+ = f.text_field :last_name, class: "form-control top js-block-emoji js-validate-length", :data => { :max_length => max_last_name_length, :max_length_message => _("Last name is too long (maximum is %{max_length} characters).") % { max_length: max_last_name_length }, :qa_selector => 'new_user_lastname_field' }, required: true, title: _("This field is required.")
.username.form-group
= f.label :username, class: 'label-bold'
= f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :min_length => min_username_length, :min_length_message => s_("SignUp|Username is too short (minimum is %{min_length} characters).") % { min_length: min_username_length }, :max_length => max_username_length, :max_length_message => _("Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
@@ -28,21 +28,12 @@
= f.label :password, class: 'label-bold'
= f.password_field :password, class: "form-control bottom", data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length }
%p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length }
- - if Gitlab::CurrentSettings.current_application_settings.enforce_terms? && !experiment_enabled?(:terms_opt_in)
- .form-group
- = check_box_tag :terms_opt_in, '1', false, required: true, data: { qa_selector: 'new_user_accept_terms_checkbox' }
- = label_tag :terms_opt_in do
- - terms_link = link_to s_("I accept the|Terms of Service and Privacy Policy"), terms_path, target: "_blank"
- - accept_terms_label = _("I accept the %{terms_link}") % { terms_link: terms_link }
- = accept_terms_label.html_safe
= render_if_exists 'devise/shared/email_opted_in', f: f
%div
- if show_recaptcha_sign_up?
= recaptcha_tags
.submit-container.mt-3
= f.submit _("Register"), class: "btn-register btn btn-block btn-success mb-0 p-2", data: { qa_selector: 'new_user_register_button' }
- - if experiment_enabled?(:terms_opt_in)
- %p.gl-text-gray-500.gl-mt-5.gl-mb-0
- = html_escape(_("By clicking Register, I agree that I have read and accepted the GitLab %{linkStart}Terms of Use and Privacy Policy%{linkEnd}")) % { linkStart: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, linkEnd: '</a>'.html_safe }
+ = render 'devise/shared/terms_of_service_notice'
- if omniauth_enabled? && button_based_providers_enabled?
= render 'devise/shared/experimental_separate_sign_up_flow_omniauth_box'
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 07ef9a7914a..f4ac9ad696b 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -1,4 +1,4 @@
-- max_name_length = 255
+- max_first_name_length = max_last_name_length = 127
- max_username_length = 255
- min_username_length = 2
#register-pane.tab-pane.login-box{ role: 'tabpanel' }
@@ -8,9 +8,13 @@
= render "devise/shared/error_messages", resource: resource
- if Feature.enabled?(:invisible_captcha)
= invisible_captcha
- .name.form-group
- = f.label :name, _('Full name'), class: 'label-bold'
- = f.text_field :name, class: "form-control top js-block-emoji js-validate-length", :data => { :max_length => max_name_length, :max_length_message => s_("SignUp|Name is too long (maximum is %{max_length} characters).") % { max_length: max_name_length }, :qa_selector => 'new_user_name_field' }, required: true, title: _("This field is required.")
+ .name.form-row
+ .col.form-group
+ = f.label :first_name, _('First name'), for: 'new_user_first_name', class: 'label-bold'
+ = f.text_field :first_name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_first_name_length, :max_length_message => _("First name is too long (maximum is %{max_length} characters).") % { max_length: max_first_name_length }, :qa_selector => 'new_user_first_name_field' }, required: true, title: _("This field is required.")
+ .col.form-group
+ = f.label :last_name, _('Last name'), for: 'new_user_last_name', class: 'label-bold'
+ = f.text_field :last_name, class: "form-control top js-block-emoji js-validate-length", :data => { :max_length => max_last_name_length, :max_length_message => _("Last name is too long (maximum is %{max_length} characters).") % { max_length: max_last_name_length }, :qa_selector => 'new_user_last_name_field' }, required: true, title: _("This field is required.")
.username.form-group
= f.label :username, class: 'label-bold'
= f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :min_length => min_username_length, :min_length_message => s_("SignUp|Username is too short (minimum is %{min_length} characters).") % { min_length: min_username_length }, :max_length => max_username_length, :max_length_message => s_("SignUp|Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
@@ -24,16 +28,10 @@
= f.label :password, class: 'label-bold'
= f.password_field :password, class: "form-control bottom", data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length }
%p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length }
- - if Gitlab::CurrentSettings.current_application_settings.enforce_terms?
- .form-group
- = check_box_tag :terms_opt_in, '1', false, required: true, data: { qa_selector: 'new_user_accept_terms_checkbox' }
- = label_tag :terms_opt_in do
- - terms_link = link_to s_("I accept the|Terms of Service and Privacy Policy"), terms_path, target: "_blank"
- - accept_terms_label = _("I accept the %{terms_link}") % { terms_link: terms_link }
- = accept_terms_label.html_safe
= render_if_exists 'devise/shared/email_opted_in', f: f
%div
- if show_recaptcha_sign_up?
= recaptcha_tags
.submit-container
= f.submit _("Register"), class: "btn-register btn", data: { qa_selector: 'new_user_register_button' }
+ = render 'devise/shared/terms_of_service_notice'
diff --git a/app/views/devise/shared/_terms_of_service_notice.html.haml b/app/views/devise/shared/_terms_of_service_notice.html.haml
new file mode 100644
index 00000000000..46b043b2831
--- /dev/null
+++ b/app/views/devise/shared/_terms_of_service_notice.html.haml
@@ -0,0 +1,5 @@
+- company_name = Gitlab.com? ? 'GitLab' : ''
+
+- if Gitlab::CurrentSettings.current_application_settings.enforce_terms?
+ %p.gl-text-gray-500.gl-mt-5.gl-mb-0
+ = html_escape(_("By clicking Register, I agree that I have read and accepted the %{company_name} %{linkStart}Terms of Use and Privacy Policy%{linkEnd}")) % { linkStart: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, linkEnd: '</a>'.html_safe, company_name: company_name }
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index 8a3c841de0b..b34b6f09662 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -29,7 +29,7 @@
%td.line_content.js-success-lazy-load
.js-code-placeholder
%td.js-error-lazy-load-diff.hidden.diff-loading-error-block
- - button = button_tag(_("Try again"), class: "btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button")
+ - button = button_tag(_("Try again"), class: "btn-link gl-button btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button")
= _("Unable to load the diff. %{button_try_again}").html_safe % { button_try_again: button}
= render "discussions/diff_discussion", discussions: [discussion], expanded: true
- else
diff --git a/app/views/discussions/_jump_to_next.html.haml b/app/views/discussions/_jump_to_next.html.haml
deleted file mode 100644
index 3db509f24a5..00000000000
--- a/app/views/discussions/_jump_to_next.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-- discussion = local_assigns.fetch(:discussion, nil)
-- if current_user
- %jump-to-discussion{ "inline-template" => true, ":discussion-id" => "'#{discussion.try(:id)}'" }
- .btn-group{ role: "group", "v-show" => "!allResolved", "v-if" => "showButton" }
- %button.btn.btn-default.discussion-next-btn.has-tooltip{ "@click" => "jumpToNextUnresolvedDiscussion",
- ":title" => "buttonText",
- ":aria-label" => "buttonText",
- data: { container: "body" } }
- = custom_icon("next_discussion")
diff --git a/app/views/discussions/_new_issue_for_all_discussions.html.haml b/app/views/discussions/_new_issue_for_all_discussions.html.haml
deleted file mode 100644
index 50dd5864195..00000000000
--- a/app/views/discussions/_new_issue_for_all_discussions.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-- if merge_request.discussions_can_be_resolved_by?(current_user) && can?(current_user, :create_issue, @project)
- .btn-group{ role: "group", "v-if" => "unresolvedDiscussionCount > 0" }
- = link_to custom_icon('icon_mr_issue'),
- new_project_issue_path(@project, merge_request_to_resolve_discussions_of: merge_request.iid),
- title: 'Resolve all discussions in new issue',
- aria: { label: 'Resolve all discussions in new issue' },
- data: { container: 'body' },
- class: 'new-issue-for-discussion btn btn-default discussion-create-issue-btn has-tooltip'
diff --git a/app/views/discussions/_new_issue_for_discussion.html.haml b/app/views/discussions/_new_issue_for_discussion.html.haml
deleted file mode 100644
index 49d5378d62e..00000000000
--- a/app/views/discussions/_new_issue_for_discussion.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-- if discussion.can_resolve?(current_user) && can?(current_user, :create_issue, @project)
- %new-issue-for-discussion-btn{ ":discussion-id" => "'#{discussion.id}'",
- "inline-template" => true }
- .btn-group{ role: "group", "v-if" => "showButton" }
- = link_to custom_icon('icon_mr_issue'),
- new_project_issue_path(@project, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id),
- title: 'Resolve this thread in a new issue',
- aria: { label: 'Resolve this thread in a new issue' },
- data: { container: 'body' },
- class: 'new-issue-for-discussion btn btn-default discussion-create-issue-btn has-tooltip'
diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml
index 0a5541c3e82..7db318f83b1 100644
--- a/app/views/discussions/_notes.html.haml
+++ b/app/views/discussions/_notes.html.haml
@@ -9,9 +9,9 @@
-# to the first note position when we click on a badge diff discussion
%ul.notes{ id: "discussion_#{discussion.id}", data: { discussion_id: discussion.id, position: discussion.notes[0].position.to_json } }
- if discussion.try(:on_image?) && show_toggle
- %button.diff-notes-collapse.js-diff-notes-toggle{ type: 'button' }
+ %button.gl-button.diff-notes-collapse.js-diff-notes-toggle{ type: 'button' }
= sprite_icon('collapse', css_class: 'collapse-icon')
- %button.btn-transparent.badge.badge-pill.js-diff-notes-toggle{ type: 'button' }
+ %button.gl-button.btn-transparent.badge.badge-pill.js-diff-notes-toggle{ type: 'button' }
= badge_counter
= render partial: "shared/notes/note", collection: discussion.notes, as: :note, locals: { badge_counter: badge_counter, show_image_comment_badge: show_image_comment_badge }
@@ -21,22 +21,8 @@
- if can_create_note?
%a.user-avatar-link.d-none.d-sm-block{ href: user_path(current_user) }
= image_tag avatar_icon_for_user(current_user), alt: current_user.to_reference, class: 'avatar s40'
- - if discussion.potentially_resolvable?
- - line_type = local_assigns.fetch(:line_type, nil)
-
- .discussion-with-resolve-btn
- .btn-group.discussion-with-resolve-btn{ role: "group" }
- .btn-group{ role: "group" }
- = link_to_reply_discussion(discussion, line_type)
-
- = render "discussions/resolve_all", discussion: discussion
-
- .btn-group.discussion-actions
- = render "discussions/new_issue_for_discussion", discussion: discussion, merge_request: discussion.noteable
- = render "discussions/jump_to_next", discussion: discussion
- - else
- .discussion-with-resolve-btn
- = link_to_reply_discussion(discussion)
+ .discussion-with-resolve-btn
+ = link_to_reply_discussion(discussion)
- elsif !current_user
.disabled-comment.text-center
Please
diff --git a/app/views/doorkeeper/applications/_delete_form.html.haml b/app/views/doorkeeper/applications/_delete_form.html.haml
index 77b7a50338c..64b872b5610 100644
--- a/app/views/doorkeeper/applications/_delete_form.html.haml
+++ b/app/views/doorkeeper/applications/_delete_form.html.haml
@@ -1,4 +1,4 @@
-- submit_btn_css ||= 'btn btn-link btn-remove btn-sm'
+- submit_btn_css ||= 'gl-button btn btn-danger btn-sm'
= form_tag oauth_application_path(application) do
%input{ :name => "_method", :type => "hidden", :value => "delete" }/
- if defined? small
diff --git a/app/views/doorkeeper/authorized_applications/_delete_form.html.haml b/app/views/doorkeeper/authorized_applications/_delete_form.html.haml
index 9bc5e2ee42f..d73d171798e 100644
--- a/app/views/doorkeeper/authorized_applications/_delete_form.html.haml
+++ b/app/views/doorkeeper/authorized_applications/_delete_form.html.haml
@@ -5,4 +5,4 @@
= form_tag path do
%input{ :name => "_method", :type => "hidden", :value => "delete" }/
- = submit_tag _('Revoke'), class: 'btn btn-remove btn-sm', data: { confirm: _('Are you sure?') }
+ = submit_tag _('Revoke'), class: 'gl-button btn btn-danger btn-sm', data: { confirm: _('Are you sure?') }
diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml
index a38d6dd3836..93c6efc9083 100644
--- a/app/views/events/event/_common.html.haml
+++ b/app/views/events/event/_common.html.haml
@@ -7,7 +7,7 @@
- if event.target
%span.event-type.d-inline-block.gl-mr-2{ class: event.action_name }
= event.action_name
- %span.event-target-type.gl-mr-2= event.target_type.titleize.downcase
+ %span.event-target-type.gl-mr-2= event.target_type_name
= link_to event.target_link_options, class: 'has-tooltip event-target-link gl-mr-2', title: event.target_title do
= event.target.reference_link_text
- unless event.milestone?
diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml
new file mode 100644
index 00000000000..51f41d58029
--- /dev/null
+++ b/app/views/groups/_invite_members_modal.html.haml
@@ -0,0 +1,6 @@
+- if invite_members_allowed?(group)
+ .js-invite-members-modal{ data: { group_id: group.id,
+ group_name: group.name,
+ access_levels: GroupMember.access_level_roles.to_json,
+ default_access_level: Gitlab::Access::GUEST,
+ help_link: help_page_url('user/permissions') } }
diff --git a/app/views/groups/_invite_members_side_nav_link.html.haml b/app/views/groups/_invite_members_side_nav_link.html.haml
new file mode 100644
index 00000000000..1c90eaee992
--- /dev/null
+++ b/app/views/groups/_invite_members_side_nav_link.html.haml
@@ -0,0 +1,3 @@
+- if invite_members_allowed?(group) && body_data_page == 'groups:show'
+ %li
+ .js-invite-members-trigger{ data: { icon: 'plus', display_text: 'Invite team members' } }
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index ed7b201323a..d999f20ef91 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -4,7 +4,6 @@
- show_access_requests = can_manage_members && @requesters.exists?
- invited_active = params[:search_invited].present? || params[:invited_members_page].present?
- vue_members_list_enabled = Feature.enabled?(:vue_group_members_list, @group)
-- data_attributes = { group_id: @group.id }
- form_item_label_css_class = 'label-bold gl-mr-2 gl-mb-0 gl-py-2 align-self-md-center'
@@ -69,7 +68,7 @@
= label_tag :sort_by, _('Sort by'), class: form_item_label_css_class
= render 'shared/members/sort_dropdown'
- if vue_members_list_enabled
- .js-group-members-list{ data: { members: members_data_json(@group, @members), **data_attributes } }
+ .js-group-members-list{ data: group_members_list_data_attributes(@group, @members) }
- else
%ul.content-list.members-list{ data: { qa_selector: 'members_list' } }
= render partial: 'shared/members/member', collection: @members, as: :member
@@ -81,7 +80,7 @@
= render 'groups/group_members/tab_pane/title' do
= html_escape(_('Groups with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- if vue_members_list_enabled
- .js-group-linked-list{ data: { members: linked_groups_data_json(@group.shared_with_group_links), **data_attributes } }
+ .js-group-linked-list{ data: linked_groups_list_data_attributes(@group) }
- else
%ul.content-list.members-list{ data: { qa_selector: 'groups_list' } }
- @group.shared_with_group_links.each do |group_link|
@@ -95,7 +94,7 @@
= form_tag group_group_members_path(@group), method: :get, class: 'user-search-form', data: { testid: 'user-search-form' } do
= render 'shared/members/search_field', name: 'search_invited'
- if vue_members_list_enabled
- .js-group-invited-members-list{ data: { members: members_data_json(@group, @invited_members), **data_attributes } }
+ .js-group-invited-members-list{ data: group_members_list_data_attributes(@group, @invited_members) }
- else
%ul.content-list.members-list
= render partial: 'shared/members/member', collection: @invited_members, as: :member
@@ -107,7 +106,7 @@
= render 'groups/group_members/tab_pane/title' do
= html_escape(_('Users requesting access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- if vue_members_list_enabled
- .js-group-access-requests-list{ data: { members: members_data_json(@group, @requesters), **data_attributes } }
+ .js-group-access-requests-list{ data: group_members_list_data_attributes(@group, @requesters) }
- else
%ul.content-list.members-list
= render partial: 'shared/members/member', collection: @requesters, as: :member
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index 1358e848154..33a9f423da6 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -1,6 +1,7 @@
- @can_bulk_update = can?(current_user, :admin_issue, @group) && @group.feature_available?(:group_bulk_edit)
- page_title _("Issues")
+- add_page_specific_style 'page_bundles/issues_list'
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues")
@@ -23,9 +24,12 @@
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
- if Feature.enabled?(:vue_issuables_list, @group)
+ - if use_startup_call?
+ - add_page_startup_api_call(api_v4_groups_issues_path(id: @group.id, params: startup_call_params))
.js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)),
'can-bulk-edit': @can_bulk_update.to_json,
'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') },
- 'sort-key': @sort } }
+ 'sort-key': @sort,
+ type: 'issues' } }
- else
= render 'shared/issues'
diff --git a/app/views/groups/labels/destroy.js.haml b/app/views/groups/labels/destroy.js.haml
deleted file mode 100644
index 3dfbfc77c0d..00000000000
--- a/app/views/groups/labels/destroy.js.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-- if @group.labels.empty?
- $('.labels').load(document.URL + ' .nothing-here-block').hide().fadeIn(1000)
diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml
index 3299d127222..debbe95d2aa 100644
--- a/app/views/groups/labels/index.html.haml
+++ b/app/views/groups/labels/index.html.haml
@@ -27,5 +27,5 @@
= render 'shared/empty_states/labels'
%template#js-badge-item-template
- %li.label-link-item.js-priority-badge.inline.gl-ml-3
- .label-badge.label-badge-blue= _('Prioritized label')
+ %li.js-priority-badge.inline.gl-ml-3
+ .label-badge.gl-bg-blue-50= _('Prioritized label')
diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml
index 1685707d457..d20fa938a68 100644
--- a/app/views/groups/milestones/index.html.haml
+++ b/app/views/groups/milestones/index.html.haml
@@ -1,4 +1,5 @@
- page_title _("Milestones")
+- add_page_specific_style 'page_bundles/milestone'
.top-area
= render 'shared/milestones_filter', counts: @milestone_states
diff --git a/app/views/groups/milestones/show.html.haml b/app/views/groups/milestones/show.html.haml
index 33e68bc766e..5bbdd3a3b19 100644
--- a/app/views/groups/milestones/show.html.haml
+++ b/app/views/groups/milestones/show.html.haml
@@ -1,3 +1,4 @@
+- add_page_specific_style 'page_bundles/milestone'
= render "header_title"
= render 'shared/milestones/top', milestone: @milestone, group: @group
= render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true
diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml
index 555c4004a3f..4fa2fc6fd4d 100644
--- a/app/views/groups/projects.html.haml
+++ b/app/views/groups/projects.html.haml
@@ -15,7 +15,7 @@
.controls
= link_to _('Members'), project_project_members_path(project), id: "edit_#{dom_id(project)}", class: "btn"
= link_to _('Edit'), edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn"
- = link_to _('Delete'), project, data: { confirm: remove_project_message(project)}, method: :delete, class: "btn btn-remove"
+ = link_to _('Delete'), project, data: { confirm: remove_project_message(project)}, method: :delete, class: "gl-button btn btn-danger"
.stats
%span.badge.badge-pill
diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml
index 2cac8e653e5..21882c3e3ce 100644
--- a/app/views/groups/registry/repositories/index.html.haml
+++ b/app/views/groups/registry/repositories/index.html.haml
@@ -12,6 +12,8 @@
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"registry_host_url_with_port" => escape_once(registry_config.host_port),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
+ "run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
+ "cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
"is_admin": current_user&.admin.to_s,
is_group_page: "true",
character_error: @character_error.to_s } }
diff --git a/app/views/groups/runners/_index.html.haml b/app/views/groups/runners/_index.html.haml
index e885fcc08eb..b342b589d93 100644
--- a/app/views/groups/runners/_index.html.haml
+++ b/app/views/groups/runners/_index.html.haml
@@ -7,6 +7,8 @@
.row
.col-sm-6
= render 'groups/runners/group_runners'
+ .col-sm-6
+ = render 'groups/runners/shared_runners'
%h4.underlined-title
= _('Available Runners: %{runners}').html_safe % { runners: limited_counter_with_delimiter(@all_group_runners) }
diff --git a/app/views/groups/runners/_shared_runners.html.haml b/app/views/groups/runners/_shared_runners.html.haml
new file mode 100644
index 00000000000..15b1199b8c9
--- /dev/null
+++ b/app/views/groups/runners/_shared_runners.html.haml
@@ -0,0 +1,3 @@
+= render 'shared/runners/shared_runners_description'
+
+#update-shared-runners-form{ data: group_shared_runners_settings_data(@group) }
diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml
index 98f4acaa5e3..c421a569a14 100644
--- a/app/views/groups/settings/_advanced.html.haml
+++ b/app/views/groups/settings/_advanced.html.haml
@@ -22,8 +22,7 @@
pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS,
title: s_('GroupSettings|Please choose a group URL with no special characters.'),
"data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
- .gl-display-flex.gl-justify-content-end
- = f.submit s_('GroupSettings|Change group URL'), class: 'btn btn-warning'
+ = f.submit s_('GroupSettings|Change group URL'), class: 'btn btn-warning'
.sub-section
%h4.warning-title= s_('GroupSettings|Transfer group')
@@ -33,14 +32,13 @@
= hidden_field_tag 'new_parent_group_id'
%ul
- - side_effects_link_start = '<a href="https://docs.gitlab.com/ce/user/project/index.html#redirects-when-changing-repository-paths" target="_blank">'
+ - side_effects_link_start = '<a href="https://docs.gitlab.com/ee/user/project/index.html#redirects-when-changing-repository-paths" target="_blank">'
- warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended %{side_effects_link_start}side effects%{side_effects_link_end}.") % { side_effects_link_start: side_effects_link_start, side_effects_link_end:'</a>' }
%li= warning_text.html_safe
%li= s_('GroupSettings|You can only transfer the group to a group you manage.')
%li= s_('GroupSettings|You will need to update your local repositories to point to the new location.')
%li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.")
- .gl-display-flex.gl-justify-content-end
- = f.submit s_('GroupSettings|Transfer group'), class: 'btn btn-warning'
+ = f.submit s_('GroupSettings|Transfer group'), class: 'btn btn-warning'
= render 'groups/settings/remove', group: @group
= render_if_exists 'groups/settings/restore', group: @group
diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml
index af06cfff397..94466b76ac8 100644
--- a/app/views/groups/settings/_export.html.haml
+++ b/app/views/groups/settings/_export.html.haml
@@ -24,6 +24,5 @@
= link_to _('Download export'), download_export_group_path(group),
rel: 'nofollow', method: :get, class: 'btn btn-default', data: { qa_selector: 'download_export_link' }
- else
- .gl-display-flex.gl-justify-content-end
- = link_to _('Export group'), export_group_path(group),
- method: :post, class: 'btn btn-default', data: { qa_selector: 'export_group_link' }
+ = link_to _('Export group'), export_group_path(group),
+ method: :post, class: 'btn btn-default', data: { qa_selector: 'export_group_link' }
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index e43d49b229e..35d82084263 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -29,5 +29,4 @@
= link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link'
= render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: 'btn btn-success mt-4 js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' }
+ = f.submit _('Save changes'), class: 'btn btn-success mt-4 js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' }
diff --git a/app/views/groups/settings/_permanent_deletion.html.haml b/app/views/groups/settings/_permanent_deletion.html.haml
index 063ff6dd132..14719200b45 100644
--- a/app/views/groups/settings/_permanent_deletion.html.haml
+++ b/app/views/groups/settings/_permanent_deletion.html.haml
@@ -5,5 +5,4 @@
= _('Removing this group also removes all child projects, including archived projects, and their resources.')
%br
%strong= _('Removed group can not be restored!')
- .gl-display-flex.gl-justify-content-end
- = button_to _('Remove group'), '#', class: 'btn btn-remove js-confirm-danger', data: { 'confirm-danger-message' => remove_group_message(group) }
+ = button_to _('Remove group'), '#', class: 'gl-button btn btn-danger js-confirm-danger', data: { 'confirm-danger-message' => remove_group_message(group) }
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index 86f49672d66..21d6a888d7b 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -38,8 +38,7 @@
= render 'groups/settings/project_creation_level', f: f, group: @group
= render 'groups/settings/subgroup_creation_level', f: f, group: @group
= render_if_exists 'groups/settings/prevent_forking', f: f, group: @group
- = render 'groups/settings/two_factor_auth', f: f
+ = render 'groups/settings/two_factor_auth', f: f, group: @group
= render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group
= render_if_exists 'groups/member_lock_setting', f: f, group: @group
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: 'btn btn-success gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' }
+ = f.submit _('Save changes'), class: 'btn btn-success gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' }
diff --git a/app/views/groups/settings/_two_factor_auth.html.haml b/app/views/groups/settings/_two_factor_auth.html.haml
index c49e61c8a31..d2d4c27c826 100644
--- a/app/views/groups/settings/_two_factor_auth.html.haml
+++ b/app/views/groups/settings/_two_factor_auth.html.haml
@@ -1,3 +1,4 @@
+- return unless group.parent_allows_two_factor_authentication?
- docs_link_url = help_page_path('security/two_factor_authentication', anchor: 'enforcing-2fa-for-all-users-in-a-group')
- docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url }
@@ -9,8 +10,14 @@
.form-check
= f.check_box :require_two_factor_authentication, class: 'form-check-input', data: { qa_selector: 'require_2fa_checkbox' }
= f.label :require_two_factor_authentication, class: 'form-check-label' do
- %span= _('Require all users in this group to setup Two-factor authentication')
+ %span= _('Require all users in this group to setup two-factor authentication')
.form-group
= f.label :two_factor_grace_period, _('Time before enforced'), class: 'label-bold'
= f.text_field :two_factor_grace_period, class: 'form-control form-control-sm w-auto'
.form-text.text-muted= _('Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication')
+- unless group.has_parent?
+ .form-group
+ .form-check
+ = f.check_box :allow_mfa_for_subgroups, class: 'form-check-input', checked: group.namespace_settings.allow_mfa_for_subgroups
+ = f.label :allow_mfa_for_subgroups, class: 'form-check-label' do
+ = _('Allow subgroups to set up their own two-factor authentication rules')
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index ec4ab603d22..fa560942c5d 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -23,6 +23,8 @@
= render_if_exists 'groups/group_activity_analytics', group: @group
+= render_if_exists 'groups/invite_members_modal', group: @group
+
.groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
.top-area.group-nav-container.justify-content-between
.scrolling-tabs-container.inner-page-scroll-tabs
diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml
index d0384fd50bc..79cba2a54b0 100644
--- a/app/views/ide/_show.html.haml
+++ b/app/views/ide/_show.html.haml
@@ -1,8 +1,7 @@
- @body_class = 'ide-layout'
- page_title _('IDE')
-- content_for :page_specific_javascripts do
- = stylesheet_link_tag 'page_bundles/ide'
+- add_page_specific_style 'page_bundles/ide'
#ide.ide-loading{ data: ide_data }
.text-center
diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml
index 9b54cbe577a..8946ab898e0 100644
--- a/app/views/import/bitbucket/status.html.haml
+++ b/app/views/import/bitbucket/status.html.haml
@@ -1,8 +1,9 @@
- page_title _('Bitbucket import')
- header_title _('Projects'), root_path
-%h3.page-title
- %i.fa.fa-bitbucket
+%h3.page-title.d-flex
+ .gl-display-flex.gl-align-items-center.gl-justify-content-center
+ = sprite_icon('bitbucket', css_class: 'gl-mr-2')
= _('Import projects from Bitbucket')
= render 'import/githubish_status', provider: 'bitbucket'
diff --git a/app/views/import/bitbucket_server/new.html.haml b/app/views/import/bitbucket_server/new.html.haml
index 735535ffc36..19c28d53087 100644
--- a/app/views/import/bitbucket_server/new.html.haml
+++ b/app/views/import/bitbucket_server/new.html.haml
@@ -3,8 +3,10 @@
- breadcrumb_title title
- header_title _("Projects"), root_path
-%h3.page-title
- = icon 'bitbucket-square', text: _('Import repositories from Bitbucket Server')
+%h3.page-title.d-flex
+ .gl-display-flex.gl-align-items-center.gl-justify-content-center
+ = sprite_icon('bitbucket', css_class: 'gl-mr-2')
+ = _('Import repositories from Bitbucket Server')
%p
= _('Enter in your Bitbucket Server URL and personal access token below')
diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml
index b3ca1beb853..7c4e6913c53 100644
--- a/app/views/import/bitbucket_server/status.html.haml
+++ b/app/views/import/bitbucket_server/status.html.haml
@@ -1,8 +1,9 @@
- page_title _('Bitbucket Server import')
- header_title _('Projects'), root_path
-%h3.page-title
- %i.fa.fa-bitbucket-square
+%h3.page-title.d-flex
+ .gl-display-flex.gl-align-items-center.gl-justify-content-center
+ = sprite_icon('bitbucket', css_class: 'gl-mr-2')
= _('Import projects from Bitbucket Server')
= render 'import/githubish_status', provider: 'bitbucket_server', paginatable: true, extra_data: { reconfigure_path: configure_import_bitbucket_server_path }
diff --git a/app/views/import/bulk_imports/status.html.haml b/app/views/import/bulk_imports/status.html.haml
new file mode 100644
index 00000000000..d909f6a13f0
--- /dev/null
+++ b/app/views/import/bulk_imports/status.html.haml
@@ -0,0 +1 @@
+- page_title 'Bulk Import'
diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml
index e86d4236be8..7e49cad7902 100644
--- a/app/views/import/github/new.html.haml
+++ b/app/views/import/github/new.html.haml
@@ -10,7 +10,9 @@
= import_github_authorize_message
- if github_import_configured? && !has_ci_cd_only_params?
- = link_to icon('github', text: title), status_import_github_path, class: 'btn btn-success'
+ = link_to status_import_github_path, class: 'btn btn-success gl-button' do
+ = sprite_icon('github', css_class: 'gl-mr-2')
+ = title
%hr
diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml
index ee295e70cce..ba6a5657d12 100644
--- a/app/views/import/github/status.html.haml
+++ b/app/views/import/github/status.html.haml
@@ -2,7 +2,9 @@
- page_title title
- breadcrumb_title title
- header_title _("Projects"), root_path
-%h3.page-title.mb-0
- = icon 'github', class: 'fa-2x', text: _('Import repositories from GitHub')
+%h3.page-title.mb-0.gl-display-flex
+ .gl-display-flex.gl-align-items-center.gl-justify-content-center
+ = sprite_icon('github', css_class: 'gl-mr-2')
+ = _('Import repositories from GitHub')
= render 'import/githubish_status', provider: 'github'
diff --git a/app/views/import/google_code/new.html.haml b/app/views/import/google_code/new.html.haml
index 7dec67191b9..1edd224956c 100644
--- a/app/views/import/google_code/new.html.haml
+++ b/app/views/import/google_code/new.html.haml
@@ -1,7 +1,8 @@
- page_title _("Google Code import")
- header_title _("Projects"), root_path
-%h3.page-title
- %i.fa.fa-google
+%h3.page-title.gl-display-flex
+ .gl-display-flex.gl-align-items-center.gl-justify-content-center
+ = sprite_icon('google', css_class: 'gl-mr-2')
= _('Import projects from Google Code')
%hr
diff --git a/app/views/import/google_code/new_user_map.html.haml b/app/views/import/google_code/new_user_map.html.haml
index 1f1bfda7ee4..833987dea4e 100644
--- a/app/views/import/google_code/new_user_map.html.haml
+++ b/app/views/import/google_code/new_user_map.html.haml
@@ -1,7 +1,8 @@
- page_title _("User map"), _("Google Code import")
- header_title _("Projects"), root_path
-%h3.page-title
- %i.fa.fa-google
+%h3.page-title.gl-display-flex
+ .gl-display-flex.gl-align-items-center.gl-justify-content-center
+ = sprite_icon('google', css_class: 'gl-mr-2')
= _('Import projects from Google Code')
%hr
diff --git a/app/views/import/google_code/status.html.haml b/app/views/import/google_code/status.html.haml
index 22984d59afe..72112c128cb 100644
--- a/app/views/import/google_code/status.html.haml
+++ b/app/views/import/google_code/status.html.haml
@@ -1,7 +1,8 @@
- page_title _("Google Code import")
- header_title _("Projects"), root_path
-%h3.page-title
- %i.fa.fa-google
+%h3.page-title.gl-display-flex
+ .gl-display-flex.gl-align-items-center.gl-justify-content-center
+ = sprite_icon('google', css_class: 'gl-mr-2')
= _('Import projects from Google Code')
- if @repos.any?
diff --git a/app/views/import/shared/_errors.html.haml b/app/views/import/shared/_errors.html.haml
index de60c15351f..32b4a39924b 100644
--- a/app/views/import/shared/_errors.html.haml
+++ b/app/views/import/shared/_errors.html.haml
@@ -1,4 +1,6 @@
- if @errors.present?
- .alert.alert-danger
- - @errors.each do |error|
- = error
+ .gl-alert.gl-alert-danger.gl-mb-5
+ = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body
+ - @errors.each do |error|
+ = error
diff --git a/app/views/invites/decline.html.haml b/app/views/invites/decline.html.haml
new file mode 100644
index 00000000000..4a57d70cb6e
--- /dev/null
+++ b/app/views/invites/decline.html.haml
@@ -0,0 +1,8 @@
+- page_title _('Invitation declined')
+.decline-page.gl-display-flex.gl-flex-direction-column.gl-mx-auto{ class: 'gl-xs-w-full!' }
+ .gl-align-self-center.gl-mb-4.gl-mt-7.gl-sm-mt-0= sprite_icon('check-circle', size: 48, css_class: 'gl-text-green-400')
+ %h2.gl-font-size-h2= _('You successfully declined the invitation')
+ %p
+ = html_escape(_('We will notify %{inviter} that you declined their invitation to join GitLab. You will stop receiving reminders.')) % { inviter: sanitize_name(@member.created_by.name) }
+ %p
+ = _('You can now close this window.')
diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml
index 6b3996bee76..37143799132 100644
--- a/app/views/invites/show.html.haml
+++ b/app/views/invites/show.html.haml
@@ -25,5 +25,5 @@
- if !member?
.actions
- = link_to _("Accept invitation"), accept_invite_url(@token, new_user_invite: params[:new_user_invite]), method: :post, class: "btn btn-success"
- = link_to _("Decline"), decline_invite_url(@token), method: :post, class: "btn btn-danger gl-ml-3"
+ = link_to _("Accept invitation"), accept_invite_url(@token, new_user_invite: params[:new_user_invite]), method: :post, class: "btn gl-button btn-success"
+ = link_to _("Decline"), decline_invite_url(@token), method: :post, class: "btn gl-button btn-danger gl-ml-3"
diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml
index f7ecfd09209..655c413f2a6 100644
--- a/app/views/jira_connect/subscriptions/index.html.haml
+++ b/app/views/jira_connect/subscriptions/index.html.haml
@@ -1,28 +1,61 @@
-%h1
- GitLab for Jira Configuration
-
-%form#add-subscription-form.subscription-form{ action: jira_connect_subscriptions_path }
- .ak-field-group
- %label
- Namespace
-
- .ak-field-group.field-group-input
- %input#namespace-input.ak-field-text{ type: 'text', required: true }
- %button.ak-button.ak-button__appearance-primary{ type: 'submit' }
- Link namespace to Jira
-
-%table.subscriptions
- %thead
- %tr
- %th Namespace
- %th Added
- %th
- %tbody
- - @subscriptions.each do |subscription|
- %tr
- %td= subscription.namespace.full_path
- %td= subscription.created_at
- %td= link_to 'Remove', jira_connect_subscription_path(subscription), class: 'remove-subscription'
+%header.jira-connect-header
+ = brand_header_logo
+
+.jira-connect-user
+ - if current_user
+ - user_link = link_to(current_user.to_reference, user_path(current_user), target: '_blank', rel: 'noopener noreferrer')
+ = _('Signed in to GitLab as %{user_link}').html_safe % { user_link: user_link }
+ - elsif @subscriptions.present?
+ = link_to _('Sign in to GitLab'), jira_connect_users_path, target: '_blank', rel: 'noopener noreferrer', class: 'js-jira-connect-sign-in'
+
+.jira-connect-app
+ %h1
+ GitLab for Jira Configuration
+
+ - if current_user.blank? && @subscriptions.empty?
+ %h2.heading-with-border Sign in to GitLab.com to get started.
+
+ .gl-mt-5
+ = external_link _('Sign in to GitLab'), jira_connect_users_path, class: 'ak-button ak-button__appearance-primary js-jira-connect-sign-in'
+
+ .gl-mt-5
+ %p Note: this integration only works with accounts on GitLab.com (SaaS).
+ - else
+ %form#add-subscription-form.subscription-form{ action: jira_connect_subscriptions_path }
+ .ak-field-group
+ %label
+ GitLab namespace
+
+ .ak-field-group.field-group-input
+ %input#namespace-input.ak-field-text{ type: 'text', required: true, placeholder: 'e.g. "MyCompany" or "MyCompany/GroupName"' }
+ %button.ak-button.ak-button__appearance-primary{ type: 'submit' }
+ Link namespace to Jira
+
+ - if @subscriptions.present?
+ %table.subscriptions
+ %thead
+ %tr
+ %th Namespace
+ %th Added
+ %th
+ %tbody
+ - @subscriptions.each do |subscription|
+ %tr
+ %td= subscription.namespace.full_path
+ %td= subscription.created_at
+ %td= link_to 'Remove', jira_connect_subscription_path(subscription), class: 'remove-subscription'
+ - else
+ %h4.empty-subscriptions
+ No linked namespaces
+
+ %p.browser-limitations-notice
+ %strong Browser limitations:
+ Adding a namespace currently works only in browsers that allow cross site cookies. Please make sure to use
+ %a{ href: 'https://www.mozilla.org/en-US/firefox/', target: '_blank', rel: 'noopener noreferrer' } Firefox
+ or
+ %a{ href: 'https://www.google.com/chrome/index.html', target: '_blank', rel: 'noopener noreferrer' } Google Chrome
+ or enable cross-site cookies in your browser when adding a namespace.
+ %a{ href: 'https://gitlab.com/gitlab-org/gitlab/-/issues/263509', target: '_blank', rel: 'noopener noreferrer' } Learn more
= page_specific_javascript_tag('jira_connect.js')
-= stylesheet_link_tag 'page_bundles/jira_connect'
+- add_page_specific_style 'page_bundles/jira_connect'
diff --git a/app/views/jira_connect/users/show.html.haml b/app/views/jira_connect/users/show.html.haml
new file mode 100644
index 00000000000..2ff92ab0dc8
--- /dev/null
+++ b/app/views/jira_connect/users/show.html.haml
@@ -0,0 +1,12 @@
+.jira-connect-users-container.gl-text-center
+ - user_link = link_to(current_user.to_reference, user_path(current_user), target: '_blank', rel: 'noopener noreferrer')
+ %h2= _('You are signed into GitLab as %{user_link}').html_safe % { user_link: user_link }
+
+ %p= s_('Integrations|You can now close this window and return to the GitLab for Jira application.')
+
+ - if @jira_app_link
+ %p= external_link s_('Integrations|Return to GitLab for Jira'), @jira_app_link, class: 'btn btn-success'
+
+ %p= link_to _('Sign out'), destroy_user_session_path, method: :post
+
+- add_page_specific_style 'page_bundles/jira_connect_users'
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 1c87452f0a3..9d0c3ad5787 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -42,35 +42,40 @@
%title= page_title(site_name)
%meta{ name: "description", content: page_description }
+ - if page_canonical_link
+ %link{ rel: 'canonical', href: page_canonical_link }
+
= favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png'
= render 'layouts/startup_css'
- if user_application_theme == 'gl-dark'
= stylesheet_link_tag_defer "application_dark"
+ = yield :page_specific_styles
+ = stylesheet_link_tag_defer "application_utilities_dark"
- else
= stylesheet_link_tag_defer "application"
+ = yield :page_specific_styles
+ = stylesheet_link_tag_defer "application_utilities"
- unless use_startup_css?
- = stylesheet_link_tag_defer "themes/theme_#{user_application_theme_name}"
+ = stylesheet_link_tag_defer "themes/#{user_application_theme_css_filename}" if user_application_theme_css_filename
= stylesheet_link_tag "disable_animations", media: "all" if Rails.env.test? || Gitlab.config.gitlab['disable_animations']
- = stylesheet_link_tag_defer 'performance_bar' if performance_bar_enabled?
= stylesheet_link_tag_defer "highlight/themes/#{user_color_scheme}"
= render 'layouts/startup_css_activation'
- = Gon::Base.render_data(nonce: content_security_policy_nonce)
+ = stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
- - if content_for?(:library_javascripts)
- = yield :library_javascripts
+ = Gon::Base.render_data(nonce: content_security_policy_nonce)
= javascript_include_tag locale_path unless I18n.locale == :en
- = webpack_bundle_tag "sentry" if Gitlab.config.sentry.enabled
+ -# Temporarily commented out to investigate performance: https://gitlab.com/gitlab-org/gitlab/-/issues/251179
+ -# = webpack_bundle_tag "sentry" if Gitlab.config.sentry.enabled
+ = webpack_bundle_tag 'performance_bar' if performance_bar_enabled?
- - if content_for?(:page_specific_javascripts)
- = yield :page_specific_javascripts
+ = yield :page_specific_javascripts
= webpack_controller_bundle_tags
- = webpack_bundle_tag 'performance_bar' if performance_bar_enabled?
= webpack_bundle_tag "chrome_84_icon_fix" if browser.chrome?([">=84", "<84.0.4147.125"]) || browser.edge?([">=84", "<84.0.522.59"])
= yield :project_javascripts
@@ -79,8 +84,6 @@
= csp_meta_tag
= action_cable_meta_tag
- - unless browser.safari?
- %meta{ name: 'referrer', content: 'origin-when-cross-origin' }
%meta{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1' }
%meta{ name: 'theme-color', content: '#474D57' }
diff --git a/app/views/layouts/_loading_hints.html.haml b/app/views/layouts/_loading_hints.html.haml
index 0ef50d1b122..a75b602ff6b 100644
--- a/app/views/layouts/_loading_hints.html.haml
+++ b/app/views/layouts/_loading_hints.html.haml
@@ -6,5 +6,6 @@
- else
%link{ { rel: 'preload', href: stylesheet_url('application'), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} }
%link{ { rel: 'preload', href: stylesheet_url("highlight/themes/#{user_color_scheme}"), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} }
+%link{ { rel: 'preload', href: asset_url("fontawesome-webfont.woff2?v=4.7.0"), as: 'font', type: 'font/woff2' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} }
- if Gitlab::CurrentSettings.snowplow_enabled? && Gitlab::CurrentSettings.snowplow_collector_hostname
%link{ rel: 'preconnect', href: Gitlab::CurrentSettings.snowplow_collector_hostname, crossorigin: '' }
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 5184bc93a81..9b925369660 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -19,7 +19,6 @@
= yield :customize_homepage_banner
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
- .d-flex
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
.content{ id: "content-body" }
= render "layouts/flash", extra_flash_class: 'limit-container-width'
diff --git a/app/views/layouts/_startup_css.haml b/app/views/layouts/_startup_css.haml
index ea05157ed19..2f674f79b2f 100644
--- a/app/views/layouts/_startup_css.haml
+++ b/app/views/layouts/_startup_css.haml
@@ -3,5 +3,5 @@
- startup_filename = current_path?("sessions#new") ? 'signin' : user_application_theme == 'gl-dark' ? 'dark' : 'general'
%style{ type: "text/css" }
- = Rails.application.assets_manifest.find_sources("themes/theme_#{user_application_theme_name}.css").first.to_s.html_safe
+ = Rails.application.assets_manifest.find_sources("themes/#{user_application_theme_css_filename}.css").first.to_s.html_safe if user_application_theme_css_filename
= Rails.application.assets_manifest.find_sources("startup/startup-#{startup_filename}.css").first.to_s.html_safe
diff --git a/app/views/layouts/_startup_css_activation.haml b/app/views/layouts/_startup_css_activation.haml
index 022b9a695bc..a426d686c34 100644
--- a/app/views/layouts/_startup_css_activation.haml
+++ b/app/views/layouts/_startup_css_activation.haml
@@ -7,4 +7,3 @@
const startupLinkLoadedEvent = new CustomEvent('CSSStartupLinkLoaded');
linkTag.addEventListener('load',function(){this.media='all';this.setAttribute('data-startupcss', 'loaded');document.dispatchEvent(startupLinkLoadedEvent);},{once: true});
})
-- return unless use_startup_css?
diff --git a/app/views/layouts/_startup_js.html.haml b/app/views/layouts/_startup_js.html.haml
index 33c759b7a7c..f312e00c394 100644
--- a/app/views/layouts/_startup_js.html.haml
+++ b/app/views/layouts/_startup_js.html.haml
@@ -1,9 +1,11 @@
-- return unless page_startup_api_calls.present?
+- return unless page_startup_api_calls.present? || page_startup_graphql_calls.present?
= javascript_tag nonce: true do
:plain
var gl = window.gl || {};
gl.startup_calls = #{page_startup_api_calls.to_json};
+ gl.startup_graphql_calls = #{page_startup_graphql_calls.to_json};
+
if (gl.startup_calls && window.fetch) {
Object.keys(gl.startup_calls).forEach(apiCall => {
// fetch won’t send cookies in older browsers, unless you set the credentials init option.
@@ -14,3 +16,21 @@
};
});
}
+ if (gl.startup_graphql_calls && window.fetch) {
+ const url = `#{api_graphql_url}`
+
+ const opts = {
+ method: "POST",
+ headers: { "Content-Type": "application/json", 'X-CSRF-Token': "#{form_authenticity_token}" },
+ };
+
+ gl.startup_graphql_calls = gl.startup_graphql_calls.map(call => ({
+ operationName: call.query.match(/^query (.+)\(/)[1],
+ fetchCall: fetch(url, {
+ ...opts,
+ credentials: 'same-origin',
+ body: JSON.stringify(call)
+ })
+ }))
+ }
+
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index bbcb525ea4f..5daee24cb51 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -14,7 +14,7 @@
.row.mt-3
.col-sm-12
%h1.mb-3.font-weight-normal
- = brand_title
+ = current_appearance&.title.presence || "GitLab"
.row.mb-3
.col-sm-7.order-12.order-sm-1.brand-holder
= brand_image
@@ -27,6 +27,9 @@
%p
= _('GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.')
+ %p
+ = _('This is a self-managed instance of GitLab.')
+
- if Gitlab::CurrentSettings.sign_in_text.present?
= markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text)
diff --git a/app/views/layouts/devise_experimental_onboarding_issues.html.haml b/app/views/layouts/devise_experimental_onboarding_issues.html.haml
index df2afbe60ae..ec9867f9e1f 100644
--- a/app/views/layouts/devise_experimental_onboarding_issues.html.haml
+++ b/app/views/layouts/devise_experimental_onboarding_issues.html.haml
@@ -1,5 +1,6 @@
!!! 5
%html.devise-layout-html.navless{ class: system_message_class }
+ - add_page_specific_style 'page_bundles/experimental_separate_sign_up'
= render "layouts/head"
%body.ui-indigo.signup-page{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } }
= render "layouts/header/logo_with_title"
diff --git a/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml b/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml
index fddfe14e05f..6be62645768 100644
--- a/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml
+++ b/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml
@@ -1,5 +1,6 @@
!!! 5
%html.devise-layout-html.navless{ class: system_message_class }
+ - add_page_specific_style 'page_bundles/experimental_separate_sign_up'
= render "layouts/head"
%body.ui-indigo.signup-page{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } }
= render "layouts/header/logo_with_title"
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index 8f4c89a9e77..6d2c5870e43 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -1,6 +1,6 @@
- page_title @group.name
-- page_description @group.description unless page_description
-- header_title group_title(@group) unless header_title
+- page_description @group.description_html unless page_description
+- header_title group_title(@group) unless header_title
- nav "group"
- display_subscription_banner!
- display_namespace_storage_limit_alert!
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index dcc6cba8444..4c6bfc0b33c 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -46,7 +46,7 @@
- if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile)
%li.d-md-none
= render 'shared/user_dropdown_contributing_link'
- = render_if_exists 'shared/user_dropdown_instance_review'
+ = render 'shared/user_dropdown_instance_review'
- if Gitlab.com_but_not_canary?
%li.d-md-none
= link_to _("Switch to GitLab Next"), "https://next.gitlab.com/"
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 845231238f6..f6dc808aa55 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -14,7 +14,7 @@
%span.logo-text.d-none.d-lg-block.gl-ml-3
= logo_text
- if Gitlab.com_and_canary?
- = link_to 'https://next.gitlab.com', class: 'label-link canary-badge bg-transparent', target: :_blank do
+ = link_to 'https://next.gitlab.com', class: 'canary-badge bg-transparent', target: :_blank do
%span.color-label.has-tooltip.badge.badge-pill.green-badge
= _('Next')
@@ -73,7 +73,7 @@
%span.gl-sr-only
= s_('Nav|Help')
= sprite_icon('question')
- = sprite_icon('angle-down', css_class: 'caret-down')
+ = sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right
= render 'layouts/header/help_dropdown'
- if header_link?(:user_dropdown)
@@ -81,7 +81,7 @@
= link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do
= image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar", alt: current_user.name
= render_if_exists 'layouts/header/user_notification_dot', project: project, namespace: group
- = sprite_icon('angle-down', css_class: 'caret-down')
+ = sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right
= render 'layouts/header/current_user_dropdown'
- if has_impersonation_link
@@ -99,8 +99,8 @@
= sprite_icon('ellipsis_h', size: 12, css_class: 'more-icon js-navbar-toggle-right')
= sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left')
-- if ::Feature.enabled?(:whats_new_drawer)
- #whats-new-app{ data: { features: whats_new_most_recent_release_items } }
+- if ::Feature.enabled?(:whats_new_drawer, current_user)
+ #whats-new-app{ data: { storage_key: whats_new_storage_key } }
- if can?(current_user, :update_user_status, current_user)
.js-set-status-modal-wrapper{ data: { current_emoji: current_user.status.present? ? current_user.status.emoji : '', current_message: current_user.status.present? ? current_user.status.message : '' } }
diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml
index 2b6cbc1c0ef..40bf45db80d 100644
--- a/app/views/layouts/header/_help_dropdown.html.haml
+++ b/app/views/layouts/header/_help_dropdown.html.haml
@@ -17,7 +17,7 @@
- if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile)
%li
= render 'shared/user_dropdown_contributing_link'
- = render_if_exists 'shared/user_dropdown_instance_review'
+ = render 'shared/user_dropdown_instance_review'
- if Gitlab.com_but_not_canary?
%li
= link_to _("Switch to GitLab Next"), "https://next.gitlab.com/"
diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml
index 0c989242194..2c5cd7e96c7 100644
--- a/app/views/layouts/header/_new_dropdown.haml
+++ b/app/views/layouts/header/_new_dropdown.haml
@@ -1,7 +1,7 @@
%li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown", track_value: "" } }
= link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", id: "js-onboarding-new-project-link", title: _("New..."), ref: 'tooltip', aria: { label: _("New...") }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static' } do
= sprite_icon('plus-square')
- = sprite_icon('angle-down', css_class: 'caret-down')
+ = sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right
%ul
- if @group&.persisted?
diff --git a/app/views/layouts/jira_connect.html.haml b/app/views/layouts/jira_connect.html.haml
index fdeb3d3c9ac..17f6e9af61a 100644
--- a/app/views/layouts/jira_connect.html.haml
+++ b/app/views/layouts/jira_connect.html.haml
@@ -7,6 +7,7 @@
= stylesheet_link_tag 'https://unpkg.com/@atlaskit/reduced-ui-pack@10.5.5/dist/bundle.css'
= javascript_include_tag 'https://connect-cdn.atl-paas.net/all.js'
= javascript_include_tag 'https://unpkg.com/jquery@3.3.1/dist/jquery.min.js'
+ = yield :page_specific_styles
= yield :head
%body
.ac-content
diff --git a/app/views/layouts/nav/_classification_level_banner.html.haml b/app/views/layouts/nav/_classification_level_banner.html.haml
index cc4caf079b8..d76fb50aa0b 100644
--- a/app/views/layouts/nav/_classification_level_banner.html.haml
+++ b/app/views/layouts/nav/_classification_level_banner.html.haml
@@ -1,5 +1,5 @@
- if ::Gitlab::ExternalAuthorization.enabled? && @project
= content_for :header_content do
- %span.badge.color-label.classification-label.has-tooltip{ title: s_('ExternalAuthorizationService|Classification label') }
+ %span.badge.color-label.gl-bg-red-500.has-tooltip{ title: s_('ExternalAuthorizationService|Classification label') }
= sprite_icon('lock-open', size: 8, css_class: 'inline')
= @project.external_authorization_classification_label
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index 40ea42091bd..abaadc89a9e 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -3,17 +3,17 @@
%ul.list-unstyled.navbar-sub-nav
- if dashboard_nav_link?(:projects)
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown", data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_value: "" } }) do
- %button.btn{ type: 'button', data: { toggle: "dropdown" } }
+ %button{ type: 'button', data: { toggle: "dropdown" } }
= _('Projects')
- = sprite_icon('angle-down', css_class: 'caret-down')
+ = sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu.frequent-items-dropdown-menu
= render "layouts/nav/projects_dropdown/show"
- if dashboard_nav_link?(:groups)
= nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "d-none d-md-block home dropdown header-groups qa-groups-dropdown", data: { track_label: "groups_dropdown", track_event: "click_dropdown", track_value: "" } }) do
- %button.btn{ type: 'button', data: { toggle: "dropdown" } }
+ %button{ type: 'button', data: { toggle: "dropdown" } }
= _('Groups')
- = sprite_icon('angle-down', css_class: 'caret-down')
+ = sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu.frequent-items-dropdown-menu
= render "layouts/nav/groups_dropdown/show"
@@ -21,7 +21,7 @@
%li.header-more.dropdown{ **tracking_attrs('main_navigation', 'click_more_link', 'navigation') }
%a{ href: "#", data: { toggle: "dropdown", qa_selector: 'more_dropdown' } }
= _('More')
- = sprite_icon('angle-down', css_class: 'caret-down')
+ = sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu
%ul
- if dashboard_nav_link?(:groups)
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index cb5277c02f0..0da4d4f7ddd 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -260,10 +260,11 @@
= link_to general_admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do
%span
= _('General')
- = nav_link(path: ['application_settings#integrations', 'integrations#edit']) do
- = link_to integrations_admin_application_settings_path, title: _('Integrations'), data: { qa_selector: 'integration_settings_link' } do
- %span
- = _('Integrations')
+ - if instance_level_integrations?
+ = nav_link(path: ['application_settings#integrations', 'integrations#edit']) do
+ = link_to integrations_admin_application_settings_path, title: _('Integrations'), data: { qa_selector: 'integration_settings_link' } do
+ %span
+ = _('Integrations')
= nav_link(path: 'application_settings#repository') do
= link_to repository_admin_application_settings_path, title: _('Repository'), class: 'qa-admin-settings-repository-item' do
%span
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 9e9e6493e5b..5f4b1f8ad45 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -139,6 +139,8 @@
%strong.fly-out-top-item-name
= _('Members')
+ = render_if_exists 'groups/invite_members_side_nav_link', group: @group
+
- if group_sidebar_link?(:settings)
= nav_link(path: group_settings_nav_link_paths) do
= link_to edit_group_path(@group) do
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 0eef587d7c7..d3d71f91176 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -229,25 +229,14 @@
%span
= _('Metrics')
- - if project_nav_tab?(:alert_management)
- = nav_link(controller: :alert_management) do
- = link_to project_alert_management_index_path(@project), title: _('Alerts') do
+ - if project_nav_tab?(:environments) && can?(current_user, :read_pod_logs, @project)
+ = nav_link(controller: :logs, action: [:index]) do
+ = link_to project_logs_path(@project), title: _('Logs') do
%span
- = _('Alerts')
-
- - if project_nav_tab?(:incidents)
- = nav_link(controller: :incidents) do
- = link_to project_incidents_path(@project), title: _('Incidents'), data: { qa_selector: 'operations_incidents_link' } do
- %span
- = _('Incidents')
+ = _('Logs')
- if project_nav_tab? :environments
- = render_if_exists "layouts/nav/sidebar/tracing_link"
-
- = nav_link(controller: :environments, action: [:index, :folder, :show, :new, :edit, :create, :update, :stop, :terminal]) do
- = link_to project_environments_path(@project), title: _('Environments'), class: 'shortcuts-environments qa-operations-environments-link' do
- %span
- = _('Environments')
+ = render "layouts/nav/sidebar/tracing_link"
- if project_nav_tab?(:error_tracking)
= nav_link(controller: :error_tracking) do
@@ -255,11 +244,17 @@
%span
= _('Error Tracking')
- - if project_nav_tab?(:product_analytics)
- = nav_link(controller: :product_analytics) do
- = link_to project_product_analytics_path(@project), title: _('Product Analytics') do
+ - if project_nav_tab?(:alert_management)
+ = nav_link(controller: :alert_management) do
+ = link_to project_alert_management_index_path(@project), title: _('Alerts') do
%span
- = _('Product Analytics')
+ = _('Alerts')
+
+ - if project_nav_tab?(:incidents)
+ = nav_link(controller: :incidents) do
+ = link_to project_incidents_path(@project), title: _('Incidents'), data: { qa_selector: 'operations_incidents_link' } do
+ %span
+ = _('Incidents')
- if project_nav_tab? :serverless
= nav_link(controller: :functions) do
@@ -267,12 +262,6 @@
%span
= _('Serverless')
- - if project_nav_tab?(:environments) && can?(current_user, :read_pod_logs, @project)
- = nav_link(controller: :logs, action: [:index]) do
- = link_to project_logs_path(@project), title: _('Logs') do
- %span
- = _('Logs')
-
- if project_nav_tab? :clusters
- show_cluster_hint = show_gke_cluster_integration_callout?(@project)
= nav_link(controller: [:clusters, :user, :gcp]) do
@@ -302,7 +291,23 @@
%span= _("Got it!")
= sprite_icon('thumb-up')
- = render_if_exists 'layouts/nav/sidebar/project_feature_flags_link'
+ - if project_nav_tab? :environments
+ = nav_link(controller: :environments, action: [:index, :folder, :show, :new, :edit, :create, :update, :stop, :terminal]) do
+ = link_to project_environments_path(@project), title: _('Environments'), class: 'shortcuts-environments qa-operations-environments-link' do
+ %span
+ = _('Environments')
+
+ - if project_nav_tab? :feature_flags
+ = nav_link(controller: :feature_flags) do
+ = link_to project_feature_flags_path(@project), title: _('Feature Flags'), class: 'shortcuts-feature-flags' do
+ %span
+ = _('Feature Flags')
+
+ - if project_nav_tab?(:product_analytics)
+ = nav_link(controller: :product_analytics) do
+ = link_to project_product_analytics_path(@project), title: _('Product Analytics') do
+ %span
+ = _('Product Analytics')
= render_if_exists 'layouts/nav/sidebar/project_packages_link'
diff --git a/app/views/layouts/nav/sidebar/_tracing_link.html.haml b/app/views/layouts/nav/sidebar/_tracing_link.html.haml
new file mode 100644
index 00000000000..7a31a20f5f0
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_tracing_link.html.haml
@@ -0,0 +1,7 @@
+- return unless can?(current_user, :read_environment, @project)
+
+- if project_nav_tab? :settings
+ = nav_link(controller: :tracings, action: [:show]) do
+ = link_to project_tracing_path(@project), title: _('Tracing') do
+ %span
+ = _('Tracing')
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index 222ca02b1df..a0c82380023 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -1,6 +1,6 @@
- page_title @project.full_name
-- page_description @project.description unless page_description
-- header_title project_title(@project) unless header_title
+- page_description @project.description_html unless page_description
+- header_title project_title(@project) unless header_title
- nav "project"
- display_subscription_banner!
- display_namespace_storage_limit_alert!
diff --git a/app/views/notify/_failed_builds.html.haml b/app/views/notify/_failed_builds.html.haml
index cde0ac21d6d..11cbd700258 100644
--- a/app/views/notify/_failed_builds.html.haml
+++ b/app/views/notify/_failed_builds.html.haml
@@ -6,7 +6,7 @@
#{'build'.pluralize(failed.size)}.
%tr.table-warning
%td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; border: 1px solid #ededed; border-bottom: 0; border-radius: 4px 4px 0 0; overflow: hidden; background-color: #fdf4f6; color: #d22852; font-size: 14px; line-height: 1.4; text-align: center; padding: 8px 16px;" }
- Logs may contain sensitive data. Please consider before forwarding this email.
+ Failed builds
%tr.section
%td{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 0 16px; border: 1px solid #ededed; border-radius: 4px; overflow: hidden; border-top: 0; border-radius: 0 0 4px 4px;" }
%table.builds{ border: "0", cellpadding: "0", cellspacing: "0", style: "width: 100%; border-collapse: collapse;" }
diff --git a/app/views/notify/_issuable_csv_export.html.haml b/app/views/notify/_issuable_csv_export.html.haml
new file mode 100644
index 00000000000..239b5b14966
--- /dev/null
+++ b/app/views/notify/_issuable_csv_export.html.haml
@@ -0,0 +1,6 @@
+%p{ style: 'font-size:18px; text-align:center; line-height:30px;' }
+ - project_link = link_to(@project.full_name, project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;")
+ = _('Your CSV export of %{count} from project %{project_link} has been added to this email as an attachment.').html_safe % { count: pluralize(@written_count, type.to_s), project_link: project_link }
+ - if @truncated
+ %p
+ = _('This attachment has been truncated to avoid exceeding the maximum allowed attachment size of 15MB. %{written_count} of %{count} issues have been included. Consider re-exporting with a narrower selection of issues.') % { written_count: @written_count, count: @count }
diff --git a/app/views/notify/autodevops_disabled_email.html.haml b/app/views/notify/autodevops_disabled_email.html.haml
index 65a2f75a3e2..72bcfbdf3af 100644
--- a/app/views/notify/autodevops_disabled_email.html.haml
+++ b/app/views/notify/autodevops_disabled_email.html.haml
@@ -46,4 +46,4 @@
%td{ style: "font-family: 'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace; font-size: 14px; line-height: 1.4; vertical-align: baseline; padding:0 8px;" }
API
-= render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.statuses.latest.failed
+= render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.latest_statuses.failed
diff --git a/app/views/notify/autodevops_disabled_email.text.erb b/app/views/notify/autodevops_disabled_email.text.erb
index f849c017265..c75857e96d7 100644
--- a/app/views/notify/autodevops_disabled_email.text.erb
+++ b/app/views/notify/autodevops_disabled_email.text.erb
@@ -7,7 +7,7 @@ The Auto DevOps pipeline failed for pipeline <%= @pipeline.iid %> (<%= pipeline_
<% else -%>
Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
<% end -%>
-<% failed = @pipeline.statuses.latest.failed -%>
+<% failed = @pipeline.latest_statuses.failed -%>
had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>.
<% failed.each do |build| -%>
diff --git a/app/views/notify/changed_reviewer_of_merge_request_email.html.haml b/app/views/notify/changed_reviewer_of_merge_request_email.html.haml
new file mode 100644
index 00000000000..ed7a3285f45
--- /dev/null
+++ b/app/views/notify/changed_reviewer_of_merge_request_email.html.haml
@@ -0,0 +1,2 @@
+%p
+ = change_reviewer_notification_text(@merge_request.reviewers, @previous_reviewers, :strong)
diff --git a/app/views/notify/changed_reviewer_of_merge_request_email.text.erb b/app/views/notify/changed_reviewer_of_merge_request_email.text.erb
new file mode 100644
index 00000000000..b6824966bb9
--- /dev/null
+++ b/app/views/notify/changed_reviewer_of_merge_request_email.text.erb
@@ -0,0 +1 @@
+<%= change_reviewer_notification_text(@merge_request.reviewers, @previous_reviewers) %>
diff --git a/app/views/notify/issue_status_changed_email.text.erb b/app/views/notify/issue_status_changed_email.text.erb
index f38b09e9820..f963e9b5c3d 100644
--- a/app/views/notify/issue_status_changed_email.text.erb
+++ b/app/views/notify/issue_status_changed_email.text.erb
@@ -1,4 +1,3 @@
Issue was <%= @issue_status %> by <%= sanitize_name(@updated_by.name) %>
Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
-
diff --git a/app/views/notify/issues_csv_email.html.haml b/app/views/notify/issues_csv_email.html.haml
index 77502a45f02..4cd47f12061 100644
--- a/app/views/notify/issues_csv_email.html.haml
+++ b/app/views/notify/issues_csv_email.html.haml
@@ -1,6 +1 @@
-%p{ style: 'font-size:18px; text-align:center; line-height:30px;' }
- - project_link = link_to(@project.full_name, project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;")
- = _('Your CSV export of %{issues_count} from project %{project_link} has been added to this email as an attachment.').html_safe % { issues_count: pluralize(@written_count, 'issue'), project_link: project_link }
- - if @truncated
- %p
- = _('This attachment has been truncated to avoid exceeding the maximum allowed attachment size of 15MB. %{written_count} of %{issues_count} issues have been included. Consider re-exporting with a narrower selection of issues.') % { written_count: @written_count, issues_count: @issues_count }
+= render 'issuable_csv_export', type: :issue
diff --git a/app/views/notify/member_invited_reminder_email.html.haml b/app/views/notify/member_invited_reminder_email.html.haml
new file mode 100644
index 00000000000..720f3510722
--- /dev/null
+++ b/app/views/notify/member_invited_reminder_email.html.haml
@@ -0,0 +1,9 @@
+%tr
+ %td.text-content
+ %h2.invite-header
+ = invitation_reminder_salutation(@reminder_index, format: :html)
+ %p.invite-body
+ = invitation_reminder_body(member, @reminder_index, format: :html)
+ %p.invite-actions
+ = invitation_reminder_accept_link(@token, format: :html)
+ = invitation_reminder_decline_link(@token, format: :html)
diff --git a/app/views/notify/member_invited_reminder_email.text.erb b/app/views/notify/member_invited_reminder_email.text.erb
new file mode 100644
index 00000000000..97f34f01385
--- /dev/null
+++ b/app/views/notify/member_invited_reminder_email.text.erb
@@ -0,0 +1,6 @@
+<%= invitation_reminder_salutation(@reminder_index) %>
+
+<%= invitation_reminder_body(member, @reminder_index) %>
+
+<%= invitation_reminder_accept_link(@token) %>
+<%= invitation_reminder_decline_link(@token) %>
diff --git a/app/views/notify/merge_requests_csv_email.html.haml b/app/views/notify/merge_requests_csv_email.html.haml
new file mode 100644
index 00000000000..225c81117b3
--- /dev/null
+++ b/app/views/notify/merge_requests_csv_email.html.haml
@@ -0,0 +1 @@
+= render 'issuable_csv_export', type: :merge_request
diff --git a/app/views/notify/merge_requests_csv_email.text.erb b/app/views/notify/merge_requests_csv_email.text.erb
new file mode 100644
index 00000000000..9ed971bbe9c
--- /dev/null
+++ b/app/views/notify/merge_requests_csv_email.text.erb
@@ -0,0 +1,5 @@
+<%= _('Your CSV export of %{written_count} from project %{project_name} (%{project_url}) has been added to this email as an attachment.') % { written_count: pluralize(@written_count, 'merge request'), project_name: @project.full_name, project_url: project_url(@project) } %>
+
+<% if @truncated %>
+ <%= _('This attachment has been truncated to avoid exceeding the maximum allowed attachment size of 15MB. %{written_count} of %{merge_requests_count} issues have been included. Consider re-exporting with a narrower selection of issues.') % { written_count: @written_count, merge_requests_count: @merge_requests_count} %>
+<% end %>
diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml
index f01181857ce..575ec8c488e 100644
--- a/app/views/notify/pipeline_failed_email.html.haml
+++ b/app/views/notify/pipeline_failed_email.html.haml
@@ -108,4 +108,4 @@
%td{ style: "font-family:'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace;font-size:14px;line-height:1.4;vertical-align:baseline;padding:0 5px;" }
API
-= render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.statuses.latest.failed
+= render 'notify/failed_builds', pipeline: @pipeline, failed: @pipeline.latest_statuses.failed
diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb
index b388aad7048..a30e331d892 100644
--- a/app/views/notify/pipeline_failed_email.text.erb
+++ b/app/views/notify/pipeline_failed_email.text.erb
@@ -27,7 +27,7 @@ Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%
<% else -%>
Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
<% end -%>
-<% failed = @pipeline.statuses.latest.failed -%>
+<% failed = @pipeline.latest_statuses.failed -%>
had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>.
<% failed.each do |build| -%>
diff --git a/app/views/notify/prometheus_alert_fired_email.html.haml b/app/views/notify/prometheus_alert_fired_email.html.haml
index 17f9481d353..75ba66b44f9 100644
--- a/app/views/notify/prometheus_alert_fired_email.html.haml
+++ b/app/views/notify/prometheus_alert_fired_email.html.haml
@@ -1,17 +1,17 @@
%p
- = _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project_full_path }
+ = _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project.full_path }
- if description = @alert.description
%p
= _('Description:')
= description
-- if env_name = @alert.environment_name
+- if env_name = @alert.environment&.name
%p
= _('Environment:')
= env_name
-- if metric_query = @alert.metric_query
+- if metric_query = @alert.prometheus_alert&.full_query
%p
= _('Metric:')
@@ -25,4 +25,3 @@
- if @alert.show_performance_dashboard_link?
%p
= link_to(_('View performance dashboard.'), @alert.performance_dashboard_link)
-
diff --git a/app/views/notify/prometheus_alert_fired_email.text.erb b/app/views/notify/prometheus_alert_fired_email.text.erb
index c3f005cfb7e..8853f2a317b 100644
--- a/app/views/notify/prometheus_alert_fired_email.text.erb
+++ b/app/views/notify/prometheus_alert_fired_email.text.erb
@@ -1,14 +1,14 @@
-<%= _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project_full_path } %>.
+<%= _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project.full_path } %>.
<% if description = @alert.description %>
<%= _('Description:') %> <%= description %>
<% end %>
-<% if env_name = @alert.environment_name %>
+<% if env_name = @alert.environment&.name %>
<%= _('Environment:') %> <%= env_name %>
<% end %>
-<% if metric_query = @alert.metric_query %>
+<% if metric_query = @alert.prometheus_alert&.full_query %>
<%= _('Metric:') %> <%= metric_query %>
<% end %>
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index c875caca94a..fed40b7f119 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -2,8 +2,10 @@
- @content_class = "limit-container-width" unless fluid_layout
- if current_user.ldap_user?
- .alert.alert-info
- = s_('Profiles|Some options are unavailable for LDAP accounts')
+ .gl-alert.gl-alert-info.gl-my-5
+ = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body
+ = s_('Profiles|Some options are unavailable for LDAP accounts')
.row.gl-mt-3
.col-lg-4.profile-settings-sidebar
@@ -15,10 +17,10 @@
%p
#{_('Status')}: #{current_user.two_factor_enabled? ? _('Enabled') : _('Disabled')}
- if current_user.two_factor_enabled?
- = link_to _('Manage two-factor authentication'), profile_two_factor_auth_path, class: 'btn btn-info'
+ = link_to _('Manage two-factor authentication'), profile_two_factor_auth_path, class: 'gl-button btn btn-info'
- else
.gl-mb-3
- = link_to _('Enable two-factor authentication'), profile_two_factor_auth_path, class: 'btn btn-success'
+ = link_to _('Enable two-factor authentication'), profile_two_factor_auth_path, class: 'gl-button btn btn-success', data: { qa_selector: 'enable_2fa_button' }
%hr
- if display_providers_on_profile?
diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml
index 97f13a55dea..9ec8d694dac 100644
--- a/app/views/profiles/active_sessions/_active_session.html.haml
+++ b/app/views/profiles/active_sessions/_active_session.html.haml
@@ -30,6 +30,6 @@
= link_to(revoke_session_path(active_session),
{ data: { confirm: _('Are you sure? The device will be signed out of GitLab and all remember me tokens revoked.') },
method: :delete,
- class: "btn btn-danger gl-ml-3" }) do
+ class: "gl-button btn btn-danger gl-ml-3" }) do
%span.sr-only= _('Revoke')
= _('Revoke')
diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml
index ff67f92ad07..6805824cebc 100644
--- a/app/views/profiles/chat_names/_chat_name.html.haml
+++ b/app/views/profiles/chat_names/_chat_name.html.haml
@@ -24,4 +24,4 @@
= _('Never')
%td
- = link_to _('Remove'), profile_chat_name_path(chat_name), method: :delete, class: 'btn btn-danger float-right', data: { confirm: _('Are you sure you want to revoke this nickname?') }
+ = link_to _('Remove'), profile_chat_name_path(chat_name), method: :delete, class: 'gl-button btn btn-danger float-right', data: { confirm: _('Are you sure you want to revoke this nickname?') }
diff --git a/app/views/profiles/chat_names/new.html.haml b/app/views/profiles/chat_names/new.html.haml
index 2134ab2bec6..4651854a551 100644
--- a/app/views/profiles/chat_names/new.html.haml
+++ b/app/views/profiles/chat_names/new.html.haml
@@ -8,7 +8,7 @@
.actions
= form_tag profile_chat_names_path, method: :post do
= hidden_field_tag :token, @chat_name_token.token
- = submit_tag _("Authorize"), class: "btn btn-success wide float-left"
+ = submit_tag _("Authorize"), class: "gl-button btn btn-success wide float-left"
= form_tag deny_profile_chat_names_path, method: :delete do
= hidden_field_tag :token, @chat_name_token.token
- = submit_tag _("Deny"), class: "btn btn-danger gl-ml-3"
+ = submit_tag _("Deny"), class: "gl-button btn btn-danger gl-ml-3"
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index a04ed87801a..0c6dc1a05d8 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -15,7 +15,7 @@
= f.label :email, _('Email'), class: 'label-bold'
= f.text_field :email, class: 'form-control', data: { qa_selector: 'email_address_field' }
.gl-mt-3
- = f.submit _('Add email address'), class: 'btn btn-success', data: { qa_selector: 'add_email_address_button' }
+ = f.submit _('Add email address'), class: 'gl-button btn btn-success', data: { qa_selector: 'add_email_address_button' }
%hr
%h4.gl-mt-0
= _('Linked emails (%{email_count})') % { email_count: @emails.load.size + 1 }
@@ -56,8 +56,8 @@
%span.badge.badge-info= s_('Profiles|Notification email')
- unless email.confirmed?
- confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}"
- = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'btn btn-sm btn-warning gl-ml-3'
+ = link_to confirm_title, resend_confirmation_instructions_profile_email_path(email), method: :put, class: 'gl-button btn btn-sm btn-warning gl-ml-3'
- = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'btn btn-sm btn-danger gl-ml-3' do
+ = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'gl-button btn btn-sm btn-danger gl-ml-3' do
%span.sr-only= _('Remove')
= sprite_icon('remove')
diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml
index 2fb07adc006..7a7b5802cd8 100644
--- a/app/views/profiles/gpg_keys/_form.html.haml
+++ b/app/views/profiles/gpg_keys/_form.html.haml
@@ -7,4 +7,4 @@
= f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: _("Don't paste the private part of the GPG key. Paste the public part which begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'.")
.gl-mt-3
- = f.submit s_('Profiles|Add key'), class: "btn btn-success"
+ = f.submit s_('Profiles|Add key'), class: "gl-button btn btn-success"
diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml
index f1abafa4149..c851601d4c3 100644
--- a/app/views/profiles/gpg_keys/_key.html.haml
+++ b/app/views/profiles/gpg_keys/_key.html.haml
@@ -19,9 +19,9 @@
.float-right
%span.key-created-at
= s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)}
- = link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "btn btn-danger gl-ml-3" do
+ = link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "gl-button btn btn-danger gl-ml-3" do
%span.sr-only= _('Remove')
= sprite_icon('remove')
- = link_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: "btn btn-danger gl-ml-3" do
+ = link_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: "gl-button btn btn-danger gl-ml-3" do
%span.sr-only= _('Revoke')
= _('Revoke')
diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml
index 078b5907623..6a420d7996a 100644
--- a/app/views/profiles/keys/_form.html.haml
+++ b/app/views/profiles/keys/_form.html.haml
@@ -4,13 +4,13 @@
.form-group
= f.label :key, s_('Profiles|Key'), class: 'label-bold'
- %p= _("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_ed25519.pub' or '~/.ssh/id_rsa.pub' and begins with 'ssh-ed25519' or 'ssh-rsa'. Don't use your private SSH key.")
+ %p= _("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_ed25519.pub' or '~/.ssh/id_rsa.pub' and begins with 'ssh-ed25519' or 'ssh-rsa'. Do not paste your private SSH key, as that can compromise your identity.")
= f.text_area :key, class: "form-control js-add-ssh-key-validation-input qa-key-public-key-field", rows: 8, required: true, placeholder: s_('Profiles|Typically starts with "ssh-ed25519 …" or "ssh-rsa …"')
.form-row
.col.form-group
= f.label :title, _('Title'), class: 'label-bold'
= f.text_field :title, class: "form-control input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|e.g. My MacBook key')
- %p.form-text.text-muted= s_('Profiles|Give your individual key a title. This will be publically visible.')
+ %p.form-text.text-muted= s_('Profiles|Give your individual key a title.')
.col.form-group
= f.label :expires_at, s_('Profiles|Expires at'), class: 'label-bold'
@@ -19,9 +19,9 @@
.js-add-ssh-key-validation-warning.hide
.bs-callout.bs-callout-warning{ role: 'alert', aria_live: 'assertive' }
%strong= _('Oops, are you sure?')
- %p= s_("Profiles|This doesn't look like a public SSH key, are you sure you want to add it?")
+ %p= s_("Profiles|This doesn't look like a public SSH key, are you sure you want to add it? It will be publicly visible.")
%button.btn.btn-success.js-add-ssh-key-validation-confirm-submit= _("Yes, add it")
.gl-mt-3
- = f.submit s_('Profiles|Add key'), class: "btn btn-success js-add-ssh-key-validation-original-submit qa-add-key-button"
+ = f.submit s_('Profiles|Add key'), class: "gl-button btn btn-success js-add-ssh-key-validation-original-submit qa-add-key-button"
diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml
index 02b45853aa0..eaf00ce6709 100644
--- a/app/views/profiles/keys/_key.html.haml
+++ b/app/views/profiles/keys/_key.html.haml
@@ -23,9 +23,10 @@
%span.expires.gl-mr-3
= s_('Profiles|Expires:')
= key.expires_at ? key.expires_at.to_date : _('Never')
- %span.key-created-at
- = s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)}
+ %span.key-created-at.gl-display-flex.gl-align-items-center
+ = s_('Profiles|Created%{time_ago}'.html_safe) % { time_ago: time_ago_with_tooltip(key.created_at, html_class: 'gl-ml-2')}
- if key.can_delete?
- = link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent gl-ml-3 align-baseline" do
- %span.sr-only= _('Remove')
- = sprite_icon('remove')
+ .gl-ml-3
+ = button_to '#', class: "btn btn-default gl-button btn-default-tertiary js-confirm-modal-button", data: ssh_key_delete_modal_data(key, path_to_key(key, is_admin)) do
+ %span.sr-only= _('Delete')
+ = sprite_icon('remove')
diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml
index 59d953678e7..22d795ca831 100644
--- a/app/views/profiles/keys/_key_details.html.haml
+++ b/app/views/profiles/keys/_key_details.html.haml
@@ -38,4 +38,4 @@
.col-md-12
.float-right
- if @key.can_delete?
- = link_to _('Remove'), path_to_key(@key, is_admin), data: {confirm: _('Are you sure?')}, method: :delete, class: "btn btn-remove delete-key qa-delete-key-button"
+ = button_to _('Delete'), '#', class: "btn btn-danger gl-button delete-key js-confirm-modal-button", data: ssh_key_delete_modal_data(@key, path_to_key(@key, is_admin))
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index da684c29372..9c5cfe35cda 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -3,7 +3,7 @@
%div
- if @user.errors.any?
- .alert.alert-danger
+ .gl-alert.gl-alert-danger
%ul
- @user.errors.full_messages.each do |msg|
%li= msg
diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml
index fe16c2e2f28..1ee5f52e407 100644
--- a/app/views/profiles/passwords/edit.html.haml
+++ b/app/views/profiles/passwords/edit.html.haml
@@ -30,6 +30,6 @@
= f.label :password_confirmation, _('Password confirmation'), class: 'label-bold'
= f.password_field :password_confirmation, required: true, class: 'form-control', data: { qa_selector: 'confirm_password_field' }
.gl-mt-3.gl-mb-3
- = f.submit _('Save password'), class: "btn btn-success gl-mr-3", data: { qa_selector: 'save_password_button' }
+ = f.submit _('Save password'), class: "gl-button btn btn-success gl-mr-3", data: { qa_selector: 'save_password_button' }
- unless @user.password_automatically_set?
= link_to _('I forgot my password'), reset_profile_password_path, method: :put
diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml
index ce60455ab89..f6783528243 100644
--- a/app/views/profiles/passwords/new.html.haml
+++ b/app/views/profiles/passwords/new.html.haml
@@ -28,4 +28,4 @@
.col-sm-10
= f.password_field :password_confirmation, required: true, class: 'form-control'
.form-actions
- = f.submit _('Set new password'), class: 'btn btn-success'
+ = f.submit _('Set new password'), class: 'gl-button btn btn-success'
diff --git a/app/views/profiles/preferences/_gitpod.html.haml b/app/views/profiles/preferences/_gitpod.html.haml
index 69c9443ebbb..589c3a27c18 100644
--- a/app/views/profiles/preferences/_gitpod.html.haml
+++ b/app/views/profiles/preferences/_gitpod.html.haml
@@ -1,5 +1,3 @@
-- gitpod_link = link_to("Gitpod#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}".html_safe, 'https://gitpod.io/', target: '_blank', rel: 'noopener noreferrer')
-
%label.label-bold#gitpod
= s_('Gitpod')
= link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information')
@@ -8,4 +6,4 @@
= f.label :gitpod_enabled, class: 'form-check-label' do
= s_('Gitpod|Enable Gitpod integration').html_safe
.form-text.text-muted
- = s_('Enable %{gitpod_link} integration to launch a development environment in your browser directly from GitLab.').html_safe % { gitpod_link: gitpod_link }
+ = gitpod_enable_description
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 2c705886f47..b8d7e1af005 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -2,7 +2,7 @@
- @content_class = "limit-container-width" unless fluid_layout
- Gitlab::Themes.each do |theme|
- = stylesheet_link_tag "themes/theme_#{theme.css_class.gsub('ui-', '')}"
+ = stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename
= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row gl-mt-3 js-preferences-form' } do |f|
.col-lg-4.application-theme#navigation-theme
@@ -143,4 +143,4 @@
.col-lg-4.profile-settings-sidebar
.col-lg-8
.form-group
- = f.submit _('Save changes'), class: 'btn btn-success'
+ = f.submit _('Save changes'), class: 'gl-button btn btn-success'
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 1eb3a14525f..f5fab727a57 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -36,7 +36,7 @@
.form-text.text-muted= s_("Profiles|The maximum file size allowed is 200KB.")
- if @user.avatar?
%hr
- = link_to s_("Profiles|Remove avatar"), profile_avatar_path, data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") }, method: :delete, class: 'btn btn-danger btn-inverted'
+ = link_to s_("Profiles|Remove avatar"), profile_avatar_path, data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") }, method: :delete, class: 'gl-button btn btn-danger btn-inverted'
%hr
.row
@@ -46,7 +46,7 @@
.col-lg-8
= f.fields_for :status, @user.status do |status_form|
- emoji_button = button_tag type: :button,
- class: 'js-toggle-emoji-menu emoji-menu-toggle-button btn has-tooltip',
+ class: 'js-toggle-emoji-menu emoji-menu-toggle-button gl-button btn has-tooltip',
title: s_("Profiles|Add status emoji") do
- if @user.status
= emoji_icon @user.status.emoji
@@ -56,7 +56,7 @@
= sprite_icon('smile', css_class: 'award-control-icon-super-positive')
- reset_message_button = button_tag type: :button,
id: 'js-clear-user-status-button',
- class: 'clear-user-status btn has-tooltip',
+ class: 'clear-user-status gl-button btn has-tooltip',
title: s_("Profiles|Clear status") do
= sprite_icon("close")
@@ -78,7 +78,7 @@
-# TODO: might need an entry in user/profile.md to describe some of these settings
-# https://gitlab.com/gitlab-org/gitlab-foss/issues/60070
%h5= ("Time zone")
- = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'btn js-timezone-dropdown input-lg', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
+ = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'gl-button btn js-timezone-dropdown input-lg', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
%input.hidden{ :type => 'hidden', :id => 'user_timezone', :name => 'user[timezone]', value: @user.timezone }
%hr
@@ -119,10 +119,10 @@
.help-block
= s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information")
.gl-mt-3.gl-mb-3
- = f.submit s_("Profiles|Update profile settings"), class: 'btn btn-success'
- = link_to _("Cancel"), user_path(current_user), class: 'btn btn-cancel'
+ = f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-success'
+ = link_to _("Cancel"), user_path(current_user), class: 'gl-button btn btn-cancel'
-.modal.modal-profile-crop
+.modal.modal-profile-crop{ data: { cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css') } }
.modal-dialog
.modal-content
.modal-header
diff --git a/app/views/profiles/two_factor_auths/_codes.html.haml b/app/views/profiles/two_factor_auths/_codes.html.haml
index 40272b6354c..2cb7e022912 100644
--- a/app/views/profiles/two_factor_auths/_codes.html.haml
+++ b/app/views/profiles/two_factor_auths/_codes.html.haml
@@ -9,5 +9,5 @@
%span.monospace{ data: { qa_selector: 'code_content' } }= code
.d-flex
- = link_to _('Proceed'), profile_account_path, class: 'btn btn-success gl-mr-3', data: { qa_selector: 'proceed_button' }
- = link_to _('Download codes'), "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'btn btn-default'
+ = link_to _('Proceed'), profile_account_path, class: 'gl-button btn btn-success gl-mr-3', data: { qa_selector: 'proceed_button' }
+ = link_to _('Download codes'), "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'gl-button btn btn-default'
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 82265938180..ff4ddd4ad69 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -20,7 +20,7 @@
= link_to _('Disable two-factor authentication'), profile_two_factor_auth_path,
method: :delete,
data: { confirm: webauthn_enabled ? _('Are you sure? This will invalidate your registered applications and U2F / WebAuthn devices.') : _('Are you sure? This will invalidate your registered applications and U2F devices.') },
- class: 'btn btn-danger gl-mr-3'
+ class: 'gl-button btn btn-danger gl-mr-3'
= form_tag codes_profile_two_factor_auth_path, {style: 'display: inline-block', method: :post} do |f|
= submit_tag _('Regenerate recovery codes'), class: 'btn'
@@ -52,7 +52,7 @@
= label_tag :pin_code, _('Pin code'), class: "label-bold"
= text_field_tag :pin_code, nil, class: "form-control", required: true, data: { qa_selector: 'pin_code_field' }
.gl-mt-3
- = submit_tag _('Register with two-factor app'), class: 'btn btn-success', data: { qa_selector: 'register_2fa_app_button' }
+ = submit_tag _('Register with two-factor app'), class: 'gl-button btn btn-success', data: { qa_selector: 'register_2fa_app_button' }
%hr
@@ -64,12 +64,12 @@
- else
= _('Register Universal Two-Factor (U2F) Device')
%p
- = _('Use a hardware device to add the second factor of authentication.')
+ = _('Set up a hardware device as a second factor to sign in.')
%p
- if webauthn_enabled
- = _("As WebAuthn devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a WebAuthn device. That way you'll always be able to log in - even when you're using an unsupported browser.")
+ = _("Not all browsers support WebAuthn. Therefore, we require that you set up a two-factor authentication app first. That way you'll always be able to sign in - even from an unsupported browser.")
- else
- = _("As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser.")
+ = _("Not all browsers support U2F devices. Therefore, we require that you set up a two-factor authentication app first. That way you'll always be able to sign in - even when you're using an unsupported browser.")
.col-lg-8
- registration = webauthn_enabled ? @webauthn_registration : @u2f_registration
- if registration.errors.present?
@@ -102,9 +102,14 @@
%tbody
- @registrations.each do |registration|
%tr
- %td= registration[:name].presence || html_escape_once(_("&lt;no name set&gt;")).html_safe
+ %td
+ - if registration[:name].present?
+ = registration[:name]
+ - else
+ %span.gl-text-gray-500
+ = _("no name set")
%td= registration[:created_at].to_date.to_s(:medium)
- %td= link_to _('Delete'), registration[:delete_path], method: :delete, class: "btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.') }
+ %td= link_to _('Delete'), registration[:delete_path], method: :delete, class: "gl-button btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.') }
- else
.settings-message.text-center
diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml
index 41e13464b1e..5ec2dc57f96 100644
--- a/app/views/projects/_export.html.haml
+++ b/app/views/projects/_export.html.haml
@@ -26,6 +26,5 @@
= link_to _('Generate new export'), generate_new_export_project_path(project),
method: :post, class: "btn btn-default"
- else
- .gl-display-flex.gl-justify-content-end
- = link_to _('Export project'), export_project_path(project),
- method: :post, class: "btn btn-default", data: { qa_selector: 'export_project_link' }
+ = link_to _('Export project'), export_project_path(project),
+ method: :post, class: "btn btn-default", data: { qa_selector: 'export_project_link' }
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 1562cc065f1..81c42de13f0 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -14,7 +14,7 @@
- if is_project_overview
.project-buttons.gl-mb-3.js-show-on-project-root
- = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout)
+ = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout), project_buttons: true
#js-tree-list{ data: vue_file_list_data(project, ref) }
- if can_edit_tree?
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 94a2bdb3bcb..9f4496e7a13 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -3,19 +3,19 @@
- max_project_topic_length = 15
- emails_disabled = @project.emails_disabled?
-.project-home-panel.js-show-on-project-root{ class: [("empty-project" if empty_repo)] }
+.project-home-panel.js-show-on-project-root.gl-my-5{ class: [("empty-project" if empty_repo)] }
.row.gl-mb-3
.home-panel-title-row.col-md-12.col-lg-6.d-flex
- .avatar-container.rect-avatar.s64.home-panel-avatar.gl-mr-3.float-none
+ .avatar-container.rect-avatar.s64.home-panel-avatar.gl-flex-shrink-0.gl-w-11.gl-h-11.gl-mr-3.float-none
= project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64)
.d-flex.flex-column.flex-wrap.align-items-baseline
.d-inline-flex.align-items-baseline
- %h1.home-panel-title.gl-mt-3.gl-mb-2{ data: { qa_selector: 'project_name_content' } }
+ %h1.home-panel-title.gl-mt-3.gl-mb-2.gl-font-size-h1.gl-line-height-24.gl-font-weight-bold{ data: { qa_selector: 'project_name_content' } }
= @project.name
%span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
= visibility_level_icon(@project.visibility_level, options: { class: 'icon' })
= render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project
- .home-panel-metadata.d-flex.flex-wrap.text-secondary
+ .home-panel-metadata.d-flex.flex-wrap.text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal
- if can?(current_user, :read_project, @project)
%span.text-secondary
= s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id }
@@ -23,8 +23,8 @@
%span.access-request-links.gl-ml-3
= render 'shared/members/access_request_links', source: @project
- if @project.tag_list.present?
- %span.home-panel-topic-list.mt-2.w-100.d-inline-flex
- = sprite_icon('tag', css_class: 'icon gl-mr-2')
+ %span.home-panel-topic-list.mt-2.w-100.d-inline-flex.gl-font-base.gl-font-weight-normal
+ = sprite_icon('tag', css_class: 'icon gl-relative gl-mr-2')
- @project.topics_to_show.each do |topic|
- project_topics_classes = "badge badge-pill badge-secondary gl-mr-2"
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index fe3354aefbb..8b94133fd8a 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -4,7 +4,7 @@
.project-import
.form-group.import-btn-container.clearfix
%h5
- Import project from
+ = _("Import project from")
.import-buttons
- if gitlab_project_import_enabled?
.import_gitlab_project.has-tooltip{ data: { container: 'body' } }
@@ -15,19 +15,22 @@
- if github_import_enabled?
%div
= link_to new_import_github_path, class: 'btn js-import-github', **tracking_attrs(track_label, 'click_button', 'github') do
- = icon('github', text: 'GitHub')
+ = sprite_icon('github')
+ GitHub
- if bitbucket_import_enabled?
%div
= link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}",
**tracking_attrs(track_label, 'click_button', 'bitbucket_cloud') do
- = icon('bitbucket', text: 'Bitbucket Cloud')
+ = sprite_icon('bitbucket')
+ Bitbucket Cloud
- unless bitbucket_import_configured?
= render 'projects/bitbucket_import_modal'
- if bitbucket_server_import_enabled?
%div
= link_to status_import_bitbucket_server_path, class: "btn import_bitbucket", **tracking_attrs(track_label, 'click_button', 'bitbucket_server') do
- = icon('bitbucket-square', text: 'Bitbucket Server')
+ = sprite_icon('bitbucket')
+ Bitbucket Server
%div
- if gitlab_import_enabled?
%div
@@ -41,7 +44,8 @@
- if google_code_import_enabled?
%div
= link_to new_import_google_code_path, class: 'btn import_google_code', **tracking_attrs(track_label, 'click_button', 'google_code') do
- = icon('google', text: 'Google Code')
+ = sprite_icon('google')
+ Google Code
- if fogbugz_import_enabled?
%div
@@ -64,7 +68,8 @@
- if manifest_import_enabled?
%div
= link_to new_import_manifest_path, class: 'btn import_manifest', **tracking_attrs(track_label, 'click_button', 'manifest_file') do
- = icon('file-text-o', text: 'Manifest file')
+ = sprite_icon('doc-text')
+ Manifest file
- if phabricator_import_enabled?
%div
diff --git a/app/views/projects/_project_templates.html.haml b/app/views/projects/_project_templates.html.haml
index 98fdb1d7a0b..79221c59ae4 100644
--- a/app/views/projects/_project_templates.html.haml
+++ b/app/views/projects/_project_templates.html.haml
@@ -1,7 +1,21 @@
- f ||= local_assigns[:f]
-.project-templates-buttons.import-buttons.col-sm-12
- = render 'projects/project_templates/built_in_templates'
+.project-templates-buttons.col-sm-12
+ %ul.nav-tabs.nav-links.nav.scrolling-tabs
+ %li.built-in-tab
+ %a.nav-link.active{ href: "#built-in", data: { toggle: 'tab'} }
+ = _('Built-in')
+ %span.badge.badge-pill= Gitlab::ProjectTemplate.all.count
+ %li.sample-data-templates-tab
+ %a.nav-link{ href: "#sample-data-templates", data: { toggle: 'tab'} }
+ = _('Sample Data')
+ %span.badge.badge-pill= Gitlab::SampleDataTemplate.all.count
+
+.tab-content
+ .project-templates-buttons.import-buttons.tab-pane.active#built-in
+ = render partial: 'projects/project_templates/template', collection: Gitlab::ProjectTemplate.all
+ .project-templates-buttons.import-buttons.tab-pane#sample-data-templates
+ = render partial: 'projects/project_templates/template', collection: Gitlab::SampleDataTemplate.all
.project-fields-form
= render 'projects/project_templates/project_fields_form'
diff --git a/app/views/projects/_stat_anchor_list.html.haml b/app/views/projects/_stat_anchor_list.html.haml
index 8e3d759b683..516790fb6d9 100644
--- a/app/views/projects/_stat_anchor_list.html.haml
+++ b/app/views/projects/_stat_anchor_list.html.haml
@@ -1,8 +1,9 @@
- anchors = local_assigns.fetch(:anchors, [])
+- project_buttons = local_assigns.fetch(:project_buttons, false)
- return unless anchors.any?
%ul.nav
- anchors.each do |anchor|
%li.nav-item
= link_to_if anchor.link, anchor.label, anchor.link, class: anchor.is_link ? 'nav-link stat-link d-flex align-items-center' : "nav-link btn btn-#{anchor.class_modifier || 'missing'} d-flex align-items-center" do
- .stat-text.d-flex.align-items-center= anchor.label
+ .stat-text.d-flex.align-items-center{ class: ('btn btn-default disabled' if project_buttons) }= anchor.label
diff --git a/app/views/projects/_visibility_modal.html.haml b/app/views/projects/_visibility_modal.html.haml
index 144f726572b..314211057f9 100644
--- a/app/views/projects/_visibility_modal.html.haml
+++ b/app/views/projects/_visibility_modal.html.haml
@@ -23,7 +23,7 @@
= ("To confirm, type %{phrase_code}").html_safe % { phrase_code: '<code class="js-confirm-danger-match">%{phrase_name}</code>'.html_safe % { phrase_name: @project.full_path } }
.form-group
= text_field_tag 'confirm_path_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input'
- .form-actions.gl-display-flex.gl-justify-content-end
+ .form-actions
%button.btn.btn-default.gl-mr-4{ type: "button", "data-dismiss": "modal" }
= _('Cancel')
= submit_tag _('Reduce project visibility'), class: "btn btn-danger js-confirm-danger-submit qa-confirm-button", disabled: true
diff --git a/app/views/projects/artifacts/_artifact.html.haml b/app/views/projects/artifacts/_artifact.html.haml
index 36e149556e0..30f30fe922f 100644
--- a/app/views/projects/artifacts/_artifact.html.haml
+++ b/app/views/projects/artifacts/_artifact.html.haml
@@ -10,7 +10,7 @@
%span.build-link ##{artifact.job_id}
- if artifact.job.ref
- .icon-container{ "aria-label" => artifact.job.tag? ? _('Tag') : _('Branch') }
+ .icon-container.gl-display-inline-block{ "aria-label" => artifact.job.tag? ? _('Tag') : _('Branch') }
= artifact.job.tag? ? sprite_icon('tag', css_class: 'sprite') : sprite_icon('branch', css_class: 'sprite')
= link_to artifact.job.ref, project_ref_path(@project, artifact.job.ref), class: 'ref-name'
- else
@@ -30,7 +30,7 @@
.table-mobile-header{ role: 'rowheader' }= _('Creation date')
.table-mobile-content
%p.finished-at
- = icon("calendar")
+ = sprite_icon("calendar")
%span= time_ago_with_tooltip(artifact.created_at)
.table-section.section-20
@@ -38,7 +38,7 @@
.table-mobile-content
- if artifact.expire_at
%p.finished-at
- = icon("calendar")
+ = sprite_icon("calendar")
%span= time_ago_with_tooltip(artifact.expire_at)
.table-section.section-10
@@ -57,5 +57,5 @@
= sprite_icon('folder-open')
- if can?(current_user, :destroy_artifacts, @project)
- = link_to project_artifact_path(@project, artifact), data: { placement: 'top', container: 'body', confirm: _('Are you sure you want to delete these artifacts?') }, method: :delete, title: _('Delete artifacts'), ref: 'tooltip', aria: { label: _('Delete artifacts') }, class: 'btn btn-remove has-tooltip' do
+ = link_to project_artifact_path(@project, artifact), data: { placement: 'top', container: 'body', confirm: _('Are you sure you want to delete these artifacts?') }, method: :delete, title: _('Delete artifacts'), ref: 'tooltip', aria: { label: _('Delete artifacts') }, class: 'gl-button btn btn-danger has-tooltip' do
= sprite_icon('remove')
diff --git a/app/views/projects/blob/_content.html.haml b/app/views/projects/blob/_content.html.haml
index 11946f22811..5b77e31eb00 100644
--- a/app/views/projects/blob/_content.html.haml
+++ b/app/views/projects/blob/_content.html.haml
@@ -2,9 +2,9 @@
- rich_viewer = blob.rich_viewer
- rich_viewer_active = rich_viewer && params[:viewer] != 'simple'
- blob_data = defined?(@blob) ? @blob.data : {}
-- filename = defined?(@blob) ? @blob.name : ''
+- is_ci_config_file = defined?(@blob) && defined?(@project) ? editing_ci_config?.to_s : 'false'
-#js-blob-toggle-graph-preview{ data: { blob_data: blob_data, filename: filename } }
+#js-blob-toggle-graph-preview{ data: { blob_data: blob_data, is_ci_config_file: is_ci_config_file } }
= render 'projects/blob/viewer', viewer: simple_viewer, hidden: rich_viewer_active
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index cea65bf9b4e..b0317d84cdc 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -20,8 +20,7 @@
required: true, class: 'form-control new-file-name js-file-path-name-input', value: params[:file_name] || (should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : '')
= render 'template_selectors'
- if should_suggest_gitlab_ci_yml?
- .js-suggest-gitlab-ci-yml{ data: { toggle: 'popover',
- target: '#gitlab-ci-yml-selector',
+ .js-suggest-gitlab-ci-yml{ data: { target: '#gitlab-ci-yml-selector',
track_label: 'suggest_gitlab_ci_yml',
merge_request_path: params[:mr_path],
dismiss_key: @project.id,
@@ -30,7 +29,7 @@
.file-buttons
- if is_markdown
= render 'shared/blob/markdown_buttons', show_fullscreen_button: false
- = button_tag class: 'soft-wrap-toggle btn', type: 'button', tabindex: '-1' do
+ = button_tag class: 'soft-wrap-toggle btn gl-button', type: 'button', tabindex: '-1' do
%span.no-wrap
= custom_icon('icon_no_wrap')
No wrap
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index 2a1545e7db7..55ae9cded1c 100644
--- a/app/views/projects/blob/_header.html.haml
+++ b/app/views/projects/blob/_header.html.haml
@@ -4,8 +4,11 @@
.file-actions<
= render 'projects/blob/viewer_switcher', blob: blob unless blame
- = edit_blob_button(@project, @ref, @path, blob: blob)
- = ide_edit_button(@project, @ref, @path, blob: blob)
+ - if Feature.enabled?(:consolidated_edit_button)
+ = render 'shared/web_ide_button', blob: blob
+ - else
+ = edit_blob_button(@project, @ref, @path, blob: blob)
+ = ide_edit_button(@project, @ref, @path, blob: blob)
.btn-group.ml-2{ role: "group" }>
= render_if_exists 'projects/blob/header_file_locks_link'
- if current_user
diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml
index e9010dc63fc..ca60827863a 100644
--- a/app/views/projects/blob/_new_dir.html.haml
+++ b/app/views/projects/blob/_new_dir.html.haml
@@ -15,7 +15,7 @@
= render 'shared/new_commit_form', placeholder: _("Add new directory")
.form-actions
- = submit_tag _("Create directory"), class: 'btn btn-success'
- = link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal"
+ = submit_tag _("Create directory"), class: 'btn gl-button btn-success'
+ = link_to "Cancel", '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal"
= render 'shared/projects/edit_information'
diff --git a/app/views/projects/blob/_remove.html.haml b/app/views/projects/blob/_remove.html.haml
index f80bae5c88c..d3440ee41b5 100644
--- a/app/views/projects/blob/_remove.html.haml
+++ b/app/views/projects/blob/_remove.html.haml
@@ -12,5 +12,5 @@
.form-group.row
.offset-sm-2.col-sm-10
- = button_tag 'Delete file', class: 'btn btn-remove btn-remove-file'
- = link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal"
+ = button_tag 'Delete file', class: 'btn gl-button btn-danger btn-remove-file'
+ = link_to "Cancel", '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal"
diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml
index d2b3c8ef96b..4dbfa2b1e3c 100644
--- a/app/views/projects/blob/_upload.html.haml
+++ b/app/views/projects/blob/_upload.html.haml
@@ -15,14 +15,14 @@
#{ dropzone_text.html_safe }
%br
- .dropzone-alerts.alert.alert-danger.data{ style: "display:none" }
+ .dropzone-alerts.gl-alert.gl-alert-danger.gl-mb-5.data{ style: "display:none" }
= render 'shared/new_commit_form', placeholder: placeholder
.form-actions
- = button_tag class: 'btn btn-success btn-upload-file', id: 'submit-all', type: 'button' do
+ = button_tag class: 'btn gl-button btn-success btn-upload-file', id: 'submit-all', type: 'button' do
= icon('spin spinner', class: 'js-loading-icon hidden' )
= button_title
- = link_to _("Cancel"), '#', class: "btn btn-cancel", "data-dismiss" => "modal"
+ = link_to _("Cancel"), '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal"
= render 'shared/projects/edit_information'
diff --git a/app/views/projects/blob/_viewer_switcher.html.haml b/app/views/projects/blob/_viewer_switcher.html.haml
index df81e509c85..8e3cf607bbf 100644
--- a/app/views/projects/blob/_viewer_switcher.html.haml
+++ b/app/views/projects/blob/_viewer_switcher.html.haml
@@ -4,9 +4,9 @@
.btn-group.js-blob-viewer-switcher.ml-2{ role: "group" }>
- simple_label = "Display #{simple_viewer.switcher_title}"
- %button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => simple_label, title: simple_label, data: { viewer: 'simple', container: 'body' } }>
+ %button.btn.gl-button.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => simple_label, title: simple_label, data: { viewer: 'simple', container: 'body' } }>
= sprite_icon(simple_viewer.switcher_icon)
- rich_label = "Display #{rich_viewer.switcher_title}"
- %button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }>
+ %button.btn.gl-button.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }>
= sprite_icon(rich_viewer.switcher_icon)
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 9bb4342ffb4..54c47e7af38 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -9,9 +9,6 @@
= link_to "the file", project_blob_path(@project, tree_join(@branch_name, @file_path)), target: "_blank", rel: 'noopener noreferrer', class: 'gl-link'
and make sure your changes will not unintentionally remove theirs.
-- if editing_ci_config? && show_web_ide_alert?
- #js-suggest-web-ide-ci{ data: { dismiss_endpoint: user_callouts_path, feature_id: UserCalloutsHelper::WEB_IDE_ALERT_DISMISSED, edit_path: ide_edit_path } }
-
.editor-title-row
%h3.page-title.blob-edit-page-title
Edit file
diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml
index a939f43d5e2..2a33afabb7c 100644
--- a/app/views/projects/blob/new.html.haml
+++ b/app/views/projects/blob/new.html.haml
@@ -13,8 +13,7 @@
= render 'projects/commit_button', ref: @ref,
cancel_path: project_tree_path(@project, @id)
- if should_suggest_gitlab_ci_yml?
- .js-suggest-gitlab-ci-yml-commit-changes{ data: { toggle: 'popover',
- target: '#commit-changes',
+ .js-suggest-gitlab-ci-yml-commit-changes{ data: { target: '#commit-changes',
merge_request_path: params[:mr_path],
track_label: 'suggest_commit_first_project_gitlab_ci_yml',
dismiss_key: @project.id,
diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml
index 8134adcbc32..703ffa8896e 100644
--- a/app/views/projects/blob/viewers/_markup.html.haml
+++ b/app/views/projects/blob/viewers/_markup.html.haml
@@ -1,4 +1,3 @@
- blob = viewer.blob
-- context = blob.respond_to?(:rendered_markup) ? { rendered: blob.rendered_markup } : {}
.file-content.md
- = markup(blob.name, blob.data, context)
+ = markup(blob.name, blob.data, viewer.banzai_render_context)
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 020a4361203..30e710ead7f 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -50,13 +50,13 @@
- if can?(current_user, :push_code, @project)
- if branch.name == @project.repository.root_ref
- %button{ class: "btn btn-remove remove-row has-tooltip disabled",
+ %button{ class: "gl-button btn btn-danger remove-row has-tooltip disabled",
disabled: true,
title: s_('Branches|The default branch cannot be deleted') }
= sprite_icon("remove")
- elsif protected_branch?(@project, branch)
- if can?(current_user, :push_to_delete_protected_branch, @project)
- %button{ class: "btn btn-remove remove-row has-tooltip",
+ %button{ class: "gl-button btn btn-danger remove-row has-tooltip",
title: s_('Branches|Delete protected branch'),
data: { toggle: "modal",
target: "#modal-delete-branch",
@@ -65,13 +65,13 @@
is_merged: ("true" if merged) } }
= sprite_icon("remove")
- else
- %button{ class: "btn btn-remove remove-row has-tooltip disabled",
+ %button{ class: "gl-button btn btn-danger remove-row has-tooltip disabled",
disabled: true,
title: s_('Branches|Only a project maintainer or owner can delete a protected branch') }
= sprite_icon("remove")
- else
= link_to project_branch_path(@project, branch.name),
- class: "btn btn-remove remove-row qa-remove-btn js-ajax-loading-spinner has-tooltip",
+ class: "gl-button btn btn-danger remove-row qa-remove-btn js-ajax-loading-spinner has-tooltip",
title: s_('Branches|Delete branch'),
method: :delete,
data: { confirm: s_("Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?") % { branch_name: branch.name } },
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index ba42f43088f..f3561ed5078 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -34,7 +34,7 @@
- if can? current_user, :push_code, @project
= link_to project_merged_branches_path(@project),
- class: 'btn btn-inverted btn-remove has-tooltip qa-delete-merged-branches',
+ class: 'gl-button btn btn-danger btn-danger-secondary has-tooltip qa-delete-merged-branches',
title: s_("Branches|Delete all branches that are merged into '%{default_branch}'") % { default_branch: @project.repository.root_ref },
method: :delete,
data: { confirm: s_('Branches|Deleting the merged branches cannot be undone. Are you sure?'),
diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml
index 2d9c7f9848f..dbe0bf35b98 100644
--- a/app/views/projects/buttons/_fork.html.haml
+++ b/app/views/projects/buttons/_fork.html.haml
@@ -3,14 +3,14 @@
.count-badge.d-inline-flex.align-item-stretch.gl-mr-3
- if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2
= link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: s_('ProjectOverview|Go to your fork'), class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center fork-btn' do
- = sprite_icon('fork', { css_class: 'icon' })
+ = sprite_icon('fork', css_class: 'icon')
%span= s_('ProjectOverview|Fork')
- else
- can_create_fork = current_user.can?(:create_fork)
= link_to new_project_fork_path(@project),
class: "btn btn-default btn-xs has-tooltip count-badge-button d-flex align-items-center fork-btn #{'has-tooltip disabled' unless can_create_fork}",
title: (s_('ProjectOverview|You have reached your project limit') unless can_create_fork) do
- = sprite_icon('fork', { css_class: 'icon' })
+ = sprite_icon('fork', css_class: 'icon')
%span= s_('ProjectOverview|Fork')
%span.fork-count.count-badge-count.d-flex.align-items-center
= link_to project_forks_path(@project), title: n_(s_('ProjectOverview|Fork'), s_('ProjectOverview|Forks'), @project.forks_count), class: 'count' do
diff --git a/app/views/projects/buttons/_remove_tag.html.haml b/app/views/projects/buttons/_remove_tag.html.haml
new file mode 100644
index 00000000000..ae776e93203
--- /dev/null
+++ b/app/views/projects/buttons/_remove_tag.html.haml
@@ -0,0 +1,6 @@
+- project = local_assigns.fetch(:project, nil)
+- tag = local_assigns.fetch(:tag, nil)
+- return unless project && tag
+
+%button{ type: "button", class: "js-remove-tag js-confirm-modal-button gl-button btn btn-danger remove-row has-tooltip gl-ml-3 #{protected_tag?(project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), data: { container: 'body', path: project_tag_path(@project, tag.name), modal_attributes: delete_tag_modal_attributes(tag.name) } }
+ = sprite_icon("remove")
diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml
index 3dac38d1356..690f0fe10f7 100644
--- a/app/views/projects/buttons/_star.html.haml
+++ b/app/views/projects/buttons/_star.html.haml
@@ -2,10 +2,10 @@
.count-badge.d-inline-flex.align-item-stretch.gl-mr-3
%button.count-badge-button.btn.btn-default.btn-xs.d-flex.align-items-center.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(@project, :json) } }
- if current_user.starred?(@project)
- = sprite_icon('star', { css_class: 'icon' })
+ = sprite_icon('star', css_class: 'icon')
%span.starred= s_('ProjectOverview|Unstar')
- else
- = sprite_icon('star-o', { css_class: 'icon' })
+ = sprite_icon('star-o', css_class: 'icon')
%span= s_('ProjectOverview|Star')
%span.star-count.count-badge-count.d-flex.align-items-center
= link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'count' do
@@ -14,7 +14,7 @@
- else
.count-badge.d-inline-flex.align-item-stretch.gl-mr-3
= link_to new_user_session_path, class: 'btn btn-default btn-xs has-tooltip count-badge-button d-flex align-items-center star-btn', title: s_('ProjectOverview|You must sign in to star a project') do
- = sprite_icon('star-o', { css_class: 'icon' })
+ = sprite_icon('star-o', css_class: 'icon')
%span= s_('ProjectOverview|Star')
%span.star-count.count-badge-count.d-flex.align-items-center
= link_to project_starrers_path(@project), title: n_(s_('ProjectOverview|Starrer'), s_('ProjectOverview|Starrers'), @project.star_count), class: 'count' do
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index c7ab01a4ef7..138f5569218 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -21,8 +21,8 @@
- if ref
- if job.ref
- .icon-container
- = job.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite')
+ .icon-container.gl-display-inline-block
+ = job.tag? ? sprite_icon('label', css_class: 'sprite') : sprite_icon('fork', css_class: 'sprite')
= link_to job.ref, project_ref_path(job.project, job.ref), class: "ref-name"
- else
.light= _('none')
@@ -33,10 +33,12 @@
= link_to job.short_sha, project_commit_path(job.project, job.sha), class: "commit-sha mr-0"
- if job.stuck?
- = icon('warning', class: 'text-warning has-tooltip', title: _('Job is stuck. Check runners.'))
+ %span.has-tooltip{ title: _('Job is stuck. Check runners.') }
+ = sprite_icon('warning', css_class: 'text-warning!')
- if retried
- = icon('refresh', class: 'text-warning has-tooltip', title: _('Job was retried'))
+ %span.has-tooltip{ title: _('Job was retried') }
+ = sprite_icon('retry', css_class: 'text-warning')
.label-container
- if job.tags.any?
@@ -87,7 +89,7 @@
- if job.finished_at
%p.finished-at
- = icon("calendar")
+ = sprite_icon("calendar")
%span= time_ago_with_tooltip(job.finished_at)
%td.coverage
@@ -101,11 +103,11 @@
= sprite_icon('download')
- if can?(current_user, :update_build, job)
- if job.active?
- = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), class: 'btn btn-build' do
+ = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), class: 'btn gl-button btn-build' do
= sprite_icon('close')
- elsif job.scheduled?
.btn-group
- .btn.btn-default{ disabled: true }
+ .btn.gl-button.btn-default{ disabled: true }
= sprite_icon('planning')
%time.js-remaining-time{ datetime: job.scheduled_at.utc.iso8601 }
= duration_in_numbers(job.execute_in)
@@ -113,17 +115,17 @@
= link_to play_project_job_path(job.project, job, return_to: request.original_url),
method: :post,
title: s_('DelayedJobs|Start now'),
- class: 'btn btn-default btn-build has-tooltip',
+ class: 'btn gl-button btn-default btn-build has-tooltip',
data: { confirm: confirmation_message } do
= sprite_icon('play')
= link_to unschedule_project_job_path(job.project, job, return_to: request.original_url),
method: :post,
title: s_('DelayedJobs|Unschedule'),
- class: 'btn btn-default btn-build has-tooltip' do
+ class: 'btn gl-button btn-default btn-build has-tooltip' do
= sprite_icon('time-out')
- elsif allow_retry
- if job.playable? && !admin && can?(current_user, :update_build, job)
- = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), class: 'btn btn-build' do
+ = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), class: 'btn gl-button btn-build' do
= custom_icon('icon_play')
- elsif job.retryable?
= link_to retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build gl-button btn-icon btn-default' do
diff --git a/app/views/projects/ci/lints/show.html.haml b/app/views/projects/ci/lints/show.html.haml
index 2e79852f4c9..64f250bd607 100644
--- a/app/views/projects/ci/lints/show.html.haml
+++ b/app/views/projects/ci/lints/show.html.haml
@@ -1,13 +1,10 @@
- page_title _("CI Lint")
- page_description _("Validate your GitLab CI configuration file")
-- unless Feature.enabled?(:monaco_ci)
- - content_for :library_javascripts do
- = page_specific_javascript_tag('lib/ace.js')
%h2.pt-3.pb-3= _("Validate your GitLab CI configuration")
- if Feature.enabled?(:ci_lint_vue, @project)
- #js-ci-lint{ data: { endpoint: project_ci_lint_path(@project) } }
+ #js-ci-lint{ data: { endpoint: project_ci_lint_path(@project), help_page_path: help_page_path('ci/lint', anchor: 'pipeline-simulation') } }
- else
.project-ci-linter
@@ -17,12 +14,9 @@
.file-holder
.js-file-title.file-title.clearfix
= _("Contents of .gitlab-ci.yml")
- - if Feature.enabled?(:monaco_ci)
- .file-editor.code
- .js-edit-mode-pane.qa-editor#editor{ data: { 'editor-loading': true } }<
- %pre.editor-loading-content= params[:content]
- - else
- #ci-editor.ci-editor= @content
+ .file-editor.code
+ .js-edit-mode-pane.qa-editor#editor{ data: { 'editor-loading': true } }<
+ %pre.editor-loading-content= params[:content]
= text_area_tag(:content, @content, class: 'hidden form-control span1', rows: 7, require: true)
.col-sm-12
.float-left.gl-mt-3
diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml
index 019894ddbb4..02d35e690ca 100644
--- a/app/views/projects/cleanup/_show.html.haml
+++ b/app/views/projects/cleanup/_show.html.haml
@@ -26,5 +26,4 @@
.form-text.text-muted
= _("The maximum file size allowed is %{size}.") % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) }
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Start cleanup'), class: 'btn btn-success'
+ = f.submit _('Start cleanup'), class: 'btn btn-success'
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 29ee4a69e83..86c80f1a8ae 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -1,6 +1,6 @@
- can_collaborate = can_collaborate_with_project?(@project)
-.page-content-header.js-commit-box{ 'data-commit-path' => branches_project_commit_path(@project, @commit.id) }
+.page-content-header
.header-main-content
= render partial: 'signature', object: @commit.signature
%strong
@@ -22,17 +22,17 @@
.header-action-buttons
- if defined?(@notes_count) && @notes_count > 0
- %span.btn.disabled.btn-grouped.d-none.d-sm-block.gl-mr-3.has-tooltip{ title: n_("%d comment on this commit", "%d comments on this commit", @notes_count) % @notes_count }
+ %span.btn.disabled.gl-button.btn-icon.d-none.d-sm-inline.gl-mr-3.has-tooltip{ title: n_("%d comment on this commit", "%d comments on this commit", @notes_count) % @notes_count }
= sprite_icon('comment')
= @notes_count
- = link_to project_tree_path(@project, @commit), class: "btn btn-default gl-mr-3 d-none d-sm-none d-md-inline" do
+ = link_to project_tree_path(@project, @commit), class: "btn gl-button gl-mr-3 d-none d-md-inline" do
#{ _('Browse files') }
.dropdown.inline
- %a.btn.btn-default.dropdown-toggle.qa-options-button.d-md-inline{ data: { toggle: "dropdown" } }
+ %a.btn.gl-button.dropdown-toggle.qa-options-button.d-md-inline{ data: { toggle: "dropdown" } }
%span= _('Options')
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-right
- %li.d-block.d-sm-none.d-md-none
+ %li.d-block.d-sm-none
= link_to project_tree_path(@project, @commit) do
#{ _('Browse Files') }
- if can_collaborate && !@commit.has_been_reverted?(current_user)
@@ -58,7 +58,7 @@
%pre.commit-description<
= preserve(markdown_field(@commit, :description))
-.info-well
+.info-well.js-commit-box-info{ 'data-commit-path' => branches_project_commit_path(@project, @commit.id) }
.well-segment.branch-info
.icon-container.commit-icon
= custom_icon("icon_commit")
diff --git a/app/views/projects/commit/pipelines.html.haml b/app/views/projects/commit/pipelines.html.haml
index f8c27f4c026..0dbd6e53212 100644
--- a/app/views/projects/commit/pipelines.html.haml
+++ b/app/views/projects/commit/pipelines.html.haml
@@ -1,4 +1,5 @@
- page_title _('Pipelines'), "#{@commit.title} (#{@commit.short_id})", _('Commits')
+- add_page_specific_style 'page_bundles/pipelines'
= render 'commit_box'
= render 'ci_menu'
diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml
index 40b96ca477e..003a27f4c9a 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -7,6 +7,7 @@
- @content_class = limited_container_width
- page_title "#{@commit.title} (#{@commit.short_id})", _('Commits')
- page_description @commit.description
+- add_page_specific_style 'page_bundles/pipelines'
.container-fluid{ class: [limited_container_width, container_class] }
= render "commit_box"
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index 293500a6c31..63cc96c2c05 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -28,7 +28,8 @@
= render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request }
- if hidden > 0
- %li.alert.alert-warning
+ %li.gl-alert.gl-alert-warning
+ = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
= n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden)
- if project.context_commits_enabled? && can_update_merge_request && context_commits&.empty?
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 28b5dc0cc67..40dd3a685d4 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -17,16 +17,16 @@
.tree-controls
- if @merge_request.present?
.control.d-none.d-md-block
- = link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'btn'
+ = link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'btn gl-button'
- elsif create_mr_button?(@repository.root_ref, @ref)
.control.d-none.d-md-block
- = link_to _("Create merge request"), create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success'
+ = link_to _("Create merge request"), create_mr_path(@repository.root_ref, @ref), class: 'btn gl-button btn-success'
.control
= form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form js-signature-container', data: { 'signatures-path' => namespace_project_signatures_path }) do
= search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control search-text-input input-short gl-mt-3 gl-sm-mt-0 gl-min-w-full', spellcheck: false }
.control.d-none.d-md-block
- = link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn btn-svg' do
+ = link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn gl-button btn-svg' do
= sprite_icon('rss', css_class: 'qa-rss-icon')
= render_if_exists 'projects/commits/mirror_status'
diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml
index 768acac96c0..a257f2e9433 100644
--- a/app/views/projects/compare/_form.html.haml
+++ b/app/views/projects/compare/_form.html.haml
@@ -1,14 +1,14 @@
= form_tag project_compare_index_path(@project), method: :post, class: 'form-inline js-requires-input js-signature-container', data: { 'signatures-path' => signatures_namespace_project_compare_index_path } do
- if params[:to] && params[:from]
.compare-switch-container
- = link_to sprite_icon('substitute'), { from: params[:to], to: params[:from] }, class: 'commits-compare-switch has-tooltip btn btn-white', title: 'Swap revisions'
+ = link_to sprite_icon('substitute'), { from: params[:to], to: params[:from] }, class: 'commits-compare-switch has-tooltip btn gl-button btn-white', title: 'Swap revisions'
.form-group.dropdown.compare-form-group.to.js-compare-to-dropdown
.input-group.inline-input-group
%span.input-group-prepend
.input-group-text
= s_("CompareBranches|Source")
= hidden_field_tag :to, params[:to]
- = button_tag type: 'button', title: params[:to], class: "btn form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do
+ = button_tag type: 'button', title: params[:to], class: "btn gl-button form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do
.dropdown-toggle-text.str-truncated.monospace.float-left= params[:to] || _("Select branch/tag")
= sprite_icon('chevron-down', css_class: 'float-right')
= render 'shared/ref_dropdown'
@@ -19,12 +19,12 @@
.input-group-text
= s_("CompareBranches|Target")
= hidden_field_tag :from, params[:from]
- = button_tag type: 'button', title: params[:from], class: "btn form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do
+ = button_tag type: 'button', title: params[:from], class: "btn gl-button form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do
.dropdown-toggle-text.str-truncated.monospace.float-left= params[:from] || _("Select branch/tag")
= sprite_icon('chevron-down', css_class: 'float-right')
= render 'shared/ref_dropdown'
&nbsp;
- = button_tag s_("CompareBranches|Compare"), class: "btn btn-success commits-compare-btn"
+ = button_tag s_("CompareBranches|Compare"), class: "btn gl-button btn-success commits-compare-btn"
- if @merge_request.present?
= link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'gl-ml-3 btn'
- elsif create_mr_button?
diff --git a/app/views/projects/confluences/show.html.haml b/app/views/projects/confluences/show.html.haml
index b87780db4cd..5814b7a00f5 100644
--- a/app/views/projects/confluences/show.html.haml
+++ b/app/views/projects/confluences/show.html.haml
@@ -1,5 +1,6 @@
- breadcrumb_title _('Confluence')
- page_title _('Confluence')
+- add_page_specific_style 'page_bundles/wiki'
= render layout: 'shared/empty_states/wikis_layout', locals: { image_path: 'illustrations/wiki_login_empty.svg' } do
%h4
= s_('WikiEmpty|Confluence is enabled')
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index d7e10efc3b1..d99579c25c0 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -1,4 +1,5 @@
- page_title _("Value Stream Analytics")
+- add_page_specific_style 'page_bundles/cycle_analytics'
#cycle-analytics{ "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
- if @cycle_analytics_no_data
diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml
index 46ee60949db..2ba12601c79 100644
--- a/app/views/projects/default_branch/_show.html.haml
+++ b/app/views/projects/default_branch/_show.html.haml
@@ -28,5 +28,4 @@
= _("Issues referenced by merge requests and commits within the default branch will be closed automatically")
= link_to sprite_icon('question-o'), help_page_path('user/project/issues/managing_issues.md', anchor: 'disabling-automatic-issue-closing'), target: '_blank'
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml
index 5127d8b77d5..7f4b99f1a3f 100644
--- a/app/views/projects/deployments/_actions.haml
+++ b/app/views/projects/deployments/_actions.haml
@@ -3,7 +3,7 @@
- if actions.present?
.btn-group
.dropdown
- %button.dropdown.dropdown-new.btn.btn-default.has-tooltip{ type: 'button', 'data-toggle' => 'dropdown', title: s_('Environments|Deploy to...') }
+ %button.dropdown.dropdown-new.btn.gl-button.btn-default.has-tooltip{ type: 'button', 'data-toggle' => 'dropdown', title: s_('Environments|Deploy to...') }
= sprite_icon('play')
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-right
diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml
index 743aa60b3ba..52e3e0fd997 100644
--- a/app/views/projects/deployments/_commit.html.haml
+++ b/app/views/projects/deployments/_commit.html.haml
@@ -1,7 +1,7 @@
.table-mobile-content
.branch-commit.cgray
- if deployment.ref
- %span.icon-container
+ %span.icon-container.gl-display-inline-block
= deployment.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite')
= link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name"
.icon-container.commit-icon
diff --git a/app/views/projects/deployments/_confirm_rollback_modal.html.haml b/app/views/projects/deployments/_confirm_rollback_modal.html.haml
index 3735ead1559..23729d6ebf9 100644
--- a/app/views/projects/deployments/_confirm_rollback_modal.html.haml
+++ b/app/views/projects/deployments/_confirm_rollback_modal.html.haml
@@ -15,8 +15,8 @@
%p
= s_('Environments|This action will run the job defined by %{environment_name} for commit %{commit_id}, putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?').html_safe % {commit_id: commit_sha, environment_name: @environment.name}
.modal-footer
- = button_tag _('Cancel'), type: 'button', class: 'btn btn-cancel', data: { dismiss: 'modal' }
- = link_to [:retry, @project, deployment.deployable], method: :post, class: 'btn btn-danger' do
+ = button_tag _('Cancel'), type: 'button', class: 'btn gl-button btn-danger', data: { dismiss: 'modal' }
+ = link_to [:retry, @project, deployment.deployable], method: :post, class: 'btn gl-button btn-danger' do
- if deployment.last?
= s_('Environments|Re-deploy')
- else
diff --git a/app/views/projects/deployments/_rollback.haml b/app/views/projects/deployments/_rollback.haml
index dffa5e4ba40..c5e884473ff 100644
--- a/app/views/projects/deployments/_rollback.haml
+++ b/app/views/projects/deployments/_rollback.haml
@@ -1,6 +1,6 @@
- if deployment.deployable && can?(current_user, :create_deployment, deployment)
- tooltip = deployment.last? ? s_('Environments|Re-deploy to environment') : s_('Environments|Rollback environment')
- = button_tag class: 'btn btn-default btn-build has-tooltip', type: 'button', data: { toggle: 'modal', target: "#confirm-rollback-modal-#{deployment.id}" }, title: tooltip do
+ = button_tag class: 'btn gl-button btn-default btn-build has-tooltip', type: 'button', data: { toggle: 'modal', target: "#confirm-rollback-modal-#{deployment.id}" }, title: tooltip do
- if deployment.last?
= sprite_icon('repeat')
- else
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index 6ba363e6555..43aaa7cb405 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -7,7 +7,7 @@
.content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed
.files-changed-inner
- .inline-parallel-buttons.d-none.d-sm-none.d-md-block
+ .inline-parallel-buttons.d-none.d-md-block
- if !diffs_expanded? && diff_files.any? { |diff_file| diff_file.collapsed? }
= link_to _('Expand all'), url_for(safe_params.merge(expanded: 1, format: nil)), class: 'btn btn-default'
- if show_whitespace_toggle
diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml
index f954b09abee..e9dfda4e927 100644
--- a/app/views/projects/diffs/_file_header.html.haml
+++ b/app/views/projects/diffs/_file_header.html.haml
@@ -6,7 +6,7 @@
- if diff_file.submodule?
- blob = diff_file.blob
%span
- = icon('archive fw')
+ = sprite_icon('archive')
%strong.file-title-name
= submodule_link(blob, diff_file.content_sha, diff_file.repository)
diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml
index d35443cca1e..4d40071e07c 100644
--- a/app/views/projects/diffs/_line.html.haml
+++ b/app/views/projects/diffs/_line.html.haml
@@ -21,9 +21,6 @@
- else
= add_diff_note_button(line_code, diff_file.position(line), type)
%a{ href: "##{line_code}", data: { linenumber: link_text } }
- - discussion = line_discussions.try(:first)
- - if discussion && discussion.resolvable? && !plain
- %diff-note-avatars{ "discussion-id" => discussion.id }
%td.new_line.diff-line-num{ class: type, data: { linenumber: line.new_pos } }
- link_text = type == "old" ? " " : line.new_pos
- if plain
diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml
index 9587ea4696b..ebe3aad064a 100644
--- a/app/views/projects/diffs/_parallel_view.html.haml
+++ b/app/views/projects/diffs/_parallel_view.html.haml
@@ -20,9 +20,6 @@
%td.old_line.diff-line-num.js-avatar-container{ class: left.type, data: { linenumber: left.old_pos } }
= add_diff_note_button(left_line_code, left_position, 'old')
%a{ href: "##{left_line_code}", data: { linenumber: left.old_pos } }
- - discussion_left = discussions_left.try(:first)
- - if discussion_left && discussion_left.resolvable?
- %diff-note-avatars{ "discussion-id" => discussion_left.id }
%td.line_content.parallel.left-side{ id: left_line_code, class: left.type }= diff_line_content(left.rich_text)
- else
%td.old_line.diff-line-num.empty-cell
@@ -41,9 +38,6 @@
%td.new_line.diff-line-num.js-avatar-container{ class: right.type, data: { linenumber: right.new_pos } }
= add_diff_note_button(right_line_code, right_position, 'new')
%a{ href: "##{right_line_code}", data: { linenumber: right.new_pos } }
- - discussion_right = discussions_right.try(:first)
- - if discussion_right && discussion_right.resolvable?
- %diff-note-avatars{ "discussion-id" => discussion_right.id }
%td.line_content.parallel.right-side{ id: right_line_code, class: right.type }= diff_line_content(right.rich_text)
- else
%td.old_line.diff-line-num.empty-cell
diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml
index b438fbbf446..cee479aab0a 100644
--- a/app/views/projects/diffs/_stats.html.haml
+++ b/app/views/projects/diffs/_stats.html.haml
@@ -1,5 +1,5 @@
-- sum_added_lines = diff_files.sum(&:added_lines) # rubocop: disable CodeReuse/ActiveRecord
-- sum_removed_lines = diff_files.sum(&:removed_lines) # rubocop: disable CodeReuse/ActiveRecord
+- sum_added_lines = diff_files.sum(&:added_lines)
+- sum_removed_lines = diff_files.sum(&:removed_lines)
.commit-stat-summary.dropdown
Showing
%button.diff-stats-summary-toggler.js-diff-stats-dropdown{ type: "button", data: { toggle: "dropdown", display: "static" } }<
diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml
index 641a0689c26..5a7830e306a 100644
--- a/app/views/projects/diffs/_text_file.html.haml
+++ b/app/views/projects/diffs/_text_file.html.haml
@@ -1,4 +1,4 @@
-- too_big = diff_file.diff_lines.count > Commit::DIFF_SAFE_LINES
+- too_big = diff_file.diff_lines.count > Commit.diff_safe_lines(project: @project)
- if too_big
.suppressed-container
%a.show-suppressed-diff.cursor-pointer.js-show-suppressed-diff= _("Changes suppressed. Click to show.")
diff --git a/app/views/projects/diffs/_warning.html.haml b/app/views/projects/diffs/_warning.html.haml
index 643d111fedd..30b0631b465 100644
--- a/app/views/projects/diffs/_warning.html.haml
+++ b/app/views/projects/diffs/_warning.html.haml
@@ -1,12 +1,15 @@
-.alert.alert-warning
- %h4
+.gl-alert.gl-alert-warning.gl-mb-5
+ %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
+ = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon')
+ %h4.gl-alert-title
= _("Too many changes to show.")
- .float-right
- - if current_controller?(:commit)
- = link_to _("Plain diff"), project_commit_path(@project, @commit, format: :diff), class: "btn btn-sm"
- = link_to _("Email patch"), project_commit_path(@project, @commit, format: :patch), class: "btn btn-sm"
- - elsif current_controller?('projects/merge_requests/diffs') && @merge_request&.persisted?
- = link_to _("Plain diff"), merge_request_path(@merge_request, format: :diff), class: "btn btn-sm"
- = link_to _("Email patch"), merge_request_path(@merge_request, format: :patch), class: "btn btn-sm"
- %p
+ .gl-alert-body
= html_escape(_("To preserve performance only %{strong_open}%{display_size} of %{real_size}%{strong_close} files are displayed.")) % { display_size: diff_files.size, real_size: diff_files.real_size, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
+ .gl-alert-actions
+ - if current_controller?(:commit)
+ = link_to _("Plain diff"), project_commit_path(@project, @commit, format: :diff), class: "btn gl-alert-action btn-default gl-button btn-default-secondary"
+ = link_to _("Email patch"), project_commit_path(@project, @commit, format: :patch), class: "btn gl-alert-action btn-default gl-button btn-default-secondary"
+ - elsif current_controller?('projects/merge_requests/diffs') && @merge_request&.persisted?
+ = link_to _("Plain diff"), merge_request_path(@merge_request, format: :diff), class: "btn gl-alert-action btn-default gl-button btn-default-secondary"
+ = link_to _("Email patch"), merge_request_path(@merge_request, format: :patch), class: "btn gl-alert-action btn-default gl-button btn-default-secondary"
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index e5c4cfcbd72..63d571e718e 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -21,10 +21,9 @@
%input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' }
%template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data_json(@project)
.js-project-permissions-form
- .gl-display-flex.gl-justify-content-end
- - if show_visibility_confirm_modal?(@project)
- = render "visibility_modal"
- = f.submit _('Save changes'), class: "btn btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level }
+ - if show_visibility_confirm_modal?(@project)
+ = render "visibility_modal"
+ = f.submit _('Save changes'), class: "btn btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level }
%section.qa-merge-request-settings.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] }
.settings-header
@@ -38,8 +37,7 @@
= form_for @project, remote: true, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' }
= render 'projects/merge_request_settings', form: f
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success qa-save-merge-request-changes rspec-save-merge-request-changes"
+ = f.submit _('Save changes'), class: "btn btn-succes qa-save-merge-request-changes rspec-save-merge-request-changes"
= render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded
@@ -70,9 +68,8 @@
.sub-section
%h4= _('Housekeeping')
%p= _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.')
- .gl-display-flex.gl-justify-content-end
- = link_to _('Run housekeeping'), housekeeping_project_path(@project),
- method: :post, class: "btn btn-default"
+ = link_to _('Run housekeeping'), housekeeping_project_path(@project),
+ method: :post, class: "btn btn-default"
= render 'export', project: @project
@@ -94,8 +91,7 @@
%li= _('You will need to update your local repositories to point to the new location.')
- if @project.deployment_platform.present?
%li= _('Your deployment services will be broken, you will need to manually fix the services after renaming.')
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Change path'), class: "btn btn-warning qa-change-path-button"
+ = f.submit _('Change path'), class: "btn btn-warning qa-change-path-button"
- if can?(current_user, :change_namespace, @project)
.sub-section
@@ -111,8 +107,7 @@
%li= _('You can only transfer the project to namespaces you manage.')
%li= _('You will need to update your local repositories to point to the new location.')
%li= _('Project visibility level will be changed to match namespace rules when transferring to a group.')
- .gl-display-flex.gl-justify-content-end
- = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) }
+ = f.submit 'Transfer project', class: "gl-button btn btn-danger js-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) }
- if @project.forked? && can?(current_user, :remove_fork_project, @project)
.sub-section
@@ -122,7 +117,7 @@
= form_for @project, url: remove_fork_project_path(@project), method: :delete, remote: true, html: { class: 'transfer-project' } do |f|
%p
%strong= _('Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source.')
- = button_to _('Remove fork relationship'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_warning_message(@project) }
+ = button_to _('Remove fork relationship'), '#', class: "gl-button btn btn-danger js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_warning_message(@project) }
= render 'remove', project: @project
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index c9edc3c12ec..c6d39f5bba0 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -1,5 +1,5 @@
- @content_class = "limit-container-width" unless fluid_layout
-- default_branch_name = Gitlab::CurrentSettings.default_branch_name.presence || "master"
+- default_branch_name = @project.default_branch || "master"
- breadcrumb_title _("Details")
- page_title _("Details")
@@ -11,19 +11,19 @@
= _('The repository for this project is empty')
- if @project.can_current_user_push_code?
- %p.gl-mb-0
+ %p
= _('You can get started by cloning the repository or start adding files to it with one of the following options.')
.project-buttons.qa-quick-actions
.project-clone-holder.d-block.d-md-none.mt-2.mr-2
= render "shared/mobile_clone_panel"
- .project-clone-holder.d-none.d-md-inline-block.mt-2.mr-2.float-left
+ .project-clone-holder.d-none.d-md-inline-block.mb-2.mr-2.float-left
= render "projects/buttons/clone"
- = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons
+ = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons, project_buttons: true
- if can?(current_user, :push_code, @project)
- .empty-wrapper.gl-mt-7
+ .empty-wrapper.gl-mt-4
%h3#repo-command-line-instructions.page-title-empty
= _('Command line instructions')
%p
diff --git a/app/views/projects/environments/edit.html.haml b/app/views/projects/environments/edit.html.haml
index 56af252d785..c4e2c1eb63d 100644
--- a/app/views/projects/environments/edit.html.haml
+++ b/app/views/projects/environments/edit.html.haml
@@ -1,4 +1,5 @@
- page_title _("Edit"), @environment.name, _("Environments")
+- add_page_specific_style 'page_bundles/environments'
%h3.page-title
= _('Edit environment')
diff --git a/app/views/projects/environments/folder.html.haml b/app/views/projects/environments/folder.html.haml
index 554cb4323f7..2b4d19a0e1d 100644
--- a/app/views/projects/environments/folder.html.haml
+++ b/app/views/projects/environments/folder.html.haml
@@ -1,5 +1,6 @@
- add_to_breadcrumbs _("Environments"), project_environments_path(@project)
- breadcrumb_title _("Folder/%{name}") % { name: @folder }
- page_title _("Environments in %{name}") % { name: @folder }
+- add_page_specific_style 'page_bundles/environments'
#environments-folder-list-view{ data: { environments_data: environments_folder_list_view_data, project_path: @project.full_path } }
diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml
index 9abc1a5a925..067c987e721 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -1,4 +1,5 @@
- page_title _("Environments")
+- add_page_specific_style 'page_bundles/environments'
#environments-list-view{ data: { environments_data: environments_list_data,
"can-read-environment" => can?(current_user, :read_environment, @project).to_s,
diff --git a/app/views/projects/environments/new.html.haml b/app/views/projects/environments/new.html.haml
index 96edd3f0bd7..6b0ccc1dcc7 100644
--- a/app/views/projects/environments/new.html.haml
+++ b/app/views/projects/environments/new.html.haml
@@ -1,5 +1,6 @@
- breadcrumb_title _("Environments")
- page_title _("New Environment")
+- add_page_specific_style 'page_bundles/environments'
%h3.page-title
= _("New environment")
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index 929015023d2..5b1556c9f52 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -1,9 +1,8 @@
- add_to_breadcrumbs _("Environments"), project_environments_path(@project)
- breadcrumb_title @environment.name
- page_title _("Environments")
-
-- content_for :page_specific_javascripts do
- = stylesheet_link_tag 'page_bundles/xterm'
+- add_page_specific_style 'page_bundles/xterm'
+- add_page_specific_style 'page_bundles/environments'
#environments-detail-view{ data: { name: @environment.name, id: @environment.id, delete_path: environment_delete_path(@environment)} }
- if @environment.available? && can?(current_user, :stop_environment, @environment)
@@ -67,7 +66,7 @@
%p.blank-state-text
= html_escape(_("Define environments in the deploy stage(s) in %{code_open}.gitlab-ci.yml%{code_close} to track deployments here.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
.text-center
- = link_to _("Read more"), help_page_path("ci/environments"), class: "btn btn-success"
+ = link_to _("Read more"), help_page_path("ci/environments/index.md"), class: "btn btn-success"
- else
.table-holder.gl-overflow-visible
.ci-table.environments{ role: 'grid' }
diff --git a/app/views/projects/environments/terminal.html.haml b/app/views/projects/environments/terminal.html.haml
index 3a705d736f3..ed0bc0680d7 100644
--- a/app/views/projects/environments/terminal.html.haml
+++ b/app/views/projects/environments/terminal.html.haml
@@ -1,5 +1,5 @@
- page_title _("Terminal for environment"), @environment.name
-
+- add_page_specific_style 'page_bundles/terminal'
- content_for :page_specific_javascripts do
= stylesheet_link_tag "xterm.css"
@@ -18,4 +18,4 @@
= render 'projects/deployments/actions', deployment: @environment.last_deployment
.terminal-container{ class: container_class }
- #terminal{ data: { project_path: "#{terminal_project_environment_path(@project, @environment)}.ws" } }
+ #terminal.gl-mt-4{ data: { project_path: "#{terminal_project_environment_path(@project, @environment)}.ws" } }
diff --git a/app/views/projects/error_tracking/details.html.haml b/app/views/projects/error_tracking/details.html.haml
index 7015dcdcb05..4a14e34cbf1 100644
--- a/app/views/projects/error_tracking/details.html.haml
+++ b/app/views/projects/error_tracking/details.html.haml
@@ -1,4 +1,5 @@
- page_title _('Error Details')
- add_to_breadcrumbs 'Errors', project_error_tracking_index_path(@project)
+- add_page_specific_style 'page_bundles/error_tracking_details'
#js-error_details{ data: error_details_data(@project, @issue_id) }
diff --git a/app/views/projects/error_tracking/index.html.haml b/app/views/projects/error_tracking/index.html.haml
index 96f61584a99..ffe0785d327 100644
--- a/app/views/projects/error_tracking/index.html.haml
+++ b/app/views/projects/error_tracking/index.html.haml
@@ -1,3 +1,4 @@
- page_title _('Errors')
+- add_page_specific_style 'page_bundles/error_tracking_index'
#js-error_tracking{ data: error_tracking_data(@current_user, @project) }
diff --git a/app/views/projects/feature_flags/_errors.html.haml b/app/views/projects/feature_flags/_errors.html.haml
deleted file mode 100644
index a32245640be..00000000000
--- a/app/views/projects/feature_flags/_errors.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-#error_explanation
- .alert.alert-danger
- - @feature_flag.errors.full_messages.each do |message|
- %p= message
diff --git a/app/views/projects/feature_flags/edit.html.haml b/app/views/projects/feature_flags/edit.html.haml
index 4de41ca4080..028595aba0b 100644
--- a/app/views/projects/feature_flags/edit.html.haml
+++ b/app/views/projects/feature_flags/edit.html.haml
@@ -1,4 +1,4 @@
-- @gfm_form = Feature.enabled?(:feature_flags_issue_links, @project, default_enabled: true)
+- @gfm_form = true
- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project)
- breadcrumb_title @feature_flag.name
@@ -9,7 +9,7 @@
feature_flags_path: project_feature_flags_path(@project),
environments_endpoint: search_project_environments_path(@project, format: :json),
user_callouts_path: user_callouts_path,
- user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERISION,
+ user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERSION,
show_user_callout: show_feature_flags_new_version?.to_s,
strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'),
environments_scope_docs_path: help_page_path('ci/environments', anchor: 'scoping-environments-with-specs'),
diff --git a/app/views/projects/feature_flags/index.html.haml b/app/views/projects/feature_flags/index.html.haml
index f425de91d12..7d48cba74d0 100644
--- a/app/views/projects/feature_flags/index.html.haml
+++ b/app/views/projects/feature_flags/index.html.haml
@@ -7,6 +7,8 @@
"feature-flags-help-page-path" => help_page_path("operations/feature_flags"),
"feature-flags-client-libraries-help-page-path" => help_page_path("operations/feature_flags", anchor: "choose-a-client-library"),
"feature-flags-client-example-help-page-path" => help_page_path("operations/feature_flags", anchor: "golang-application-example"),
+ "feature-flags-limit-exceeded" => @project.actual_limits.exceeded?(:project_feature_flags, @project.operations_feature_flags.count),
+ "feature-flags-limit" => @project.actual_limits.project_feature_flags,
"unleash-api-url" => (unleash_api_url(@project) if can?(current_user, :admin_feature_flag, @project)),
"unleash-api-instance-id" => (unleash_api_instance_id(@project) if can?(current_user, :admin_feature_flag, @project)),
"can-user-admin-feature-flag" => can?(current_user, :admin_feature_flag, @project),
diff --git a/app/views/projects/feature_flags/new.html.haml b/app/views/projects/feature_flags/new.html.haml
index a7388361da5..3bad1d9773c 100644
--- a/app/views/projects/feature_flags/new.html.haml
+++ b/app/views/projects/feature_flags/new.html.haml
@@ -7,7 +7,7 @@
feature_flags_path: project_feature_flags_path(@project),
environments_endpoint: search_project_environments_path(@project, format: :json),
user_callouts_path: user_callouts_path,
- user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERISION,
+ user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERSION,
show_user_callout: show_feature_flags_new_version?.to_s,
strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'),
environments_scope_docs_path: help_page_path('ci/environments', anchor: 'scoping-environments-with-specs'),
diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml
index dd49e8bdb4b..cfef2a19420 100644
--- a/app/views/projects/forks/_fork_button.html.haml
+++ b/app/views/projects/forks/_fork_button.html.haml
@@ -10,11 +10,11 @@
%h5.gl-mt-3
= namespace.human_name
- if forked_project = namespace.find_fork_of(@project)
- = link_to _("Go to project"), project_path(forked_project), class: "btn"
+ = link_to _("Go to project"), project_path(forked_project), class: "btn gl-button btn-default"
- else
%div{ class: ('has-tooltip' unless can_create_project),
title: (_('You have reached your project limit') unless can_create_project) }
= link_to _("Select"), project_forks_path(@project, namespace_key: namespace.id),
data: { qa_selector: 'fork_namespace_button', qa_name: namespace.human_name },
method: "POST",
- class: ["btn btn-success", ("disabled" unless can_create_project)]
+ class: ["btn gl-button btn-success", ("disabled" unless can_create_project)]
diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml
index 8384561891a..67dc07fb785 100644
--- a/app/views/projects/forks/index.html.haml
+++ b/app/views/projects/forks/index.html.haml
@@ -30,11 +30,11 @@
- if current_user && can?(current_user, :fork_project, @project)
- if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2
- = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn btn-success' do
+ = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn gl-button btn-success' do
= sprite_icon('fork', size: 12)
%span= _('Fork')
- else
- = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn btn-success' do
+ = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn gl-button btn-success' do
= sprite_icon('fork', size: 12)
%span= _('Fork')
diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
index 1118b44d7a2..e341831e17d 100644
--- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
+++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
@@ -75,7 +75,7 @@
- if generic_commit_status.finished_at
%p.finished-at
- = icon("calendar")
+ = sprite_icon("calendar")
%span= time_ago_with_tooltip(generic_commit_status.finished_at)
%td.coverage
diff --git a/app/views/projects/group_links/update.js.haml b/app/views/projects/group_links/update.js.haml
deleted file mode 100644
index 55520fda494..00000000000
--- a/app/views/projects/group_links/update.js.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-:plain
- var $listItem = $('#{escape_javascript(render('shared/members/group', group_link: @group_link))}');
- $("#group_member_#{@group_link.id} .list-item-name").replaceWith($listItem.find('.list-item-name'));
- gl.utils.localTimeAgo($('.js-timeago'), $("#group_member_#{@group_link.id}"));
diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml
index 8a8c396a9e4..ebe179c3454 100644
--- a/app/views/projects/hook_logs/show.html.haml
+++ b/app/views/projects/hook_logs/show.html.haml
@@ -7,6 +7,6 @@
%h4.gl-mt-0
Request details
.col-lg-9
- = link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn btn-default float-right gl-ml-3"
+ = link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn gl-button btn-default float-right gl-ml-3"
= render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log }
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index f728ef5ac1a..fb19b251d41 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -10,9 +10,9 @@
= form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
- %span>= f.submit 'Save changes', class: 'btn btn-success gl-mr-3'
+ = f.submit 'Save changes', class: 'btn gl-button btn-success gl-mr-3'
= render 'shared/web_hooks/test_button', hook: @hook
- = link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: _('Are you sure?') }
+ = link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn gl-button btn-danger float-right', data: { confirm: _('Are you sure?') }
%hr
diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml
index 5c6a87ddb26..e40c36da29d 100644
--- a/app/views/projects/hooks/index.html.haml
+++ b/app/views/projects/hooks/index.html.haml
@@ -9,7 +9,6 @@
.col-lg-8.gl-mb-3
= form_for @hook, as: :hook, url: polymorphic_path([@project, :hooks]) do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
- .gl-display-flex.gl-justify-content-end
- = f.submit 'Add webhook', class: 'btn btn-success'
+ = f.submit 'Add webhook', class: 'btn btn-success'
= render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class
diff --git a/app/views/projects/incidents/_new_branch.html.haml b/app/views/projects/incidents/_new_branch.html.haml
new file mode 100644
index 00000000000..f250fbc4b8b
--- /dev/null
+++ b/app/views/projects/incidents/_new_branch.html.haml
@@ -0,0 +1 @@
+= render 'projects/issues/new_branch'
diff --git a/app/views/projects/incidents/index.html.haml b/app/views/projects/incidents/index.html.haml
index 3d66c254601..a89e93618bc 100644
--- a/app/views/projects/incidents/index.html.haml
+++ b/app/views/projects/incidents/index.html.haml
@@ -1,3 +1,3 @@
- page_title _('Incidents')
-#js-incidents{ data: incidents_data(@project) }
+#js-incidents{ data: incidents_data(@project, params) }
diff --git a/app/views/projects/incidents/show.html.haml b/app/views/projects/incidents/show.html.haml
new file mode 100644
index 00000000000..b0ddc85df5d
--- /dev/null
+++ b/app/views/projects/incidents/show.html.haml
@@ -0,0 +1 @@
+= render template: 'projects/issues/show'
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 4273130bbc2..e1f1d8bb8f7 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -1,4 +1,5 @@
- add_page_startup_api_call discussions_path(@issue)
+- add_page_startup_api_call notes_url
- @gfm_form = true
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index ba9ab50cb3a..4f188ae273c 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -35,7 +35,7 @@
- if issue.due_date
%span.issuable-due-date.d-none.d-sm-inline-block.has-tooltip{ class: "#{'cred' if issue.overdue?}", title: _('Due date') }
&nbsp;
- = icon('calendar')
+ = sprite_icon('calendar')
= issue.due_date.to_s(:medium)
- if issue.labels.any?
&nbsp;
@@ -54,7 +54,7 @@
%li.issuable-status
= _('CLOSED')
- if issue.assignees.any?
- %li
+ %li.gl-display-flex
= render 'shared/issuable/assignees', project: @project, issuable: issue
= render 'shared/issuable_meta_data', issuable: issue
diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml
index 1a557cce33c..fa08c39e407 100644
--- a/app/views/projects/issues/_issues.html.haml
+++ b/app/views/projects/issues/_issues.html.haml
@@ -4,12 +4,14 @@
- data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id)))
- default_empty_state_meta = { create_issue_path: new_project_issue_path(@project), svg_path: image_path('illustrations/issues.svg') }
- data_empty_state_meta = local_assigns.fetch(:data_empty_state_meta, default_empty_state_meta)
- - type = local_assigns.fetch(:type, '')
+ - type = local_assigns.fetch(:type, 'issues')
+ - if type == 'issues' && use_startup_call?
+ - add_page_startup_api_call(api_v4_projects_issues_path(id: @project.id, params: startup_call_params))
.js-issuables-list{ data: { endpoint: data_endpoint,
'empty-state-meta': data_empty_state_meta.to_json,
'can-bulk-edit': @can_bulk_update.to_json,
'sort-key': @sort,
- 'type': type } }
+ type: type } }
- else
- empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues')
%ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position') }
@@ -18,4 +20,4 @@
= render empty_state_path
- if @issues.present?
- = paginate @issues, theme: "gitlab", total_pages: @total_pages
+ = paginate_collection @issues, total_pages: @total_pages
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index 9bbab925f6a..aa95cecb5fe 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -43,7 +43,7 @@
%li.droplab-item-ignore.gl-ml-3.gl-mr-3.gl-mt-5
- if can_create_confidential_merge_request?
- #js-forked-project{ data: { namespace_path: @project.namespace.full_path, project_path: @project.full_path, new_fork_path: new_project_fork_path(@project), help_page_path: help_page_path('user/project/merge_requests') } }
+ #js-forked-project{ data: { namespace_path: @project.namespace.full_path, project_path: @project.full_path, new_fork_path: new_project_fork_path(@project), help_page_path: help_page_path('user/project/merge_requests/index.md') } }
.form-group
%label{ for: 'new-branch-name' }
= _('Branch name')
diff --git a/app/views/projects/issues/export_csv/_button.html.haml b/app/views/projects/issues/export_csv/_button.html.haml
index ef3fb438641..e5710fcdb60 100644
--- a/app/views/projects/issues/export_csv/_button.html.haml
+++ b/app/views/projects/issues/export_csv/_button.html.haml
@@ -1,4 +1,4 @@
- if current_user
- %button.csv_download_link.btn.has-tooltip{ title: _('Export as CSV'),
+ %button.csv_download_link.btn.gl-button.has-tooltip{ title: _('Export as CSV'),
data: { toggle: 'modal', target: '.issues-export-modal', qa_selector: 'export_as_csv_button' } }
= sprite_icon('export')
diff --git a/app/views/projects/issues/export_csv/_modal.html.haml b/app/views/projects/issues/export_csv/_modal.html.haml
index 793e43da935..6610af63445 100644
--- a/app/views/projects/issues/export_csv/_modal.html.haml
+++ b/app/views/projects/issues/export_csv/_modal.html.haml
@@ -10,12 +10,13 @@
%a.close{ href: '#', 'data-dismiss' => 'modal' }
= sprite_icon('close', css_class: 'gl-icon')
.modal-body
- .modal-subheader
- = icon('check', { class: 'checkmark' })
- %strong.gl-ml-3
- - issues_count = issuables_count_for_state(:issues, params[:state])
- = n_('%d issue selected', '%d issues selected', issues_count) % issues_count
+ - issues_count = issuables_count_for_state(:issues, params[:state])
+ - unless issues_count == -1 # The count timed out
+ .modal-subheader
+ = icon('check', { class: 'checkmark' })
+ %strong.gl-ml-3
+ = n_('%d issue selected', '%d issues selected', issues_count) % issues_count
.modal-text
= html_escape(_('The CSV export will be created in the background. Once finished, it will be sent to %{strong_open}%{email}%{strong_close} in an attachment.')) % { email: @current_user.notification_email, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
.modal-footer
- = link_to _('Export issues'), export_csv_project_issues_path(@project, request.query_parameters), method: :post, class: 'btn btn-success float-left', title: _('Export issues'), data: { track_label: "export_issues_csv", track_event: "click_button", track_value: "", qa_selector: "export_issues_button" }
+ = link_to _('Export issues'), export_csv_project_issues_path(@project, request.query_parameters), method: :post, class: 'btn gl-button btn-success float-left', title: _('Export issues'), data: { track_label: "export_issues_csv", track_event: "click_button", track_value: "", qa_selector: "export_issues_button" }
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index cfc423da57a..842b3432991 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -2,6 +2,7 @@
- page_title _("Issues")
- new_issue_email = @project.new_issuable_address(current_user, 'issue')
+- add_page_specific_style 'page_bundles/issues_list'
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} issues")
diff --git a/app/views/projects/issues/service_desk.html.haml b/app/views/projects/issues/service_desk.html.haml
index 65580a94cd0..b0d8791c566 100644
--- a/app/views/projects/issues/service_desk.html.haml
+++ b/app/views/projects/issues/service_desk.html.haml
@@ -1,7 +1,7 @@
- @can_bulk_update = false
- page_title _("Service Desk")
-
+- add_page_specific_style 'page_bundles/issues_list'
- content_for :breadcrumbs_extra do
= render "projects/issues/nav_btns", show_export_button: false, show_rss_button: false
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index c762b044c3e..7785093466b 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -2,8 +2,12 @@
- add_to_breadcrumbs _("Issues"), project_issues_path(@project)
- breadcrumb_title @issue.to_reference
- page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues")
-- page_description @issue.description
+- page_description @issue.description_html
- page_card_attributes @issue.card_attributes
+- if @issue.relocation_target
+ - page_canonical_link @issue.relocation_target.present(current_user: current_user).web_url
+- if @issue.sentry_issue.present?
+ - add_page_specific_style 'page_bundles/error_tracking_details'
- can_update_issue = can?(current_user, :update_issue, @issue)
- can_reopen_issue = can?(current_user, :reopen_issue, @issue)
@@ -61,15 +65,12 @@
.issue-details.issuable-details
.detail-page-description.content-block
- -# haml-lint:disable InlineJavaScript
- %script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue).to_json
- #js-issuable-app
+ #js-issuable-app{ data: { initial: issuable_initial_data(@issue).to_json} }
.title-container
%h2.title= markdown_field(@issue, :title)
- if @issue.description.present?
- .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' }
+ .description
.md= markdown_field(@issue, :description)
- %textarea.hidden.js-task-list-field= @issue.description
= edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
@@ -92,6 +93,7 @@
.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @issue, inline: true
.new-branch-col
+ = render_if_exists "projects/issues/timeline_toggle", issue: @issue
#js-vue-sort-issue-discussions
#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@issue), notes_filters: UserPreference.notes_filters.to_json } }
= render 'new_branch' if show_new_branch_button?
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index df98a1c7cce..d7a778088ee 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -1,9 +1,7 @@
- add_to_breadcrumbs _("Jobs"), project_jobs_path(@project)
- breadcrumb_title "##{@build.id}"
- page_title "#{@build.name} (##{@build.id})", _("Jobs")
-
-- content_for :page_specific_javascripts do
- = stylesheet_link_tag 'page_bundles/xterm'
+- add_page_specific_style 'page_bundles/xterm'
= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
diff --git a/app/views/projects/jobs/terminal.html.haml b/app/views/projects/jobs/terminal.html.haml
index 01f40543926..95acbcae6d9 100644
--- a/app/views/projects/jobs/terminal.html.haml
+++ b/app/views/projects/jobs/terminal.html.haml
@@ -2,9 +2,10 @@
- add_to_breadcrumbs "##{@build.id}", project_job_path(@project, @build)
- breadcrumb_title _('Terminal')
- page_title _('Terminal'), "#{@build.name} (##{@build.id})", _('Jobs')
-
+- add_page_specific_style 'page_bundles/terminal'
- content_for :page_specific_javascripts do
= stylesheet_link_tag "xterm.css"
+
.terminal-container
- #terminal{ data: { project_path: terminal_project_job_path(@project, @build, format: :ws) } }
+ #terminal.gl-mt-4{ data: { project_path: terminal_project_job_path(@project, @build, format: :ws) } }
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index 8d8270847a3..2699192adc9 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -52,5 +52,5 @@
= render 'shared/empty_states/labels'
%template#js-badge-item-template
- %li.label-link-item.js-priority-badge.inline.gl-ml-3
- .label-badge.label-badge-blue= _('Prioritized label')
+ %li.js-priority-badge.inline.gl-ml-3
+ .label-badge.gl-bg-blue-50= _('Prioritized label')
diff --git a/app/views/projects/merge_requests/_description.html.haml b/app/views/projects/merge_requests/_description.html.haml
index 354a384b647..c20479662dd 100644
--- a/app/views/projects/merge_requests/_description.html.haml
+++ b/app/views/projects/merge_requests/_description.html.haml
@@ -3,7 +3,6 @@
.description.qa-description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' }
.md
= markdown_field(@merge_request, :description)
- %textarea.hidden.js-task-list-field
- = @merge_request.description
+ %textarea.hidden.js-task-list-field{ data: { value: @merge_request.description } }
= edited_time_ago_with_tooltip(@merge_request, placement: 'bottom')
diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml
deleted file mode 100644
index ecb51aca847..00000000000
--- a/app/views/projects/merge_requests/_discussion.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-- content_for :note_actions do
- - if can?(current_user, :update_merge_request, @merge_request)
- - if @merge_request.open?
- = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: { original_text: "Close merge request", alternative_text: "Comment & close merge request"}
- - if @merge_request.reopenable?
- = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-close js-note-target-reopen", title: "Reopen merge request", data: { original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"}
- %comment-and-resolve-btn{ "inline-template" => true }
- %button.btn.btn-nr.btn-default.gl-mr-3.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } }
- {{ buttonText }}
-
-#notes= render "shared/notes/notes_with_form", :autocomplete => true
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index ad0f4d03f9a..092055a5f85 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -53,8 +53,11 @@
= link_to merge_request_path(merge_request), class: "has-tooltip", title: _('Cannot be merged automatically') do
= sprite_icon('warning-solid')
- if merge_request.assignees.any?
- %li.d-flex
+ %li.gl-display-flex.gl-align-items-center
= render 'shared/issuable/assignees', project: merge_request.project, issuable: merge_request
+ - if Feature.enabled?(:merge_request_reviewers, @project) && merge_request.reviewers.any?
+ %li.gl-display-flex.issuable-reviewers
+ = render 'shared/issuable/reviewers', project: merge_request.project, issuable: merge_request
= render 'projects/merge_requests/approvals_count', merge_request: merge_request
= render 'shared/issuable_meta_data', issuable: merge_request
diff --git a/app/views/projects/merge_requests/_merge_requests.html.haml b/app/views/projects/merge_requests/_merge_requests.html.haml
index 57fbd360d46..e2123e36e67 100644
--- a/app/views/projects/merge_requests/_merge_requests.html.haml
+++ b/app/views/projects/merge_requests/_merge_requests.html.haml
@@ -5,4 +5,4 @@
= render 'shared/empty_states/merge_requests'
- if @merge_requests.present?
- = paginate @merge_requests, theme: "gitlab", total_pages: @total_pages
+ = paginate_collection @merge_requests, total_pages: @total_pages
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index 454a0694355..b56e2c3f985 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -32,16 +32,19 @@
%ul
- if can_update_merge_request
%li= link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
- - unless current_user == @merge_request.author
- %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request))
- if can_update_merge_request
+ - unless @merge_request.closed?
+ %li
+ = link_to @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft'), toggle_draft_issuable_path(@merge_request), method: :put, class: "js-draft-toggle-button"
%li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] }
= link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request'
- if can_reopen_merge_request
%li{ class: merge_request_button_visibility(@merge_request, false) }
= link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request'
+ - unless current_user == @merge_request.author
+ %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request))
- if can_update_merge_request
- = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-sm-none d-md-block btn btn-grouped js-issuable-edit qa-edit-button"
+ = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-sm-none d-md-block btn gl-button btn-grouped js-issuable-edit qa-edit-button"
= render 'shared/issuable/close_reopen_button', issuable: @merge_request, can_update: can_update_merge_request, can_reopen: can_reopen_merge_request
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
index 28ba2b6ac75..a7ffe825139 100644
--- a/app/views/projects/merge_requests/conflicts/show.html.haml
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -1,4 +1,5 @@
- page_title _("Merge Conflicts"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge Requests")
+- add_page_specific_style 'page_bundles/merge_conflicts'
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/ace.js')
= render "projects/merge_requests/mr_title"
diff --git a/app/views/projects/merge_requests/creations/new.html.haml b/app/views/projects/merge_requests/creations/new.html.haml
index ad4980fa57f..4c968c8e8eb 100644
--- a/app/views/projects/merge_requests/creations/new.html.haml
+++ b/app/views/projects/merge_requests/creations/new.html.haml
@@ -1,6 +1,7 @@
- add_to_breadcrumbs _("Merge Requests"), project_merge_requests_path(@project)
- breadcrumb_title _("New")
- page_title _("New Merge Request")
+- add_page_specific_style 'page_bundles/pipelines'
- if @merge_request.can_be_created && !params[:change_branches]
= render 'new_submit'
diff --git a/app/views/projects/merge_requests/diffs/_commit_widget.html.haml b/app/views/projects/merge_requests/diffs/_commit_widget.html.haml
deleted file mode 100644
index c022d2c70d8..00000000000
--- a/app/views/projects/merge_requests/diffs/_commit_widget.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
--#-----------------------------------------------------------------
- WARNING: Please keep changes up-to-date with the following files:
- - `assets/javascripts/diffs/components/commit_widget.vue`
--#-----------------------------------------------------------------
-- collapsible = local_assigns.fetch(:collapsible, true)
-
-- if @commit
- .info-well.mw-100.mx-0
- .well-segment
- %ul.blob-commit-info
- = render 'projects/commits/commit', commit: @commit, merge_request: @merge_request, view_details: true, collapsible: collapsible
diff --git a/app/views/projects/merge_requests/diffs/_different_base.html.haml b/app/views/projects/merge_requests/diffs/_different_base.html.haml
deleted file mode 100644
index 06a15b96653..00000000000
--- a/app/views/projects/merge_requests/diffs/_different_base.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-- if @merge_request_diff && different_base?(@start_version, @merge_request_diff)
- .mr-version-controls
- .content-block
- = sprite_icon('information-o')
- Selected versions have different base commits.
- Changes will include
- = link_to project_compare_path(@project, from: @start_version.base_commit_sha, to: @merge_request_diff.base_commit_sha) do
- new commits
- from
- = succeed '.' do
- %code.ref-name= @merge_request.target_branch
diff --git a/app/views/projects/merge_requests/diffs/_diffs.html.haml b/app/views/projects/merge_requests/diffs/_diffs.html.haml
deleted file mode 100644
index 9ebd91dea0b..00000000000
--- a/app/views/projects/merge_requests/diffs/_diffs.html.haml
+++ /dev/null
@@ -1,21 +0,0 @@
-= render 'projects/merge_requests/diffs/version_controls'
-= render 'projects/merge_requests/diffs/different_base'
-= render 'projects/merge_requests/diffs/not_all_comments_displayed'
-= render 'projects/merge_requests/diffs/commit_widget'
-
-- if @merge_request_diff&.empty?
- .row.empty-state.nothing-here-block
- .col-12
- .svg-content= image_tag 'illustrations/merge_request_changes_empty.svg'
- .col-12
- .text-content.text-center
- %p
- No changes between
- %span.ref-name= @merge_request.source_branch
- and
- %span.ref-name= @merge_request.target_branch
- .text-center= link_to 'Create commit', project_new_blob_path(@project, @merge_request.source_branch), class: 'btn btn-success'
-- else
- - diff_viewable = @merge_request_diff ? @merge_request_diff.viewable? : true
- - if diff_viewable
- = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, merge_request: true
diff --git a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml b/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml
deleted file mode 100644
index b9dc37c9b54..00000000000
--- a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-- if @commit || @start_version || (@merge_request_diff && !@merge_request_diff.latest?)
- .mr-version-controls
- .content-block.comments-disabled-notif.clearfix
- = sprite_icon('information-o')
- = succeed '.' do
- - if @commit
- Only comments from the following commit are shown below
- - else
- Not all comments are displayed because you're
- - if @start_version
- comparing two versions of the diff
- - else
- viewing an old version of the diff
- .float-right
- = link_to diffs_project_merge_request_path(@merge_request.project, @merge_request), class: 'btn btn-sm' do
- Show latest version
- = "of the diff" if @commit
diff --git a/app/views/projects/merge_requests/diffs/_version_controls.html.haml b/app/views/projects/merge_requests/diffs/_version_controls.html.haml
deleted file mode 100644
index 52bf584d550..00000000000
--- a/app/views/projects/merge_requests/diffs/_version_controls.html.haml
+++ /dev/null
@@ -1,73 +0,0 @@
-- if @merge_request_diff && @merge_request_diffs.size > 1
- .mr-version-controls
- .mr-version-menus-container.content-block
- Changes between
- %span.dropdown.inline.mr-version-dropdown
- %a.dropdown-toggle.btn.btn-default{ data: { toggle: :dropdown, display: 'static' } }
- %span
- - if @merge_request_diff.latest?
- latest version
- - else
- version #{version_index(@merge_request_diff)}
- = icon('caret-down')
- .dropdown-menu.dropdown-select.dropdown-menu-selectable
- .dropdown-title
- %span Version:
- %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
- = icon('times', class: 'dropdown-menu-close-icon')
- .dropdown-content
- %ul
- - @merge_request_diffs.each do |merge_request_diff|
- %li
- = link_to merge_request_version_path(@project, @merge_request, merge_request_diff, @start_sha), class: ('is-active' if merge_request_diff == @merge_request_diff) do
- %div
- %strong
- - if merge_request_diff.latest?
- latest version
- - else
- version #{version_index(merge_request_diff)}
- %div
- %small.commit-sha= short_sha(merge_request_diff.head_commit_sha)
- %div
- %small
- #{number_with_delimiter(merge_request_diff.commits_count)} #{'commit'.pluralize(merge_request_diff.commits_count)},
- = time_ago_with_tooltip(merge_request_diff.created_at)
-
- - if @merge_request_diff.base_commit_sha
- and
- %span.dropdown.inline.mr-version-compare-dropdown
- %a.btn.btn-default.dropdown-toggle{ data: { toggle: :dropdown, display: 'static' } }
- - if @start_version
- version #{version_index(@start_version)}
- - else
- %span.ref-name= @merge_request.target_branch
- = icon('caret-down')
- .dropdown-menu.dropdown-select.dropdown-menu-selectable
- .dropdown-title
- %span Compared with:
- %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
- = icon('times', class: 'dropdown-menu-close-icon')
- .dropdown-content
- %ul
- - @comparable_diffs.each do |merge_request_diff|
- %li
- = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff, merge_request_diff.head_commit_sha), class: ('is-active' if merge_request_diff == @start_version) do
- %div
- %strong
- - if merge_request_diff.latest?
- latest version
- - else
- version #{version_index(merge_request_diff)}
- %div
- %small.commit-sha= short_sha(merge_request_diff.head_commit_sha)
- %div
- %small
- = time_ago_with_tooltip(merge_request_diff.created_at)
- %li
- = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_version) do
- %div
- %strong
- %span.ref-name= @merge_request.target_branch
- (base)
- %div
- %strong.commit-sha= short_sha(@merge_request_diff.base_commit_sha)
diff --git a/app/views/projects/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml
index 7b831aa2d01..df942c11883 100644
--- a/app/views/projects/merge_requests/invalid.html.haml
+++ b/app/views/projects/merge_requests/invalid.html.haml
@@ -1,25 +1,28 @@
- page_title "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge Requests")
+- badge_css_classes = "badge gl-text-white"
+- badge_info_css_classes = "#{badge_css_classes} badge-info"
+- badge_inverse_css_classes = "#{badge_css_classes} badge-inverse"
.merge-request
= render "projects/merge_requests/mr_title"
= render "projects/merge_requests/mr_box"
- .alert.alert-danger
+ .gl-alert.gl-alert-danger
+ = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
%p
We cannot render this merge request properly because
- if @merge_request.for_fork? && !@merge_request.source_project
fork project was removed
- elsif !@merge_request.source_branch_exists?
- %span.badge.badge-inverse= @merge_request.source_branch
+ %span{ class: badge_inverse_css_classes }= @merge_request.source_branch
does not exist in
- %span.badge.badge-info= @merge_request.source_project_path
+ %span{ class: badge_info_css_classes }= @merge_request.source_project_path
- elsif !@merge_request.target_branch_exists?
- %span.badge.badge-inverse= @merge_request.target_branch
+ %span{ class: badge_inverse_css_classes }= @merge_request.target_branch
does not exist in
- %span.badge.badge-info= @merge_request.target_project_path
+ %span{ class: badge_info_css_classes }= @merge_request.target_project_path
- else
of internal error
%strong
Please close Merge Request or change branches with existing one
-
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index b579f7510f9..1dbcd613ceb 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -3,11 +3,14 @@
- add_to_breadcrumbs _("Merge Requests"), project_merge_requests_path(@project)
- breadcrumb_title @merge_request.to_reference
- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", _("Merge Requests")
-- page_description @merge_request.description
+- page_description @merge_request.description_html
- page_card_attributes @merge_request.card_attributes
- suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes')
- number_of_pipelines = @pipelines.size
- mr_action = j(params[:tab].presence || 'show')
+- add_page_specific_style 'page_bundles/merge_requests'
+- add_page_specific_style 'page_bundles/pipelines'
+- add_page_specific_style 'page_bundles/reports'
.merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } }
= render "projects/merge_requests/mr_title"
@@ -43,7 +46,6 @@
.tab-content#diff-notes-app
#js-diff-file-finder
- - if native_code_navigation_enabled?(@project)
#js-code-navigation
= render "projects/merge_requests/tabs/pane", id: "notes", class: "notes voting_notes" do
.row
@@ -92,7 +94,7 @@
.loading.hide
.spinner.spinner-md
-= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, source_branch: @merge_request.source_branch
+= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch
- if @merge_request.can_be_reverted?(current_user)
= render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title
diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml
index 2bab2a0fb03..2c52d2a5fbc 100644
--- a/app/views/projects/milestones/index.html.haml
+++ b/app/views/projects/milestones/index.html.haml
@@ -1,4 +1,5 @@
- page_title _('Milestones')
+- add_page_specific_style 'page_bundles/milestone'
.top-area
= render 'shared/milestones_filter', counts: milestone_counts(@project.milestones)
@@ -11,7 +12,7 @@
= _('New milestone')
.milestones
- #delete-milestone-modal
+ #js-delete-milestone-modal
#promote-milestone-modal
%ul.content-list
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index 99e626161c4..2185df3a994 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -1,7 +1,8 @@
- add_to_breadcrumbs _('Milestones'), project_milestones_path(@project)
- breadcrumb_title @milestone.title
- page_title @milestone.title, _('Milestones')
-- page_description @milestone.description
+- page_description @milestone.description_html
+- add_page_specific_style 'page_bundles/milestone'
= render 'shared/milestones/header', milestone: @milestone
= render 'shared/milestones/description', milestone: @milestone
@@ -9,11 +10,15 @@
= render_if_exists 'shared/milestones/burndown', milestone: @milestone, project: @project
- if can?(current_user, :read_issue, @project) && @milestone.total_issues_count == 0
- .alert.alert-success.gl-mt-3
- %span= _('Assign some issues to this milestone.')
+ .gl-alert.gl-alert-info.gl-mt-3.gl-mb-5{ data: { testid: 'no-issues-alert' } }
+ = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body
+ %span= _('Assign some issues to this milestone.')
- elsif @milestone.complete? && @milestone.active?
- .alert.alert-success.gl-mt-3
- %span= _('All issues for this milestone are closed. You may close this milestone now.')
+ .gl-alert.gl-alert-success.gl-mt-3.gl-mb-5{ data: { testid: 'all-issues-closed-alert' } }
+ = sprite_icon('check-circle', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body
+ %span= _('All issues for this milestone are closed. You may close this milestone now.')
= render 'shared/milestones/tabs', milestone: @milestone
= render 'shared/milestones/sidebar', milestone: @milestone, project: @project, affix_offset: 153
diff --git a/app/views/projects/milestones/update.js.haml b/app/views/projects/milestones/update.js.haml
deleted file mode 100644
index 3ff84915e97..00000000000
--- a/app/views/projects/milestones/update.js.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-:plain
- $('##{dom_id(@milestone)}').fadeOut();
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index d7098bbb69d..d2847de6ece 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -6,7 +6,7 @@
%section.settings.project-mirror-settings.no-animate#js-push-remote-settings{ class: mirror_settings_class, data: { qa_selector: 'mirroring_repositories_settings_content' } }
.settings-header
%h4= _('Mirroring repositories')
- %button.btn.js-settings-toggle
+ %button.btn.gl-button.js-settings-toggle
= expanded ? _('Collapse') : _('Expand')
%p
= _('Set up your project to automatically push and/or pull changes to/from another repository. Branches, tags, and commits will be synced automatically.')
@@ -32,7 +32,7 @@
= label_tag :only_protected_branches, _('Only mirror protected branches'), class: 'form-check-label'
= link_to sprite_icon('question-o'), help_page_path('user/project/protected_branches'), target: '_blank'
- .panel-footer.gl-display-flex.gl-justify-content-end
+ .panel-footer
= f.submit _('Mirror repository'), class: 'btn btn-success js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror
- else
.gl-alert.gl-alert-info{ role: 'alert' }
@@ -74,4 +74,4 @@
- if mirror.ssh_key_auth?
= clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key'), qa_selector: 'copy_public_key_button')
= render 'shared/remote_mirror_update_button', remote_mirror: mirror
- %button.js-delete-mirror.qa-delete-mirror.rspec-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= sprite_icon('remove')
+ %button.js-delete-mirror.qa-delete-mirror.rspec-delete-mirror.btn.gl-button.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= sprite_icon('remove')
diff --git a/app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml b/app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml
index 327552c9b2c..e6f3060af3e 100644
--- a/app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml
+++ b/app/views/projects/mirrors/_regenerate_public_ssh_key_confirm_modal.html.haml
@@ -9,5 +9,5 @@
.modal-body
%p= _('Are you sure you want to regenerate the public key? You will have to update the public key on the remote server before mirroring will work again.')
.form-actions.modal-footer
- = button_tag _('Cancel'), type: 'button', class: 'btn js-cancel'
- = button_tag _('Regenerate key'), type: 'button', class: 'btn btn-inverted btn-warning js-confirm'
+ = button_tag _('Cancel'), type: 'button', class: 'btn gl-button js-cancel'
+ = button_tag _('Regenerate key'), type: 'button', class: 'btn gl-button btn-inverted btn-warning js-confirm'
diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml
index 786918c4970..1690188f07a 100644
--- a/app/views/projects/mirrors/_ssh_host_keys.html.haml
+++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml
@@ -3,7 +3,7 @@
- verified_at = mirror.ssh_known_hosts_verified_at
.form-group.js-ssh-host-keys-section{ class: ('collapse' unless mirror.ssh_mirror_url?) }
- %button.btn.btn-inverted.btn-secondary.inline.js-detect-host-keys.gl-mr-3{ type: 'button', data: { qa_selector: 'detect_host_keys' } }
+ %button.btn.gl-button.btn-inverted.btn-secondary.inline.js-detect-host-keys.gl-mr-3{ type: 'button', data: { qa_selector: 'detect_host_keys' } }
.js-spinner.d-none.spinner.mr-1
= _('Detect host keys')
.fingerprint-ssh-info.js-fingerprint-ssh-info.gl-mt-3.gl-mb-3{ class: ('collapse' unless mirror.ssh_mirror_url?) }
@@ -23,7 +23,7 @@
#{time_ago_in_words(verified_at)} ago
.js-ssh-hosts-advanced.inline
- %button.btn.btn-default.btn-show-advanced.show-advanced{ type: 'button' }
+ %button.btn.gl-button.btn-default.btn-show-advanced.show-advanced{ type: 'button' }
%span.label-show
= _('Input host keys manually')
%span.label-hide
diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml
index d134bfb488e..4366676bd45 100644
--- a/app/views/projects/network/show.html.haml
+++ b/app/views/projects/network/show.html.haml
@@ -2,12 +2,12 @@
- page_title _("Graph"), @ref
= render "head"
%div{ class: container_class }
- .project-network
- .controls
+ .project-network.gl-border-1.gl-border-solid.gl-border-gray-300
+ .controls.gl-bg-gray-50.gl-p-2.gl-font-base.gl-text-gray-400.gl-border-b-1.gl-border-b-solid.gl-border-b-gray-300
= form_tag project_network_path(@project, @id), method: :get, class: 'form-inline network-form' do |f|
= text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: _("Git revision"), class: 'search-input form-control input-mx-250 search-sha'
= button_tag class: 'btn btn-success' do
- = icon('search')
+ = sprite_icon('search')
.inline.prepend-left-20
.form-check.light
= check_box_tag :filter_ref, 1, @options[:filter_ref], class: 'form-check-input'
@@ -15,6 +15,6 @@
%span= _("Begin with the selected commit")
- if @commit
- .network-graph{ data: { url: @url, commit_url: @commit_url, ref: @ref, commit_id: @commit.id } }
+ .network-graph.gl-bg-white.gl-overflow-scroll.gl-overflow-x-hidden{ data: { url: @url, commit_url: @commit_url, ref: @ref, commit_id: @commit.id } }
.text-center.gl-mt-3
.spinner.spinner-md
diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml
index 0ab9d9c4005..c44d3da23bb 100644
--- a/app/views/projects/no_repo.html.haml
+++ b/app/views/projects/no_repo.html.haml
@@ -22,4 +22,4 @@
- if can? current_user, :remove_project, @project
.prepend-top-20
- = link_to _('Delete project'), project_path(@project), data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-inverted btn-remove float-right"
+ = link_to _('Delete project'), project_path(@project), data: { confirm: remove_project_message(@project)}, method: :delete, class: "gl-button btn btn-danger btn-danger-secondary float-right"
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
index 058366eb75d..a785e36fad5 100644
--- a/app/views/projects/notes/_actions.html.haml
+++ b/app/views/projects/notes/_actions.html.haml
@@ -2,7 +2,7 @@
- if note.noteable_author?(@noteable)
%span{ class: 'note-role user-access-role has-tooltip d-none d-md-inline-block', title: _("This user is the author of this %{noteable}.") % { noteable: @noteable.human_class_name } }= _("Author")
- if access
- %span{ class: 'note-role user-access-role has-tooltip', title: _("This user is a %{access} of the %{name} project.") % { access: access.downcase, name: note.project_name } }= access
+ %span{ class: 'note-role user-access-role has-tooltip', title: _("This user has the %{access} role in the %{name} project.") % { access: access.downcase, name: note.project_name } }= access
- elsif note.contributor?
%span{ class: 'note-role user-access-role has-tooltip', title: _("This user has previously committed to the %{name} project.") % { name: note.project_name } }= _("Contributor")
diff --git a/app/views/projects/packages/packages/show.html.haml b/app/views/projects/packages/packages/show.html.haml
index 97a3c6e7092..aeca3f5b3e3 100644
--- a/app/views/projects/packages/packages/show.html.haml
+++ b/app/views/projects/packages/packages/show.html.haml
@@ -24,4 +24,5 @@
composer_help_path: help_page_path('user/packages/composer_repository/index'),
project_name: @project.name,
project_list_url: project_packages_path(@project),
- group_list_url: @project.group ? group_packages_path(@project.group) : ''} }
+ group_list_url: @project.group ? group_packages_path(@project.group) : '',
+ composer_config_repository_name: composer_config_repository_name(@project.group&.id)} }
diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml
index 58dbbb5bcfc..2714b5f221a 100644
--- a/app/views/projects/pages/_destroy.haml
+++ b/app/views/projects/pages/_destroy.haml
@@ -8,7 +8,7 @@
%p
= s_('GitLabPages|Removing pages will prevent them from being exposed to the outside world.')
.form-actions
- = link_to s_('GitLabPages|Remove pages'), project_pages_path(@project), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn btn-remove"
+ = link_to s_('GitLabPages|Remove pages'), project_pages_path(@project), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn gl-button btn-danger"
- else
.nothing-here-block
= s_('GitLabPages|Only project maintainers can remove pages')
diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml
index af6de10b2a0..84c8ab0ceba 100644
--- a/app/views/projects/pages/_list.html.haml
+++ b/app/views/projects/pages/_list.html.haml
@@ -21,8 +21,8 @@
%span.badge.badge-danger
= s_('GitLabPages|Expired')
%div
- = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped btn-success btn-inverted"
- = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn btn-remove btn-sm btn-grouped"
+ = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn gl-button btn-sm btn-grouped btn-success btn-inverted"
+ = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn gl-button btn-danger btn-sm btn-grouped"
- if domain.needs_verification?
%li.list-group-item.bs-callout-warning
- details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe
diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml
index 8aa02074205..51483176d6f 100644
--- a/app/views/projects/pages/_pages_settings.html.haml
+++ b/app/views/projects/pages/_pages_settings.html.haml
@@ -1,6 +1,7 @@
= form_for @project, url: project_pages_path(@project), html: { class: 'inline', title: pages_https_only_title } do |f|
+ = render_if_exists 'shared/pages/max_pages_size_input', form: f
+
- if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
- = render_if_exists 'shared/pages/max_pages_size_input', form: f
.form-group
.form-check
@@ -9,5 +10,5 @@
%strong
= s_('GitLabPages|Force HTTPS (requires valid certificates)')
- .gl-mt-3
- = f.submit s_('GitLabPages|Save'), class: 'btn btn-success'
+ .gl-mt-3
+ = f.submit s_('GitLabPages|Save'), class: 'btn btn-success gl-button'
diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml
index 8a01945ffac..4347cbdbd9b 100644
--- a/app/views/projects/pages/show.html.haml
+++ b/app/views/projects/pages/show.html.haml
@@ -5,7 +5,7 @@
= s_('GitLabPages|Pages')
- if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https)
- = link_to new_project_pages_domain_path(@project), class: 'btn btn-success float-right', title: s_('GitLabPages|New Domain') do
+ = link_to new_project_pages_domain_path(@project), class: 'btn gl-button btn-success float-right', title: s_('GitLabPages|New Domain') do
= s_('GitLabPages|New Domain')
%p.light
diff --git a/app/views/projects/pages_domains/_certificate.html.haml b/app/views/projects/pages_domains/_certificate.html.haml
index 16d949c416b..33db7896065 100644
--- a/app/views/projects/pages_domains/_certificate.html.haml
+++ b/app/views/projects/pages_domains/_certificate.html.haml
@@ -40,7 +40,7 @@
= link_to _('Remove'),
clean_certificate_project_pages_domain_path(@project, domain_presenter),
data: { confirm: _('Are you sure?') },
- class: 'btn btn-remove btn-sm',
+ class: 'gl-button btn btn-danger btn-sm',
method: :delete
- else
.row
diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml
index 9e9f60a6f09..453134ce5ab 100644
--- a/app/views/projects/pages_domains/_form.html.haml
+++ b/app/views/projects/pages_domains/_form.html.haml
@@ -1,5 +1,6 @@
- if domain_presenter.errors.any?
- .alert.alert-danger
+ .gl-alert.gl-alert-danger
+ = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- domain_presenter.errors.full_messages.each do |msg|
= msg
diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
index ca71aa8a24d..45aaf2b64bf 100644
--- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -36,5 +36,5 @@
= link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn gl-display-flex' do
= sprite_icon('pencil')
- if can?(current_user, :admin_pipeline_schedule, pipeline_schedule)
- = link_to pipeline_schedule_path(pipeline_schedule), title: _('Delete'), method: :delete, class: 'btn btn-remove', data: { confirm: _("Are you sure you want to delete this pipeline schedule?") } do
+ = link_to pipeline_schedule_path(pipeline_schedule), title: _('Delete'), method: :delete, class: 'gl-button btn btn-danger', data: { confirm: _("Are you sure you want to delete this pipeline schedule?") } do
= sprite_icon('remove')
diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml
index 2b2b79d886b..91083cc0768 100644
--- a/app/views/projects/pipeline_schedules/index.html.haml
+++ b/app/views/projects/pipeline_schedules/index.html.haml
@@ -2,7 +2,7 @@
- page_title _("Pipeline Schedules")
-#pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules') } }
+#pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules'), image_url: image_path('illustrations/pipeline_schedule_callout.svg') } }
.top-area
- schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) }
= render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index c54a19b8f61..6d3b3f815e4 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -43,8 +43,8 @@
placement: "top",
html: "true",
trigger: "focus",
- title: "<div class='autodevops-title'>#{popover_title_text}</div>",
- content: "<a class='autodevops-link' href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>",
+ title: "<div class='gl-font-weight-normal gl-line-height-normal'>#{popover_title_text}</div>",
+ content: "<a href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>",
} }
Auto DevOps
- if @pipeline.detached_merge_request_pipeline?
@@ -58,7 +58,7 @@
.icon-container.commit-icon
= custom_icon("icon_commit")
= link_to commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha js-details-short"
- = link_to("#", class: "js-details-expand d-none d-sm-none d-md-inline") do
+ = link_to("#", class: "js-details-expand d-none d-md-inline") do
%span.text-expander
= sprite_icon('ellipsis_h', size: 12)
%span.js-details-content.hide
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index f1ed67f8f82..40a52f76641 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -68,7 +68,7 @@
%td.responsive-table-cell.build-failure{ data: { column: _('Failure')} }
= build.present.callout_failure_message
%td.responsive-table-cell.build-actions
- - if can?(current_user, :update_build, job)
+ - if can?(current_user, :update_build, job) && job.retryable?
= link_to retry_project_job_path(build.project, build, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build gl-button btn-icon btn-default' do
= sprite_icon('repeat', css_class: 'gl-icon')
- if can?(current_user, :read_build, job)
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index 05f8a126a02..ca07f33136b 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -1,4 +1,5 @@
- page_title _('Pipelines')
+- add_page_specific_style 'page_bundles/pipelines'
= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index 2be75106000..cb5401cd329 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -6,8 +6,16 @@
= s_('Pipeline|Run Pipeline')
%hr
-- if Feature.enabled?(:new_pipeline_form)
- #js-new-pipeline{ data: { project_id: @project.id, pipelines_path: project_pipelines_path(@project), ref_param: params[:ref] || @project.default_branch, var_param: params[:var].to_json, file_param: params[:file_var].to_json, ref_names: @project.repository.ref_names.to_json.html_safe, settings_link: project_settings_ci_cd_path(@project), max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } }
+- if Feature.enabled?(:new_pipeline_form, @project)
+ #js-new-pipeline{ data: { project_id: @project.id,
+ pipelines_path: project_pipelines_path(@project),
+ config_variables_path: config_variables_namespace_project_pipelines_path(@project.namespace, @project),
+ ref_param: params[:ref] || @project.default_branch,
+ var_param: params[:var].to_json,
+ file_param: params[:file_var].to_json,
+ ref_names: @project.repository.ref_names.to_json.html_safe,
+ settings_link: project_settings_ci_cd_path(@project),
+ max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } }
- else
= form_for @pipeline, as: :pipeline, url: project_pipelines_path(@project), html: { id: "new-pipeline-form", class: "js-new-pipeline-form js-requires-input" } do |f|
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index a9c140aee5f..34f7744f825 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -2,10 +2,11 @@
- breadcrumb_title "##{@pipeline.id}"
- page_title _('Pipeline')
- pipeline_has_errors = @pipeline.builds.empty? && @pipeline.yaml_errors.present?
+- add_page_specific_style 'page_bundles/pipeline'
+- add_page_specific_style 'page_bundles/reports'
.js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } }
- #js-pipeline-header-vue.pipeline-header-container
-
+ #js-pipeline-header-vue.pipeline-header-container{ data: {full_path: @project.full_path, retry_path: retry_project_pipeline_path(@pipeline.project, @pipeline), cancel_path: cancel_project_pipeline_path(@pipeline.project, @pipeline), delete_path: project_pipeline_path(@pipeline.project, @pipeline), pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id} }
- if @pipeline.commit.present?
= render "projects/pipelines/info", commit: @pipeline.commit
diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml
index 4b3fdf8d0b1..4d4705c4ed5 100644
--- a/app/views/projects/project_members/_team.html.haml
+++ b/app/views/projects/project_members/_team.html.haml
@@ -11,7 +11,7 @@
.position-relative
= search_field_tag :search, params[:search], { placeholder: _('Find existing members by name'), class: 'form-control', spellcheck: false }
%button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") }
- = icon("search")
+ = sprite_icon('search', css_class: 'gl-vertical-align-middle!')
= label_tag :sort_by, _('Sort by'), class: 'col-form-label label-bold px-2'
= render 'shared/members/sort_dropdown'
%ul.content-list.members-list{ data: { qa_selector: 'members_list' } }
diff --git a/app/views/projects/project_members/import.html.haml b/app/views/projects/project_members/import.html.haml
index bcca943de6a..2f953db0d65 100644
--- a/app/views/projects/project_members/import.html.haml
+++ b/app/views/projects/project_members/import.html.haml
@@ -11,5 +11,5 @@
.col-sm-10= select_tag(:source_project_id, options_from_collection_for_select(@projects, :id, :name_with_namespace), prompt: "Select project", class: "select2 lg", required: true)
.form-actions
- = button_tag _('Import project members'), class: "btn btn-success"
- = link_to _("Cancel"), project_project_members_path(@project), class: "btn btn-cancel"
+ = button_tag _('Import project members'), class: "btn gl-button btn-success"
+ = link_to _("Cancel"), project_project_members_path(@project), class: "btn gl-button btn-cancel"
diff --git a/app/views/projects/project_templates/_built_in_templates.html.haml b/app/views/projects/project_templates/_built_in_templates.html.haml
deleted file mode 100644
index 43352952b37..00000000000
--- a/app/views/projects/project_templates/_built_in_templates.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-- Gitlab::ProjectTemplate.all.each do |template|
- .template-option.d-flex.align-items-center{ data: { qa_selector: 'template_option_row' } }
- .logo.gl-mr-3.px-1
- = image_tag template.logo, size: 32, class: "btn-template-icon icon-#{template.name}"
- .description
- %strong
- = template.title
- %br
- .text-muted
- = template.description
- .controls.d-flex.align-items-center
- %a.btn.btn-default.gl-mr-3{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_event: "click_button", track_value: "" } }
- = _("Preview")
- %label.btn.btn-success.template-button.choose-template.gl-mb-0{ for: template.name }
- %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "template_use", track_property: template.name, track_event: "click_button", track_value: "" } }
- %span{ data: { qa_selector: 'use_template_button' } }
- = _("Use template")
diff --git a/app/views/projects/project_templates/_template.html.haml b/app/views/projects/project_templates/_template.html.haml
new file mode 100644
index 00000000000..e2bfd0881b5
--- /dev/null
+++ b/app/views/projects/project_templates/_template.html.haml
@@ -0,0 +1,16 @@
+.template-option.d-flex.align-items-center{ data: { qa_selector: 'template_option_row' } }
+ .logo.gl-mr-3.px-1
+ = image_tag template.logo, size: 32, class: "btn-template-icon icon-#{template.name}"
+ .description
+ %strong
+ = template.title
+ %br
+ .text-muted
+ = template.description
+ .controls.d-flex.align-items-center
+ %a.btn.gl-button.btn-default.gl-mr-3{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_event: "click_button", track_value: "" } }
+ = _("Preview")
+ %label.btn.gl-button.btn-success.template-button.choose-template.gl-mb-0{ for: template.name }
+ %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "template_use", track_property: template.name, track_event: "click_button", track_value: "" } }
+ %span{ data: { qa_selector: 'use_template_button' } }
+ = _("Use template")
diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml
index ee359a01e74..33be875d9a6 100644
--- a/app/views/projects/protected_branches/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml
@@ -1,3 +1,5 @@
+- select_mode_for_dropdown = Feature.enabled?(:deploy_keys_on_protected_branches, @project) ? 'js-multiselect' : ''
+
- content_for :merge_access_levels do
.merge_access_levels-container
= dropdown_tag('Select',
@@ -7,7 +9,7 @@
- content_for :push_access_levels do
.push_access_levels-container
= dropdown_tag('Select',
- options: { toggle_class: 'js-allowed-to-push qa-allowed-to-push-select wide',
+ options: { toggle_class: "js-allowed-to-push qa-allowed-to-push-select #{select_mode_for_dropdown} wide",
dropdown_class: 'dropdown-menu-selectable qa-allowed-to-push-dropdown rspec-allowed-to-push-dropdown capitalize-header',
data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }})
diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
index dc7514badb6..c4bf2d20ecf 100644
--- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
@@ -24,5 +24,5 @@
.create_access_levels-container
= yield :create_access_levels
- .card-footer.gl-display-flex.gl-justify-content-end
+ .card-footer
= f.submit _('Protect'), class: 'btn-success btn', disabled: true, data: { qa_selector: 'protect_tag_button' }
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index 8540ce30060..9ac1fda169f 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -15,5 +15,8 @@
"registry_host_url_with_port" => escape_once(registry_config.host_port),
"expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
+ "run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
+ "cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
+
"is_admin": current_user&.admin.to_s,
character_error: @character_error.to_s } }
diff --git a/app/views/projects/registry/settings/_index.haml b/app/views/projects/registry/settings/_index.haml
index c0cef8503e0..c6fae2cc7a1 100644
--- a/app/views/projects/registry/settings/_index.haml
+++ b/app/views/projects/registry/settings/_index.haml
@@ -1,7 +1,8 @@
#js-registry-settings{ data: { project_id: @project.id,
+ project_path: @project.full_path,
cadence_options: cadence_options.to_json,
keep_n_options: keep_n_options.to_json,
older_than_options: older_than_options.to_json,
is_admin: current_user&.admin.to_s,
admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'),
- enable_historic_entries: Gitlab::CurrentSettings.try(:container_expiration_policies_enable_historic_entries).to_s} }
+ enable_historic_entries: container_expiration_policies_historic_entry_enabled?(@project).to_s} }
diff --git a/app/views/projects/releases/show.html.haml b/app/views/projects/releases/show.html.haml
index 188262fb34c..91ee9ad70a3 100644
--- a/app/views/projects/releases/show.html.haml
+++ b/app/views/projects/releases/show.html.haml
@@ -1,4 +1,5 @@
- add_to_breadcrumbs _("Releases"), project_releases_path(@project)
- page_title @release.name
+- page_description @release.description_html
-#js-show-release-page{ data: { project_id: @project.id, tag_name: @release.tag } }
+#js-show-release-page{ data: data_for_show_page }
diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml
index 8a17ca3c670..c567b453bf2 100644
--- a/app/views/projects/runners/_shared_runners.html.haml
+++ b/app/views/projects/runners/_shared_runners.html.haml
@@ -1,19 +1,16 @@
-%h3
- = _('Shared Runners')
-
-.bs-callout.shared-runners-description
- - if Gitlab::CurrentSettings.shared_runners_text.present?
- = markdown_field(Gitlab::CurrentSettings.current_application_settings, :shared_runners_text)
- - else
- = _('GitLab Shared Runners execute code of different projects on the same Runner unless you configure GitLab Runner Autoscale with MaxBuilds 1 (which it is on GitLab.com).')
+= render layout: 'shared/runners/shared_runners_description' do
%hr
- - if @project.shared_runners_enabled?
- = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-close', method: :post do
- = _('Disable shared Runners')
+ - if @project.group&.shared_runners_setting == 'disabled_and_unoverridable'
+ %h5.gl-text-red-500
+ = _('Shared runners disabled on group level')
- else
- = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do
- = _('Enable shared Runners')
- &nbsp; for this project
+ - if @project.shared_runners_enabled?
+ = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-close', method: :post do
+ = _('Disable shared runners')
+ - else
+ = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do
+ = _('Enable shared runners')
+ &nbsp; for this project
- if @shared_runners_count == 0
= _('This GitLab instance does not provide any shared Runners yet. Instance administrators can register shared Runners in the admin area.')
diff --git a/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml b/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml
index aee81ea744a..53ee363de53 100644
--- a/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml
+++ b/app/views/projects/services/mattermost_slash_commands/_installation_info.html.haml
@@ -2,6 +2,6 @@
- unless @service.activated?
.row
.col-sm-9.offset-sm-3
- = link_to new_project_mattermost_path(@project), class: 'btn btn-lg' do
+ = link_to new_project_mattermost_path(@project), class: 'btn gl-button btn-lg' do
= custom_icon('mattermost_logo', size: 15)
= s_("MattermostService|Add to Mattermost")
diff --git a/app/views/projects/services/prometheus/_configuration_banner.html.haml b/app/views/projects/services/prometheus/_configuration_banner.html.haml
index b4e8458d8b9..717df405fa7 100644
--- a/app/views/projects/services/prometheus/_configuration_banner.html.haml
+++ b/app/views/projects/services/prometheus/_configuration_banner.html.haml
@@ -14,13 +14,13 @@
.col-sm-10
%p.text-success.gl-mt-3
= s_('PrometheusService|Prometheus is being automatically managed on your clusters')
- = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn'
+ = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn gl-button'
- else
.col-sm-2
= image_tag 'illustrations/monitoring/loading.svg'
.col-sm-10
%p.gl-mt-3
= s_('PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments')
- = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn btn-success'
+ = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn gl-button btn-success'
%hr
diff --git a/app/views/projects/services/prometheus/_custom_metrics.html.haml b/app/views/projects/services/prometheus/_custom_metrics.html.haml
index 57100282c34..70685a8a9eb 100644
--- a/app/views/projects/services/prometheus/_custom_metrics.html.haml
+++ b/app/views/projects/services/prometheus/_custom_metrics.html.haml
@@ -13,7 +13,7 @@
-# haml-lint:disable NoPlainNodes
%span.badge.badge-pill.js-custom-monitored-count 0
-# haml-lint:enable NoPlainNodes
- = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn btn-success js-new-metric-button hidden', data: { qa_selector: 'new_metric_button' }
+ = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn gl-button btn-success js-new-metric-button hidden', data: { qa_selector: 'new_metric_button' }
.card-body
.flash-container.hidden
.flash-warning
diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml
index cbeedbd080c..4133129fde2 100644
--- a/app/views/projects/settings/_archive.html.haml
+++ b/app/views/projects/settings/_archive.html.haml
@@ -13,7 +13,6 @@
method: :post, class: "btn btn-success"
- else
%p= _("Archiving the project will make it entirely read only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- .gl-display-flex.gl-justify-content-end
- = link_to _('Archive project'), archive_project_path(@project),
- data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' },
- method: :post, class: "btn btn-warning"
+ = link_to _('Archive project'), archive_project_path(@project),
+ data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' },
+ method: :post, class: "btn btn-warning"
diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml
index 50f80fd1e2f..5d5f1d54439 100644
--- a/app/views/projects/settings/_general.html.haml
+++ b/app/views/projects/settings/_general.html.haml
@@ -40,5 +40,4 @@
%hr
= link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link'
- .gl-display-flex.gl-justify-content-end
- = f.submit _('Save changes'), class: "btn btn-success mt-4 qa-save-naming-topics-avatar-button"
+ = f.submit _('Save changes'), class: "btn btn-success mt-4 qa-save-naming-topics-avatar-button"
diff --git a/app/views/projects/settings/ci_cd/_badge.html.haml b/app/views/projects/settings/ci_cd/_badge.html.haml
index 82c8ec088e5..2c3e6387972 100644
--- a/app/views/projects/settings/ci_cd/_badge.html.haml
+++ b/app/views/projects/settings/ci_cd/_badge.html.haml
@@ -15,18 +15,18 @@
.col-md-2.text-center
Markdown
.col-md-10.code.js-syntax-highlight
- = highlight('.md', badge.to_markdown, language: 'markdown')
+ = highlight_badge('.md', badge.to_markdown, language: 'markdown')
.row
%hr
.row
.col-md-2.text-center
HTML
.col-md-10.code.js-syntax-highlight
- = highlight('.html', badge.to_html, language: 'html')
+ = highlight_badge('.html', badge.to_html, language: 'html')
.row
%hr
.row
.col-md-2.text-center
AsciiDoc
.col-md-10.code.js-syntax-highlight
- = highlight('.adoc', badge.to_asciidoc)
+ = highlight_badge('.adoc', badge.to_asciidoc)
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index 414a5f264bd..4793e685163 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -90,12 +90,13 @@
.form-group
.form-check
- = f.check_box :forward_deployment_enabled, { class: 'form-check-input' }
- = f.label :forward_deployment_enabled, class: 'form-check-label' do
- %strong= _("Skip outdated deployment jobs")
- .form-text.text-muted
- = _("When a deployment job is successful, skip older deployment jobs that are still pending")
- = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'skip-outdated-deployment-jobs'), target: '_blank'
+ = f.fields_for :ci_cd_settings_attributes, @project.ci_cd_settings do |form|
+ = form.check_box :forward_deployment_enabled, { class: 'form-check-input' }
+ = form.label :forward_deployment_enabled, class: 'form-check-label' do
+ %strong= _("Skip outdated deployment jobs")
+ .form-text.text-muted
+ = _("When a deployment job is successful, skip older deployment jobs that are still pending")
+ = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'skip-outdated-deployment-jobs'), target: '_blank'
%hr
.form-group
diff --git a/app/views/projects/settings/operations/_alert_management.html.haml b/app/views/projects/settings/operations/_alert_management.html.haml
index f8f3ecb6273..5c16a5e2758 100644
--- a/app/views/projects/settings/operations/_alert_management.html.haml
+++ b/app/views/projects/settings/operations/_alert_management.html.haml
@@ -9,6 +9,6 @@
= _('Expand')
%p
= _('Display alerts from all your monitoring tools directly within GitLab.')
- = link_to _('More information'), help_page_path('user/project/operations/alert_management'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('More information'), help_page_path('operations/incident_management/index.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
.js-alerts-settings{ data: alerts_settings_data }
diff --git a/app/views/projects/settings/operations/_tracing.html.haml b/app/views/projects/settings/operations/_tracing.html.haml
new file mode 100644
index 00000000000..f654c723e36
--- /dev/null
+++ b/app/views/projects/settings/operations/_tracing.html.haml
@@ -0,0 +1,33 @@
+- setting = tracing_setting
+- has_jaeger_url = setting.external_url.present?
+
+%section.settings.border-0.no-animate
+ .settings-header{ :class => "border-top" }
+ %h3{ :class => "h4" }
+ = _("Jaeger tracing")
+ %button.btn.gl-button.js-settings-toggle{ type: 'button' }
+ = _('Expand')
+ %p
+ - if has_jaeger_url
+ - tracing_link = link_to sanitize(setting.external_url, scrubber: Rails::Html::TextOnlyScrubber.new), target: "_blank", rel: 'noopener noreferrer' do
+ %span
+ = _('Tracing')
+ = sprite_icon('external-link', css_class: 'ml-1 vertical-align-middle')
+ - else
+ - tracing_link = link_to project_tracing_path(@project) do
+ %span
+ = _('Tracing')
+ = _("To open Jaeger and easily view tracing from GitLab, link the %{link} page to your server").html_safe % { link: tracing_link }
+ .settings-content
+ = form_for @project, url: project_settings_operations_path(@project), method: :patch do |f|
+ = form_errors(@project)
+ .form-group
+ = f.fields_for :tracing_setting_attributes, setting do |form|
+ = form.label :external_url, _('Jaeger URL'), class: 'label-bold'
+ = form.url_field :external_url, class: 'form-control', placeholder: 'e.g. https://jaeger.mycompany.com'
+ %p.form-text.text-muted
+ - jaeger_help_url = "https://www.jaegertracing.io/docs/1.7/getting-started/"
+ - link_start_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: jaeger_help_url }
+ - link_end_tag = "#{sprite_icon('external-link', css_class: 'ml-1 vertical-align-middle')}</a>".html_safe
+ = _("For more information, please review %{link_start_tag}Jaeger's configuration doc%{link_end_tag}").html_safe % { link_start_tag: link_start_tag, link_end_tag: link_end_tag }
+ = f.submit _('Save changes'), class: 'btn btn-success'
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index 103828ee0a0..e5d34ff0fc9 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -8,5 +8,5 @@
= render 'projects/settings/operations/prometheus', service: prometheus_service if Feature.enabled?(:settings_operations_prometheus_service)
= render 'projects/settings/operations/metrics_dashboard'
= render 'projects/settings/operations/grafana_integration'
-= render_if_exists 'projects/settings/operations/tracing'
+= render 'projects/settings/operations/tracing'
= render_if_exists 'projects/settings/operations/status_page'
diff --git a/app/views/projects/snippets/_actions.html.haml b/app/views/projects/snippets/_actions.html.haml
deleted file mode 100644
index e4645101765..00000000000
--- a/app/views/projects/snippets/_actions.html.haml
+++ /dev/null
@@ -1,36 +0,0 @@
-- return unless current_user
-
-.d-none.d-sm-block
- - if can?(current_user, :update_snippet, @snippet)
- = link_to edit_project_snippet_path(@project, @snippet), class: "btn btn-grouped" do
- = _('Edit')
- - if can?(current_user, :admin_snippet, @snippet)
- = link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do
- = _('Delete')
- - if can?(current_user, :create_snippet, @project)
- = link_to new_project_snippet_path(@project), class: 'btn btn-grouped btn-inverted btn-success', title: _("New snippet") do
- = _('New snippet')
- - if @snippet.submittable_as_spam_by?(current_user)
- = link_to _('Submit as spam'), mark_as_spam_project_snippet_path(@project, @snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam')
-- if can?(current_user, :create_snippet, @project) || can?(current_user, :update_snippet, @snippet)
- .d-block.d-sm-none.dropdown
- %button.btn.btn-default.btn-block.gl-mb-0.gl-mt-2{ data: { toggle: "dropdown" } }
- = _('Options')
- = icon('caret-down')
- .dropdown-menu.dropdown-menu-full-width
- %ul
- - if can?(current_user, :create_snippet, @project)
- %li
- = link_to new_project_snippet_path(@project), title: _("New snippet") do
- = _('New snippet')
- - if can?(current_user, :admin_snippet, @snippet)
- %li
- = link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: _("Are you sure?") }, title: _('Delete Snippet') do
- = _('Delete')
- - if can?(current_user, :update_snippet, @snippet)
- %li
- = link_to edit_project_snippet_path(@project, @snippet) do
- = _('Edit')
- - if @snippet.submittable_as_spam_by?(current_user)
- %li
- = link_to _('Submit as spam'), mark_as_spam_project_snippet_path(@project, @snippet), method: :post
diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index 7cf5de8947c..726ab7d2372 100644
--- a/app/views/projects/snippets/show.html.haml
+++ b/app/views/projects/snippets/show.html.haml
@@ -3,13 +3,7 @@
- breadcrumb_title @snippet.to_reference
- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
-- if Feature.enabled?(:snippets_vue, default_enabled: true)
- #js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} }
-- else
- = render 'shared/snippets/header'
-
- .project-snippets
- = render 'shared/snippets/blob', blob: @blob
+#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} }
.row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true
diff --git a/app/views/projects/starrers/index.html.haml b/app/views/projects/starrers/index.html.haml
index 97996562e2c..7c8314c157d 100644
--- a/app/views/projects/starrers/index.html.haml
+++ b/app/views/projects/starrers/index.html.haml
@@ -11,7 +11,7 @@
.position-relative
= search_field_tag :search, params[:search], { placeholder: _('Search'), class: 'form-control', spellcheck: false }
%button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") }
- = icon("search")
+ = sprite_icon('search')
.dropdown.inline.user-sort-dropdown
= dropdown_toggle(starrers_sort_options_hash[@sort], { toggle: 'dropdown' })
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index dba9b20fcff..d7231e758c7 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -1,6 +1,7 @@
- commit = @repository.commit(tag.dereferenced_target)
- release = @releases.find { |release| release.tag == tag.name }
-%li.flex-row.allow-wrap
+
+%li.flex-row.allow-wrap.js-tag-list
.row-main-content
= sprite_icon('tag')
= link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name'
@@ -24,7 +25,7 @@
.text-secondary
= sprite_icon("rocket", size: 12)
= _("Release")
- = link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'tag-release-link'
+ = link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'gl-text-blue-600!'
- if release.description.present?
.md.gl-mt-3
= markdown_field(release, :description)
@@ -38,5 +39,4 @@
- if can?(current_user, :admin_tag, @project)
= link_to edit_project_tag_release_path(@project, tag.name), class: 'btn btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do
= sprite_icon("pencil")
- = link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip gl-ml-3 #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do
- = sprite_icon("remove")
+ = render 'projects/buttons/remove_tag', project: @project, tag: tag
diff --git a/app/views/projects/tags/destroy.js.haml b/app/views/projects/tags/destroy.js.haml
deleted file mode 100644
index 59d359bbf10..00000000000
--- a/app/views/projects/tags/destroy.js.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- if @error.present?
- new Flash({ message: '#{escape_javascript(@error)}', type: 'alert' });
-- elsif @repository.tags.empty?
- $('.tags').load(document.URL + ' .nothing-here-block').hide().fadeIn(1000)
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index 25a560da5c6..d726d2ab233 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -52,8 +52,7 @@
= render 'projects/buttons/download', project: @project, ref: @tag.name
- if can?(current_user, :admin_tag, @project)
.btn-container.controls-item-full
- = link_to project_tag_path(@project, @tag.name), class: "btn btn-icon btn-danger gl-button remove-row has-tooltip #{protected_tag?(@project, @tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: @tag.name } } do
- = sprite_icon('remove', css_class: 'gl-icon')
+ = render 'projects/buttons/remove_tag', project: @project, tag: @tag
- if @tag.message.present?
%pre.wrap{ data: { qa_selector: 'tag_message_content' } }
diff --git a/app/views/projects/tracings/_tracing_button.html.haml b/app/views/projects/tracings/_tracing_button.html.haml
new file mode 100644
index 00000000000..c9a6afd3761
--- /dev/null
+++ b/app/views/projects/tracings/_tracing_button.html.haml
@@ -0,0 +1,2 @@
+= link_to project_settings_operations_path(@project), title: _('Configure Tracing'), class: 'btn btn-success' do
+ = _('Add Jaeger URL')
diff --git a/app/views/projects/tracings/show.html.haml b/app/views/projects/tracings/show.html.haml
new file mode 100644
index 00000000000..8c9bffc81bf
--- /dev/null
+++ b/app/views/projects/tracings/show.html.haml
@@ -0,0 +1,33 @@
+- @content_class = "limit-container-width" unless fluid_layout
+- page_title _("Tracing")
+
+- if @project.tracing_external_url.present?
+ %h3.page-title= _('Tracing')
+ .gl-alert.gl-alert-info.alert.flex-alert
+ = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .alert-message
+ = _("Your password isn't required to view this page. If a password or any other personal details are requested, please contact your administrator to report abuse.")
+ - jaeger_link = link_to('Jaeger tracing', 'https://www.jaegertracing.io/', target: "_blank", rel: "noreferrer")
+ %p.light= _("GitLab uses %{jaeger_link} to monitor distributed systems.").html_safe % { jaeger_link: jaeger_link }
+
+
+ .card
+ - iframe_permissions = "allow-forms allow-scripts allow-same-origin allow-popups"
+ %iframe.border-0{ src: sanitize(@project.tracing_external_url, scrubber: Rails::Html::TextOnlyScrubber.new), width: '100%', height: 970, sandbox: iframe_permissions }
+- else
+ .row.empty-state
+ .col-12
+ .svg-content
+ = image_tag 'illustrations/monitoring/tracing.svg'
+
+ .col-12
+ .text-content
+ %h4.text-left= _('Troubleshoot and monitor your application with tracing')
+ %p
+ - jaeger_help_url = "https://www.jaegertracing.io/docs/1.7/getting-started/"
+ - link_start_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: jaeger_help_url }
+ - link_end_tag = "#{sprite_icon('external-link', css_class: 'ml-1 vertical-align-middle')}</a>".html_safe
+ = _('To get started, link this page to your Jaeger server, or find out how to %{link_start_tag}install Jaeger%{link_end_tag}').html_safe % { link_start_tag: link_start_tag, link_end_tag: link_end_tag }
+
+ .text-center
+ = render 'tracing_button'
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index 268858f8ff8..dc9fb9e7792 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -1,7 +1,3 @@
-- can_collaborate = can_collaborate_with_project?(@project)
-- can_create_mr_from_fork = can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project)
-- can_visit_ide = can_collaborate || current_user&.already_forked?(@project)
-
.tree-ref-container
.tree-ref-holder
= render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true
@@ -14,13 +10,7 @@
#js-tree-history-link.d-inline-block{ data: { history_link: project_commits_path(@project, @ref) } }
= render 'projects/find_file_link'
-
- - if can_visit_ide || can_create_mr_from_fork
- #js-tree-web-ide-link.d-inline-block{ data: { options: vue_ide_link_data(@project, @ref).to_json } }
- - if !can_visit_ide
- = render 'shared/confirm_fork_modal', fork_path: ide_fork_and_edit_path(@project, @ref, @path)
- - unless current_user&.gitpod_enabled
- = render 'shared/gitpod/enable_gitpod_modal'
+ = render 'shared/web_ide_button', blob: nil
- if show_xcode_link?(@project)
.project-action-button.project-xcode.inline<
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index 3dd12a7b641..4d8c357cee1 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -1,3 +1,5 @@
+- current_route_path = request.fullpath.match(/-\/tree\/[^\/]+\/(.+$)/).to_a[1]
+- add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path })
- breadcrumb_title _("Repository")
- @content_class = "limit-container-width" unless fluid_layout
diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml
index 4e097f345c2..4f39c839630 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -6,23 +6,26 @@
.card-body
= render "projects/triggers/form", btn_text: "Add trigger"
%hr
- - if @triggers.any?
- .table-responsive.triggers-list
- %table.table
- %thead
- %th
- %strong Token
- %th
- %strong Description
- %th
- %strong Owner
- %th
- %strong Last used
- %th
- = render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger
+ - if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project)
+ #js-ci-pipeline-triggers-list.triggers-list{ data: { triggers: @triggers_json } }
- else
- %p.settings-message.text-center.gl-mb-3
- No triggers have been created yet. Add one using the form above.
+ - if @triggers.any?
+ .table-responsive.triggers-list
+ %table.table
+ %thead
+ %th
+ %strong Token
+ %th
+ %strong Description
+ %th
+ %strong Owner
+ %th
+ %strong Last used
+ %th
+ = render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger
+ - else
+ %p.settings-message.text-center.gl-mb-3{ data: { testid: 'no_triggers_content' } }
+ No triggers have been created yet. Add one using the form above.
.card-footer
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index 579b8ba2766..b25199b405a 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -2,7 +2,7 @@
%td
- if trigger.has_token_exposed?
%span= trigger.token
- = clipboard_button(text: trigger.token, title: _("Copy trigger token"))
+ = clipboard_button(text: trigger.token, title: _("Copy trigger token"), testid: 'clipboard-btn')
- else
%span= trigger.short_token
@@ -33,5 +33,5 @@
= link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "btn btn-default btn-sm" do
= sprite_icon('pencil')
- if can?(current_user, :manage_trigger, trigger)
- = link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do
+ = link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation, testid: 'trigger_revoke_button' }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do
= sprite_icon('remove')
diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/projects/wikis/git_access.html.haml
index 1db4554541d..c166642bae2 100644
--- a/app/views/projects/wikis/git_access.html.haml
+++ b/app/views/projects/wikis/git_access.html.haml
@@ -1,5 +1,6 @@
- @content_class = "limit-container-width" unless fluid_layout
- page_title s_("WikiClone|Git Access"), _("Wiki")
+- add_page_specific_style 'page_bundles/wiki'
.wiki-page-header.top-area.has-sidebar-toggle.py-3.flex-column.flex-lg-row
= wiki_sidebar_toggle_button
diff --git a/app/views/registrations/welcome.html.haml b/app/views/registrations/welcome.html.haml
index 5ad0fbf8fbc..bebcc2152af 100644
--- a/app/views/registrations/welcome.html.haml
+++ b/app/views/registrations/welcome.html.haml
@@ -22,4 +22,4 @@
- if partial_exists? "registrations/welcome/button"
= render "registrations/welcome/button"
- else
- = f.submit _('Get started!'), class: 'btn-register btn btn-block gl-mb-0 gl-p-3', data: { qa_selector: 'get_started_button' }
+ = f.submit _('Get started!'), class: 'btn-register gl-button btn btn-block gl-mb-0 gl-p-3', data: { qa_selector: 'get_started_button' }
diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml
index d6e38ddd5c6..f094a6f5e3b 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -30,5 +30,6 @@
= search_filter_link 'issues', _("Issues")
= search_filter_link 'merge_requests', _("Merge requests")
= search_filter_link 'milestones', _("Milestones")
+ = render_if_exists 'search/epics_filter_link'
= render_if_exists 'search/category_elasticsearch'
= users
diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml
index bee4aff605f..e7febd4638b 100644
--- a/app/views/search/_filter.html.haml
+++ b/app/views/search/_filter.html.haml
@@ -2,15 +2,14 @@
= hidden_field_tag :group_id, params[:group_id]
- if params[:project_id].present?
= hidden_field_tag :project_id, params[:project_id]
-.dropdown.form-group.mb-lg-0.mx-lg-1
+.dropdown.form-group.mb-lg-0.mx-lg-1{ data: { testid: "group-filter" } }
%label.d-block{ for: "dashboard_search_group" }
= _("Group")
- %button.dropdown-menu-toggle.js-search-group-dropdown.mt-0{ type: "button", id: "dashboard_search_group", data: { toggle: "dropdown", group_id: params[:group_id] } }
- %span.dropdown-toggle-text
- - if @group.present?
- = @group.name
- - else
- = _("Any")
+ %button.dropdown-menu-toggle.gl-display-inline-flex.js-search-group-dropdown.gl-mt-0{ type: "button", id: "dashboard_search_group", data: { toggle: "dropdown", group_id: params[:group_id] } }
+ %span.dropdown-toggle-text.gl-flex-grow-1.str-truncated-100
+ = @group&.name || _("Any")
+ - if @group.present?
+ = link_to sprite_icon("clear"), url_for(safe_params.except(:project_id, :group_id)), class: 'search-clear js-search-clear has-tooltip', title: _('Clear')
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-right
= dropdown_title(_("Filter results by group"))
@@ -21,12 +20,11 @@
.dropdown.project-filter.form-group.mb-lg-0.mx-lg-1
%label.d-block{ for: "dashboard_search_project" }
= _("Project")
- %button.dropdown-menu-toggle.js-search-project-dropdown.mt-0{ type: "button", id: "dashboard_search_project", data: { toggle: "dropdown"} }
- %span.dropdown-toggle-text
- - if @project.present?
- = @project.full_name
- - else
- = _("Any")
+ %button.dropdown-menu-toggle.gl-display-inline-flex.js-search-project-dropdown.gl-mt-0{ type: "button", id: "dashboard_search_project", data: { toggle: "dropdown", target: '.project-filter' } }
+ %span.dropdown-toggle-text.gl-flex-grow-1.str-truncated-100
+ = @project&.full_name || _("Any")
+ - if @project.present?
+ = link_to sprite_icon("clear"), url_for(safe_params.except(:project_id)), class: 'search-clear js-search-clear has-tooltip', title: _('Clear')
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-right
= dropdown_title(_("Filter results by project"))
diff --git a/app/views/search/_form.html.haml b/app/views/search/_form.html.haml
index b29707d391d..c8fa016662f 100644
--- a/app/views/search/_form.html.haml
+++ b/app/views/search/_form.html.haml
@@ -9,8 +9,8 @@
= _("What are you searching for?")
.position-relative
= search_field_tag :search, params[:search], placeholder: _("Search for projects, issues, etc."), class: "form-control search-text-input js-search-input", id: "dashboard_search", autofocus: true, spellcheck: false
- = icon("search", class: "search-icon")
- %button.search-clear.js-search-clear{ class: ("hidden" if !params[:search].present?), type: "button", tabindex: "-1" }
+ = sprite_icon('search', css_class: 'search-icon')
+ %button.search-clear.js-search-clear{ class: [("hidden" if params[:search].blank?), "has-tooltip"], type: "button", tabindex: "-1", title: _('Clear') }
= sprite_icon('clear')
%span.sr-only
= _("Clear search")
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index e0dbb5135e9..95c378bff7c 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -1,7 +1,7 @@
- if @search_objects.to_a.empty?
+ = render partial: "search/results/filters"
= render partial: "search/results/empty"
= render_if_exists 'shared/promotions/promote_advanced_search'
- = render_if_exists 'search/form_revert_to_basic'
- else
.row-content-block.d-md-flex.text-left.align-items-center
- unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount)
@@ -10,10 +10,9 @@
- if @project
- link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1')
- if @scope == 'blobs'
- - repository_ref = params[:repository_ref].to_s.presence || @project.default_branch
= s_("SearchCodeResults|in")
.mx-md-1
- = render partial: "shared/ref_switcher", locals: { ref: repository_ref, form_path: request.fullpath, field_name: 'repository_ref' }
+ = render partial: "shared/ref_switcher", locals: { ref: repository_ref(@project), form_path: request.fullpath, field_name: 'repository_ref' }
= s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project }
- else
= _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project }
@@ -21,8 +20,7 @@
- link_to_group = link_to(@group.name, @group, class: 'ml-md-1')
= _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group }
= render_if_exists 'shared/promotions/promote_advanced_search'
-
- #js-search-filter-by-state{ 'v-cloak': true, data: { scope: @scope, state: params[:state] } }
+ = render partial: "search/results/filters"
.results.gl-mt-3
- if @scope == 'commits'
@@ -34,7 +32,7 @@
.term
= render 'shared/projects/list', projects: @search_objects, pipeline_status: false
- else
- = render partial: "search/results/#{@scope.singularize}", collection: @search_objects
+ = render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects
- if @scope != 'projects'
= paginate_collection(@search_objects)
diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml
index 6e17a25c713..aeb37022f99 100644
--- a/app/views/search/results/_blob.html.haml
+++ b/app/views/search/results/_blob.html.haml
@@ -1,5 +1,5 @@
- project = blob.project
- return unless project
-- blob_link = project_blob_path(project, tree_join(blob.ref, blob.path))
+- blob_link = project_blob_path(project, tree_join(repository_ref(project), blob.path))
= render partial: 'search/results/blob_data', locals: { blob: blob, project: project, path: blob.path, blob_link: blob_link }
diff --git a/app/views/search/results/_blob_data.html.haml b/app/views/search/results/_blob_data.html.haml
index 27d4dbe1085..d873a15d051 100644
--- a/app/views/search/results/_blob_data.html.haml
+++ b/app/views/search/results/_blob_data.html.haml
@@ -2,7 +2,7 @@
.file-holder
.js-file-title.file-title{ data: { qa_selector: 'file_title_content' } }
= link_to blob_link, data: {track_event: 'click_text', track_label: 'blob_path', track_property: 'search_result'} do
- %i.fa.fa-file
+ = sprite_icon('document')
%strong
= search_blob_title(project, path)
- if blob.data
diff --git a/app/views/search/results/_empty.html.haml b/app/views/search/results/_empty.html.haml
index 6c7c6de1178..3cd1c901f8e 100644
--- a/app/views/search/results/_empty.html.haml
+++ b/app/views/search/results/_empty.html.haml
@@ -1,5 +1,5 @@
.search_box
.search_glyph
%h4
- = icon('search')
+ = sprite_icon('search', size: 24, css_class: 'gl-vertical-align-text-bottom')
= search_entries_empty_message(@scope, @search_term)
diff --git a/app/views/search/results/_filters.html.haml b/app/views/search/results/_filters.html.haml
new file mode 100644
index 00000000000..632d3dfd58c
--- /dev/null
+++ b/app/views/search/results/_filters.html.haml
@@ -0,0 +1,7 @@
+.d-lg-flex.align-items-end
+ #js-search-filter-by-state{ 'v-cloak': true }
+ - if Feature.enabled?(:search_filter_by_confidential, @group)
+ #js-search-filter-by-confidential{ 'v-cloak': true }
+
+ - if %w(issues merge_requests).include?(@scope)
+ %hr.gl-mt-4.gl-mb-4
diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml
index e0336d98f04..a101e60f297 100644
--- a/app/views/search/results/_issue.html.haml
+++ b/app/views/search/results/_issue.html.haml
@@ -9,6 +9,5 @@
%span.term.str-truncated.gl-font-weight-bold.gl-ml-2= issue.title
.gl-text-gray-500.gl-my-3
= sprintf(s_(' %{project_name}#%{issue_iid} &middot; opened %{issue_created} by %{author}'), { project_name: issue.project.full_name, issue_iid: issue.iid, issue_created: time_ago_with_tooltip(issue.created_at, placement: 'bottom'), author: link_to_member(@project, issue.author, avatar: false) }).html_safe
- - if issue.description.present?
- .description.term.col-sm-10.gl-px-0
- = truncate(issue.description, length: 200)
+ .description.term.col-sm-10.gl-px-0
+ = highlight_and_truncate_issue(issue, @search_term, @search_highlight)
diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml
index 18eaccb46b2..3fb91428c56 100644
--- a/app/views/search/show.html.haml
+++ b/app/views/search/show.html.haml
@@ -6,10 +6,9 @@
- page_description(_("%{count} %{scope} for term '%{term}'") % { count: @search_results.formatted_count(@scope), scope: @scope, term: @search_term })
- page_card_attributes("Namespace" => @group&.full_path, "Project" => @project&.full_path)
-.page-title-holder.d-sm-flex.align-items-sm-center
- %h1.page-title<
- = _('Search')
- = render_if_exists 'search/form_elasticsearch', attrs: { class: 'ml-sm-auto' }
+.page-title-holder.d-flex.flex-wrap.justify-content-between
+ %h1.page-title.mr-3= _('Search')
+ = render_if_exists 'search/form_elasticsearch', attrs: { class: 'mb-2 mb-sm-0 align-self-center' }
.gl-mt-3
= render 'search/form'
diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
index d378e6cb22c..6f70c927147 100644
--- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
+++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
@@ -1,13 +1,13 @@
- if show_auto_devops_implicitly_enabled_banner?(project, current_user)
- .qa-auto-devops-banner.auto-devops-implicitly-enabled-banner.alert.alert-info
- - more_information_link = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link'
- - auto_devops_message = s_("AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found. %{more_information_link}") % { more_information_link: more_information_link }
- = auto_devops_message.html_safe
- .alert-link-group
- = link_to _('Settings'), project_settings_ci_cd_path(project), class: 'alert-link'
- |
- = link_to _('Dismiss'), '#', class: 'hide-auto-devops-implicitly-enabled-banner alert-link', data: { project_id: project.id }
+ .qa-auto-devops-banner.auto-devops-implicitly-enabled-banner.gl-alert.gl-alert-info
+ = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss'), class: 'hide-auto-devops-implicitly-enabled-banner alert-link', data: { project_id: project.id } }
+ = sprite_icon('close', css_class: 'gl-icon')
+ .gl-alert-body
+ = s_("AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found.")
- unless Gitlab.config.registry.enabled
%div
- = icon('exclamation-triangle')
= _('Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for Auto DevOps to work.')
+ .gl-alert-actions.gl-mt-3
+ = link_to _('Settings'), project_settings_ci_cd_path(project), class: 'alert-link btn gl-button btn-info'
+ = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link btn gl-button btn-default gl-ml-2'
diff --git a/app/views/shared/_confirm_fork_modal.html.haml b/app/views/shared/_confirm_fork_modal.html.haml
index f2a193e0bbc..1390d821899 100644
--- a/app/views/shared/_confirm_fork_modal.html.haml
+++ b/app/views/shared/_confirm_fork_modal.html.haml
@@ -1,4 +1,4 @@
-#modal-confirm-fork.modal{ data: { qa_selector: 'confirm_fork_modal' } }
+.modal{ data: { qa_selector: 'confirm_fork_modal'}, id: "modal-confirm-fork-#{type}" }
.modal-dialog
.modal-content
.modal-header
diff --git a/app/views/shared/_confirm_modal.html.haml b/app/views/shared/_confirm_modal.html.haml
index dc95bcdc756..ecb462205b0 100644
--- a/app/views/shared/_confirm_modal.html.haml
+++ b/app/views/shared/_confirm_modal.html.haml
@@ -17,5 +17,5 @@
.form-group
= text_field_tag 'confirm_name_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input'
- .form-actions.gl-display-flex.gl-justify-content-end
+ .form-actions
= submit_tag _('Confirm'), class: "btn btn-danger js-confirm-danger-submit qa-confirm-button"
diff --git a/app/views/shared/_delete_label_modal.html.haml b/app/views/shared/_delete_label_modal.html.haml
index ffc34ff34c3..8d761e3b9c4 100644
--- a/app/views/shared/_delete_label_modal.html.haml
+++ b/app/views/shared/_delete_label_modal.html.haml
@@ -17,4 +17,4 @@
label.destroy_path,
title: _('Delete'),
method: :delete,
- class: 'btn btn-remove'
+ class: 'gl-button btn btn-danger'
diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml
index 3eb27f002ef..f21ec45eefb 100644
--- a/app/views/shared/_issuable_meta_data.html.haml
+++ b/app/views/shared/_issuable_meta_data.html.haml
@@ -5,21 +5,23 @@
- issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count
- if issuable_mr > 0
- %li.issuable-mr.d-none.d-sm-block.has-tooltip{ title: _('Related merge requests') }
+ %li.issuable-mr.gl-display-none.gl-display-sm-block.has-tooltip{ title: _('Related merge requests') }
= image_tag('icon-merge-request-unmerged.svg', class: 'icon-merge-request-unmerged')
= issuable_mr
- if upvotes > 0
- %li.issuable-upvotes.d-none.d-sm-block.has-tooltip{ title: _('Upvotes') }
- = sprite_icon('thumb-up', css_class: "vertical-align-middle")
+ %li.issuable-upvotes.gl-display-none.gl-display-sm-block.has-tooltip{ title: _('Upvotes') }
+ = sprite_icon('thumb-up', css_class: "gl-vertical-align-middle")
= upvotes
- if downvotes > 0
- %li.issuable-downvotes.d-none.d-sm-block.has-tooltip{ title: _('Downvotes') }
- = sprite_icon('thumb-down', css_class: "vertical-align-middle")
+ %li.issuable-downvotes.gl-display-none.gl-display-sm-block.has-tooltip{ title: _('Downvotes') }
+ = sprite_icon('thumb-down', css_class: "gl-vertical-align-middle")
= downvotes
-%li.issuable-comments.d-none.d-sm-block
+= render_if_exists 'shared/issuable/blocking_issues_count', issuable: issuable
+
+%li.issuable-comments.gl-display-none.gl-display-sm-block
= link_to issuable_path, class: ['has-tooltip', ('no-comments' if note_count == 0)], title: _('Comments') do
= sprite_icon('comments', css_class: 'gl-vertical-align-text-bottom')
= note_count
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index c4b7ef481fd..1dadb4384b9 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -9,9 +9,6 @@
%li.label-list-item{ id: label_css_id, data: { id: label.id } }
= render "shared/label_row", label: label, force_priority: force_priority
%ul.label-actions-list
- - if @project
- %li.inline
- .label-badge.label-badge-gray= label.model_name.human.capitalize
- if can?(current_user, :admin_label, @project)
%li.inline.js-toggle-priority{ data: { url: remove_priority_project_label_path(@project, label),
dom_id: dom_id(label), type: label.type } }
diff --git a/app/views/shared/_label_full_path.html.haml b/app/views/shared/_label_full_path.html.haml
new file mode 100644
index 00000000000..fd67bbbbd10
--- /dev/null
+++ b/app/views/shared/_label_full_path.html.haml
@@ -0,0 +1,4 @@
+- full_path = label.subject_full_name
+
+.label-badge.gl-bg-gray-50.gl-max-w-full.gl-text-truncate{ title: full_path }
+ = full_path
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index 3d2ae772135..9c9ac5f7b2c 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -3,23 +3,28 @@
- show_label_issues_link = subject_or_group_defined && show_label_issuables_link?(label, :issues)
- show_label_merge_requests_link = subject_or_group_defined && show_label_issuables_link?(label, :merge_requests)
-.label-name
+.label-name.gl-flex-shrink-0.gl-mt-2.gl-mr-3
= render_label(label, tooltip: false)
-.label-description
- .label-description-wrapper
- - if label.description.present?
- .description-text
+.label-description.gl-flex-grow-1.gl-overflow-hidden
+ .gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-mt-2
+ .description-text.gl-flex-grow-1.gl-overflow-hidden
+ - if label.description.present?
= markdown_field(label, :description)
- %ul.label-links
+ - elsif show_labels_full_path?(@project, @group)
+ = render 'shared/label_full_path', label: label
+ %ul.label-links.gl-m-0.gl-p-0.gl-white-space-nowrap
- if show_label_issues_link
- %li.label-link-item.inline
- = link_to_label(label) { _('Issues') }
+ %li.inline
+ = link_to_label(label, css_class: 'gl-text-blue-600!') { _('Issues') }
- if show_label_merge_requests_link
&middot;
- %li.label-link-item.inline
- = link_to_label(label, type: :merge_request) { _('Merge requests') }
+ %li.inline
+ = link_to_label(label, type: :merge_request, css_class: 'gl-text-blue-600!') { _('Merge requests') }
+ = render_if_exists 'shared/label_row_epics_link', label: label
- if force_priority
&middot;
- %li.label-link-item.priority-badge.js-priority-badge.inline.gl-ml-3
- .label-badge.label-badge-blue= _('Prioritized label')
- = render_if_exists 'shared/label_row_epics_link', label: label
+ %li.js-priority-badge.inline.gl-ml-3
+ .label-badge.gl-bg-blue-50= _('Prioritized label')
+ - if label.description.present? && show_labels_full_path?(@project, @group)
+ .gl-mt-3
+ = render 'shared/label_full_path', label: label
diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml
index 47fb38d979d..4340a34dc26 100644
--- a/app/views/shared/_new_project_item_select.html.haml
+++ b/app/views/shared/_new_project_item_select.html.haml
@@ -1,7 +1,7 @@
- if any_projects?(@projects)
- .project-item-select-holder.btn-group
- %a.btn.btn-success.new-project-item-link.qa-new-project-item-link{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] } }
+ .project-item-select-holder.btn-group.gl-ml-auto.gl-mr-auto.gl-py-3.gl-relative.gl-display-flex.gl-overflow-hidden
+ %a.btn.gl-button.btn-success.new-project-item-link.block-truncated.qa-new-project-item-link{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] }, class: "gl-m-0!" }
= loading_icon(color: 'light')
- = project_select_tag :project_path, class: "project-item-select", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at', relative_path: local_assigns[:path], with_shared: local_assigns[:with_shared], include_projects_in_subgroups: local_assigns[:include_projects_in_subgroups] }, with_feature_enabled: local_assigns[:with_feature_enabled]
- %button.btn.btn-success.new-project-item-select-button.qa-new-project-item-select-button.gl-p-0
+ = project_select_tag :project_path, class: "project-item-select gl-absolute gl-visibility-hidden", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at', relative_path: local_assigns[:path], with_shared: local_assigns[:with_shared], include_projects_in_subgroups: local_assigns[:include_projects_in_subgroups] }, with_feature_enabled: local_assigns[:with_feature_enabled]
+ %button.btn.dropdown-toggle.btn-success.btn-md.gl-button.gl-dropdown-toggle.dropdown-toggle-split.new-project-item-select-button.qa-new-project-item-select-button.gl-p-0.gl-w-100{ class: "gl-m-0!", 'aria-label': _('Toggle project select') }
= sprite_icon('chevron-down')
diff --git a/app/views/shared/_user_dropdown_instance_review.html.haml b/app/views/shared/_user_dropdown_instance_review.html.haml
new file mode 100644
index 00000000000..18bfb5d7e3e
--- /dev/null
+++ b/app/views/shared/_user_dropdown_instance_review.html.haml
@@ -0,0 +1,6 @@
+- return unless instance_review_permitted?
+
+%li.divider
+%li
+ = link_to admin_instance_review_path, target: '_blank', class: 'text-nowrap' do
+ = _("Get a free instance review")
diff --git a/app/views/shared/_web_ide_button.html.haml b/app/views/shared/_web_ide_button.html.haml
new file mode 100644
index 00000000000..75f5b8647f2
--- /dev/null
+++ b/app/views/shared/_web_ide_button.html.haml
@@ -0,0 +1,10 @@
+- type = blob ? 'blob' : 'tree'
+
+.d-inline-block{ data: { options: web_ide_button_data(blob: blob).to_json }, id: "js-#{type}-web-ide-link" }
+
+- if show_edit_button?
+ = render 'shared/confirm_fork_modal', fork_path: fork_and_edit_path(@project, @ref, @path), type: 'edit'
+- if show_web_ide_button?
+ = render 'shared/confirm_fork_modal', fork_path: ide_fork_and_edit_path(@project, @ref, @path), type: 'webide'
+- if show_gitpod_button?
+ = render 'shared/gitpod/enable_gitpod_modal'
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index e5808bfe878..c3137120034 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -8,6 +8,7 @@
- @content_class = "issue-boards-content js-focus-mode-board"
- breadcrumb_title _("Issue Boards")
- page_title("#{board.name}", _("Boards"))
+- add_page_specific_style 'page_bundles/boards'
- content_for :page_specific_javascripts do
@@ -35,7 +36,7 @@
":disabled" => "disabled",
":key" => "list.id" }
= render "shared/boards/components/sidebar", group: group
- = render_if_exists 'shared/boards/components/board_settings_sidebar'
+ %board-settings-sidebar{ ":can-admin-list" => can_admin_list }
- if @project
%board-add-issues-modal{ "new-issue-path" => new_project_issue_path(@project),
"milestone-path" => milestones_filter_dropdown_path,
diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml
index 815967b0372..179ec33ee65 100644
--- a/app/views/shared/deploy_keys/_project_group_form.html.haml
+++ b/app/views/shared/deploy_keys/_project_group_form.html.haml
@@ -20,5 +20,5 @@
%p.light.gl-mb-0
= _('Allow this key to push to repository as well? (Default only allows pull access.)')
- .form-group.row.gl-display-flex.gl-justify-content-end
+ .form-group.row
= f.submit _("Add key"), class: "btn-success btn"
diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml
index cc5addaa3a0..da634d37c55 100644
--- a/app/views/shared/deploy_tokens/_form.html.haml
+++ b/app/views/shared/deploy_tokens/_form.html.haml
@@ -46,5 +46,5 @@
= label_tag ("deploy_token_write_package_registry"), 'write_package_registry', class: 'label-bold form-check-label'
.text-secondary= s_('DeployTokens|Allows write access to the package registry')
- .gl-mt-3.gl-display-flex.gl-justify-content-end
+ .gl-mt-3
= f.submit s_('DeployTokens|Create deploy token'), class: 'btn btn-success qa-create-deploy-token'
diff --git a/app/views/shared/icons/_next_discussion.svg b/app/views/shared/icons/_next_discussion.svg
deleted file mode 100644
index 43559a60cb0..00000000000
--- a/app/views/shared/icons/_next_discussion.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg viewBox="0 0 20 19" ><path d="M15.21 7.783h-3.317c-.268 0-.472.218-.472.486v.953c0 .28.212.486.473.486h3.318v1.575c0 .36.233.452.52.23l3.06-2.37c.274-.213.286-.582 0-.804l-3.06-2.37c-.275-.213-.52-.12-.52.23v1.583zm.57-3.66c-1.558-1.22-3.783-1.98-6.254-1.98C4.816 2.143 1 4.91 1 8.333c0 1.964 1.256 3.715 3.216 4.846-.447 1.615-1.132 2.195-1.732 2.882-.142.174-.304.32-.256.56v.01c.047.213.218.368.41.368h.046c.37-.048.743-.116 1.085-.213 1.645-.425 3.13-1.22 4.377-2.34.447.048.913.077 1.38.077 2.092 0 4.01-.546 5.492-1.454-.416-.208-.798-.475-1.134-.792-1.227.63-2.743 1.008-4.36 1.008-.41 0-.828-.03-1.237-.078l-.543-.058-.41.368c-.78.696-1.655 1.248-2.616 1.654.248-.445.486-.977.667-1.664l.257-.928-.828-.484c-1.646-.948-2.598-2.32-2.598-3.763 0-2.69 3.35-4.952 7.308-4.952 1.893 0 3.647.518 4.962 1.353.393-.266.827-.473 1.29-.61z" /></svg>
diff --git a/app/views/shared/issuable/_approved_by_dropdown.html.haml b/app/views/shared/issuable/_approved_by_dropdown.html.haml
new file mode 100644
index 00000000000..8014545ab85
--- /dev/null
+++ b/app/views/shared/issuable/_approved_by_dropdown.html.haml
@@ -0,0 +1,16 @@
+#js-dropdown-approved-by.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('Any')
+ %li.divider.droplab-item-ignore
+ - if current_user
+ = render 'shared/issuable/user_dropdown_item',
+ user: current_user
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ = render 'shared/issuable/user_dropdown_item',
+ user: User.new(username: '{{username}}', name: '{{name}}'),
+ avatar: { lazy: true, url: '{{avatar_url}}' }
diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml
index 8e46db6dea2..196d0417fb8 100644
--- a/app/views/shared/issuable/_assignees.html.haml
+++ b/app/views/shared/issuable/_assignees.html.haml
@@ -4,7 +4,8 @@
- more_assignees_count = issuable.assignees.size - render_count
- issuable.assignees.take(render_count).each do |assignee| # rubocop: disable CodeReuse/ActiveRecord
- = link_to_member(@project, assignee, name: false, title: "Assigned to :name")
+ = link_to_member(@project, assignee, name: false, title: _("Assigned to %{name}") % { name: assignee.name})
- if more_assignees_count > 0
- %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{more_assignees_count} more assignees", qa_selector: 'avatar_counter_content' } } +#{more_assignees_count}
+ %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', qa_selector: 'avatar_counter_content' }, title: _("+%{more_assignees_count} more assignees") % { more_assignees_count: more_assignees_count} }
+ = _("+%{more_assignees_count}") % { more_assignees_count: more_assignees_count}
diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml
index 59d0c46b92f..8365bc6f863 100644
--- a/app/views/shared/issuable/_close_reopen_button.html.haml
+++ b/app/views/shared/issuable/_close_reopen_button.html.haml
@@ -5,18 +5,21 @@
- if defined? warn_before_close
- add_blocked_class = warn_before_close
-- if is_current_user
+- if is_current_user && !issuable.is_a?(MergeRequest)
- if can_update
- %button{ class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)} #{(add_blocked_class ? 'btn-issue-blocked' : '')}",
+ %button{ class: "d-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)} #{(add_blocked_class ? 'btn-issue-blocked' : '')}",
data: { remote: 'true', endpoint: close_issuable_path(issuable), qa_selector: 'close_issue_button' } }
= _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }
- if can_reopen
- %button{ class: "d-none d-sm-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}",
+ %button{ class: "d-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}",
data: { remote: 'true', endpoint: reopen_issuable_path(issuable), qa_selector: 'reopen_issue_button' } }
= _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }
- else
- if can_update && !are_close_and_open_buttons_hidden
- = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable, warn_before_close: add_blocked_class
+ - if issuable.is_a?(MergeRequest)
+ = render 'shared/issuable/close_reopen_draft_report_toggle', issuable: issuable
+ - else
+ = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable, warn_before_close: add_blocked_class
- else
= link_to _('Report abuse'), new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)),
- class: 'd-none d-sm-none d-md-block btn btn-grouped btn-close-color', title: _('Report abuse')
+ class: 'd-none d-md-block btn btn-grouped btn-close-color', title: _('Report abuse')
diff --git a/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml
new file mode 100644
index 00000000000..bdb53dfe323
--- /dev/null
+++ b/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml
@@ -0,0 +1,37 @@
+- display_issuable_type = issuable_display_type(issuable)
+- button_action_class = issuable.closed? ? 'btn-default' : 'btn-warning btn-warning-secondary'
+- button_class = "btn gl-button #{!issuable.closed? && 'js-draft-toggle-button'}"
+- toggle_class = "btn gl-button dropdown-toggle"
+
+.float-left.btn-group.gl-ml-3.issuable-close-dropdown.d-none.d-md-inline-flex.js-issuable-close-dropdown
+ = link_to issuable.closed? ? reopen_issuable_path(issuable) : toggle_draft_issuable_path(issuable), method: :put, class: "#{button_class} #{button_action_class}" do
+ - if issuable.closed?
+ = _('Reopen')
+ = display_issuable_type
+ - else
+ = issuable.work_in_progress? ? _('Mark as ready') : _('Mark as draft')
+
+ - if !issuable.closed? || !issuable_author_is_current_user(issuable)
+ = button_tag type: 'button', class: "#{toggle_class} #{button_action_class}", data: { 'toggle' => 'dropdown' } do
+ %span.sr-only= _('Toggle dropdown')
+ = sprite_icon "angle-down", size: 12
+
+ %ul.js-issuable-close-menu.dropdown-menu.dropdown-menu-right
+ - if issuable.open?
+ %li
+ = link_to close_issuable_path(issuable), method: :put do
+ .description
+ %strong.title
+ = _('Close')
+ = display_issuable_type
+
+ - unless issuable_author_is_current_user(issuable)
+ - unless issuable.closed?
+ %li.divider.droplab-item-ignore
+
+ %li.report-item
+ %a.report-abuse-link{ href: new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)) }
+ .description
+ %strong.title= _('Report abuse')
+ %p.text
+ = _('Report %{display_issuable_type} that are abusive, inappropriate or spam.') % { display_issuable_type: display_issuable_type.pluralize }
diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
index 4c7aee09406..df441e6d0af 100644
--- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
+++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
@@ -1,7 +1,7 @@
- display_issuable_type = issuable_display_type(issuable)
- button_action = issuable.closed? ? 'reopen' : 'close'
- display_button_action = button_action.capitalize
-- button_responsive_class = 'd-none d-sm-none d-md-block'
+- button_responsive_class = 'd-none d-md-block'
- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button js-btn-issue-action issuable-close-button"
- toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle"
- add_blocked_class = false
diff --git a/app/views/shared/issuable/_reviewers.html.haml b/app/views/shared/issuable/_reviewers.html.haml
new file mode 100644
index 00000000000..8e66135a20b
--- /dev/null
+++ b/app/views/shared/issuable/_reviewers.html.haml
@@ -0,0 +1,11 @@
+- max_render = 4
+- reviewers_rendering_overflow = issuable.reviewers.size > max_render
+- render_count = reviewers_rendering_overflow ? max_render - 1 : max_render
+- more_reviewers_count = issuable.reviewers.size - render_count
+
+- issuable.reviewers.take(render_count).each do |reviewer| # rubocop: disable CodeReuse/ActiveRecord
+ = link_to_member(@project, reviewer, name: false, title: _("Review requested from %{name}") % { name: reviewer.name})
+
+- if more_reviewers_count > 0
+ %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old' }, title: _("+%{more_reviewers_count} more reviewers") % { more_reviewers_count: more_reviewers_count} }
+ = _("+%{more_reviewers_count}") % { more_reviewers_count: more_reviewers_count}
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index cd7d792738d..ae79d5e3c3e 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -1,6 +1,7 @@
- type = local_assigns.fetch(:type)
- board = local_assigns.fetch(:board, nil)
- show_sorting_dropdown = local_assigns.fetch(:show_sorting_dropdown, true)
+- disable_target_branch = local_assigns.fetch(:disable_target_branch, false)
- placeholder = local_assigns[:placeholder] || _('Search or filter results...')
- is_not_boards_modal_or_productivity_analytics = type != :boards_modal && type != :productivity_analytics
- block_css_class = is_not_boards_modal_or_productivity_analytics ? 'row-content-block second-block' : ''
@@ -154,10 +155,16 @@
%li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
%button.btn.btn-link{ type: 'button' }
= _('No')
- #js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu
+ - unless disable_target_branch
+ #js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link.js-data-value.monospace
+ {{title}}
+ #js-dropdown-environment.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.btn.btn-link.js-data-value.monospace
+ %button.btn.btn-link.js-data-value{ type: 'button' }
{{title}}
= render_if_exists 'shared/issuable/filter_weight', type: type
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 620e9b5ea31..458703ebc5f 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -5,204 +5,173 @@
- signed_in = !!issuable_sidebar.dig(:current_user, :id)
- can_edit_issuable = issuable_sidebar.dig(:current_user, :can_edit)
- add_page_startup_api_call "#{issuable_sidebar[:issuable_json_path]}?serializer=sidebar_extras"
-
-- if Feature.enabled?(:vue_issuable_sidebar, @project.group)
- %aside#js-vue-issuable-sidebar{ data: { signed_in: signed_in,
- sidebar_status_class: sidebar_gutter_collapsed_class } }
-- else
- %aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
- .issuable-sidebar
- .block.issuable-sidebar-header
- - if signed_in
- %span.issuable-header-text.hide-collapsed.float-left
- = _('To Do')
- %a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }
- = sidebar_gutter_toggle_icon
- - if signed_in
- = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar
-
- = form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f|
- - if signed_in
- .block.todo.hide-expanded
- = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar, is_collapsed: true
- .block.assignee.qa-assignee-block
- = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees
-
- = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar
-
- - if issuable_sidebar[:supports_milestone]
- - milestone = issuable_sidebar[:milestone] || {}
- .block.milestone{ data: { qa_selector: 'milestone_block' } }
- .sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
- = sprite_icon('clock')
- %span.milestone-title.collapse-truncated-title
- - if milestone.present?
- = milestone[:title]
- - else
- = _('None')
- .title.hide-collapsed
- = _('Milestone')
- = loading_icon(css_class: 'gl-vertical-align-text-bottom hidden block-loading')
- - if can_edit_issuable
- = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "edit_milestone_link", track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" }
- .value.hide-collapsed
+- reviewers = local_assigns.fetch(:reviewers, nil)
+
+%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in }, issuable_type: issuable_type }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
+ .issuable-sidebar
+ .block.issuable-sidebar-header
+ - if signed_in
+ %span.issuable-header-text.hide-collapsed.float-left
+ = _('To Do')
+ %a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }
+ = sidebar_gutter_toggle_icon
+ - if signed_in
+ = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar
+
+ = form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f|
+ - if signed_in
+ .block.todo.hide-expanded
+ = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar, is_collapsed: true
+ .block.assignee.qa-assignee-block
+ = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees, signed_in: signed_in
+
+ - if Feature.enabled?(:merge_request_reviewers, @project) && reviewers
+ .block.reviewer.qa-reviewer-block
+ = render "shared/issuable/sidebar_reviewers", issuable_sidebar: issuable_sidebar, reviewers: reviewers, signed_in: signed_in
+
+ = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar
+
+ - if issuable_sidebar[:supports_milestone]
+ - milestone = issuable_sidebar[:milestone] || {}
+ .block.milestone{ data: { qa_selector: 'milestone_block' } }
+ .sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
+ = sprite_icon('clock')
+ %span.milestone-title.collapse-truncated-title
- if milestone.present?
- - milestone_title = milestone[:expired] ? _("%{milestone_name} (Past due)").html_safe % { milestone_name: milestone[:title] } : milestone[:title]
- = link_to milestone_title, milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link', qa_title: milestone[:title] }
+ = milestone[:title]
- else
- %span.no-value
- = _('None')
-
- .selectbox.hide-collapsed
- = f.hidden_field 'milestone_id', value: milestone[:id], id: nil
- = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }})
- - if @project.group.present?
- = render_if_exists 'shared/issuable/iteration_select', { can_edit: can_edit_issuable, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type }
-
- - if issuable_sidebar[:supports_time_tracking]
- #issuable-time-tracker.block
- // Fallback while content is loading
- .title.hide-collapsed
- = _('Time tracking')
- = loading_icon
- - if issuable_sidebar.has_key?(:due_date)
- .block.due_date
- .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable_sidebar[:due_date]) }
- = sprite_icon('calendar')
- %span.js-due-date-sidebar-value
- = issuable_sidebar[:due_date].try(:to_s, :medium) || _('None')
- .title.hide-collapsed
- = _('Due date')
- = loading_icon(css_class: 'gl-vertical-align-text-bottom hidden block-loading')
- - if can_edit_issuable
- = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "due_date", track_event: "click_edit_button", track_value: "" }
- .value.hide-collapsed
- %span.value-content
- - if issuable_sidebar[:due_date]
- %span.bold= issuable_sidebar[:due_date].to_s(:medium)
- - else
- %span.no-value
- = _('None')
- - if can_edit_issuable
- %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable_sidebar[:due_date].nil?) }
- \-
- %a.js-remove-due-date{ href: "#", role: "button" }
- = _('remove due date')
+ = _('None')
+ .title.hide-collapsed
+ = _('Milestone')
+ = loading_icon(css_class: 'gl-vertical-align-text-bottom hidden block-loading')
- if can_edit_issuable
- .selectbox.hide-collapsed
- = f.hidden_field :due_date, value: issuable_sidebar[:due_date].try(:strftime, 'yy-mm-dd')
- .dropdown
- %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable_type}[due_date]", ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], display: 'static' } }
- %span.dropdown-toggle-text
- = _('Due date')
- = icon('chevron-down', 'aria-hidden': 'true')
- .dropdown-menu.dropdown-menu-due-date
- = dropdown_title(_('Due date'))
- = dropdown_content do
- .js-due-date-calendar
-
-
- - if Feature.enabled?(:vue_sidebar_labels, @project)
- .js-sidebar-labels{ data: { allow_label_create: issuable_sidebar.dig(:current_user, :can_admin_label).to_s,
- allow_scoped_labels: issuable_sidebar[:scoped_labels_available].to_s,
- can_edit: can_edit_issuable.to_s,
- iid: issuable_sidebar[:iid],
- issuable_type: issuable_type,
- labels_fetch_path: issuable_sidebar[:project_labels_path],
- labels_manage_path: project_labels_path(@project),
- labels_update_path: issuable_sidebar[:issuable_json_path],
- project_issues_path: issuable_sidebar[:project_issuables_path],
- project_path: @project.full_path,
- selected_labels: issuable_sidebar[:labels].to_json } }
- - else
- - selected_labels = issuable_sidebar[:labels]
- .block.labels
- .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(selected_labels), data: { placement: "left", container: "body", boundary: 'viewport' } }
- = sprite_icon('labels')
- %span
- = selected_labels.size
- .title.hide-collapsed
- = _('Labels')
- = loading_icon(css_class: 'gl-vertical-align-text-bottom hidden block-loading')
- - if can_edit_issuable
- = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "edit_labels_link", track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" }
- .value.issuable-show-labels.dont-hide.hide-collapsed{ class: ("has-labels" if selected_labels.any?), data: { qa_selector: 'labels_block' } }
- - if selected_labels.any?
- - selected_labels.each do |label_hash|
- = render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]), dataset: { qa_selector: 'label', qa_label_name: label_hash[:title] })
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "edit_milestone_link", track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" }
+ .value.hide-collapsed
+ - if milestone.present?
+ - milestone_title = milestone[:expired] ? _("%{milestone_name} (Past due)").html_safe % { milestone_name: milestone[:title] } : milestone[:title]
+ = link_to milestone_title, milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link', qa_title: milestone[:title] }
+ - else
+ %span.no-value
+ = _('None')
+
+ .selectbox.hide-collapsed
+ = f.hidden_field 'milestone_id', value: milestone[:id], id: nil
+ = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }})
+ - if @project.group.present?
+ = render_if_exists 'shared/issuable/iteration_select', { can_edit: can_edit_issuable, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type }
+
+ - if issuable_sidebar[:supports_time_tracking]
+ #issuable-time-tracker.block
+ // Fallback while content is loading
+ .title.hide-collapsed
+ = _('Time tracking')
+ = loading_icon
+ - if issuable_sidebar.has_key?(:due_date)
+ .block.due_date
+ .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable_sidebar[:due_date]) }
+ = sprite_icon('calendar')
+ %span.js-due-date-sidebar-value
+ = issuable_sidebar[:due_date].try(:to_s, :medium) || _('None')
+ .title.hide-collapsed
+ = _('Due date')
+ = loading_icon(css_class: 'gl-vertical-align-text-bottom hidden block-loading')
+ - if can_edit_issuable
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "due_date", track_event: "click_edit_button", track_value: "" }
+ .value.hide-collapsed
+ %span.value-content
+ - if issuable_sidebar[:due_date]
+ %span.bold= issuable_sidebar[:due_date].to_s(:medium)
- else
%span.no-value
= _('None')
+ - if can_edit_issuable
+ %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable_sidebar[:due_date].nil?) }
+ \-
+ %a.js-remove-due-date{ href: "#", role: "button" }
+ = _('remove due date')
+ - if can_edit_issuable
.selectbox.hide-collapsed
- - selected_labels.each do |label|
- = hidden_field_tag "#{issuable_type}[label_names][]", label[:id], id: nil
+ = f.hidden_field :due_date, value: issuable_sidebar[:due_date].try(:strftime, 'yy-mm-dd')
.dropdown
- %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: sidebar_label_dropdown_data(issuable_type, issuable_sidebar) }
- %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) }
- = multi_label_name(selected_labels, "Labels")
+ %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable_type}[due_date]", ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], display: 'static' } }
+ %span.dropdown-toggle-text
+ = _('Due date')
= icon('chevron-down', 'aria-hidden': 'true')
- .dropdown-menu.dropdown-select.dropdown-menu-paging.qa-dropdown-menu-labels.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height
- = render partial: "shared/issuable/label_page_default"
- - if issuable_sidebar.dig(:current_user, :can_admin_label)
- = render partial: "shared/issuable/label_page_create"
-
- = render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar
-
- - if issuable_sidebar[:supports_severity]
- #js-severity
-
- - if issuable_sidebar.dig(:features_available, :health_status)
- .js-sidebar-status-entry-point
-
- - if issuable_sidebar.has_key?(:confidential)
- %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe
- #js-confidential-entry-point
-
- %script#js-lock-issue-data{ type: "application/json" }= { is_locked: !!issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe
- #js-lock-entry-point
-
- .js-sidebar-participants-entry-point
-
- - if signed_in
- .js-sidebar-subscriptions-entry-point
-
- - project_ref = issuable_sidebar[:reference]
- .block.with-sub-blocks
- .project-reference.sub-block
+ .dropdown-menu.dropdown-menu-due-date
+ = dropdown_title(_('Due date'))
+ = dropdown_content do
+ .js-due-date-calendar
+
+
+ .js-sidebar-labels{ data: { allow_label_create: issuable_sidebar.dig(:current_user, :can_admin_label).to_s,
+ allow_scoped_labels: issuable_sidebar[:scoped_labels_available].to_s,
+ can_edit: can_edit_issuable.to_s,
+ iid: issuable_sidebar[:iid],
+ issuable_type: issuable_type,
+ labels_fetch_path: issuable_sidebar[:project_labels_path],
+ labels_manage_path: project_labels_path(@project),
+ labels_update_path: issuable_sidebar[:issuable_json_path],
+ project_issues_path: issuable_sidebar[:project_issuables_path],
+ project_path: @project.full_path,
+ selected_labels: issuable_sidebar[:labels].to_json } }
+
+ = render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar
+
+ - if issuable_sidebar[:supports_severity]
+ #js-severity
+
+ - if issuable_sidebar.dig(:features_available, :health_status)
+ .js-sidebar-status-entry-point
+
+ - if issuable_sidebar.has_key?(:confidential)
+ %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe
+ #js-confidential-entry-point
+
+ %script#js-lock-issue-data{ type: "application/json" }= { is_locked: !!issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe
+ #js-lock-entry-point
+
+ .js-sidebar-participants-entry-point
+
+ - if signed_in
+ .js-sidebar-subscriptions-entry-point
+
+ - project_ref = issuable_sidebar[:reference]
+ .block.with-sub-blocks
+ .project-reference.sub-block
+ .sidebar-collapsed-icon.dont-change-state
+ = clipboard_button(text: project_ref, title: _('Copy reference'), placement: "left", boundary: 'viewport')
+ .cross-project-reference.hide-collapsed
+ %span
+ = _('Reference:')
+ %cite{ title: project_ref }
+ = project_ref
+ = clipboard_button(text: project_ref, title: _('Copy reference'), placement: "left", boundary: 'viewport')
+ - if issuable_type == 'merge_request'
+ .sidebar-source-branch.sub-block
.sidebar-collapsed-icon.dont-change-state
- = clipboard_button(text: project_ref, title: _('Copy reference'), placement: "left", boundary: 'viewport')
- .cross-project-reference.hide-collapsed
+ = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport')
+ .sidebar-mr-source-branch.hide-collapsed
%span
- = _('Reference:')
- %cite{ title: project_ref }
- = project_ref
- = clipboard_button(text: project_ref, title: _('Copy reference'), placement: "left", boundary: 'viewport')
- - if issuable_type == 'merge_request'
- .sidebar-source-branch.sub-block
- .sidebar-collapsed-icon.dont-change-state
- = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport')
- .sidebar-mr-source-branch.hide-collapsed
- %span
- = _('Source branch: %{source_branch_open}%{source_branch}%{source_branch_close}').html_safe % { source_branch_open: "<cite class='ref-name' title='#{source_branch}'>".html_safe, source_branch_close: "</cite>".html_safe, source_branch: source_branch }
- = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport')
-
- - if issuable_sidebar.dig(:current_user, :can_move)
- .block.js-sidebar-move-issue-block
- .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body', boundary: 'viewport' }, title: _('Move issue') }
- = custom_icon('icon_arrow_right')
- .dropdown.sidebar-move-issue-dropdown.hide-collapsed
- %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button',
- data: { toggle: 'dropdown', display: 'static', track_label: "right_sidebar", track_property: "move_issue", track_event: "click_button", track_value: "" } }
- = _('Move issue')
- .dropdown-menu.dropdown-menu-selectable.dropdown-extended-height
- = dropdown_title(_('Move issue'))
- = dropdown_filter(_('Search project'), search_id: 'sidebar-move-issue-dropdown-search')
- = dropdown_content
- = dropdown_loading
- = dropdown_footer add_content_class: true do
- %button.btn.btn-success.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ type: 'button', disabled: true }
- = _('Move')
- = loading_icon(css_class: 'gl-vertical-align-text-bottom sidebar-move-issue-confirmation-loading-icon')
-
- -# haml-lint:disable InlineJavaScript
- %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable_sidebar).to_json.html_safe
+ = _('Source branch: %{source_branch_open}%{source_branch}%{source_branch_close}').html_safe % { source_branch_open: "<cite class='ref-name' title='#{source_branch}'>".html_safe, source_branch_close: "</cite>".html_safe, source_branch: source_branch }
+ = clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport')
+
+ - if issuable_sidebar.dig(:current_user, :can_move)
+ .block.js-sidebar-move-issue-block
+ .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body', boundary: 'viewport' }, title: _('Move issue') }
+ = custom_icon('icon_arrow_right')
+ .dropdown.sidebar-move-issue-dropdown.hide-collapsed
+ %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button',
+ data: { toggle: 'dropdown', display: 'static', track_label: "right_sidebar", track_property: "move_issue", track_event: "click_button", track_value: "" } }
+ = _('Move issue')
+ .dropdown-menu.dropdown-menu-selectable.dropdown-extended-height
+ = dropdown_title(_('Move issue'))
+ = dropdown_filter(_('Search project'), search_id: 'sidebar-move-issue-dropdown-search')
+ = dropdown_content
+ = dropdown_loading
+ = dropdown_footer add_content_class: true do
+ %button.btn.btn-success.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ type: 'button', disabled: true }
+ = _('Move')
+ = loading_icon(css_class: 'gl-vertical-align-text-bottom sidebar-move-issue-confirmation-loading-icon')
+
+ -# haml-lint:disable InlineJavaScript
+ %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable_sidebar).to_json.html_safe
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index 175713751ef..a7f435edb90 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -1,5 +1,4 @@
- issuable_type = issuable_sidebar[:type]
-- signed_in = !!issuable_sidebar.dig(:current_user, :id)
#js-vue-sidebar-assignees{ data: { field: issuable_type, signed_in: signed_in } }
.title.hide-collapsed
@@ -40,17 +39,25 @@
- data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select']
- options[:data].merge!(data)
- - if experiment_enabled?(:invite_members_version_a) && can_import_members?
+ - if directly_invite_members? || indirectly_invite_members?
- options[:dropdown_class] += ' dropdown-extended-height'
- options[:footer_content] = true
- options[:wrapper_class] = 'js-sidebar-assignee-dropdown'
+ - invite_text = _('Invite Members')
+ - track_label = 'edit_assignee'
= dropdown_tag(title, options: options) do
%ul.dropdown-footer-list
%li
- = link_to _('Invite Members'),
- project_project_members_path(@project),
- title: _('Invite Members'),
- data: { 'is-link': true, 'track-event': 'click_invite_members', 'track-label': 'edit_assignee' }
+ - if directly_invite_members?
+ = link_to invite_text,
+ project_project_members_path(@project),
+ title: invite_text,
+ data: { 'is-link': true, 'track-event': 'click_invite_members', 'track-label': track_label }
+ - else
+ .js-invite-member-trigger{ data: { display_text: invite_text, event: 'click_invite_members_version_b', label: track_label } }
- else
= dropdown_tag(title, options: options)
+
+- if indirectly_invite_members?
+ .js-invite-member-modal{ data: { members_path: project_project_members_path(@project, sort: :access_level_desc) } }
diff --git a/app/views/shared/issuable/_sidebar_reviewers.html.haml b/app/views/shared/issuable/_sidebar_reviewers.html.haml
new file mode 100644
index 00000000000..0142c87aeb0
--- /dev/null
+++ b/app/views/shared/issuable/_sidebar_reviewers.html.haml
@@ -0,0 +1,55 @@
+- issuable_type = issuable_sidebar[:type]
+
+#js-vue-sidebar-reviewers{ data: { field: issuable_type, signed_in: signed_in } }
+ .title.hide-collapsed
+ = _('Reviewer')
+ = loading_icon(css_class: 'gl-vertical-align-text-bottom')
+
+.selectbox.hide-collapsed
+ - if reviewers.none?
+ = hidden_field_tag "#{issuable_type}[reviewer_ids][]", 0, id: nil
+ - else
+ - reviewers.each do |reviewer|
+ = hidden_field_tag "#{issuable_type}[reviewer_ids][]", reviewer.id, id: nil, data: reviewer_sidebar_data(reviewer, merge_request: @merge_request)
+
+ - options = { toggle_class: 'js-reviewer-search js-author-search',
+ title: _('Request review from'),
+ filter: true,
+ dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author',
+ placeholder: _('Search users'),
+ data: { first_user: issuable_sidebar.dig(:current_user, :username),
+ current_user: true,
+ iid: issuable_sidebar[:iid],
+ issuable_type: issuable_type,
+ project_id: issuable_sidebar[:project_id],
+ author_id: issuable_sidebar[:author_id],
+ field_name: "#{issuable_type}[reviewer_ids][]",
+ issue_update: issuable_sidebar[:issuable_json_path],
+ ability_name: issuable_type,
+ null_user: true,
+ display: 'static' } }
+
+ - dropdown_options = reviewers_dropdown_options(issuable_type)
+ - title = dropdown_options[:title]
+ - options[:toggle_class] += ' js-multiselect js-save-user-data'
+ - data = { field_name: "#{issuable_type}[reviewer_ids][]" }
+ - data[:multi_select] = true
+ - data['dropdown-title'] = title
+ - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header']
+ - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select']
+ - options[:data].merge!(data)
+
+ - if experiment_enabled?(:invite_members_version_a) && can_import_members?
+ - options[:dropdown_class] += ' dropdown-extended-height'
+ - options[:footer_content] = true
+ - options[:wrapper_class] = 'js-sidebar-reviewer-dropdown'
+
+ = dropdown_tag(title, options: options) do
+ %ul.dropdown-footer-list
+ %li
+ = link_to _('Invite Members'),
+ project_project_members_path(@project),
+ title: _('Invite Members'),
+ data: { 'is-link': true, 'track-event': 'click_invite_members', 'track-label': 'edit_reviewer' }
+ - else
+ = dropdown_tag(title, options: options)
diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml
index 7a8120d2d02..3347966f39a 100644
--- a/app/views/shared/issuable/form/_type_selector.html.haml
+++ b/app/views/shared/issuable/form/_type_selector.html.haml
@@ -1,4 +1,4 @@
-- return unless issuable.supports_issue_type? && can?(current_user, :admin_issue, @project)
+- return unless issuable.supports_issue_type? && can?(current_user, :create_issue, @project)
.form-group.row.gl-mb-0
= form.label :type, 'Type', class: 'col-form-label col-sm-2'
@@ -20,11 +20,11 @@
%li.js-filter-issuable-type
= link_to new_project_issue_path(@project), class: ("is-active" if issuable.issue?) do
= _("Issue")
- %li.js-filter-issuable-type
+ %li.js-filter-issuable-type{ data: { track: { event: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } }
= link_to new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }), class: ("is-active" if issuable.incident?) do
= _("Incident")
- if issuable.incident?
%p.form-text.text-muted
- - incident_docs_url = help_page_path('operations/incident_management/incidents.md', anchor: 'create-and-manage-incidents-in-gitlab')
+ - incident_docs_url = help_page_path('operations/incident_management/incidents.md')
- incident_docs_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: incident_docs_url }
= _('A %{incident_docs_start}modified issue%{incident_docs_end} to guide the resolution of incidents.').html_safe % { incident_docs_start: incident_docs_start, incident_docs_end: '</a>'.html_safe }
diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml
index 78ff225daad..2df6c3a6afd 100644
--- a/app/views/shared/labels/_form.html.haml
+++ b/app/views/shared/labels/_form.html.haml
@@ -28,7 +28,7 @@
= render_suggested_colors
.form-actions
- if @label.persisted?
- = f.submit 'Save changes', class: 'btn btn-success js-save-button'
+ = f.submit 'Save changes', class: 'btn gl-button btn-success js-save-button'
- else
- = f.submit 'Create label', class: 'btn btn-success js-save-button qa-label-create-button'
- = link_to 'Cancel', back_path, class: 'btn btn-cancel'
+ = f.submit 'Create label', class: 'btn gl-button btn-success js-save-button qa-label-create-button'
+ = link_to 'Cancel', back_path, class: 'btn gl-button btn-cancel'
diff --git a/app/views/shared/labels/_nav.html.haml b/app/views/shared/labels/_nav.html.haml
index d613ea466fa..6d1d422f227 100644
--- a/app/views/shared/labels/_nav.html.haml
+++ b/app/views/shared/labels/_nav.html.haml
@@ -15,10 +15,10 @@
.input-group
= search_field_tag :search, params[:search], { placeholder: _('Filter'), id: 'label-search', class: 'form-control search-text-input input-short', spellcheck: false, autofocus: true }
%span.input-group-append
- %button.btn.btn-default{ type: "submit", "aria-label" => _('Submit search') }
- = icon("search")
+ %button.btn.gl-button.btn-default{ type: "submit", "aria-label" => _('Submit search') }
+ = sprite_icon('search')
= render 'shared/labels/sort_dropdown'
- if labels_or_filters && can_admin_label && @project
- = link_to _('New label'), new_project_label_path(@project), class: "btn btn-success qa-label-create-new"
+ = link_to _('New label'), new_project_label_path(@project), class: "btn gl-button btn-success qa-label-create-new"
- if labels_or_filters && can_admin_label && @group
- = link_to _('New label'), new_group_label_path(@group), class: "btn btn-success qa-label-create-new"
+ = link_to _('New label'), new_group_label_path(@group), class: "btn gl-button btn-success qa-label-create-new"
diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml
index b4b06640bd9..a983a736a1e 100644
--- a/app/views/shared/members/_access_request_links.html.haml
+++ b/app/views/shared/members/_access_request_links.html.haml
@@ -5,13 +5,13 @@
= link_to link_text, polymorphic_path([:leave, source, :members]),
method: :delete,
data: { confirm: leave_confirmation_message(source), qa_selector: 'leave_group_link' },
- class: 'access-request-link js-leave-link'
+ class: '.gl-pl-3.gl-border-l-1.gl-border-l-solid.gl-border-l-gray-500 js-leave-link'
- elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord
= link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]),
method: :delete,
data: { confirm: remove_member_message(requester) },
- class: 'access-request-link'
+ class: '.gl-pl-3.gl-border-l-1.gl-border-l-solid.gl-border-l-gray-500'
- elsif source.request_access_enabled && can?(current_user, :request_access, source)
= link_to _('Request Access'), polymorphic_path([:request_access, source, :members]),
method: :post,
- class: 'access-request-link'
+ class: '.gl-pl-3.gl-border-l-1.gl-border-l-solid.gl-border-l-gray-500'
diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml
index 8e5763842d9..42e12d92a7d 100644
--- a/app/views/shared/members/_group.html.haml
+++ b/app/views/shared/members/_group.html.haml
@@ -6,17 +6,18 @@
-# Note this is just for groups. For individual members please see shared/members/_member
-%li.member.group_member.py-2.px-3.d-flex.flex-column.flex-md-row{ id: dom_id, data: { qa_selector: 'group_row' } }
+%li.member.js-member.group_member.py-2.px-3.d-flex.flex-column.flex-md-row{ id: dom_id, data: { qa_selector: 'group_row' } }
%span.list-item-name.mb-2.m-md-0
= group_icon(group, class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '')
.user-info
= link_to group.full_name, group_path(group), class: 'member'
.cgray
Given access #{time_ago_with_tooltip(group_link.created_at)}
- - if group_link.expires?
- ·
- %span{ class: ('text-warning' if group_link.expires_soon?) }
- = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) }
+ %span.js-expires-in{ class: ('gl-display-none' unless group_link.expires?) }
+ &middot;
+ %span.js-expires-in-text{ class: ('text-warning' if group_link.expires_soon?) }
+ - if group_link.expires?
+ = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) }
.controls.member-controls.align-items-center
= form_tag group_link_path, method: :put, remote: true, class: 'js-edit-member-form form-group d-sm-flex' do
= hidden_field_tag "group_link[group_access]", group_link.group_access
@@ -43,7 +44,7 @@
= link_to group_link_path,
method: :delete,
data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name }, qa_selector: 'delete_group_access_link' },
- class: 'btn btn-remove m-0 ml-sm-2 align-self-center' do
+ class: 'gl-button btn btn-danger m-0 ml-sm-2 align-self-center' do
%span.d-block.d-sm-none
= _("Delete")
= sprite_icon('remove', css_class: 'd-none d-sm-block')
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index 7573c2f6d56..164d38986ec 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -8,7 +8,7 @@
-# Note this is just for individual members. For groups please see shared/members/_group
-%li.member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("is-overridden" if override), ("flex-md-row" unless force_mobile_view)], id: dom_id(member), data: { qa_selector: 'member_row' } }
+%li.member.js-member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("is-overridden" if override), ("flex-md-row" unless force_mobile_view)], id: dom_id(member), data: { qa_selector: 'member_row' } }
%span.list-item-name.mb-2.m-md-0
- if user
= image_tag avatar_icon_for_user(user, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: ''
@@ -40,10 +40,11 @@
= _("Requested %{time_ago}").html_safe % { time_ago: time_ago_with_tooltip(member.requested_at) }
- else
= _("Given access %{time_ago}").html_safe % { time_ago: time_ago_with_tooltip(member.created_at) }
- - if member.expires?
- ·
- %span{ class: "#{"text-warning" if member.expires_soon?} has-tooltip", title: member.expires_at.to_time.in_time_zone.to_s(:medium) }
- = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(member.expires_at) }
+ %span.js-expires-in{ class: ('gl-display-none' unless member.expires?) }
+ &middot;
+ %span.js-expires-in-text{ class: "has-tooltip#{' text-warning' if member.expires_soon?}", title: (member.expires_at.to_time.in_time_zone.to_s(:medium) if member.expires?) }
+ - if member.expires?
+ = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(member.expires_at) }
- else
= image_tag avatar_icon_for_email(member.invite_email, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: ''
diff --git a/app/views/shared/members/update.js.haml b/app/views/shared/members/update.js.haml
deleted file mode 100644
index 55050bd8a15..00000000000
--- a/app/views/shared/members/update.js.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-- member = local_assigns.fetch(:member)
-
-:plain
- var $listItem = $('#{escape_javascript(render('shared/members/member', member: member))}');
- $("##{dom_id(member)} .list-item-name").replaceWith($listItem.find('.list-item-name'));
- gl.utils.localTimeAgo($('.js-timeago'), $("##{dom_id(member)}"));
diff --git a/app/views/shared/milestones/_delete_button.html.haml b/app/views/shared/milestones/_delete_button.html.haml
index e00a10398d3..7a813e110c4 100644
--- a/app/views/shared/milestones/_delete_button.html.haml
+++ b/app/views/shared/milestones/_delete_button.html.haml
@@ -1,8 +1,6 @@
- milestone_url = @milestone.project_milestone? ? project_milestone_path(@project, @milestone) : group_milestone_path(@group, @milestone)
-%button.js-delete-milestone-button.btn.btn-grouped.btn-danger{ data: { toggle: 'modal',
- target: '#delete-milestone-modal',
- milestone_id: @milestone.id,
+%button.js-delete-milestone-button.btn.btn-grouped.btn-danger{ data: { milestone_id: @milestone.id,
milestone_title: markdown_field(@milestone, :title),
milestone_url: milestone_url,
milestone_issue_count: @milestone.issues.count,
@@ -11,4 +9,4 @@
= _('Delete')
.spinner.js-loading-icon.hidden
-#delete-milestone-modal
+#js-delete-milestone-modal
diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml
index f8bf3e7ad6a..a62ed009552 100644
--- a/app/views/shared/milestones/_issuable.html.haml
+++ b/app/views/shared/milestones/_issuable.html.haml
@@ -10,8 +10,6 @@
%span
- if show_project_name
%strong #{project.name} &middot;
- - elsif show_full_project_name
- %strong #{project.full_name} &middot;
- if issuable.is_a?(Issue)
= confidential_icon(issuable)
= link_to issuable.title, issuable_url_args, title: issuable.title
diff --git a/app/views/shared/milestones/_issuables.html.haml b/app/views/shared/milestones/_issuables.html.haml
index ee97f0172da..9147e1c50e3 100644
--- a/app/views/shared/milestones/_issuables.html.haml
+++ b/app/views/shared/milestones/_issuables.html.haml
@@ -15,4 +15,4 @@
= render partial: 'shared/milestones/issuable',
collection: issuables,
as: :issuable,
- locals: { show_project_name: show_project_name, show_full_project_name: show_full_project_name }
+ locals: { show_project_name: show_project_name }
diff --git a/app/views/shared/milestones/_issues_tab.html.haml b/app/views/shared/milestones/_issues_tab.html.haml
index dc54eefbaa9..76ef636ec96 100644
--- a/app/views/shared/milestones/_issues_tab.html.haml
+++ b/app/views/shared/milestones/_issues_tab.html.haml
@@ -1,5 +1,4 @@
-- args = { show_project_name: local_assigns.fetch(:show_project_name, false),
- show_full_project_name: local_assigns.fetch(:show_full_project_name, false) }
+- args = { show_project_name: local_assigns.fetch(:show_project_name, false) }
- if display_issues_count_warning?(@milestone)
.flash-container
diff --git a/app/views/shared/milestones/_merge_requests_tab.haml b/app/views/shared/milestones/_merge_requests_tab.haml
index 0dbf2b27c8d..a78440600ad 100644
--- a/app/views/shared/milestones/_merge_requests_tab.haml
+++ b/app/views/shared/milestones/_merge_requests_tab.haml
@@ -1,5 +1,4 @@
-- args = { show_project_name: local_assigns.fetch(:show_project_name, false),
- show_full_project_name: local_assigns.fetch(:show_full_project_name, false) }
+- args = { show_project_name: local_assigns.fetch(:show_project_name, false) }
.row.gl-mt-3
.col-md-3
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 27b771b281b..f28aa406784 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -29,10 +29,10 @@
%div
= render('shared/milestone_expired', milestone: milestone)
- if milestone.group_milestone?
- .label-badge.label-badge-blue.d-inline-block
+ .label-badge.gl-bg-blue-50.d-inline-block
= milestone.group.full_name
- if milestone.project_milestone?
- .label-badge.label-badge-gray.d-inline-block
+ .label-badge.gl-bg-gray-50.d-inline-block
= milestone.project.full_name
.col-sm-4.milestone-progress
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index bdacdb23141..d9d7d18c732 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -32,7 +32,8 @@
.block.due_date
.sidebar-collapsed-icon
- = icon('calendar', 'aria-hidden': 'true')
+ %span{ 'aria-hidden': 'true' }
+ = sprite_icon('calendar')
%span.collapsed-milestone-date
- if milestone.start_date && milestone.due_date
- if milestone.start_date.year == milestone.due_date.year
diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml
index 34f476241c6..33e634c3e7b 100644
--- a/app/views/shared/milestones/_tabs.html.haml
+++ b/app/views/shared/milestones/_tabs.html.haml
@@ -1,14 +1,16 @@
+- show_project_name = local_assigns.fetch(:show_project_name, false)
+
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
.fade-left= sprite_icon('chevron-lg-left', size: 12)
.fade-right= sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.scrolling-tabs.js-milestone-tabs.nav.nav-tabs
%li.nav-item
- = link_to '#tab-issues', class: 'nav-link active', data: { toggle: 'tab', show: '.tab-issues-buttons' } do
+ = link_to '#tab-issues', class: 'nav-link active', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'issues', show_project_name: show_project_name) } do
= _('Issues')
%span.badge.badge-pill= milestone.issues_visible_to_user(current_user).size
- if milestone.merge_requests_enabled?
%li.nav-item
- = link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests') } do
+ = link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests', show_project_name: show_project_name) } do
= _('Merge Requests')
%span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size
%li.nav-item
@@ -20,20 +22,13 @@
= _('Labels')
%span.badge.badge-pill= milestone.issue_labels_visible_by_user(current_user).count
-- issues = milestone.sorted_issues(current_user)
-- show_project_name = local_assigns.fetch(:show_project_name, false)
-- show_full_project_name = local_assigns.fetch(:show_full_project_name, false)
-
.tab-content.milestone-content
- .tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_project_milestone_path(@project, @milestone) if @project && current_user) } }
- = render 'shared/milestones/issues_tab', issues: issues, show_project_name: show_project_name, show_full_project_name: show_full_project_name
+ .tab-pane.active#tab-issues
+ = render "shared/milestones/tab_loading"
- if milestone.merge_requests_enabled?
.tab-pane#tab-merge-requests
- -# loaded async
= render "shared/milestones/tab_loading"
.tab-pane#tab-participants
- -# loaded async
= render "shared/milestones/tab_loading"
.tab-pane#tab-labels
- -# loaded async
= render "shared/milestones/tab_loading"
diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml
index 4d209c30e7b..c37fdf0c98f 100644
--- a/app/views/shared/milestones/_top.html.haml
+++ b/app/views/shared/milestones/_top.html.haml
@@ -7,7 +7,7 @@
= render 'shared/milestones/description', milestone: milestone
- if milestone.complete? && milestone.active?
- .alert.alert-success.gl-mt-3
+ .gl-alert.gl-alert-success.gl-mt-3
%span
= _('All issues for this milestone are closed.')
= group ? _('You may close the milestone now.') : _('Navigate to the project to close the milestone.')
diff --git a/app/views/shared/notes/_edit.html.haml b/app/views/shared/notes/_edit.html.haml
index 84a3ef9d8fe..9cfb3f3b576 100644
--- a/app/views/shared/notes/_edit.html.haml
+++ b/app/views/shared/notes/_edit.html.haml
@@ -1 +1 @@
-%textarea.hidden.js-task-list-field.original-task-list{ data: { update_url: note_url(note) } }= note.note
+%textarea.hidden.js-task-list-field.original-task-list{ data: { update_url: note_url(note), value: note.note } }
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index 5b7a0b99598..9baa340376b 100644
--- a/app/views/shared/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -12,7 +12,7 @@
.timeline-entry-inner
.flash-container.timeline-content
- .timeline-icon.d-none.d-sm-none.d-md-block
+ .timeline-icon.d-none.d-md-block
%a.author-link{ href: user_path(current_user) }
= image_tag avatar_icon_for_user(current_user), alt: current_user.to_reference, class: 'avatar s40'
.timeline-content.timeline-content-form
diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml
index f2c7ab648c0..d7b53810f76 100644
--- a/app/views/shared/notifications/_button.html.haml
+++ b/app/views/shared/notifications/_button.html.haml
@@ -17,7 +17,7 @@
.js-notification-toggle-btns
%div{ class: ("btn-group" if notification_setting.custom?) }
- if notification_setting.custom?
- %button.dropdown-new.btn.btn-defaul.btn-icon.gl-button.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
+ %button.dropdown-new.btn.btn-default.btn-icon.gl-button.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
= sprite_icon("notifications", css_class: "js-notification-loading")
= notification_title(notification_setting.level)
%button.btn.dropdown-toggle.d-flex{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml
index 4365e3f6877..7b76d6d789b 100644
--- a/app/views/shared/projects/_search_form.html.haml
+++ b/app/views/shared/projects/_search_form.html.haml
@@ -11,7 +11,7 @@
autofocus: local_assigns[:autofocus]
- if local_assigns[:icon]
- = icon("search", class: "search-icon")
+ = sprite_icon('search', css_class: 'search-icon')
- if params[:sort].present?
= hidden_field_tag :sort, params[:sort]
diff --git a/app/views/shared/runners/_shared_runners_description.html.haml b/app/views/shared/runners/_shared_runners_description.html.haml
new file mode 100644
index 00000000000..b9fb518b1aa
--- /dev/null
+++ b/app/views/shared/runners/_shared_runners_description.html.haml
@@ -0,0 +1,11 @@
+- link = link_to _('MaxBuilds'), 'https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runnersmachine-section', target: '_blank'
+
+%h3
+ = _('Shared runners')
+
+.bs-callout.shared-runners-description
+ - if Gitlab::CurrentSettings.shared_runners_text.present?
+ = markdown_field(Gitlab::CurrentSettings.current_application_settings, :shared_runners_text)
+ - else
+ = _('The same shared runner executes code from multiple projects, unless you configure autoscaling with %{link} set to 1 (which it is on GitLab.com).').html_safe % { link: link }
+ = yield
diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml
deleted file mode 100644
index a2169deb592..00000000000
--- a/app/views/shared/snippets/_blob.html.haml
+++ /dev/null
@@ -1,13 +0,0 @@
-%article.file-holder.snippet-file-content
- .js-file-title.file-title-flex-parent
- = render 'projects/blob/header_content', blob: blob
-
- .file-actions.d-none.d-sm-block
- = render 'projects/blob/viewer_switcher', blob: blob
-
- .btn-group{ role: "group" }<
- = copy_blob_source_button(blob)
- = open_raw_blob_button(blob)
- = download_raw_snippet_button(@snippet)
-
- = render 'projects/blob/content', blob: blob
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 198735df5ee..5f511b35b61 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -1,56 +1,2 @@
-- if Feature.enabled?(:snippets_edit_vue, default_enabled: true)
- - available_visibility_levels = available_visibility_levels(@snippet)
- #js-snippet-edit.snippet-form{ data: {'project_path': @snippet.project&.full_path, 'snippet-gid': @snippet.new_record? ? '' : @snippet.to_global_id, 'markdown-preview-path': preview_markdown_path(parent), 'markdown-docs-path': help_page_path('user/markdown'), 'visibility-help-link': help_page_path("public_access/public_access"), 'visibility_levels': available_visibility_levels, 'selected_level': snippets_selected_visibility_level(available_visibility_levels, @snippet.visibility_level), 'multiple_levels_restricted': multiple_visibility_levels_restricted? } }
-- else
- .snippet-form-holder
- = form_for @snippet, url: url,
- html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" },
- data: { "snippet-type": @snippet.project_id ? 'project' : 'personal'} do |f|
- = form_errors(@snippet)
-
- .form-group
- = f.label :title, class: 'label-bold'
- = f.text_field :title, class: 'form-control', required: true, autofocus: true, data: { qa_selector: 'snippet_title_field' }
-
- .form-group.js-description-input
- - description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...')
- - is_expanded = @snippet.description && !@snippet.description.empty?
- = f.label :description, s_("Snippets|Description (optional)"), class: 'label-bold'
- .js-collapsible-input
- .js-collapsed{ class: ('d-none' if is_expanded) }
- = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' }
- .js-expanded{ class: ('d-none' if !is_expanded) }
- = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
- = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'snippet_description_field'
- = render 'shared/notes/hints'
-
- .form-group.file-editor
- = f.label :file_name, s_('Snippets|File')
- .file-holder.snippet
- .js-file-title.file-title-flex-parent
- = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name', data: { qa_selector: 'file_name_field' }
- .file-content.code
- #editor{ data: { 'editor-loading': true } }<
- %pre.editor-loading-content= @snippet.content
- = f.hidden_field :content, class: 'snippet-file-content'
-
- .form-group
- .font-weight-bold
- = _('Visibility level')
- = link_to sprite_icon('question-o'), help_page_path('public_access/public_access'), target: '_blank'
- = render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet, with_label: false
-
- - if params[:files]
- - params[:files].each_with_index do |file, index|
- = hidden_field_tag "files[]", file, id: "files_#{index}"
-
- .form-actions
- - if @snippet.new_record?
- = f.submit 'Create snippet', class: "btn-success btn", data: { qa_selector: 'submit_button' }
- - else
- = f.submit 'Save changes', class: "btn-success btn", data: { qa_selector: 'submit_button' }
-
- - if @snippet.project_id
- = link_to "Cancel", project_snippets_path(@project), class: "btn btn-cancel"
- - else
- = link_to "Cancel", snippets_path(@project), class: "btn btn-cancel"
+- available_visibility_levels = available_visibility_levels(@snippet)
+#js-snippet-edit.snippet-form{ data: {'project_path': @snippet.project&.full_path, 'snippet-gid': @snippet.new_record? ? '' : @snippet.to_global_id, 'markdown-preview-path': preview_markdown_path(parent), 'markdown-docs-path': help_page_path('user/markdown'), 'visibility-help-link': help_page_path("public_access/public_access"), 'visibility_levels': available_visibility_levels, 'selected_level': snippets_selected_visibility_level(available_visibility_levels, @snippet.visibility_level), 'multiple_levels_restricted': multiple_visibility_levels_restricted? } }
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
deleted file mode 100644
index a9226117727..00000000000
--- a/app/views/shared/snippets/_header.html.haml
+++ /dev/null
@@ -1,47 +0,0 @@
-.detail-page-header
- .detail-page-header-body
- .snippet-box.has-tooltip.inline.gl-mr-2{ title: snippet_visibility_level_description(@snippet.visibility_level, @snippet), data: { container: "body" } }
- %span.sr-only
- = visibility_level_label(@snippet.visibility_level)
- = visibility_level_icon(@snippet.visibility_level)
- %span.creator
- = s_('Snippets|Authored %{time_ago} by %{author}').html_safe % { time_ago: time_ago_with_tooltip(@snippet.created_at, placement: 'bottom', html_class: 'snippet_updated_ago'), author: link_to_member(@project, @snippet.author, size: 24, author_class: "author item-title", avatar_class: "d-none d-sm-inline") + user_status(@snippet.author) }
-
- .detail-page-header-actions
- - if @snippet.project_id?
- = render "projects/snippets/actions"
- - else
- = render "snippets/actions"
-
-.snippet-header.limited-header-width
- %h2.snippet-title.gl-mt-0.mb-3
- = markdown_field(@snippet, :title)
-
- - if @snippet.description.present?
- .description
- .md
- = markdown_field(@snippet, :description)
- %textarea.hidden.js-task-list-field
- = @snippet.description
-
- - if @snippet.updated_at != @snippet.created_at
- = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', exclude_author: true)
-
- - if @snippet.embeddable?
- .embed-snippet
- .input-group
- .input-group-prepend
- %button.btn.btn-svg.embed-toggle.input-group-text{ 'data-toggle': 'dropdown', type: 'button' }
- %span.js-embed-action= _("Embed")
- = sprite_icon('angle-down', size: 12, css_class: 'caret-down')
- %ul.dropdown-menu.dropdown-menu-selectable.embed-toggle-list
- %li
- %button.js-embed-btn.btn.btn-transparent.is-active{ type: 'button' }
- %strong.embed-toggle-list-item= _("Embed")
- %li
- %button.js-share-btn.btn.btn-transparent{ type: 'button' }
- %strong.embed-toggle-list-item= _("Share")
- = snippet_embed_input(@snippet)
- .input-group-append
- = clipboard_button(title: _('Copy'), class: 'js-clipboard-btn snippet-clipboard-btn btn btn-default', target: '.js-snippet-url-area')
- .clearfix
diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml
index 25e31fd519b..5f0ecb2ee79 100644
--- a/app/views/shared/snippets/_snippet.html.haml
+++ b/app/views/shared/snippets/_snippet.html.haml
@@ -1,7 +1,7 @@
- link_project = local_assigns.fetch(:link_project, false)
- notes_count = @noteable_meta_data[snippet.id].user_notes_count
-%li.snippet-row.py-3
+%li.snippet-row.py-3{ data: { qa_selector: 'snippet_link', qa_snippet_title: snippet.title } }
= image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 d-none d-sm-block", alt: ''
.title
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index 96da5136908..9c60201412c 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -77,7 +77,7 @@
= form.label :deployment_events, class: 'list-label form-check-label ml-1' do
%strong= s_('Webhooks|Deployment events')
%p.text-muted.ml-1
- = s_('Webhooks|This URL will be triggered when a deployment is finished/failed/canceled')
+ = s_('Webhooks|This URL is triggered when a deployment starts, finishes, fails, or is canceled')
.form-group
= form.label :enable_ssl_verification, s_('Webhooks|SSL verification'), class: 'label-bold checkbox'
.form-check
diff --git a/app/views/shared/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml
index 66c0f64c32c..dde1b3afa2d 100644
--- a/app/views/shared/wikis/_form.html.haml
+++ b/app/views/shared/wikis/_form.html.haml
@@ -17,10 +17,10 @@
= f.hidden_field :last_commit_sha, value: @page.last_commit_sha
.form-group.row
- .col-sm-12= f.label :title, class: 'control-label-full-width'
- .col-sm-12
+ .col-sm-2.col-form-label= f.label :title, class: 'control-label-full-width'
+ .col-sm-10
= f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title, required: true, autofocus: !@page.persisted?, placeholder: s_('Wiki|Page title')
- %span.d-inline-block.mw-100.gl-mt-2
+ %span.gl-display-inline-block.gl-max-w-full.gl-mt-2.gl-text-gray-600
= sprite_icon('bulb', size: 12, css_class: 'gl-mr-n1')
- if @page.persisted?
= s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.")
@@ -29,18 +29,18 @@
- else
= s_("WikiNewPageTip|Tip: You can specify the full path for the new file. We will automatically create any missing directories.")
= succeed '.' do
- = link_to _('Learn more'), help_page_path('user/project/wiki/index', anchor: 'creating-a-new-wiki-page'),
+ = link_to _('More information'), help_page_path('user/project/wiki/index', anchor: 'creating-a-new-wiki-page'),
target: '_blank', rel: 'noopener noreferrer'
.form-group.row
- .col-sm-12= f.label :format, class: 'control-label-full-width'
- .col-sm-12
+ .col-sm-2.col-form-label= f.label :format, class: 'control-label-full-width'
+ .col-sm-10
.select-wrapper
= f.select :format, options_for_select(Wiki::MARKUPS, {selected: @page.format}), {}, class: 'form-control select-control'
= icon('chevron-down')
.form-group.row
- .col-sm-12= f.label :content, class: 'control-label-full-width'
- .col-sm-12
+ .col-sm-2.col-form-label= f.label :content, class: 'control-label-full-width'
+ .col-sm-10
= render layout: 'shared/md_preview', locals: { url: wiki_page_path(@wiki, @page, action: :preview_markdown) } do
= render 'shared/zen', f: f, attr: :content, classes: 'note-textarea qa-wiki-content-textarea', placeholder: s_("WikiPage|Write your content or drag files here…")
= render 'shared/notes/hints'
@@ -48,7 +48,7 @@
.clearfix
.error-alert
- .form-text.text-muted
+ .form-text.gl-text-gray-600
= succeed '.' do
- case @page.format.to_s
- when 'rdoc'
@@ -65,15 +65,15 @@
= (s_("WikiMarkdownDocs|More examples are in the %{docs_link}") % { docs_link: markdown_link }).html_safe
.form-group.row
- .col-sm-12= f.label :commit_message, class: 'control-label-full-width'
- .col-sm-12= f.text_field :message, class: 'form-control qa-wiki-message-textbox', rows: 18, value: nil
+ .col-sm-2.col-form-label= f.label :commit_message, class: 'control-label-full-width'
+ .col-sm-10= f.text_field :message, class: 'form-control qa-wiki-message-textbox', rows: 18, value: nil
.form-actions
- if @page && @page.persisted?
= f.submit _("Save changes"), class: 'btn-success btn qa-save-changes-button'
.float-right
- = link_to _("Cancel"), wiki_page_path(@wiki, @page), class: 'btn btn-cancel btn-grouped'
+ = link_to _("Cancel"), wiki_page_path(@wiki, @page), class: 'btn gl-button btn-cancel btn-grouped'
- else
= f.submit s_("Wiki|Create page"), class: 'btn-success btn qa-create-page-button rspec-create-page-button'
.float-right
- = link_to _("Cancel"), wiki_path(@wiki), class: 'btn btn-cancel'
+ = link_to _("Cancel"), wiki_path(@wiki), class: 'btn gl-button btn-cancel'
diff --git a/app/views/shared/wikis/_main_links.html.haml b/app/views/shared/wikis/_main_links.html.haml
index e173ef72d11..8568c36559a 100644
--- a/app/views/shared/wikis/_main_links.html.haml
+++ b/app/views/shared/wikis/_main_links.html.haml
@@ -1,9 +1,6 @@
- if @page&.persisted?
+ = link_to wiki_page_path(@wiki, @page, action: :history), class: "btn gl-button", role: "button", data: { qa_selector: 'page_history_button' } do
+ = s_("Wiki|Page history")
- if can?(current_user, :create_wiki, @wiki.container)
- = link_to wiki_path(@wiki, action: :new), class: "btn btn-success", role: "button", data: { qa_selector: 'new_page_button' } do
+ = link_to wiki_path(@wiki, action: :new), class: "btn gl-button btn-success btn-inverted", role: "button", data: { qa_selector: 'new_page_button' } do
= s_("Wiki|New page")
- = link_to wiki_page_path(@wiki, @page, action: :history), class: "btn", role: "button", data: { qa_selector: 'page_history_button' } do
- = s_("Wiki|Page history")
- - if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding
- = link_to wiki_page_path(@wiki, @page, action: :edit), class: "btn js-wiki-edit", role: "button", data: { qa_selector: 'edit_page_button' } do
- = _("Edit")
diff --git a/app/views/shared/wikis/_pages_wiki_page.html.haml b/app/views/shared/wikis/_pages_wiki_page.html.haml
index b56ae2bf9b1..fb6f58d044d 100644
--- a/app/views/shared/wikis/_pages_wiki_page.html.haml
+++ b/app/views/shared/wikis/_pages_wiki_page.html.haml
@@ -1,5 +1,5 @@
%li
- = link_to wiki_page.title, wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.slug }
+ = link_to wiki_page.human_title, wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.slug }
%small (#{wiki_page.format})
.float-right
- if wiki_page.last_version
diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml
index 54f285671a1..893661755ab 100644
--- a/app/views/shared/wikis/_sidebar.html.haml
+++ b/app/views/shared/wikis/_sidebar.html.haml
@@ -4,10 +4,11 @@
%a.gutter-toggle.float-right.d-block.d-sm-block.d-md-none.js-sidebar-wiki-toggle{ href: "#" }
= sprite_icon('chevron-double-lg-right', css_class: 'gl-icon')
- - git_access_url = wiki_path(@wiki, action: :git_access)
- = link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '', data: { qa_selector: 'clone_repository_link' } do
- = sprite_icon('download', css_class: 'gl-mr-2')
- %span= _("Clone repository")
+ - if @wiki.container.is_a?(Project)
+ - git_access_url = wiki_path(@wiki, action: :git_access)
+ = link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '', data: { qa_selector: 'clone_repository_link' } do
+ = sprite_icon('download', css_class: 'gl-mr-2')
+ %span= _("Clone repository")
.blocks-container
.block.block-first.w-100
@@ -18,5 +19,5 @@
= render @sidebar_wiki_entries, context: 'sidebar'
.block.w-100
- if @sidebar_limited
- = link_to wiki_path(@wiki, action: :pages), class: 'btn btn-block', data: { qa_selector: 'view_all_pages_button' } do
+ = link_to wiki_path(@wiki, action: :pages), class: 'btn gl-button btn-block', data: { qa_selector: 'view_all_pages_button' } do
= s_("Wiki|View All Pages")
diff --git a/app/views/shared/wikis/_wiki_directory.html.haml b/app/views/shared/wikis/_wiki_directory.html.haml
index 21e829d86a6..a492d1e5aa0 100644
--- a/app/views/shared/wikis/_wiki_directory.html.haml
+++ b/app/views/shared/wikis/_wiki_directory.html.haml
@@ -1,4 +1,4 @@
%li{ data: { qa_selector: 'wiki_directory_content' } }
- = wiki_directory.slug
+ = wiki_directory.title
%ul
- = render wiki_directory.pages, context: context
+ = render wiki_directory.entries, context: context
diff --git a/app/views/shared/wikis/diff.html.haml b/app/views/shared/wikis/diff.html.haml
index 6fce3f5894e..68bbbd66f4a 100644
--- a/app/views/shared/wikis/diff.html.haml
+++ b/app/views/shared/wikis/diff.html.haml
@@ -1,4 +1,5 @@
- wiki_page_title @page, _('Changes')
+- add_page_specific_style 'page_bundles/wiki'
- commit = @diffs.diffable
.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row
@@ -12,7 +13,7 @@
= _('Changes')
.nav-controls.pb-md-3.pb-lg-0
- = link_to wiki_page_path(@wiki, @page, action: :history), class: 'btn', role: 'button', data: { qa_selector: 'page_history_button' } do
+ = link_to wiki_page_path(@wiki, @page, action: :history), class: 'btn gl-button', role: 'button', data: { qa_selector: 'page_history_button' } do
= s_('Wiki|Page history')
.page-content-header
diff --git a/app/views/shared/wikis/edit.html.haml b/app/views/shared/wikis/edit.html.haml
index 64a4816def6..834749caaba 100644
--- a/app/views/shared/wikis/edit.html.haml
+++ b/app/views/shared/wikis/edit.html.haml
@@ -1,4 +1,5 @@
- wiki_page_title @page, @page.persisted? ? _('Edit') : _('New')
+- add_page_specific_style 'page_bundles/wiki'
= wiki_page_errors(@error)
@@ -17,8 +18,6 @@
.nav-controls.pb-md-3.pb-lg-0
- if @page.persisted?
- = link_to wiki_page_path(@wiki, @page, action: :history), class: "btn" do
- = s_("Wiki|Page history")
- if can?(current_user, :admin_wiki, @wiki.container)
#delete-wiki-modal-wrapper{ data: { delete_wiki_url: wiki_page_path(@wiki, @page), page_title: @page.human_title } }
diff --git a/app/views/shared/wikis/empty.html.haml b/app/views/shared/wikis/empty.html.haml
index 62fa6e1907b..c52ead74b4c 100644
--- a/app/views/shared/wikis/empty.html.haml
+++ b/app/views/shared/wikis/empty.html.haml
@@ -1,4 +1,5 @@
- page_title _("Wiki")
- @right_sidebar = false
+- add_page_specific_style 'page_bundles/wiki'
= render 'shared/empty_states/wikis'
diff --git a/app/views/shared/wikis/history.html.haml b/app/views/shared/wikis/history.html.haml
index f9d21c8fb57..50ccfdeabd5 100644
--- a/app/views/shared/wikis/history.html.haml
+++ b/app/views/shared/wikis/history.html.haml
@@ -1,4 +1,5 @@
- wiki_page_title @page, _('History')
+- add_page_specific_style 'page_bundles/wiki'
.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row
= wiki_sidebar_toggle_button
diff --git a/app/views/shared/wikis/pages.html.haml b/app/views/shared/wikis/pages.html.haml
index 35a62ec2bb4..76fc9510740 100644
--- a/app/views/shared/wikis/pages.html.haml
+++ b/app/views/shared/wikis/pages.html.haml
@@ -2,6 +2,7 @@
- breadcrumb_title s_("Wiki|Pages")
- page_title s_("Wiki|Pages"), _("Wiki")
- sort_title = wiki_sort_title(params[:sort])
+- add_page_specific_style 'page_bundles/wiki'
.wiki-page-header.top-area.flex-column.flex-lg-row
@@ -10,14 +11,14 @@
= s_("Wiki|Wiki Pages")
.nav-controls.pb-md-3.pb-lg-0
- = link_to wiki_path(@wiki, action: :git_access), class: 'btn' do
+ = link_to wiki_path(@wiki, action: :git_access), class: 'btn gl-button' do
= sprite_icon('download')
= _("Clone repository")
.dropdown.inline.wiki-sort-dropdown
.btn-group{ role: 'group' }
.btn-group{ role: 'group' }
- %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
+ %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn gl-button btn-default' }
= sort_title
= sprite_icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml
index a7c734f5af4..6f1c1a3a801 100644
--- a/app/views/shared/wikis/show.html.haml
+++ b/app/views/shared/wikis/show.html.haml
@@ -1,10 +1,10 @@
- wiki_page_title @page
+- add_page_specific_style 'page_bundles/wiki'
.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row
= wiki_sidebar_toggle_button
.nav-text.flex-fill
- %h2.wiki-page-title{ data: { qa_selector: 'wiki_page_title' } }= @page.human_title
%span.wiki-last-edit-by
- if @page.last_version
= (_("Last edited by %{name}") % { name: "<strong>#{@page.last_version.author_name}</strong>" }).html_safe
@@ -20,8 +20,13 @@
- history_link = link_to s_("WikiHistoricalPage|history"), wiki_page_path(@wiki, @page, action: :history)
= (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe
-.gl-mt-3.gl-mb-3
- .js-wiki-page-content.md{ data: { qa_selector: 'wiki_page_content', tracking_context: wiki_page_tracking_context(@page).to_json } }
+.gl-mt-5.gl-mb-3
+ .gl-display-flex.gl-justify-content-space-between
+ %h2.gl-mt-0.gl-mb-5{ data: { qa_selector: 'wiki_page_title', testid: 'wiki_page_title' } }= @page.human_title
+ %div
+ - if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding
+ = link_to sprite_icon('pencil', css_class: 'gl-icon'), wiki_page_path(@wiki, @page, action: :edit), title: 'Edit', role: "button", class: 'btn gl-button btn-icon btn-default js-wiki-edit', data: { qa_selector: 'edit_page_button', testid: 'wiki_edit_button' }
+ .js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki_page_content', tracking_context: wiki_page_tracking_context(@page).to_json } }
= render_wiki_content(@page)
= render 'shared/wikis/sidebar'
diff --git a/app/views/sherlock/file_samples/show.html.haml b/app/views/sherlock/file_samples/show.html.haml
index 5fef56f7fc3..b7e6f883667 100644
--- a/app/views/sherlock/file_samples/show.html.haml
+++ b/app/views/sherlock/file_samples/show.html.haml
@@ -5,7 +5,7 @@
.row-content-block
.float-right
- = link_to(sherlock_transaction_path(@transaction), class: 'btn') do
+ = link_to(sherlock_transaction_path(@transaction), class: 'btn gl-button') do
= sprite_icon('arrow-left')
= t('sherlock.transaction')
.oneline
@@ -27,7 +27,7 @@
%article.file-holder
.js-file-title.file-title
- %i.fa.fa-file-text-o.fa-fw
+ = sprite_icon("doc-text")
%strong
= @file_sample.file
.code.file-content.js-syntax-highlight
diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml
deleted file mode 100644
index 566395133a1..00000000000
--- a/app/views/snippets/_actions.html.haml
+++ /dev/null
@@ -1,35 +0,0 @@
-- return unless current_user
-
-.d-none.d-sm-block
- - if can?(current_user, :update_snippet, @snippet)
- = link_to edit_snippet_path(@snippet), class: "btn btn-grouped" do
- = _("Edit")
- - if can?(current_user, :admin_snippet, @snippet)
- = link_to gitlab_snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do
- = _("Delete")
- - if can?(current_user, :create_snippet)
- = link_to new_snippet_path, class: "btn btn-grouped btn-success btn-inverted", title: _("New snippet") do
- = _("New snippet")
- - if @snippet.submittable_as_spam_by?(current_user)
- = link_to _('Submit as spam'), mark_as_spam_snippet_path(@snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam')
-.d-block.d-sm-none.dropdown
- %button.btn.btn-default.btn-block.gl-mb-0.gl-mt-2{ data: { toggle: "dropdown" } }
- = _("Options")
- = icon('caret-down')
- .dropdown-menu.dropdown-menu-full-width
- %ul
- - if can?(current_user, :create_snippet)
- %li
- = link_to new_snippet_path, title: _("New snippet") do
- = _("New snippet")
- - if can?(current_user, :admin_snippet, @snippet)
- %li
- = link_to gitlab_snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, title: _('Delete Snippet') do
- = _("Delete")
- - if can?(current_user, :update_snippet, @snippet)
- %li
- = link_to edit_snippet_path(@snippet) do
- = _("Edit")
- - if @snippet.submittable_as_spam_by?(current_user)
- %li
- = link_to _('Submit as spam'), mark_as_spam_snippet_path(@snippet), method: :post
diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml
index 819f02b78fe..77a6ff5455e 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -4,13 +4,7 @@
- breadcrumb_title @snippet.to_reference
- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
-- if Feature.enabled?(:snippets_vue, default_enabled: true)
- #js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} }
-- else
- = render 'shared/snippets/header'
-
- .personal-snippets
- = render 'shared/snippets/blob', blob: @blob
+#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} }
.row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true
diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml
index 5b6d1169b4b..294af53e35b 100644
--- a/app/views/users/_overview.html.haml
+++ b/app/views/users/_overview.html.haml
@@ -1,3 +1,5 @@
+- activity_pane_class = Feature.enabled?(:security_auto_fix) && @user.bot? ? "col-12" : "col-md-12 col-lg-6"
+
.row
.col-12
.calendar-block.gl-mt-3.gl-mb-3
@@ -6,25 +8,26 @@
.spinner.spinner-md
.user-calendar-activities.d-none.d-sm-block
.row
- .col-md-12.col-lg-6
+ %div{ class: activity_pane_class }
- if can?(current_user, :read_cross_project)
.activities-block
.gl-mt-5
- .d-flex.align-items-center.border-bottom
- %h4.flex-grow
- = s_('UserProfile|Activity')
+ .gl-display-flex.gl-align-items-center.gl-border-b-1.gl-border-b-gray-100.gl-border-b-solid
+ %h4.gl-flex-grow-1
+ = Feature.enabled?(:security_auto_fix) && @user.bot? ? s_('UserProfile|Bot activity') : s_('UserProfile|Activity')
= link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all"
.overview-content-list{ data: { href: user_path } }
.center.light.loading
.spinner.spinner-md
- .col-md-12.col-lg-6
- .projects-block
- .gl-mt-5
- .d-flex.align-items-center.border-bottom
- %h4.flex-grow
- = s_('UserProfile|Personal projects')
- = link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all"
- .overview-content-list{ data: { href: user_projects_path } }
- .center.light.loading
- .spinner.spinner-md
+ - unless Feature.enabled?(:security_auto_fix) && @user.bot?
+ .col-md-12.col-lg-6
+ .projects-block
+ .gl-mt-5
+ .gl-display-flex.gl-align-items-center.gl-border-b-1.gl-border-b-gray-100.gl-border-b-solid
+ %h4.gl-flex-grow-1
+ = s_('UserProfile|Personal projects')
+ = link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all"
+ .overview-content-list{ data: { href: user_projects_path } }
+ .center.light.loading
+ .spinner.spinner-md
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index fbda9b79e82..2746a139dd0 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -2,7 +2,7 @@
- @hide_breadcrumbs = true
- @no_container = true
- page_title @user.blocked? ? s_('UserProfile|Blocked user') : @user.name
-- page_description @user.bio
+- page_description @user.bio_html
- header_title @user.name, user_path(@user)
- link_classes = "flex-grow-1 mx-1 "
@@ -67,17 +67,19 @@
- unless @user.skype.blank?
.profile-link-holder.middle-dot-divider
= link_to "skype:#{@user.skype}", title: "Skype" do
- = icon('skype')
+ = sprite_icon('skype')
- unless @user.linkedin.blank?
.profile-link-holder.middle-dot-divider
= link_to linkedin_url(@user), title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do
- = icon('linkedin-square')
+ = sprite_icon('linkedin')
- unless @user.twitter.blank?
.profile-link-holder.middle-dot-divider-sm
= link_to twitter_url(@user), title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do
- = icon('twitter-square')
+ = sprite_icon('twitter')
- unless @user.website_url.blank?
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0
+ - if Feature.enabled?(:security_auto_fix) && @user.bot?
+ = sprite_icon('question', css_class: 'gl-text-blue-600')
= link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'me noopener noreferrer nofollow'
- unless @user.public_email.blank?
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0
@@ -101,26 +103,27 @@
%li.js-activity-tab
= link_to user_activity_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do
= s_('UserProfile|Activity')
- - if profile_tab?(:groups)
- %li.js-groups-tab
- = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do
- = s_('UserProfile|Groups')
- - if profile_tab?(:contributed)
- %li.js-contributed-tab
- = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do
- = s_('UserProfile|Contributed projects')
- - if profile_tab?(:projects)
- %li.js-projects-tab
- = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
- = s_('UserProfile|Personal projects')
- - if profile_tab?(:starred)
- %li.js-starred-tab
- = link_to user_starred_projects_path, data: { target: 'div#starred', action: 'starred', toggle: 'tab', endpoint: user_starred_projects_path(format: :json) } do
- = s_('UserProfile|Starred projects')
- - if profile_tab?(:snippets)
- %li.js-snippets-tab
- = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
- = s_('UserProfile|Snippets')
+ - unless Feature.enabled?(:security_auto_fix) && @user.bot?
+ - if profile_tab?(:groups)
+ %li.js-groups-tab
+ = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do
+ = s_('UserProfile|Groups')
+ - if profile_tab?(:contributed)
+ %li.js-contributed-tab
+ = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do
+ = s_('UserProfile|Contributed projects')
+ - if profile_tab?(:projects)
+ %li.js-projects-tab
+ = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do
+ = s_('UserProfile|Personal projects')
+ - if profile_tab?(:starred)
+ %li.js-starred-tab
+ = link_to user_starred_projects_path, data: { target: 'div#starred', action: 'starred', toggle: 'tab', endpoint: user_starred_projects_path(format: :json) } do
+ = s_('UserProfile|Starred projects')
+ - if profile_tab?(:snippets)
+ %li.js-snippets-tab
+ = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
+ = s_('UserProfile|Snippets')
%div{ class: container_class }
.tab-content
@@ -136,26 +139,26 @@
.content_list{ data: { href: user_path } }
.loading
.spinner.spinner-md
-
- - if profile_tab?(:groups)
- #groups.tab-pane
- -# This tab is always loaded via AJAX
-
- - if profile_tab?(:contributed)
- #contributed.tab-pane
- -# This tab is always loaded via AJAX
-
- - if profile_tab?(:projects)
- #projects.tab-pane
- -# This tab is always loaded via AJAX
-
- - if profile_tab?(:starred)
- #starred.tab-pane
- -# This tab is always loaded via AJAX
-
- - if profile_tab?(:snippets)
- #snippets.tab-pane
- -# This tab is always loaded via AJAX
+ - unless @user.bot?
+ - if profile_tab?(:groups)
+ #groups.tab-pane
+ -# This tab is always loaded via AJAX
+
+ - if profile_tab?(:contributed)
+ #contributed.tab-pane
+ -# This tab is always loaded via AJAX
+
+ - if profile_tab?(:projects)
+ #projects.tab-pane
+ -# This tab is always loaded via AJAX
+
+ - if profile_tab?(:starred)
+ #starred.tab-pane
+ -# This tab is always loaded via AJAX
+
+ - if profile_tab?(:snippets)
+ #snippets.tab-pane
+ -# This tab is always loaded via AJAX
.loading.hide
.spinner.spinner-md
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 11bf797fb90..30b89f37562 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -147,6 +147,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: cronjob:ci_schedule_delete_objects_cron
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:container_expiration_policy
:feature_category: :container_registry
:has_external_dependencies:
@@ -211,6 +219,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: cronjob:member_invitation_reminder_emails
+ :feature_category: :subgroups
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: cronjob:metrics_dashboard_schedule_annotations_prune
:feature_category: :metrics
:has_external_dependencies:
@@ -419,6 +435,22 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: deployment:deployments_drop_older_deployments
+ :feature_category: :continuous_delivery
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 3
+ :idempotent:
+ :tags: []
+- :name: deployment:deployments_execute_hooks
+ :feature_category: :continuous_delivery
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 3
+ :idempotent:
+ :tags: []
- :name: deployment:deployments_finished
:feature_category: :continuous_delivery
:has_external_dependencies:
@@ -435,6 +467,14 @@
:weight: 3
:idempotent:
:tags: []
+- :name: deployment:deployments_link_merge_request
+ :feature_category: :continuous_delivery
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 3
+ :idempotent: true
+ :tags: []
- :name: deployment:deployments_success
:feature_category: :continuous_delivery
:has_external_dependencies:
@@ -443,6 +483,14 @@
:weight: 3
:idempotent:
:tags: []
+- :name: deployment:deployments_update_environment
+ :feature_category: :continuous_delivery
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 3
+ :idempotent: true
+ :tags: []
- :name: gcp_cluster:cluster_configure_istio
:feature_category: :kubernetes_management
:has_external_dependencies: true
@@ -723,6 +771,14 @@
:weight: 2
:idempotent: true
:tags: []
+- :name: incident_management:incident_management_add_severity_system_note
+ :feature_category: :incident_management
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 2
+ :idempotent:
+ :tags: []
- :name: incident_management:incident_management_pager_duty_process_incident
:feature_category: :incident_management
:has_external_dependencies:
@@ -906,7 +962,8 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags: []
+ :tags:
+ - :requires_disk_io
- :name: pipeline_background:ci_build_report_result
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -914,7 +971,8 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags: []
+ :tags:
+ - :requires_disk_io
- :name: pipeline_background:ci_build_trace_chunk_flush
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -994,7 +1052,8 @@
:resource_boundary: :unknown
:weight: 3
:idempotent:
- :tags: []
+ :tags:
+ - :requires_disk_io
- :name: pipeline_default:build_trace_sections
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -1002,7 +1061,8 @@
:resource_boundary: :unknown
:weight: 3
:idempotent:
- :tags: []
+ :tags:
+ - :requires_disk_io
- :name: pipeline_default:ci_create_cross_project_pipeline
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -1259,6 +1319,15 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
+ :tags:
+ - :requires_disk_io
+- :name: ci_delete_objects
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
:tags: []
- :name: create_commit_signature
:feature_category: :source_code_management
@@ -1324,6 +1393,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: design_management_copy_design_collection
+ :feature_category: :design_management
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: design_management_new_version
:feature_category: :design_management
:has_external_dependencies:
@@ -1340,6 +1417,22 @@
:weight: 1
:idempotent:
:tags: []
+- :name: disallow_two_factor_for_group
+ :feature_category: :subgroups
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: disallow_two_factor_for_subgroups
+ :feature_category: :subgroups
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: email_receiver
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -1371,7 +1464,8 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags: []
+ :tags:
+ - :requires_disk_io
- :name: export_csv
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -1435,7 +1529,8 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags: []
+ :tags:
+ - :requires_disk_io
- :name: group_export
:feature_category: :importers
:has_external_dependencies:
@@ -1476,6 +1571,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: issuable_export_csv
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: issue_placement
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -1532,6 +1635,14 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: metrics_dashboard_sync_dashboards
+ :feature_category: :metrics
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: migrate_external_diffs
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1579,7 +1690,8 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags: []
+ :tags:
+ - :requires_disk_io
- :name: pages_domain_ssl_renewal
:feature_category: :pages
:has_external_dependencies:
@@ -1587,7 +1699,8 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags: []
+ :tags:
+ - :requires_disk_io
- :name: pages_domain_verification
:feature_category: :pages
:has_external_dependencies:
@@ -1595,7 +1708,8 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags: []
+ :tags:
+ - :requires_disk_io
- :name: pages_remove
:feature_category: :pages
:has_external_dependencies:
@@ -1667,7 +1781,8 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags: []
+ :tags:
+ - :requires_disk_io
- :name: project_export
:feature_category: :importers
:has_external_dependencies:
@@ -1708,6 +1823,30 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: propagate_integration_group
+ :feature_category: :integrations
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: propagate_integration_inherit
+ :feature_category: :integrations
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: propagate_integration_project
+ :feature_category: :integrations
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: propagate_service_template
:feature_category: :integrations
:has_external_dependencies:
@@ -1860,6 +1999,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: web_hooks_destroy
+ :feature_category: :integrations
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: x509_certificate_revoke
:feature_category: :source_code_management
:has_external_dependencies:
diff --git a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb
index a9976c6e5cb..bf57619fc6e 100644
--- a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb
+++ b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb
@@ -14,13 +14,10 @@ module Analytics
idempotent!
def perform
- return if Feature.disabled?(:store_instance_statistics_measurements, default_enabled: true)
-
recorded_at = Time.zone.now
- measurement_identifiers = Analytics::InstanceStatistics::Measurement.identifiers
worker_arguments = Gitlab::Analytics::InstanceStatistics::WorkersArgumentBuilder.new(
- measurement_identifiers: measurement_identifiers.values,
+ measurement_identifiers: ::Analytics::InstanceStatistics::Measurement.measurement_identifier_values,
recorded_at: recorded_at
).execute
diff --git a/app/workers/archive_trace_worker.rb b/app/workers/archive_trace_worker.rb
index 3ddb5686bf2..b0c5bef336a 100644
--- a/app/workers/archive_trace_worker.rb
+++ b/app/workers/archive_trace_worker.rb
@@ -4,6 +4,8 @@ class ArchiveTraceWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
include PipelineBackgroundQueue
+ tags :requires_disk_io
+
# rubocop: disable CodeReuse/ActiveRecord
def perform(job_id)
Ci::Build.without_archived_trace.find_by(id: job_id).try do |job|
diff --git a/app/workers/authorized_project_update/periodic_recalculate_worker.rb b/app/workers/authorized_project_update/periodic_recalculate_worker.rb
index 0d1ad67d7bb..78ffdbca4d6 100644
--- a/app/workers/authorized_project_update/periodic_recalculate_worker.rb
+++ b/app/workers/authorized_project_update/periodic_recalculate_worker.rb
@@ -12,9 +12,7 @@ module AuthorizedProjectUpdate
idempotent!
def perform
- if ::Feature.enabled?(:periodic_project_authorization_recalculation, default_enabled: true)
- AuthorizedProjectUpdate::PeriodicRecalculateService.new.execute
- end
+ AuthorizedProjectUpdate::PeriodicRecalculateService.new.execute
end
end
end
diff --git a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
index 336b1c5443e..9bd1ad2ed30 100644
--- a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
+++ b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
@@ -12,9 +12,7 @@ module AuthorizedProjectUpdate
idempotent!
def perform(start_user_id, end_user_id)
- if ::Feature.enabled?(:periodic_project_authorization_recalculation, default_enabled: true)
- AuthorizedProjectUpdate::RecalculateForUserRangeService.new(start_user_id, end_user_id).execute
- end
+ AuthorizedProjectUpdate::RecalculateForUserRangeService.new(start_user_id, end_user_id).execute
end
end
end
diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb
index bff864ba420..74a12dbff77 100644
--- a/app/workers/background_migration_worker.rb
+++ b/app/workers/background_migration_worker.rb
@@ -23,7 +23,9 @@ class BackgroundMigrationWorker # rubocop:disable Scalability/IdempotentWorker
#
# class_name - The class name of the background migration to run.
# arguments - The arguments to pass to the migration class.
- def perform(class_name, arguments = [])
+ # lease_attempts - The number of times we will try to obtain an exclusive
+ # lease on the class before running anyway. Pass 0 to always run.
+ def perform(class_name, arguments = [], lease_attempts = 5)
with_context(caller_id: class_name.to_s) do
should_perform, ttl = perform_and_ttl(class_name)
diff --git a/app/workers/build_coverage_worker.rb b/app/workers/build_coverage_worker.rb
index 7d893024abc..d63d8549f09 100644
--- a/app/workers/build_coverage_worker.rb
+++ b/app/workers/build_coverage_worker.rb
@@ -4,6 +4,8 @@ class BuildCoverageWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
include PipelineQueue
+ tags :requires_disk_io
+
# rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
Ci::Build.find_by(id: build_id)&.update_coverage
diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb
index d0f7d65aed6..d7a5fcf4f18 100644
--- a/app/workers/build_finished_worker.rb
+++ b/app/workers/build_finished_worker.rb
@@ -9,6 +9,8 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker
worker_resource_boundary :cpu
tags :requires_disk_io
+ ARCHIVE_TRACES_IN = 2.minutes.freeze
+
# rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
Ci::Build.find_by(id: build_id).try do |build|
@@ -33,9 +35,22 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker
# We execute these async as these are independent operations.
BuildHooksWorker.perform_async(build.id)
- ArchiveTraceWorker.perform_async(build.id)
ExpirePipelineCacheWorker.perform_async(build.pipeline_id) if build.pipeline.cacheable?
ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat?
+
+ ##
+ # We want to delay sending a build trace to object storage operation to
+ # validate that this fixes a race condition between this and flushing live
+ # trace chunks and chunks being removed after consolidation and putting
+ # them into object storage archive.
+ #
+ # TODO This is temporary fix we should improve later, after we validate
+ # that this is indeed the culprit.
+ #
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/267112 for more
+ # details.
+ #
+ ArchiveTraceWorker.perform_in(ARCHIVE_TRACES_IN, build.id)
end
end
diff --git a/app/workers/build_trace_sections_worker.rb b/app/workers/build_trace_sections_worker.rb
index c25f77974e9..59f019b827e 100644
--- a/app/workers/build_trace_sections_worker.rb
+++ b/app/workers/build_trace_sections_worker.rb
@@ -4,6 +4,8 @@ class BuildTraceSectionsWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
include PipelineQueue
+ tags :requires_disk_io
+
# rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
Ci::Build.find_by(id: build_id)&.parse_trace_sections!
diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb
index 5fab437f49f..94a0197b862 100644
--- a/app/workers/chat_notification_worker.rb
+++ b/app/workers/chat_notification_worker.rb
@@ -7,6 +7,7 @@ class ChatNotificationWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: false
feature_category :chatops
+ tags :requires_disk_io
urgency :low # Can't be high as it has external dependencies
weight 2
worker_has_external_dependencies!
diff --git a/app/workers/ci/build_report_result_worker.rb b/app/workers/ci/build_report_result_worker.rb
index 60387936d0b..01a45490541 100644
--- a/app/workers/ci/build_report_result_worker.rb
+++ b/app/workers/ci/build_report_result_worker.rb
@@ -5,6 +5,8 @@ module Ci
include ApplicationWorker
include PipelineBackgroundQueue
+ tags :requires_disk_io
+
idempotent!
def perform(build_id)
diff --git a/app/workers/ci/build_trace_chunk_flush_worker.rb b/app/workers/ci/build_trace_chunk_flush_worker.rb
index 2908c7c2d0b..89400247a7b 100644
--- a/app/workers/ci/build_trace_chunk_flush_worker.rb
+++ b/app/workers/ci/build_trace_chunk_flush_worker.rb
@@ -8,8 +8,8 @@ module Ci
idempotent!
# rubocop: disable CodeReuse/ActiveRecord
- def perform(chunk_id)
- ::Ci::BuildTraceChunk.find_by(id: chunk_id).try do |chunk|
+ def perform(id)
+ ::Ci::BuildTraceChunk.find_by(id: id).try do |chunk|
chunk.persist_data!
end
end
diff --git a/app/workers/ci/delete_objects_worker.rb b/app/workers/ci/delete_objects_worker.rb
new file mode 100644
index 00000000000..e34be33b438
--- /dev/null
+++ b/app/workers/ci/delete_objects_worker.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Ci
+ class DeleteObjectsWorker
+ include ApplicationWorker
+ include LimitedCapacity::Worker
+
+ feature_category :continuous_integration
+ idempotent!
+
+ def perform_work(*args)
+ service.execute
+ end
+
+ def remaining_work_count(*args)
+ @remaining_work_count ||= service
+ .remaining_batches_count(max_batch_count: remaining_capacity)
+ end
+
+ def max_running_jobs
+ if ::Feature.enabled?(:ci_delete_objects_low_concurrency)
+ 2
+ elsif ::Feature.enabled?(:ci_delete_objects_medium_concurrency)
+ 20
+ elsif ::Feature.enabled?(:ci_delete_objects_high_concurrency)
+ 50
+ else
+ 0
+ end
+ end
+
+ private
+
+ def service
+ @service ||= DeleteObjectsService.new
+ end
+ end
+end
diff --git a/app/workers/ci/schedule_delete_objects_cron_worker.rb b/app/workers/ci/schedule_delete_objects_cron_worker.rb
new file mode 100644
index 00000000000..fa0b15deb56
--- /dev/null
+++ b/app/workers/ci/schedule_delete_objects_cron_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Ci
+ class ScheduleDeleteObjectsCronWorker
+ include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
+ include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
+
+ feature_category :continuous_integration
+ idempotent!
+
+ def perform(*args)
+ Ci::DeleteObjectsWorker.perform_with_capacity(*args)
+ end
+ end
+end
diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb
index 4469ea8cff9..80cc296fff5 100644
--- a/app/workers/cleanup_container_repository_worker.rb
+++ b/app/workers/cleanup_container_repository_worker.rb
@@ -16,9 +16,17 @@ class CleanupContainerRepositoryWorker # rubocop:disable Scalability/IdempotentW
return unless valid?
- Projects::ContainerRepository::CleanupTagsService
+ if run_by_container_expiration_policy?
+ container_repository.start_expiration_policy!
+ end
+
+ result = Projects::ContainerRepository::CleanupTagsService
.new(project, current_user, params)
.execute(container_repository)
+
+ if run_by_container_expiration_policy? && result[:status] == :success
+ container_repository.reset_expiration_policy_started_at!
+ end
end
private
@@ -30,7 +38,7 @@ class CleanupContainerRepositoryWorker # rubocop:disable Scalability/IdempotentW
end
def run_by_container_expiration_policy?
- @params['container_expiration_policy'] && container_repository && project
+ @params['container_expiration_policy'] && container_repository.present? && project.present?
end
def project
diff --git a/app/workers/concerns/limited_capacity/job_tracker.rb b/app/workers/concerns/limited_capacity/job_tracker.rb
new file mode 100644
index 00000000000..96b6e1a2024
--- /dev/null
+++ b/app/workers/concerns/limited_capacity/job_tracker.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+module LimitedCapacity
+ class JobTracker # rubocop:disable Scalability/IdempotentWorker
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(namespace)
+ @namespace = namespace
+ end
+
+ def register(jid)
+ _added, @count = with_redis_pipeline do |redis|
+ register_job_keys(redis, jid)
+ get_job_count(redis)
+ end
+ end
+
+ def remove(jid)
+ _removed, @count = with_redis_pipeline do |redis|
+ remove_job_keys(redis, jid)
+ get_job_count(redis)
+ end
+ end
+
+ def clean_up
+ completed_jids = Gitlab::SidekiqStatus.completed_jids(running_jids)
+ return unless completed_jids.any?
+
+ _removed, @count = with_redis_pipeline do |redis|
+ remove_job_keys(redis, completed_jids)
+ get_job_count(redis)
+ end
+ end
+
+ def count
+ @count ||= with_redis { |redis| get_job_count(redis) }
+ end
+
+ def running_jids
+ with_redis do |redis|
+ redis.smembers(counter_key)
+ end
+ end
+
+ private
+
+ attr_reader :namespace
+
+ def counter_key
+ "worker:#{namespace.to_s.underscore}:running"
+ end
+
+ def get_job_count(redis)
+ redis.scard(counter_key)
+ end
+
+ def register_job_keys(redis, keys)
+ redis.sadd(counter_key, keys)
+ end
+
+ def remove_job_keys(redis, keys)
+ redis.srem(counter_key, keys)
+ end
+
+ def with_redis(&block)
+ Gitlab::Redis::Queues.with(&block) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def with_redis_pipeline(&block)
+ with_redis do |redis|
+ redis.pipelined(&block)
+ end
+ end
+ end
+end
diff --git a/app/workers/concerns/limited_capacity/worker.rb b/app/workers/concerns/limited_capacity/worker.rb
new file mode 100644
index 00000000000..c0d6bfff2f5
--- /dev/null
+++ b/app/workers/concerns/limited_capacity/worker.rb
@@ -0,0 +1,164 @@
+# frozen_string_literal: true
+
+# Usage:
+#
+# Worker that performs the tasks:
+#
+# class DummyWorker
+# include ApplicationWorker
+# include LimitedCapacity::Worker
+#
+# # For each job that raises any error, a worker instance will be disabled
+# # until the next schedule-run.
+# # If you wish to get around this, exceptions must by handled by the implementer.
+# #
+# def perform_work(*args)
+# end
+#
+# def remaining_work_count(*args)
+# 5
+# end
+#
+# def max_running_jobs
+# 25
+# end
+# end
+#
+# Cron worker to fill the pool of regular workers:
+#
+# class ScheduleDummyCronWorker
+# include ApplicationWorker
+# include CronjobQueue
+#
+# def perform(*args)
+# DummyWorker.perform_with_capacity(*args)
+# end
+# end
+#
+
+module LimitedCapacity
+ module Worker
+ extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ included do
+ # Disable Sidekiq retries, log the error, and send the job to the dead queue.
+ # This is done to have only one source that produces jobs and because the slot
+ # would be occupied by a job that will be performed in the distant future.
+ # We let the cron worker enqueue new jobs, this could be seen as our retry and
+ # back off mechanism because the job might fail again if executed immediately.
+ sidekiq_options retry: 0
+ deduplicate :none
+ end
+
+ class_methods do
+ def perform_with_capacity(*args)
+ worker = self.new
+ worker.remove_failed_jobs
+ worker.report_prometheus_metrics(*args)
+ required_jobs_count = worker.required_jobs_count(*args)
+
+ arguments = Array.new(required_jobs_count) { args }
+ self.bulk_perform_async(arguments) # rubocop:disable Scalability/BulkPerformWithContext
+ end
+ end
+
+ def perform(*args)
+ return unless has_capacity?
+
+ job_tracker.register(jid)
+ perform_work(*args)
+ rescue => exception
+ raise
+ ensure
+ job_tracker.remove(jid)
+ report_prometheus_metrics
+ re_enqueue(*args) unless exception
+ end
+
+ def perform_work(*args)
+ raise NotImplementedError
+ end
+
+ def remaining_work_count(*args)
+ raise NotImplementedError
+ end
+
+ def max_running_jobs
+ raise NotImplementedError
+ end
+
+ def has_capacity?
+ remaining_capacity > 0
+ end
+
+ def remaining_capacity
+ [
+ max_running_jobs - running_jobs_count - self.class.queue_size,
+ 0
+ ].max
+ end
+
+ def has_work?(*args)
+ remaining_work_count(*args) > 0
+ end
+
+ def remove_failed_jobs
+ job_tracker.clean_up
+ end
+
+ def report_prometheus_metrics(*args)
+ running_jobs_gauge.set(prometheus_labels, running_jobs_count)
+ remaining_work_gauge.set(prometheus_labels, remaining_work_count(*args))
+ max_running_jobs_gauge.set(prometheus_labels, max_running_jobs)
+ end
+
+ def required_jobs_count(*args)
+ [
+ remaining_work_count(*args),
+ remaining_capacity
+ ].min
+ end
+
+ private
+
+ def running_jobs_count
+ job_tracker.count
+ end
+
+ def job_tracker
+ strong_memoize(:job_tracker) do
+ JobTracker.new(self.class.name)
+ end
+ end
+
+ def re_enqueue(*args)
+ return unless has_capacity?
+ return unless has_work?(*args)
+
+ self.class.perform_async(*args)
+ end
+
+ def running_jobs_gauge
+ strong_memoize(:running_jobs_gauge) do
+ Gitlab::Metrics.gauge(:limited_capacity_worker_running_jobs, 'Number of running jobs')
+ end
+ end
+
+ def max_running_jobs_gauge
+ strong_memoize(:max_running_jobs_gauge) do
+ Gitlab::Metrics.gauge(:limited_capacity_worker_max_running_jobs, 'Maximum number of running jobs')
+ end
+ end
+
+ def remaining_work_gauge
+ strong_memoize(:remaining_work_gauge) do
+ Gitlab::Metrics.gauge(:limited_capacity_worker_remaining_work_count, 'Number of jobs waiting to be enqueued')
+ end
+ end
+
+ def prometheus_labels
+ { worker: self.class.name }
+ end
+ end
+end
diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb
index 96590e165ae..61ba27f00d2 100644
--- a/app/workers/container_expiration_policy_worker.rb
+++ b/app/workers/container_expiration_policy_worker.rb
@@ -7,13 +7,15 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo
feature_category :container_registry
def perform
- ContainerExpirationPolicy.runnable_schedules.preloaded.find_each do |container_expiration_policy|
- with_context(project: container_expiration_policy.project,
- user: container_expiration_policy.project.owner) do |project:, user:|
- ContainerExpirationPolicyService.new(project, user)
- .execute(container_expiration_policy)
- rescue ContainerExpirationPolicyService::InvalidPolicyError => e
- Gitlab::ErrorTracking.log_exception(e, container_expiration_policy_id: container_expiration_policy.id)
+ ContainerExpirationPolicy.executable.preloaded.each_batch do |relation|
+ relation.each do |container_expiration_policy|
+ with_context(project: container_expiration_policy.project,
+ user: container_expiration_policy.project.owner) do |project:, user:|
+ ContainerExpirationPolicyService.new(project, user)
+ .execute(container_expiration_policy)
+ rescue ContainerExpirationPolicyService::InvalidPolicyError => e
+ Gitlab::ErrorTracking.log_exception(e, container_expiration_policy_id: container_expiration_policy.id)
+ end
end
end
end
diff --git a/app/workers/deployments/drop_older_deployments_worker.rb b/app/workers/deployments/drop_older_deployments_worker.rb
new file mode 100644
index 00000000000..d6cd92c1da4
--- /dev/null
+++ b/app/workers/deployments/drop_older_deployments_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Deployments
+ class DropOlderDeploymentsWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ queue_namespace :deployment
+ feature_category :continuous_delivery
+
+ def perform(deployment_id)
+ Deployments::OlderDeploymentsDropService.new(deployment_id).execute
+ end
+ end
+end
diff --git a/app/workers/deployments/execute_hooks_worker.rb b/app/workers/deployments/execute_hooks_worker.rb
new file mode 100644
index 00000000000..6be05232321
--- /dev/null
+++ b/app/workers/deployments/execute_hooks_worker.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Deployments
+ class ExecuteHooksWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ queue_namespace :deployment
+ feature_category :continuous_delivery
+ worker_resource_boundary :cpu
+
+ def perform(deployment_id)
+ if (deploy = Deployment.find_by_id(deployment_id))
+ deploy.execute_hooks
+ end
+ end
+ end
+end
diff --git a/app/workers/deployments/finished_worker.rb b/app/workers/deployments/finished_worker.rb
index 0be420af718..62c886010a3 100644
--- a/app/workers/deployments/finished_worker.rb
+++ b/app/workers/deployments/finished_worker.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+# This worker is deprecated and will be removed in 14.0
+# See: https://gitlab.com/gitlab-org/gitlab/-/issues/266381
module Deployments
class FinishedWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
diff --git a/app/workers/deployments/forward_deployment_worker.rb b/app/workers/deployments/forward_deployment_worker.rb
index a6f246dbbbd..dd01fcbbafe 100644
--- a/app/workers/deployments/forward_deployment_worker.rb
+++ b/app/workers/deployments/forward_deployment_worker.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+# This worker is deprecated and will be removed in 14.0
+# See: https://gitlab.com/gitlab-org/gitlab/-/issues/266381
module Deployments
class ForwardDeploymentWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
diff --git a/app/workers/deployments/link_merge_request_worker.rb b/app/workers/deployments/link_merge_request_worker.rb
new file mode 100644
index 00000000000..4723691a0bb
--- /dev/null
+++ b/app/workers/deployments/link_merge_request_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Deployments
+ class LinkMergeRequestWorker
+ include ApplicationWorker
+
+ queue_namespace :deployment
+ idempotent!
+ feature_category :continuous_delivery
+ worker_resource_boundary :cpu
+
+ def perform(deployment_id)
+ if (deploy = Deployment.find_by_id(deployment_id))
+ LinkMergeRequestsService.new(deploy).execute
+ end
+ end
+ end
+end
diff --git a/app/workers/deployments/success_worker.rb b/app/workers/deployments/success_worker.rb
index 17f790d2f6f..b72b107985b 100644
--- a/app/workers/deployments/success_worker.rb
+++ b/app/workers/deployments/success_worker.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+# This worker is deprecated and will be removed in 14.0
+# See: https://gitlab.com/gitlab-org/gitlab/-/issues/266381
module Deployments
class SuccessWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
@@ -12,7 +14,7 @@ module Deployments
Deployment.find_by_id(deployment_id).try do |deployment|
break unless deployment.success?
- Deployments::AfterCreateService.new(deployment).execute
+ Deployments::UpdateEnvironmentService.new(deployment).execute
end
end
end
diff --git a/app/workers/deployments/update_environment_worker.rb b/app/workers/deployments/update_environment_worker.rb
new file mode 100644
index 00000000000..2381f9926bc
--- /dev/null
+++ b/app/workers/deployments/update_environment_worker.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Deployments
+ class UpdateEnvironmentWorker
+ include ApplicationWorker
+
+ queue_namespace :deployment
+ idempotent!
+ feature_category :continuous_delivery
+ worker_resource_boundary :cpu
+
+ def perform(deployment_id)
+ Deployment.find_by_id(deployment_id).try do |deployment|
+ break unless deployment.success?
+
+ Deployments::UpdateEnvironmentService.new(deployment).execute
+ end
+ end
+ end
+end
diff --git a/app/workers/design_management/copy_design_collection_worker.rb b/app/workers/design_management/copy_design_collection_worker.rb
new file mode 100644
index 00000000000..0a6e23fe9da
--- /dev/null
+++ b/app/workers/design_management/copy_design_collection_worker.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class CopyDesignCollectionWorker
+ include ApplicationWorker
+
+ feature_category :design_management
+ idempotent!
+ urgency :low
+
+ def perform(user_id, issue_id, target_issue_id)
+ user = User.find(user_id)
+ issue = Issue.find(issue_id)
+ target_issue = Issue.find(target_issue_id)
+
+ response = DesignManagement::CopyDesignCollection::CopyService.new(
+ target_issue.project,
+ user,
+ issue: issue,
+ target_issue: target_issue
+ ).execute
+
+ Gitlab::AppLogger.warn(response.message) if response.error?
+ end
+ end
+end
diff --git a/app/workers/design_management/new_version_worker.rb b/app/workers/design_management/new_version_worker.rb
index 3634dcbcebd..4fbf2067be4 100644
--- a/app/workers/design_management/new_version_worker.rb
+++ b/app/workers/design_management/new_version_worker.rb
@@ -9,10 +9,10 @@ module DesignManagement
# `GenerateImageVersionsService` resizing designs
worker_resource_boundary :memory
- def perform(version_id)
+ def perform(version_id, skip_system_notes = false)
version = DesignManagement::Version.find(version_id)
- add_system_note(version)
+ add_system_note(version) unless skip_system_notes
generate_image_versions(version)
rescue ActiveRecord::RecordNotFound => e
Sidekiq.logger.warn(e)
diff --git a/app/workers/disallow_two_factor_for_group_worker.rb b/app/workers/disallow_two_factor_for_group_worker.rb
new file mode 100644
index 00000000000..b3cc7a44672
--- /dev/null
+++ b/app/workers/disallow_two_factor_for_group_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class DisallowTwoFactorForGroupWorker
+ include ApplicationWorker
+ include ExceptionBacktrace
+
+ feature_category :subgroups
+ idempotent!
+
+ def perform(group_id)
+ begin
+ group = Group.find(group_id)
+ rescue ActiveRecord::RecordNotFound
+ return
+ end
+
+ group.update!(require_two_factor_authentication: false)
+ end
+end
diff --git a/app/workers/disallow_two_factor_for_subgroups_worker.rb b/app/workers/disallow_two_factor_for_subgroups_worker.rb
new file mode 100644
index 00000000000..1ca227030e2
--- /dev/null
+++ b/app/workers/disallow_two_factor_for_subgroups_worker.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class DisallowTwoFactorForSubgroupsWorker
+ include ApplicationWorker
+ include ExceptionBacktrace
+
+ INTERVAL = 2.seconds.to_i
+
+ feature_category :subgroups
+ idempotent!
+
+ def perform(group_id)
+ begin
+ group = Group.find(group_id)
+ rescue ActiveRecord::RecordNotFound
+ return
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ subgroups = group.descendants.where(require_two_factor_authentication: true) # rubocop: disable CodeReuse/ActiveRecord
+ subgroups.find_each(batch_size: 100).with_index do |subgroup, index|
+ delay = index * INTERVAL
+
+ with_context(namespace: subgroup) do
+ DisallowTwoFactorForGroupWorker.perform_in(delay, subgroup.id)
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/workers/expire_build_instance_artifacts_worker.rb b/app/workers/expire_build_instance_artifacts_worker.rb
index e6cd60a3e47..a5571473b43 100644
--- a/app/workers/expire_build_instance_artifacts_worker.rb
+++ b/app/workers/expire_build_instance_artifacts_worker.rb
@@ -4,6 +4,7 @@ class ExpireBuildInstanceArtifactsWorker # rubocop:disable Scalability/Idempoten
include ApplicationWorker
feature_category :continuous_integration
+ tags :requires_disk_io
# rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
diff --git a/app/workers/export_csv_worker.rb b/app/workers/export_csv_worker.rb
index e7baaf40a41..f2da381a34a 100644
--- a/app/workers/export_csv_worker.rb
+++ b/app/workers/export_csv_worker.rb
@@ -15,8 +15,6 @@ class ExportCsvWorker # rubocop:disable Scalability/IdempotentWorker
params[:project_id] = project_id
params.delete(:sort)
- issues = IssuesFinder.new(@current_user, params).execute
-
- Issues::ExportCsvService.new(issues, @project).email(@current_user)
+ IssuableExportCsvWorker.perform_async(:issue, @current_user.id, @project.id, params)
end
end
diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb
index b0307571448..9071e4b8a1b 100644
--- a/app/workers/git_garbage_collect_worker.rb
+++ b/app/workers/git_garbage_collect_worker.rb
@@ -91,14 +91,16 @@ class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker
end
def cleanup_orphan_lfs_file_references(project)
- return unless Feature.enabled?(:cleanup_lfs_during_gc, project)
return if Gitlab::Database.read_only? # GitGarbageCollectWorker may be run on a Geo secondary
::Gitlab::Cleanup::OrphanLfsFileReferences.new(project, dry_run: false, logger: logger).run!
+ rescue => err
+ Gitlab::GitLogger.warn(message: "Cleaning up orphan LFS objects files failed", error: err.message)
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(err)
end
def flush_ref_caches(project)
- project.repository.after_create_branch
+ project.repository.expire_branches_cache
project.repository.branch_names
project.repository.has_visible_content?
end
diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb
index d80a2dad7d9..901785f462b 100644
--- a/app/workers/group_destroy_worker.rb
+++ b/app/workers/group_destroy_worker.rb
@@ -5,6 +5,7 @@ class GroupDestroyWorker # rubocop:disable Scalability/IdempotentWorker
include ExceptionBacktrace
feature_category :subgroups
+ tags :requires_disk_io
def perform(group_id, user_id)
begin
diff --git a/app/workers/group_export_worker.rb b/app/workers/group_export_worker.rb
index e22b691d35e..a212147d8fd 100644
--- a/app/workers/group_export_worker.rb
+++ b/app/workers/group_export_worker.rb
@@ -6,7 +6,7 @@ class GroupExportWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :importers
loggable_arguments 2
- sidekiq_options retry: false
+ sidekiq_options retry: false, dead: false
def perform(current_user_id, group_id, params = {})
current_user = User.find(current_user_id)
diff --git a/app/workers/group_import_worker.rb b/app/workers/group_import_worker.rb
index 36d81468d55..b8b596f459b 100644
--- a/app/workers/group_import_worker.rb
+++ b/app/workers/group_import_worker.rb
@@ -3,13 +3,13 @@
class GroupImportWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- sidekiq_options retry: false
+ sidekiq_options retry: false, dead: false
feature_category :importers
def perform(user_id, group_id)
current_user = User.find(user_id)
group = Group.find(group_id)
- group_import_state = group.import_state || group.build_import_state
+ group_import_state = group.import_state
group_import_state.jid = self.jid
group_import_state.start!
diff --git a/app/workers/incident_management/add_severity_system_note_worker.rb b/app/workers/incident_management/add_severity_system_note_worker.rb
new file mode 100644
index 00000000000..9f132531562
--- /dev/null
+++ b/app/workers/incident_management/add_severity_system_note_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ class AddSeveritySystemNoteWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ queue_namespace :incident_management
+ feature_category :incident_management
+
+ def perform(incident_id, user_id)
+ return if incident_id.blank? || user_id.blank?
+
+ incident = Issue.with_issue_type(:incident).find_by_id(incident_id)
+ return unless incident
+
+ user = User.find_by_id(user_id)
+ return unless user
+
+ SystemNoteService.change_incident_severity(incident, user)
+ end
+ end
+end
diff --git a/app/workers/issuable_export_csv_worker.rb b/app/workers/issuable_export_csv_worker.rb
new file mode 100644
index 00000000000..d91ba77287f
--- /dev/null
+++ b/app/workers/issuable_export_csv_worker.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ feature_category :issue_tracking
+ worker_resource_boundary :cpu
+ loggable_arguments 2
+
+ PERMITTED_TYPES = [:merge_request, :issue].freeze
+
+ def perform(type, current_user_id, project_id, params)
+ @type = type.to_sym
+ check_permitted_type!
+ process_params!(params, project_id)
+
+ @current_user = User.find(current_user_id)
+ @project = Project.find(project_id)
+ @service = service(find_objects(params))
+
+ @service.email(@current_user)
+ end
+
+ private
+
+ def find_objects(params)
+ case @type
+ when :issue
+ IssuesFinder.new(@current_user, params).execute
+ when :merge_request
+ MergeRequestsFinder.new(@current_user, params).execute
+ end
+ end
+
+ def service(issuables)
+ case @type
+ when :issue
+ Issues::ExportCsvService.new(issuables, @project)
+ when :merge_request
+ MergeRequests::ExportCsvService.new(issuables, @project)
+ end
+ end
+
+ def process_params!(params, project_id)
+ params.symbolize_keys!
+ params[:project_id] = project_id
+ params.delete(:sort)
+ end
+
+ def check_permitted_type!
+ raise ArgumentError, "type parameter must be :issue or :merge_request, it was #{@type}" unless PERMITTED_TYPES.include?(@type)
+ end
+end
diff --git a/app/workers/issue_placement_worker.rb b/app/workers/issue_placement_worker.rb
index a8d59e9125c..5b547ab0c8d 100644
--- a/app/workers/issue_placement_worker.rb
+++ b/app/workers/issue_placement_worker.rb
@@ -36,14 +36,14 @@ class IssuePlacementWorker
Gitlab::ErrorTracking.log_exception(e, issue_id: issue_id, project_id: project_id)
IssueRebalancingWorker.perform_async(nil, project_id.presence || issue.project_id)
end
- # rubocop: enable CodeReuse/ActiveRecord
def find_issue(issue_id, project_id)
- return Issue.id_in(issue_id).first if issue_id
+ return Issue.id_in(issue_id).take if issue_id
- project = Project.id_in(project_id).first
+ project = Project.id_in(project_id).take
return unless project
- project.issues.first
+ project.issues.take
end
+ # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/issue_rebalancing_worker.rb b/app/workers/issue_rebalancing_worker.rb
index 032ba5534e6..a9ad66198f3 100644
--- a/app/workers/issue_rebalancing_worker.rb
+++ b/app/workers/issue_rebalancing_worker.rb
@@ -11,7 +11,8 @@ class IssueRebalancingWorker
return if project_id.nil?
project = Project.find(project_id)
- issue = project.issues.first # All issues are equivalent as far as we are concerned
+ # All issues are equivalent as far as we are concerned
+ issue = project.issues.take # rubocop: disable CodeReuse/ActiveRecord
IssueRebalancingService.new(issue).execute
rescue ActiveRecord::RecordNotFound, IssueRebalancingService::TooManyIssues => e
diff --git a/app/workers/member_invitation_reminder_emails_worker.rb b/app/workers/member_invitation_reminder_emails_worker.rb
new file mode 100644
index 00000000000..50f583005c0
--- /dev/null
+++ b/app/workers/member_invitation_reminder_emails_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class MemberInvitationReminderEmailsWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ feature_category :subgroups
+ urgency :low
+
+ def perform
+ return unless Gitlab::Experimentation.enabled?(:invitation_reminders)
+
+ Member.not_accepted_invitations.not_expired.last_ten_days_excluding_today.find_in_batches do |invitations|
+ invitations.each do |invitation|
+ Members::InvitationReminderEmailService.new(invitation).execute
+ end
+ end
+ end
+end
diff --git a/app/workers/metrics/dashboard/sync_dashboards_worker.rb b/app/workers/metrics/dashboard/sync_dashboards_worker.rb
new file mode 100644
index 00000000000..7a124a33f9e
--- /dev/null
+++ b/app/workers/metrics/dashboard/sync_dashboards_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Metrics
+ module Dashboard
+ class SyncDashboardsWorker
+ include ApplicationWorker
+
+ feature_category :metrics
+
+ idempotent!
+
+ def perform(project_id)
+ project = Project.find(project_id)
+ dashboard_paths = ::Gitlab::Metrics::Dashboard::RepoDashboardFinder.list_dashboards(project)
+
+ dashboard_paths.each do |dashboard_path|
+ ::Gitlab::Metrics::Dashboard::Importer.new(dashboard_path, project).execute!
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/pages_domain_ssl_renewal_worker.rb b/app/workers/pages_domain_ssl_renewal_worker.rb
index 561fd59d471..125ba343948 100644
--- a/app/workers/pages_domain_ssl_renewal_worker.rb
+++ b/app/workers/pages_domain_ssl_renewal_worker.rb
@@ -4,6 +4,7 @@ class PagesDomainSslRenewalWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
feature_category :pages
+ tags :requires_disk_io
def perform(domain_id)
domain = PagesDomain.find_by_id(domain_id)
diff --git a/app/workers/pages_domain_verification_worker.rb b/app/workers/pages_domain_verification_worker.rb
index 1b4d9d3994c..ff0463481cd 100644
--- a/app/workers/pages_domain_verification_worker.rb
+++ b/app/workers/pages_domain_verification_worker.rb
@@ -4,6 +4,7 @@ class PagesDomainVerificationWorker # rubocop:disable Scalability/IdempotentWork
include ApplicationWorker
feature_category :pages
+ tags :requires_disk_io
# rubocop: disable CodeReuse/ActiveRecord
def perform(domain_id)
diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb
index aefa4bc4223..0c561626f8c 100644
--- a/app/workers/pages_worker.rb
+++ b/app/workers/pages_worker.rb
@@ -6,6 +6,7 @@ class PagesWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: 3
feature_category :pages
loggable_arguments 0, 1
+ tags :requires_disk_io
def perform(action, *arg)
send(action, *arg) # rubocop:disable GitlabSecurity/PublicSend
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 4a93b1af166..0b224b88e4d 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -24,7 +24,7 @@ class PostReceive # rubocop:disable Scalability/IdempotentWorker
post_received = Gitlab::GitPostReceive.new(container, identifier, changes, push_options)
if repo_type.wiki?
- process_wiki_changes(post_received, container.wiki)
+ process_wiki_changes(post_received, container)
elsif repo_type.project?
process_project_changes(post_received, container)
elsif repo_type.snippet?
diff --git a/app/workers/project_destroy_worker.rb b/app/workers/project_destroy_worker.rb
index b3e7996f4a4..99d51fc5c2e 100644
--- a/app/workers/project_destroy_worker.rb
+++ b/app/workers/project_destroy_worker.rb
@@ -5,6 +5,7 @@ class ProjectDestroyWorker # rubocop:disable Scalability/IdempotentWorker
include ExceptionBacktrace
feature_category :source_code_management
+ tags :requires_disk_io
def perform(project_id, user_id, params)
project = Project.find(project_id)
diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb
index 6c8640138a1..1c4aa3f7e49 100644
--- a/app/workers/project_export_worker.rb
+++ b/app/workers/project_export_worker.rb
@@ -8,7 +8,7 @@ class ProjectExportWorker # rubocop:disable Scalability/IdempotentWorker
worker_resource_boundary :memory
urgency :throttled
loggable_arguments 2, 3
- sidekiq_options retry: false
+ sidekiq_options retry: false, dead: false
sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
def perform(current_user_id, project_id, after_export_strategy = {}, params = {})
diff --git a/app/workers/propagate_integration_group_worker.rb b/app/workers/propagate_integration_group_worker.rb
new file mode 100644
index 00000000000..01155753877
--- /dev/null
+++ b/app/workers/propagate_integration_group_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class PropagateIntegrationGroupWorker
+ include ApplicationWorker
+
+ feature_category :integrations
+ idempotent!
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(integration_id, min_id, max_id)
+ integration = Service.find_by_id(integration_id)
+ return unless integration
+
+ batch = if integration.instance?
+ Group.where(id: min_id..max_id).without_integration(integration)
+ else
+ integration.group.descendants.where(id: min_id..max_id).without_integration(integration)
+ end
+
+ return if batch.empty?
+
+ BulkCreateIntegrationService.new(integration, batch, 'group').execute
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+end
diff --git a/app/workers/propagate_integration_inherit_worker.rb b/app/workers/propagate_integration_inherit_worker.rb
new file mode 100644
index 00000000000..ef3132202f6
--- /dev/null
+++ b/app/workers/propagate_integration_inherit_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class PropagateIntegrationInheritWorker
+ include ApplicationWorker
+
+ feature_category :integrations
+ idempotent!
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(integration_id, min_id, max_id)
+ integration = Service.find_by_id(integration_id)
+ return unless integration
+
+ services = Service.where(id: min_id..max_id).by_type(integration.type).inherit_from_id(integration.id)
+
+ BulkUpdateIntegrationService.new(integration, services).execute
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+end
diff --git a/app/workers/propagate_integration_project_worker.rb b/app/workers/propagate_integration_project_worker.rb
new file mode 100644
index 00000000000..188d81e5fc1
--- /dev/null
+++ b/app/workers/propagate_integration_project_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class PropagateIntegrationProjectWorker
+ include ApplicationWorker
+
+ feature_category :integrations
+ idempotent!
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(integration_id, min_id, max_id)
+ integration = Service.find_by_id(integration_id)
+ return unless integration
+
+ batch = Project.where(id: min_id..max_id).without_integration(integration)
+ batch = batch.in_namespace(integration.group.self_and_descendants) if integration.group_id
+
+ return if batch.empty?
+
+ BulkCreateIntegrationService.new(integration, batch, 'project').execute
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+end
diff --git a/app/workers/propagate_integration_worker.rb b/app/workers/propagate_integration_worker.rb
index 68e38386372..bb954b12a25 100644
--- a/app/workers/propagate_integration_worker.rb
+++ b/app/workers/propagate_integration_worker.rb
@@ -7,7 +7,8 @@ class PropagateIntegrationWorker
idempotent!
loggable_arguments 1
- # Keep overwrite parameter for backwards compatibility.
+ # TODO: Keep overwrite parameter for backwards compatibility. Remove after >= 14.0
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/255382
def perform(integration_id, overwrite = nil)
Admin::PropagateIntegrationService.propagate(Service.find(integration_id))
end
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index 54052bda675..51fe60e25fc 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -7,7 +7,8 @@ class RepositoryImportWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :importers
worker_has_external_dependencies!
- sidekiq_options retry: false
+ # Do not retry on Import/Export until https://gitlab.com/gitlab-org/gitlab/-/issues/16812 is solved.
+ sidekiq_options retry: false, dead: false
sidekiq_options status_expiration: Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION
# technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991
diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb
index f8209ae5e63..eca0a248a37 100644
--- a/app/workers/stuck_ci_jobs_worker.rb
+++ b/app/workers/stuck_ci_jobs_worker.rb
@@ -17,7 +17,7 @@ class StuckCiJobsWorker # rubocop:disable Scalability/IdempotentWorker
def perform
return unless try_obtain_lease
- Gitlab::AppLogger.info "#{self.class}: Cleaning stuck builds" # rubocop:disable Gitlab/RailsLogger
+ Gitlab::AppLogger.info "#{self.class}: Cleaning stuck builds"
drop :running, BUILD_RUNNING_OUTDATED_TIMEOUT, 'ci_builds.updated_at < ?', :stuck_or_timeout_failure
drop :pending, BUILD_PENDING_OUTDATED_TIMEOUT, 'ci_builds.updated_at < ?', :stuck_or_timeout_failure
diff --git a/app/workers/web_hooks/destroy_worker.rb b/app/workers/web_hooks/destroy_worker.rb
new file mode 100644
index 00000000000..13a5a7bf1e6
--- /dev/null
+++ b/app/workers/web_hooks/destroy_worker.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module WebHooks
+ class DestroyWorker
+ include ApplicationWorker
+
+ feature_category :integrations
+ urgency :low
+ idempotent!
+
+ def perform(user_id, web_hook_id)
+ user = User.find_by_id(user_id)
+ hook = WebHook.find_by_id(web_hook_id)
+
+ return unless user && hook
+
+ result = ::WebHooks::DestroyService.new(user).sync_destroy(hook)
+
+ return result if result[:status] == :success
+
+ e = ::WebHooks::DestroyService::DestroyError.new(result[:message])
+ Gitlab::ErrorTracking.track_exception(e, web_hook_id: hook.id)
+
+ raise e
+ end
+ end
+end