summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/access_tokens/components/expires_at_field.vue26
-rw-r--r--app/assets/javascripts/access_tokens/index.js31
-rw-r--r--app/assets/javascripts/admin/dev_ops_report/devops_adoption.js2
-rw-r--r--app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js27
-rw-r--r--app/assets/javascripts/alert_management/components/alert_details.vue9
-rw-r--r--app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue221
-rw-r--r--app/assets/javascripts/alerts_settings/components/alert_settings_form_help_block.vue32
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue133
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue661
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue (renamed from app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue)362
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue331
-rw-r--r--app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json112
-rw-r--r--app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json121
-rw-r--r--app/assets/javascripts/alerts_settings/constants.js32
-rw-r--r--app/assets/javascripts/alerts_settings/graphql.js44
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql9
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql10
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql12
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql10
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql10
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql10
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql19
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql10
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql10
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/queries/get_current_integration.query.graphql3
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql11
-rw-r--r--app/assets/javascripts/alerts_settings/index.js39
-rw-r--r--app/assets/javascripts/alerts_settings/services/index.js5
-rw-r--r--app/assets/javascripts/alerts_settings/utils/cache_updates.js84
-rw-r--r--app/assets/javascripts/alerts_settings/utils/error_messages.js21
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/components/app.vue24
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/components/charts_config.js87
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue206
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue215
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue224
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql13
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql13
-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/projects.query.graphql13
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/utils.js45
-rw-r--r--app/assets/javascripts/analytics/product_analytics/components/activity_chart.vue13
-rw-r--r--app/assets/javascripts/analytics/shared/components/metric_card.vue2
-rw-r--r--app/assets/javascripts/api.js59
-rw-r--r--app/assets/javascripts/awards_handler.js37
-rw-r--r--app/assets/javascripts/badges/components/badge.vue14
-rw-r--r--app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue32
-rw-r--r--app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue49
-rw-r--r--app/assets/javascripts/batch_comments/mixins/resolved_status.js6
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js30
-rw-r--r--app/assets/javascripts/behaviors/details_behavior.js28
-rw-r--r--app/assets/javascripts/behaviors/index.js1
-rw-r--r--app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js1
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js2
-rw-r--r--app/assets/javascripts/behaviors/quick_submit.js16
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js17
-rw-r--r--app/assets/javascripts/behaviors/toggler_behavior.js18
-rw-r--r--app/assets/javascripts/blob/components/blob_edit_content.vue2
-rw-r--r--app/assets/javascripts/blob/components/blob_edit_header.vue1
-rw-r--r--app/assets/javascripts/blob/components/blob_header.vue2
-rw-r--r--app/assets/javascripts/blob/components/blob_header_default_actions.vue6
-rw-r--r--app/assets/javascripts/blob/components/blob_header_filepath.vue2
-rw-r--r--app/assets/javascripts/blob/pipeline_tour_success_modal.vue14
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js58
-rw-r--r--app/assets/javascripts/boards/components/board_assignee_dropdown.vue178
-rw-r--r--app/assets/javascripts/boards/components/board_card_layout.vue5
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue49
-rw-r--r--app/assets/javascripts/boards/components/board_column_new.vue94
-rw-r--r--app/assets/javascripts/boards/components/board_configuration_options.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue8
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue15
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue50
-rw-r--r--app/assets/javascripts/boards/components/board_list_header_new.vue358
-rw-r--r--app/assets/javascripts/boards/components/board_list_new.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue34
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue_new.vue129
-rw-r--r--app/assets/javascripts/boards/components/board_promotion_state.js1
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue18
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue2
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue35
-rw-r--r--app/assets/javascripts/boards/components/modal/empty_state.vue21
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js3
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue12
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue111
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue14
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue71
-rw-r--r--app/assets/javascripts/boards/constants.js4
-rw-r--r--app/assets/javascripts/boards/graphql/mutations/issue_set_subscription.mutation.graphql8
-rw-r--r--app/assets/javascripts/boards/index.js12
-rw-r--r--app/assets/javascripts/boards/mixins/sortable_default_options.js5
-rw-r--r--app/assets/javascripts/boards/queries/board_labels.query.graphql23
-rw-r--r--app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql5
-rw-r--r--app/assets/javascripts/boards/queries/issue_create.mutation.graphql10
-rw-r--r--app/assets/javascripts/boards/queries/issue_set_due_date.mutation.graphql8
-rw-r--r--app/assets/javascripts/boards/queries/users_search.query.graphql11
-rw-r--r--app/assets/javascripts/boards/stores/actions.js193
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js7
-rw-r--r--app/assets/javascripts/boards/stores/getters.js11
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js9
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js41
-rw-r--r--app/assets/javascripts/boards/toggle_focus.js5
-rw-r--r--app/assets/javascripts/ci_lint/components/ci_lint.vue14
-rw-r--r--app/assets/javascripts/ci_lint/components/ci_lint_results.vue37
-rw-r--r--app/assets/javascripts/ci_lint/graphql/resolvers.js34
-rw-r--r--app/assets/javascripts/ci_lint/index.js37
-rw-r--r--app/assets/javascripts/ci_variable_list/ajax_variable_list.js128
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue4
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue8
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue2
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue15
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue10
-rw-r--r--app/assets/javascripts/compare_autocomplete.js3
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue61
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue40
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/constants.js2
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js21
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/actions.js16
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/index.js5
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue11
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js6
-rw-r--r--app/assets/javascripts/dependency_proxy.js5
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue19
-rw-r--r--app/assets/javascripts/design_management/components/design_destroyer.vue2
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue6
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue4
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue2
-rw-r--r--app/assets/javascripts/design_management/components/design_overlay.vue4
-rw-r--r--app/assets/javascripts/design_management/components/design_scaler.vue23
-rw-r--r--app/assets/javascripts/design_management/components/design_sidebar.vue2
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/design_navigation.vue7
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/index.vue16
-rw-r--r--app/assets/javascripts/design_management/components/upload/button.vue5
-rw-r--r--app/assets/javascripts/design_management/constants.js3
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql1
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/reposition_image_diff_note.mutation.graphql10
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/update_image_diff_note.mutation.graphql10
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql7
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql23
-rw-r--r--app/assets/javascripts/design_management/mixins/all_designs.js18
-rw-r--r--app/assets/javascripts/design_management/mixins/all_versions.js2
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue41
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue91
-rw-r--r--app/assets/javascripts/design_management/router/index.js17
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js14
-rw-r--r--app/assets/javascripts/design_management/utils/design_management_utils.js6
-rw-r--r--app/assets/javascripts/diffs/components/app.vue25
-rw-r--r--app/assets/javascripts/diffs/components/collapsed_files_warning.vue8
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue11
-rw-r--r--app/assets/javascripts/diffs/components/diff_comment_cell.vue69
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue35
-rw-r--r--app/assets/javascripts/diffs/components/diff_expansion_cell.vue55
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue209
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue26
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_row.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue28
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue271
-rw-r--r--app/assets/javascripts/diffs/components/diff_row_utils.js73
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue151
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_comment_row.vue82
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue51
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue61
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_view.vue70
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue175
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_expansion_row.vue56
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_table_row.vue63
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_view.vue89
-rw-r--r--app/assets/javascripts/diffs/constants.js12
-rw-r--r--app/assets/javascripts/diffs/diff_file.js24
-rw-r--r--app/assets/javascripts/diffs/event_hub.js3
-rw-r--r--app/assets/javascripts/diffs/i18n.js13
-rw-r--r--app/assets/javascripts/diffs/store/actions.js57
-rw-r--r--app/assets/javascripts/diffs/store/getters.js21
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js52
-rw-r--r--app/assets/javascripts/diffs/store/utils.js8
-rw-r--r--app/assets/javascripts/diffs/utils/performance.js80
-rw-r--r--app/assets/javascripts/dropzone_input.js3
-rw-r--r--app/assets/javascripts/editor/editor_lite.js3
-rw-r--r--app/assets/javascripts/environments/components/container.vue2
-rw-r--r--app/assets/javascripts/environments/components/delete_environment_modal.vue42
-rw-r--r--app/assets/javascripts/environments/components/environment_delete.vue23
-rw-r--r--app/assets/javascripts/environments/components/environment_stop.vue6
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue2
-rw-r--r--app/assets/javascripts/environments/components/stop_environment_modal.vue48
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue34
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue57
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue16
-rw-r--r--app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue50
-rw-r--r--app/assets/javascripts/feature_flags/components/edit_feature_flag.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue4
-rw-r--r--app/assets/javascripts/feature_flags/components/form.vue22
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue4
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue73
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue4
-rw-r--r--app/assets/javascripts/feature_flags/edit.js2
-rw-r--r--app/assets/javascripts/feature_flags/new.js2
-rw-r--r--app/assets/javascripts/feature_flags/store/edit/index.js4
-rw-r--r--app/assets/javascripts/feature_flags/store/gitlab_user_list/actions.js17
-rw-r--r--app/assets/javascripts/feature_flags/store/gitlab_user_list/getters.js11
-rw-r--r--app/assets/javascripts/feature_flags/store/gitlab_user_list/index.js12
-rw-r--r--app/assets/javascripts/feature_flags/store/gitlab_user_list/mutation_types.js5
-rw-r--r--app/assets/javascripts/feature_flags/store/gitlab_user_list/mutations.js19
-rw-r--r--app/assets/javascripts/feature_flags/store/gitlab_user_list/state.js9
-rw-r--r--app/assets/javascripts/feature_flags/store/gitlab_user_list/status.js6
-rw-r--r--app/assets/javascripts/feature_flags/store/helpers.js4
-rw-r--r--app/assets/javascripts/feature_flags/store/new/index.js4
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js83
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_ajax_filter.js10
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_operator.js5
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js6
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js16
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js14
-rw-r--r--app/assets/javascripts/frequent_items/index.js82
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js51
-rw-r--r--app/assets/javascripts/graphql_shared/utils.js8
-rw-r--r--app/assets/javascripts/groups/components/app.vue58
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue8
-rw-r--r--app/assets/javascripts/groups/components/item_actions.vue40
-rw-r--r--app/assets/javascripts/groups/constants.js6
-rw-r--r--app/assets/javascripts/groups/index.js7
-rw-r--r--app/assets/javascripts/groups/members/index.js3
-rw-r--r--app/assets/javascripts/groups/new_group_child.js65
-rw-r--r--app/assets/javascripts/header.js24
-rw-r--r--app/assets/javascripts/helpers/startup_css_helper.js14
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue26
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue12
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue103
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue7
-rw-r--r--app/assets/javascripts/ide/components/file_row_extra.vue7
-rw-r--r--app/assets/javascripts/ide/components/ide.vue74
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue10
-rw-r--r--app/assets/javascripts/ide/components/ide_status_list.vue7
-rw-r--r--app/assets/javascripts/ide/components/ide_tree.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue11
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail.vue9
-rw-r--r--app/assets/javascripts/ide/components/jobs/item.vue6
-rw-r--r--app/assets/javascripts/ide/components/mr_file_icon.vue7
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/button.vue7
-rw-r--r--app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue4
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue36
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue5
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue45
-rw-r--r--app/assets/javascripts/ide/components/repo_file_status_icon.vue7
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue4
-rw-r--r--app/assets/javascripts/ide/components/terminal/empty_state.vue22
-rw-r--r--app/assets/javascripts/ide/stores/actions.js4
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js24
-rw-r--r--app/assets/javascripts/ide/stores/index.js11
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/getters.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/editor/actions.js19
-rw-r--r--app/assets/javascripts/ide/stores/modules/editor/getters.js13
-rw-r--r--app/assets/javascripts/ide/stores/modules/editor/index.js12
-rw-r--r--app/assets/javascripts/ide/stores/modules/editor/mutation_types.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/editor/mutations.js25
-rw-r--r--app/assets/javascripts/ide/stores/modules/editor/setup.js19
-rw-r--r--app/assets/javascripts/ide/stores/modules/editor/state.js8
-rw-r--r--app/assets/javascripts/ide/stores/modules/editor/utils.js11
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js16
-rw-r--r--app/assets/javascripts/ide/stores/utils.js6
-rw-r--r--app/assets/javascripts/ide/utils.js2
-rw-r--r--app/assets/javascripts/import_projects/store/actions.js21
-rw-r--r--app/assets/javascripts/importer_status.js4
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue24
-rw-r--r--app/assets/javascripts/integrations/edit/components/confirmation_modal.vue6
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue41
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue7
-rw-r--r--app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue61
-rw-r--r--app/assets/javascripts/integrations/edit/index.js2
-rw-r--r--app/assets/javascripts/integrations/edit/store/actions.js2
-rw-r--r--app/assets/javascripts/integrations/edit/store/getters.js2
-rw-r--r--app/assets/javascripts/integrations/edit/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/integrations/edit/store/mutations.js3
-rw-r--r--app/assets/javascripts/integrations/edit/store/state.js1
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue85
-rw-r--r--app/assets/javascripts/invite_members/components/members_token_select.vue120
-rw-r--r--app/assets/javascripts/invite_members/constants.js1
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js1
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue35
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_item.vue124
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_list_root.vue153
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_tabs.vue5
-rw-r--r--app/assets/javascripts/issuable_list/constants.js51
-rw-r--r--app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue1
-rw-r--r--app/assets/javascripts/issue.js8
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue34
-rw-r--r--app/assets/javascripts/issue_show/components/header_actions.vue281
-rw-r--r--app/assets/javascripts/issue_show/constants.js11
-rw-r--r--app/assets/javascripts/issue_show/issue.js58
-rw-r--r--app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql8
-rw-r--r--app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql5
-rw-r--r--app/assets/javascripts/issues_list/components/issuable.vue6
-rw-r--r--app/assets/javascripts/issues_list/index.js5
-rw-r--r--app/assets/javascripts/jira_connect/.eslintrc.yml5
-rw-r--r--app/assets/javascripts/jira_connect/components/app.vue7
-rw-r--r--app/assets/javascripts/jira_connect/index.js15
-rw-r--r--app/assets/javascripts/jobs/components/job_retry_forward_deployment_modal.vue66
-rw-r--r--app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue45
-rw-r--r--app/assets/javascripts/jobs/components/jobs_container.vue2
-rw-r--r--app/assets/javascripts/jobs/components/log/line.vue51
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue174
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_job_details_container.vue102
-rw-r--r--app/assets/javascripts/jobs/components/stages_dropdown.vue29
-rw-r--r--app/assets/javascripts/jobs/constants.js24
-rw-r--r--app/assets/javascripts/jobs/index.js32
-rw-r--r--app/assets/javascripts/jobs/store/getters.js5
-rw-r--r--app/assets/javascripts/jobs/utils.js4
-rw-r--r--app/assets/javascripts/lib/ace.js4
-rw-r--r--app/assets/javascripts/lib/ace/ace_config_paths.js.erb34
-rw-r--r--app/assets/javascripts/lib/graphql.js3
-rw-r--r--app/assets/javascripts/lib/utils/ace_utils.js6
-rw-r--r--app/assets/javascripts/lib/utils/apollo_startup_js_link.js106
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js21
-rw-r--r--app/assets/javascripts/lib/utils/constants.js1
-rw-r--r--app/assets/javascripts/lib/utils/css_utils.js2
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js4
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js22
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js1
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js14
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js12
-rw-r--r--app/assets/javascripts/main.js11
-rw-r--r--app/assets/javascripts/merge_conflicts/components/diff_file_editor.js38
-rw-r--r--app/assets/javascripts/milestones/components/milestone_combobox.vue250
-rw-r--r--app/assets/javascripts/milestones/components/milestone_results_section.vue93
-rw-r--r--app/assets/javascripts/milestones/project_milestone_combobox.vue249
-rw-r--r--app/assets/javascripts/milestones/stores/actions.js61
-rw-r--r--app/assets/javascripts/milestones/stores/getters.js4
-rw-r--r--app/assets/javascripts/milestones/stores/mutation_types.js8
-rw-r--r--app/assets/javascripts/milestones/stores/mutations.js30
-rw-r--r--app/assets/javascripts/milestones/stores/state.js8
-rw-r--r--app/assets/javascripts/monitoring/components/charts/column.vue10
-rw-r--r--app/assets/javascripts/monitoring/components/charts/stacked_column.vue21
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue8
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue4
-rw-r--r--app/assets/javascripts/monitoring/components/embeds/embed_group.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/variables/dropdown_field.vue17
-rw-r--r--app/assets/javascripts/notebook/cells/output/html.vue2
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue9
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue6
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue5
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter_note.vue42
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue118
-rw-r--r--app/assets/javascripts/notes/components/note_actions/reply_button.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_attachment.vue7
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue8
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue28
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue13
-rw-r--r--app/assets/javascripts/notes/components/toggle_replies_widget.vue12
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js13
-rw-r--r--app/assets/javascripts/notes/stores/actions.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js38
-rw-r--r--app/assets/javascripts/notifications_form.js19
-rw-r--r--app/assets/javascripts/packages/details/components/package_title.vue35
-rw-r--r--app/assets/javascripts/pages/admin/admin.js16
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/index.js20
-rw-r--r--app/assets/javascripts/pages/admin/dev_ops_report/index.js30
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue23
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/index.js11
-rw-r--r--app/assets/javascripts/pages/admin/runners/index.js13
-rw-r--r--app/assets/javascripts/pages/admin/users/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/boards/index.js8
-rw-r--r--app/assets/javascripts/pages/groups/clusters/destroy/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/clusters/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/clusters/index.js6
-rw-r--r--app/assets/javascripts/pages/groups/clusters/index/index.js8
-rw-r--r--app/assets/javascripts/pages/groups/clusters/new/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/clusters/show/index.js6
-rw-r--r--app/assets/javascripts/pages/groups/dependency_proxies/index.js13
-rw-r--r--app/assets/javascripts/pages/groups/details/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js61
-rw-r--r--app/assets/javascripts/pages/groups/labels/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/labels/index/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/labels/new/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/merge_requests/index.js18
-rw-r--r--app/assets/javascripts/pages/groups/milestones/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/milestones/new/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js16
-rw-r--r--app/assets/javascripts/pages/groups/packages/index/index.js8
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js38
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_details.js5
-rw-r--r--app/assets/javascripts/pages/profiles/preferences/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/alert_management/details/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/alert_management/index/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js32
-rw-r--r--app/assets/javascripts/pages/projects/ci/lints/new/index.js17
-rw-r--r--app/assets/javascripts/pages/projects/ci/lints/show/index.js18
-rw-r--r--app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/error_tracking/details/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/error_tracking/index/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/graphs/charts/index.js14
-rw-r--r--app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue4
-rw-r--r--app/assets/javascripts/pages/projects/incidents/index/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/incidents/show/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js5
-rw-r--r--app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue24
-rw-r--r--app/assets/javascripts/pages/projects/labels/index/index.js102
-rw-r--r--app/assets/javascripts/pages/projects/metrics_dashboard/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js34
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue23
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/show/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/project.js31
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js59
-rw-r--r--app/assets/javascripts/pages/projects/settings/integrations/show/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/settings/operations/show/index.js18
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue25
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js1
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/terraform/index/index.js3
-rw-r--r--app/assets/javascripts/pages/search/show/index.js4
-rw-r--r--app/assets/javascripts/pages/search/show/search.js36
-rw-r--r--app/assets/javascripts/pages/shared/mount_runner_instructions.js32
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue49
-rw-r--r--app/assets/javascripts/pages/shared/wikis/wikis.js7
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js11
-rw-r--r--app/assets/javascripts/pages/users/index.js5
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js17
-rw-r--r--app/assets/javascripts/performance/constants.js (renamed from app/assets/javascripts/performance_constants.js)14
-rw-r--r--app/assets/javascripts/performance/utils.js (renamed from app/assets/javascripts/performance_utils.js)0
-rw-r--r--app/assets/javascripts/performance/vue_performance_plugin.js53
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue64
-rw-r--r--app/assets/javascripts/performance_bar/performance_bar_log.js2
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/text_editor.vue26
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql5
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/resolvers.js16
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql7
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js34
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue108
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag.vue21
-rw-r--r--app/assets/javascripts/pipelines/components/graph/constants.js3
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue73
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue27
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue15
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue72
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue28
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/stage_pill.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue9
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/stage.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue67
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_reports.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue39
-rw-r--r--app/assets/javascripts/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql5
-rw-r--r--app/assets/javascripts/pipelines/graphql/mutations/delete_pipeline.mutation.graphql5
-rw-r--r--app/assets/javascripts/pipelines/graphql/mutations/retry_pipeline.mutation.graphql5
-rw-r--r--app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js6
-rw-r--r--app/assets/javascripts/pipelines/mixins/stage_column_mixin.js4
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js58
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_dag.js18
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_graph.js7
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_header.js6
-rw-r--r--app/assets/javascripts/popovers/components/popovers.vue92
-rw-r--r--app/assets/javascripts/popovers/index.js51
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue44
-rw-r--r--app/assets/javascripts/profile/preferences/components/integration_view.vue81
-rw-r--r--app/assets/javascripts/profile/preferences/components/profile_preferences.vue56
-rw-r--r--app/assets/javascripts/profile/preferences/profile_preferences_bundle.js23
-rw-r--r--app/assets/javascripts/projects/commit_box/info/index.js3
-rw-r--r--app/assets/javascripts/projects/commit_box/info/init_details_button.js11
-rw-r--r--app/assets/javascripts/projects/components/project_delete_button.vue2
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app.vue11
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue54
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/index.js2
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue33
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue25
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/index.js4
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js4
-rw-r--r--app/assets/javascripts/prometheus_metrics/prometheus_metrics.js17
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue8
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue6
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue13
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue13
-rw-r--r--app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue4
-rw-r--r--app/assets/javascripts/registry/explorer/constants/details.js4
-rw-r--r--app/assets/javascripts/registry/explorer/pages/details.vue30
-rw-r--r--app/assets/javascripts/registry/explorer/router.js3
-rw-r--r--app/assets/javascripts/registry/explorer/stores/actions.js43
-rw-r--r--app/assets/javascripts/registry/explorer/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/registry/explorer/stores/mutations.js4
-rw-r--r--app/assets/javascripts/registry/explorer/stores/state.js1
-rw-r--r--app/assets/javascripts/registry/explorer/utils.js17
-rw-r--r--app/assets/javascripts/registry/settings/components/settings_form.vue3
-rw-r--r--app/assets/javascripts/registry/shared/constants.js2
-rw-r--r--app/assets/javascripts/related_issues/components/issue_token.vue11
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue4
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue12
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue7
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue26
-rw-r--r--app/assets/javascripts/releases/components/issuable_stats.vue97
-rw-r--r--app/assets/javascripts/releases/components/release_block.vue7
-rw-r--r--app/assets/javascripts/releases/components/release_block_milestone_info.vue150
-rw-r--r--app/assets/javascripts/releases/components/releases_sort.vue62
-rw-r--r--app/assets/javascripts/releases/components/tag_field_existing.vue20
-rw-r--r--app/assets/javascripts/releases/constants.js18
-rw-r--r--app/assets/javascripts/releases/queries/all_releases.query.graphql11
-rw-r--r--app/assets/javascripts/releases/queries/release.fragment.graphql8
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/state.js6
-rw-r--r--app/assets/javascripts/releases/stores/modules/list/actions.js11
-rw-r--r--app/assets/javascripts/releases/stores/modules/list/mutation_types.js1
-rw-r--r--app/assets/javascripts/releases/stores/modules/list/mutations.js4
-rw-r--r--app/assets/javascripts/releases/stores/modules/list/state.js6
-rw-r--r--app/assets/javascripts/releases/util.js4
-rw-r--r--app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue44
-rw-r--r--app/assets/javascripts/reports/codequality_report/constants.js17
-rw-r--r--app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue1
-rw-r--r--app/assets/javascripts/reports/codequality_report/store/getters.js3
-rw-r--r--app/assets/javascripts/reports/components/grouped_test_reports_app.vue45
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue5
-rw-r--r--app/assets/javascripts/reports/components/test_issue_body.vue24
-rw-r--r--app/assets/javascripts/reports/store/mutations.js8
-rw-r--r--app/assets/javascripts/reports/store/utils.js42
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue2
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue4
-rw-r--r--app/assets/javascripts/repository/components/preview/index.vue2
-rw-r--r--app/assets/javascripts/repository/components/tree_action_link.vue2
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue2
-rw-r--r--app/assets/javascripts/repository/index.js28
-rw-r--r--app/assets/javascripts/repository/mixins/preload.js2
-rw-r--r--app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue100
-rw-r--r--app/assets/javascripts/search/dropdown_filter/index.js38
-rw-r--r--app/assets/javascripts/search/group_filter/components/group_filter.vue124
-rw-r--r--app/assets/javascripts/search/group_filter/constants.js10
-rw-r--r--app/assets/javascripts/search/group_filter/index.js28
-rw-r--r--app/assets/javascripts/search/highlight_blob_search_result.js (renamed from app/assets/javascripts/pages/search/show/highlight_blob_search_result.js)0
-rw-r--r--app/assets/javascripts/search/index.js13
-rw-r--r--app/assets/javascripts/search/sidebar/components/app.vue41
-rw-r--r--app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue26
-rw-r--r--app/assets/javascripts/search/sidebar/components/radio_filter.vue68
-rw-r--r--app/assets/javascripts/search/sidebar/components/status_filter.vue26
-rw-r--r--app/assets/javascripts/search/sidebar/constants/confidential_filter_data.js (renamed from app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js)2
-rw-r--r--app/assets/javascripts/search/sidebar/constants/state_filter_data.js (renamed from app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js)2
-rw-r--r--app/assets/javascripts/search/sidebar/index.js19
-rw-r--r--app/assets/javascripts/search/store/actions.js29
-rw-r--r--app/assets/javascripts/search/store/index.js4
-rw-r--r--app/assets/javascripts/search/store/mutation_types.js5
-rw-r--r--app/assets/javascripts/search/store/mutations.js18
-rw-r--r--app/assets/javascripts/search/store/state.js2
-rw-r--r--app/assets/javascripts/serverless/components/area.vue4
-rw-r--r--app/assets/javascripts/set_status_modal/components/user_availability_status.vue26
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue63
-rw-r--r--app/assets/javascripts/set_status_modal/utils.js9
-rw-r--r--app/assets/javascripts/shared/milestones/form.js1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue100
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue12
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue9
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue30
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js5
-rw-r--r--app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql15
-rw-r--r--app/assets/javascripts/sidebar/utils.js1
-rw-r--r--app/assets/javascripts/single_file_diff.js7
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue51
-rw-r--r--app/assets/javascripts/snippets/components/show.vue4
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_view.vue13
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue22
-rw-r--r--app/assets/javascripts/snippets/components/snippet_visibility_edit.vue27
-rw-r--r--app/assets/javascripts/snippets/constants.js2
-rw-r--r--app/assets/javascripts/snippets/index.js11
-rw-r--r--app/assets/javascripts/snippets/mixins/snippets.js12
-rw-r--r--app/assets/javascripts/snippets/queries/snippet.query.graphql15
-rw-r--r--app/assets/javascripts/snippets/queries/snippet_visibility.query.graphql5
-rw-r--r--app/assets/javascripts/snippets/utils/blob.js4
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_area.vue50
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue100
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue45
-rw-r--r--app/assets/javascripts/static_site_editor/constants.js3
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/index.js4
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql7
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/typedefs.graphql9
-rw-r--r--app/assets/javascripts/static_site_editor/image_repository.js4
-rw-r--r--app/assets/javascripts/static_site_editor/index.js9
-rw-r--r--app/assets/javascripts/static_site_editor/pages/home.vue10
-rw-r--r--app/assets/javascripts/static_site_editor/services/front_matterify.js2
-rw-r--r--app/assets/javascripts/static_site_editor/services/parse_source_file.js18
-rw-r--r--app/assets/javascripts/static_site_editor/services/renderers/render_image.js89
-rw-r--r--app/assets/javascripts/terraform/components/empty_state.vue44
-rw-r--r--app/assets/javascripts/terraform/components/states_table.vue101
-rw-r--r--app/assets/javascripts/terraform/components/terraform_list.vue134
-rw-r--r--app/assets/javascripts/terraform/constants.js1
-rw-r--r--app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql17
-rw-r--r--app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql9
-rw-r--r--app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql18
-rw-r--r--app/assets/javascripts/terraform/index.js31
-rw-r--r--app/assets/javascripts/tooltips/components/tooltips.vue1
-rw-r--r--app/assets/javascripts/tooltips/index.js6
-rw-r--r--app/assets/javascripts/tracking.js1
-rw-r--r--app/assets/javascripts/users_select/index.js7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue157
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue13
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue86
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js66
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql13
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/actions_button.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/alert_details_table.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_modal.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row_header.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_mentions.vue36
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_modal.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/integrations_help_text.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/local_storage_sync.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue44
-rw-r--r--app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/members/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue99
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/members_table.vue56
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/members/utils.js29
-rw-r--r--app/assets/javascripts/vue_shared/components/modal_copy_button.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/title_area.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue25
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js23
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql20
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql16
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue220
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue211
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql13
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql17
-rw-r--r--app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/constants.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue (renamed from app/assets/javascripts/design_management/components/upload/design_dropzone.vue)101
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/utils.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue2
-rw-r--r--app/assets/javascripts/vue_shared/directives/validation.js132
-rw-r--r--app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js4
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue26
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js24
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js10
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js4
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js31
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js16
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js24
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js10
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js4
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js30
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js16
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/utils.js75
-rw-r--r--app/assets/javascripts/vuex_shared/modules/members/actions.js19
-rw-r--r--app/assets/javascripts/vuex_shared/modules/members/index.js4
-rw-r--r--app/assets/javascripts/vuex_shared/modules/members/mutation_types.js3
-rw-r--r--app/assets/javascripts/vuex_shared/modules/members/mutations.js15
-rw-r--r--app/assets/javascripts/vuex_shared/modules/members/state.js2
-rw-r--r--app/assets/javascripts/whats_new/components/app.vue80
-rw-r--r--app/assets/javascripts/whats_new/store/actions.js33
-rw-r--r--app/assets/javascripts/whats_new/store/mutation_types.js5
-rw-r--r--app/assets/javascripts/whats_new/store/mutations.js13
-rw-r--r--app/assets/javascripts/whats_new/store/state.js7
-rw-r--r--app/assets/javascripts/whats_new/utils/get_drawer_body_height.js6
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss5
-rw-r--r--app/assets/stylesheets/behaviors.scss25
-rw-r--r--app/assets/stylesheets/bootstrap_migration.scss14
-rw-r--r--app/assets/stylesheets/components/design_management/design.scss42
-rw-r--r--app/assets/stylesheets/components/severity/icons.scss (renamed from app/assets/stylesheets/pages/alert_management/severity-icons.scss)12
-rw-r--r--app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss37
-rw-r--r--app/assets/stylesheets/components/whats_new.scss13
-rw-r--r--app/assets/stylesheets/fontawesome_custom.scss76
-rw-r--r--app/assets/stylesheets/framework/broadcast_messages.scss8
-rw-r--r--app/assets/stylesheets/framework/calendar.scss14
-rw-r--r--app/assets/stylesheets/framework/common.scss1
-rw-r--r--app/assets/stylesheets/framework/diffs.scss96
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss3
-rw-r--r--app/assets/stylesheets/framework/editor-lite.scss18
-rw-r--r--app/assets/stylesheets/framework/files.scss37
-rw-r--r--app/assets/stylesheets/framework/flash.scss8
-rw-r--r--app/assets/stylesheets/framework/mixins.scss32
-rw-r--r--app/assets/stylesheets/framework/modal.scss12
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss68
-rw-r--r--app/assets/stylesheets/framework/spinner.scss14
-rw-r--r--app/assets/stylesheets/framework/typography.scss6
-rw-r--r--app/assets/stylesheets/highlight/common.scss10
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss17
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss17
-rw-r--r--app/assets/stylesheets/highlight/themes/none.scss14
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss17
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss17
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss14
-rw-r--r--app/assets/stylesheets/lazy_bundles/select2.scss654
-rw-r--r--app/assets/stylesheets/lazy_bundles/select2_overrides.scss359
-rw-r--r--app/assets/stylesheets/mailer.scss17
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss6
-rw-r--r--app/assets/stylesheets/page_bundles/alert_management_details.scss (renamed from app/assets/stylesheets/pages/alert_management/details.scss)16
-rw-r--r--app/assets/stylesheets/page_bundles/alert_management_settings.scss24
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss89
-rw-r--r--app/assets/stylesheets/page_bundles/build.scss (renamed from app/assets/stylesheets/pages/builds.scss)67
-rw-r--r--app/assets/stylesheets/page_bundles/ci_status.scss (renamed from app/assets/stylesheets/pages/status.scss)2
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss14
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/_dark.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/_monokai.scss66
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss57
-rw-r--r--app/assets/stylesheets/page_bundles/jira_connect.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss12
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline_schedules.scss (renamed from app/assets/stylesheets/pages/pipeline_schedules.scss)14
-rw-r--r--app/assets/stylesheets/page_bundles/pipelines.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/reports.scss6
-rw-r--r--app/assets/stylesheets/page_bundles/signup.scss (renamed from app/assets/stylesheets/page_bundles/experimental_separate_sign_up.scss)21
-rw-r--r--app/assets/stylesheets/page_bundles/todos.scss1
-rw-r--r--app/assets/stylesheets/pages/admin.scss8
-rw-r--r--app/assets/stylesheets/pages/commits.scss21
-rw-r--r--app/assets/stylesheets/pages/editor.scss18
-rw-r--r--app/assets/stylesheets/pages/groups.scss8
-rw-r--r--app/assets/stylesheets/pages/incident_management_list.scss27
-rw-r--r--app/assets/stylesheets/pages/issuable.scss45
-rw-r--r--app/assets/stylesheets/pages/issues.scss12
-rw-r--r--app/assets/stylesheets/pages/labels.scss147
-rw-r--r--app/assets/stylesheets/pages/members.scss7
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss17
-rw-r--r--app/assets/stylesheets/pages/note_form.scss49
-rw-r--r--app/assets/stylesheets/pages/notes.scss36
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss61
-rw-r--r--app/assets/stylesheets/pages/projects.scss181
-rw-r--r--app/assets/stylesheets/pages/search.scss3
-rw-r--r--app/assets/stylesheets/pages/tree.scss2
-rw-r--r--app/assets/stylesheets/pages/users.scss37
-rw-r--r--app/assets/stylesheets/performance_bar.scss2
-rw-r--r--app/assets/stylesheets/print.scss2
-rw-r--r--app/assets/stylesheets/themes/_dark.scss18
-rw-r--r--app/assets/stylesheets/utilities.scss7
-rw-r--r--app/controllers/admin/application_settings_controller.rb12
-rw-r--r--app/controllers/admin/dashboard_controller.rb1
-rw-r--r--app/controllers/admin/instance_statistics_controller.rb2
-rw-r--r--app/controllers/admin/integrations_controller.rb4
-rw-r--r--app/controllers/admin/users_controller.rb5
-rw-r--r--app/controllers/application_controller.rb10
-rw-r--r--app/controllers/autocomplete_controller.rb2
-rw-r--r--app/controllers/clusters/clusters_controller.rb2
-rw-r--r--app/controllers/concerns/controller_with_feature_category.rb48
-rw-r--r--app/controllers/concerns/dependency_proxy_access.rb24
-rw-r--r--app/controllers/concerns/integrations_actions.rb2
-rw-r--r--app/controllers/concerns/issuable_actions.rb3
-rw-r--r--app/controllers/concerns/lfs_request.rb24
-rw-r--r--app/controllers/concerns/notes_actions.rb6
-rw-r--r--app/controllers/concerns/routable_actions.rb2
-rw-r--r--app/controllers/concerns/send_file_upload.rb11
-rw-r--r--app/controllers/concerns/sends_blob.rb1
-rw-r--r--app/controllers/concerns/snippets_actions.rb5
-rw-r--r--app/controllers/concerns/wiki_actions.rb15
-rw-r--r--app/controllers/groups/boards_controller.rb3
-rw-r--r--app/controllers/groups/dependency_proxies_controller.rb34
-rw-r--r--app/controllers/groups/dependency_proxy_for_containers_controller.rb63
-rw-r--r--app/controllers/groups/milestones_controller.rb2
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb3
-rw-r--r--app/controllers/groups/settings/integrations_controller.rb8
-rw-r--r--app/controllers/groups_controller.rb17
-rw-r--r--app/controllers/import/base_controller.rb8
-rw-r--r--app/controllers/import/bitbucket_controller.rb4
-rw-r--r--app/controllers/import/bitbucket_server_controller.rb4
-rw-r--r--app/controllers/import/bulk_imports_controller.rb40
-rw-r--r--app/controllers/import/github_controller.rb20
-rw-r--r--app/controllers/invites_controller.rb16
-rw-r--r--app/controllers/jwks_controller.rb26
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb8
-rw-r--r--app/controllers/profiles_controller.rb2
-rw-r--r--app/controllers/projects/alert_management_controller.rb1
-rw-r--r--app/controllers/projects/alerting/notifications_controller.rb14
-rw-r--r--app/controllers/projects/autocomplete_sources_controller.rb2
-rw-r--r--app/controllers/projects/avatars_controller.rb2
-rw-r--r--app/controllers/projects/blob_controller.rb2
-rw-r--r--app/controllers/projects/boards_controller.rb3
-rw-r--r--app/controllers/projects/branches_controller.rb13
-rw-r--r--app/controllers/projects/ci/lints_controller.rb20
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb17
-rw-r--r--app/controllers/projects/imports_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb7
-rw-r--r--app/controllers/projects/jobs_controller.rb3
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb18
-rw-r--r--app/controllers/projects/merge_requests_controller.rb18
-rw-r--r--app/controllers/projects/milestones_controller.rb2
-rw-r--r--app/controllers/projects/notes_controller.rb2
-rw-r--r--app/controllers/projects/pipelines_controller.rb7
-rw-r--r--app/controllers/projects/raw_controller.rb3
-rw-r--r--app/controllers/projects/releases_controller.rb2
-rw-r--r--app/controllers/projects/repositories_controller.rb2
-rw-r--r--app/controllers/projects/runners_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.rb9
-rw-r--r--app/controllers/projects/settings/operations_controller.rb5
-rw-r--r--app/controllers/projects/settings/repository_controller.rb7
-rw-r--r--app/controllers/projects/static_site_editor_controller.rb12
-rw-r--r--app/controllers/projects/tags_controller.rb1
-rw-r--r--app/controllers/projects/templates_controller.rb8
-rw-r--r--app/controllers/projects/terraform_controller.rb16
-rw-r--r--app/controllers/projects_controller.rb32
-rw-r--r--app/controllers/registrations/welcome_controller.rb73
-rw-r--r--app/controllers/registrations_controller.rb88
-rw-r--r--app/controllers/repositories/git_http_client_controller.rb8
-rw-r--r--app/controllers/repositories/lfs_api_controller.rb5
-rw-r--r--app/controllers/repositories/lfs_storage_controller.rb20
-rw-r--r--app/controllers/search_controller.rb37
-rw-r--r--app/controllers/sessions_controller.rb8
-rw-r--r--app/controllers/whats_new_controller.rb23
-rw-r--r--app/finders/alert_management/http_integrations_finder.rb54
-rw-r--r--app/finders/ci/commit_statuses_finder.rb40
-rw-r--r--app/finders/ci/jobs_finder.rb16
-rw-r--r--app/finders/concerns/finder_with_cross_project_access.rb2
-rw-r--r--app/finders/environment_names_finder.rb18
-rw-r--r--app/finders/feature_flags_user_lists_finder.rb34
-rw-r--r--app/finders/group_projects_finder.rb1
-rw-r--r--app/finders/issuable_finder.rb13
-rw-r--r--app/finders/issues_finder.rb2
-rw-r--r--app/finders/merge_requests_finder.rb30
-rw-r--r--app/finders/packages/group_packages_finder.rb9
-rw-r--r--app/finders/packages/npm/package_finder.rb2
-rw-r--r--app/finders/personal_access_tokens_finder.rb7
-rw-r--r--app/finders/security/jobs_finder.rb71
-rw-r--r--app/finders/security/license_compliance_jobs_finder.rb18
-rw-r--r--app/finders/security/security_jobs_finder.rb19
-rw-r--r--app/finders/user_groups_counter.rb30
-rw-r--r--app/graphql/gitlab_schema.rb9
-rw-r--r--app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb6
-rw-r--r--app/graphql/mutations/alert_management/base.rb8
-rw-r--r--app/graphql/mutations/alert_management/http_integration/create.rb41
-rw-r--r--app/graphql/mutations/alert_management/http_integration/destroy.rb24
-rw-r--r--app/graphql/mutations/alert_management/http_integration/http_integration_base.rb29
-rw-r--r--app/graphql/mutations/alert_management/http_integration/reset_token.rb25
-rw-r--r--app/graphql/mutations/alert_management/http_integration/update.rb33
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/create.rb50
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb42
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb27
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/update.rb35
-rw-r--r--app/graphql/mutations/alert_management/update_alert_status.rb6
-rw-r--r--app/graphql/mutations/boards/create.rb37
-rw-r--r--app/graphql/mutations/boards/lists/update.rb2
-rw-r--r--app/graphql/mutations/commits/create.rb10
-rw-r--r--app/graphql/mutations/concerns/mutations/package_eventable.rb14
-rw-r--r--app/graphql/mutations/concerns/mutations/resolves_resource_parent.rb43
-rw-r--r--app/graphql/mutations/container_repositories/destroy.rb45
-rw-r--r--app/graphql/mutations/custom_emoji/create.rb50
-rw-r--r--app/graphql/mutations/labels/create.rb43
-rw-r--r--app/graphql/mutations/merge_requests/set_labels.rb15
-rw-r--r--app/graphql/mutations/metrics/dashboard/annotations/delete.rb3
-rw-r--r--app/graphql/mutations/notes/reposition_image_diff_note.rb65
-rw-r--r--app/graphql/mutations/notes/update/image_diff_note.rb5
-rw-r--r--app/graphql/mutations/releases/base.rb19
-rw-r--r--app/graphql/mutations/releases/create.rb68
-rw-r--r--app/graphql/mutations/snippets/destroy.rb3
-rw-r--r--app/graphql/mutations/snippets/mark_as_spam.rb3
-rw-r--r--app/graphql/mutations/snippets/update.rb3
-rw-r--r--app/graphql/mutations/terraform/state/base.rb22
-rw-r--r--app/graphql/mutations/terraform/state/delete.rb18
-rw-r--r--app/graphql/mutations/terraform/state/lock.rb29
-rw-r--r--app/graphql/mutations/terraform/state/unlock.rb18
-rw-r--r--app/graphql/mutations/todos/base.rb10
-rw-r--r--app/graphql/mutations/todos/create.rb39
-rw-r--r--app/graphql/mutations/todos/mark_all_done.rb4
-rw-r--r--app/graphql/mutations/todos/restore_many.rb8
-rw-r--r--app/graphql/queries/design_management/design_permissions.query.graphql (renamed from app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql)3
-rw-r--r--app/graphql/queries/design_management/get_design_list.query.graphql40
-rw-r--r--app/graphql/queries/repository/files.query.graphql (renamed from app/assets/javascripts/repository/queries/files.query.graphql)18
-rw-r--r--app/graphql/queries/repository/permissions.query.graphql (renamed from app/assets/javascripts/repository/queries/permissions.query.graphql)2
-rw-r--r--app/graphql/queries/snippet/project_permissions.query.graphql (renamed from app/assets/javascripts/snippets/queries/projectPermissions.query.graphql)2
-rw-r--r--app/graphql/queries/snippet/snippet.query.graphql65
-rw-r--r--app/graphql/queries/snippet/snippet_blob_content.query.graphql (renamed from app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql)4
-rw-r--r--app/graphql/queries/snippet/user_permissions.query.graphql (renamed from app/assets/javascripts/snippets/queries/userPermissions.query.graphql)2
-rw-r--r--app/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver.rb12
-rw-r--r--app/graphql/resolvers/alert_management/alert_resolver.rb2
-rw-r--r--app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb2
-rw-r--r--app/graphql/resolvers/alert_management/integrations_resolver.rb29
-rw-r--r--app/graphql/resolvers/assigned_merge_requests_resolver.rb3
-rw-r--r--app/graphql/resolvers/authored_merge_requests_resolver.rb3
-rw-r--r--app/graphql/resolvers/base_resolver.rb86
-rw-r--r--app/graphql/resolvers/board_lists_resolver.rb2
-rw-r--r--app/graphql/resolvers/boards_resolver.rb11
-rw-r--r--app/graphql/resolvers/ci/jobs_resolver.rb24
-rw-r--r--app/graphql/resolvers/ci/runner_setup_resolver.rb64
-rw-r--r--app/graphql/resolvers/commit_pipelines_resolver.rb3
-rw-r--r--app/graphql/resolvers/concerns/caching_array_resolver.rb128
-rw-r--r--app/graphql/resolvers/concerns/issue_resolver_arguments.rb2
-rw-r--r--app/graphql/resolvers/concerns/looks_ahead.rb1
-rw-r--r--app/graphql/resolvers/concerns/resolves_pipelines.rb2
-rw-r--r--app/graphql/resolvers/concerns/resolves_project.rb3
-rw-r--r--app/graphql/resolvers/concerns/resolves_snippets.rb21
-rw-r--r--app/graphql/resolvers/container_repositories_resolver.rb19
-rw-r--r--app/graphql/resolvers/design_management/design_at_version_resolver.rb9
-rw-r--r--app/graphql/resolvers/design_management/design_resolver.rb10
-rw-r--r--app/graphql/resolvers/design_management/designs_resolver.rb29
-rw-r--r--app/graphql/resolvers/design_management/version/design_at_version_resolver.rb22
-rw-r--r--app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb18
-rw-r--r--app/graphql/resolvers/design_management/version_in_collection_resolver.rb15
-rw-r--r--app/graphql/resolvers/design_management/version_resolver.rb8
-rw-r--r--app/graphql/resolvers/design_management/versions_resolver.rb20
-rw-r--r--app/graphql/resolvers/echo_resolver.rb7
-rw-r--r--app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb13
-rw-r--r--app/graphql/resolvers/error_tracking/sentry_error_collection_resolver.rb2
-rw-r--r--app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb10
-rw-r--r--app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb2
-rw-r--r--app/graphql/resolvers/group_issues_resolver.rb1
-rw-r--r--app/graphql/resolvers/group_members_resolver.rb2
-rw-r--r--app/graphql/resolvers/group_merge_requests_resolver.rb2
-rw-r--r--app/graphql/resolvers/group_milestones_resolver.rb3
-rw-r--r--app/graphql/resolvers/issues_resolver.rb2
-rw-r--r--app/graphql/resolvers/members_resolver.rb2
-rw-r--r--app/graphql/resolvers/merge_request_pipelines_resolver.rb3
-rw-r--r--app/graphql/resolvers/merge_request_resolver.rb2
-rw-r--r--app/graphql/resolvers/metadata_resolver.rb2
-rw-r--r--app/graphql/resolvers/milestones_resolver.rb2
-rw-r--r--app/graphql/resolvers/namespace_projects_resolver.rb9
-rw-r--r--app/graphql/resolvers/project_members_resolver.rb3
-rw-r--r--app/graphql/resolvers/project_merge_requests_resolver.rb1
-rw-r--r--app/graphql/resolvers/project_milestones_resolver.rb3
-rw-r--r--app/graphql/resolvers/project_pipeline_resolver.rb2
-rw-r--r--app/graphql/resolvers/project_pipelines_resolver.rb19
-rw-r--r--app/graphql/resolvers/projects/jira_imports_resolver.rb2
-rw-r--r--app/graphql/resolvers/projects/jira_projects_resolver.rb2
-rw-r--r--app/graphql/resolvers/projects/services_resolver.rb2
-rw-r--r--app/graphql/resolvers/projects/snippets_resolver.rb1
-rw-r--r--app/graphql/resolvers/releases_resolver.rb16
-rw-r--r--app/graphql/resolvers/snippets/blobs_resolver.rb2
-rw-r--r--app/graphql/resolvers/snippets_resolver.rb11
-rw-r--r--app/graphql/resolvers/todo_resolver.rb2
-rw-r--r--app/graphql/resolvers/tree_resolver.rb2
-rw-r--r--app/graphql/resolvers/user_merge_requests_resolver_base.rb (renamed from app/graphql/resolvers/user_merge_requests_resolver.rb)10
-rw-r--r--app/graphql/resolvers/user_resolver.rb2
-rw-r--r--app/graphql/resolvers/users/group_count_resolver.rb25
-rw-r--r--app/graphql/resolvers/users/snippets_resolver.rb1
-rw-r--r--app/graphql/resolvers/users_resolver.rb12
-rw-r--r--app/graphql/types/alert_management/http_integration_type.rb22
-rw-r--r--app/graphql/types/alert_management/integration_type.rb58
-rw-r--r--app/graphql/types/alert_management/integration_type_enum.rb13
-rw-r--r--app/graphql/types/alert_management/prometheus_integration_type.rb38
-rw-r--r--app/graphql/types/availability_enum.rb12
-rw-r--r--app/graphql/types/base_object.rb14
-rw-r--r--app/graphql/types/board_type.rb2
-rw-r--r--app/graphql/types/ci/detailed_status_type.rb2
-rw-r--r--app/graphql/types/ci/job_type.rb3
-rw-r--r--app/graphql/types/ci/pipeline_type.rb40
-rw-r--r--app/graphql/types/ci/runner_setup_type.rb15
-rw-r--r--app/graphql/types/commit_type.rb9
-rw-r--r--app/graphql/types/container_repository_cleanup_status_enum.rb13
-rw-r--r--app/graphql/types/container_repository_details_type.rb21
-rw-r--r--app/graphql/types/container_repository_status_enum.rb12
-rw-r--r--app/graphql/types/container_repository_tag_type.rb25
-rw-r--r--app/graphql/types/container_repository_type.rb27
-rw-r--r--app/graphql/types/countable_connection_type.rb2
-rw-r--r--app/graphql/types/custom_emoji_type.rb27
-rw-r--r--app/graphql/types/environment_type.rb5
-rw-r--r--app/graphql/types/global_id_type.rb2
-rw-r--r--app/graphql/types/grafana_integration_type.rb8
-rw-r--r--app/graphql/types/group_invitation_type.rb17
-rw-r--r--app/graphql/types/group_type.rb13
-rw-r--r--app/graphql/types/invitation_interface.rb41
-rw-r--r--app/graphql/types/issue_connection_type.rb9
-rw-r--r--app/graphql/types/issue_type.rb53
-rw-r--r--app/graphql/types/merge_request_type.rb26
-rw-r--r--app/graphql/types/mutation_type.rb16
-rw-r--r--app/graphql/types/notes/update_diff_image_position_input_type.rb8
-rw-r--r--app/graphql/types/permission_types/custom_emoji.rb11
-rw-r--r--app/graphql/types/permission_types/note.rb2
-rw-r--r--app/graphql/types/project_invitation_type.rb21
-rw-r--r--app/graphql/types/project_statistics_type.rb16
-rw-r--r--app/graphql/types/project_type.rb15
-rw-r--r--app/graphql/types/projects/namespace_project_sort_enum.rb1
-rw-r--r--app/graphql/types/projects/service_type_enum.rb2
-rw-r--r--app/graphql/types/query_type.rb23
-rw-r--r--app/graphql/types/release_asset_link_input_type.rb25
-rw-r--r--app/graphql/types/release_asset_link_type.rb6
-rw-r--r--app/graphql/types/release_asset_link_type_enum.rb2
-rw-r--r--app/graphql/types/release_assets_input_type.rb13
-rw-r--r--app/graphql/types/release_links_type.rb14
-rw-r--r--app/graphql/types/release_sort_enum.rb18
-rw-r--r--app/graphql/types/root_storage_statistics_type.rb1
-rw-r--r--app/graphql/types/security/report_type_enum.rb15
-rw-r--r--app/graphql/types/snippet_type.rb2
-rw-r--r--app/graphql/types/terraform/state_type.rb7
-rw-r--r--app/graphql/types/terraform/state_version_type.rb35
-rw-r--r--app/graphql/types/user_status_type.rb2
-rw-r--r--app/graphql/types/user_type.rb8
-rw-r--r--app/helpers/application_settings_helper.rb20
-rw-r--r--app/helpers/auth_helper.rb7
-rw-r--r--app/helpers/blob_helper.rb25
-rw-r--r--app/helpers/branches_helper.rb6
-rw-r--r--app/helpers/breadcrumbs_helper.rb49
-rw-r--r--app/helpers/ci/jobs_helper.rb3
-rw-r--r--app/helpers/ci/pipeline_editor_helper.rb12
-rw-r--r--app/helpers/ci/runners_helper.rb8
-rw-r--r--app/helpers/commits_helper.rb2
-rw-r--r--app/helpers/defer_script_tag_helper.rb4
-rw-r--r--app/helpers/diff_helper.rb30
-rw-r--r--app/helpers/dropdowns_helper.rb5
-rw-r--r--app/helpers/emails_helper.rb18
-rw-r--r--app/helpers/gitlab_routing_helper.rb16
-rw-r--r--app/helpers/gitpod_helper.rb5
-rw-r--r--app/helpers/groups_helper.rb17
-rw-r--r--app/helpers/icons_helper.rb65
-rw-r--r--app/helpers/invite_members_helper.rb4
-rw-r--r--app/helpers/issuables_helper.rb26
-rw-r--r--app/helpers/issues_helper.rb23
-rw-r--r--app/helpers/notes_helper.rb1
-rw-r--r--app/helpers/notifications_helper.rb1
-rw-r--r--app/helpers/operations_helper.rb4
-rw-r--r--app/helpers/page_layout_helper.rb51
-rw-r--r--app/helpers/preferences_helper.rb4
-rw-r--r--app/helpers/profiles_helper.rb14
-rw-r--r--app/helpers/projects/terraform_helper.rb10
-rw-r--r--app/helpers/projects_helper.rb45
-rw-r--r--app/helpers/recaptcha_experiment_helper.rb9
-rw-r--r--app/helpers/recaptcha_helper.rb7
-rw-r--r--app/helpers/releases_helper.rb9
-rw-r--r--app/helpers/search_helper.rb102
-rw-r--r--app/helpers/services_helper.rb12
-rw-r--r--app/helpers/sorting_helper.rb29
-rw-r--r--app/helpers/sourcegraph_helper.rb26
-rw-r--r--app/helpers/stat_anchors_helper.rb24
-rw-r--r--app/helpers/suggest_pipeline_helper.rb2
-rw-r--r--app/helpers/time_helper.rb4
-rw-r--r--app/helpers/tree_helper.rb20
-rw-r--r--app/helpers/user_callouts_helper.rb5
-rw-r--r--app/helpers/users_helper.rb63
-rw-r--r--app/helpers/visibility_level_helper.rb4
-rw-r--r--app/helpers/whats_new_helper.rb6
-rw-r--r--app/mailers/devise_mailer.rb4
-rw-r--r--app/mailers/emails/members.rb51
-rw-r--r--app/mailers/emails/profile.rb26
-rw-r--r--app/mailers/emails/projects.rb9
-rw-r--r--app/mailers/emails/service_desk.rb6
-rw-r--r--app/mailers/previews/devise_mailer_preview.rb4
-rw-r--r--app/models/alert_management/alert.rb2
-rw-r--r--app/models/alert_management/http_integration.rb44
-rw-r--r--app/models/analytics/devops_adoption.rb6
-rw-r--r--app/models/analytics/devops_adoption/segment.rb24
-rw-r--r--app/models/analytics/devops_adoption/segment_selection.rb36
-rw-r--r--app/models/analytics/instance_statistics/measurement.rb50
-rw-r--r--app/models/application_record.rb2
-rw-r--r--app/models/application_setting.rb21
-rw-r--r--app/models/application_setting_implementation.rb53
-rw-r--r--app/models/audit_event.rb11
-rw-r--r--app/models/authentication_event.rb8
-rw-r--r--app/models/broadcast_message.rb1
-rw-r--r--app/models/bulk_import.rb18
-rw-r--r--app/models/bulk_imports/configuration.rb2
-rw-r--r--app/models/bulk_imports/entity.rb46
-rw-r--r--app/models/bulk_imports/tracker.rb18
-rw-r--r--app/models/ci/bridge.rb6
-rw-r--r--app/models/ci/build.rb24
-rw-r--r--app/models/ci/build_trace_chunk.rb55
-rw-r--r--app/models/ci/build_trace_chunks/legacy_fog.rb77
-rw-r--r--app/models/ci/daily_build_group_report_result.rb28
-rw-r--r--app/models/ci/job_artifact.rb1
-rw-r--r--app/models/ci/pipeline.rb24
-rw-r--r--app/models/ci/processable.rb20
-rw-r--r--app/models/ci/test_case.rb35
-rw-r--r--app/models/ci/test_case_failure.rb29
-rw-r--r--app/models/clusters/agent_token.rb2
-rw-r--r--app/models/clusters/applications/cert_manager.rb4
-rw-r--r--app/models/clusters/applications/crossplane.rb2
-rw-r--r--app/models/clusters/applications/elastic_stack.rb8
-rw-r--r--app/models/clusters/applications/fluentd.rb2
-rw-r--r--app/models/clusters/applications/helm.rb12
-rw-r--r--app/models/clusters/applications/ingress.rb2
-rw-r--r--app/models/clusters/applications/jupyter.rb2
-rw-r--r--app/models/clusters/applications/knative.rb4
-rw-r--r--app/models/clusters/applications/prometheus.rb6
-rw-r--r--app/models/clusters/applications/runner.rb4
-rw-r--r--app/models/clusters/cluster.rb3
-rw-r--r--app/models/clusters/concerns/application_core.rb11
-rw-r--r--app/models/clusters/concerns/application_data.rb2
-rw-r--r--app/models/clusters/providers/aws.rb3
-rw-r--r--app/models/commit.rb16
-rw-r--r--app/models/commit_status.rb9
-rw-r--r--app/models/concerns/atomic_internal_id.rb118
-rw-r--r--app/models/concerns/enums/ci/pipeline.rb4
-rw-r--r--app/models/concerns/enums/internal_id.rb3
-rw-r--r--app/models/concerns/featurable.rb3
-rw-r--r--app/models/concerns/from_union.rb21
-rw-r--r--app/models/concerns/has_repository.rb5
-rw-r--r--app/models/concerns/issue_available_features.rb17
-rw-r--r--app/models/concerns/mentionable/reference_regexes.rb2
-rw-r--r--app/models/concerns/project_features_compatibility.rb2
-rw-r--r--app/models/concerns/project_services_loggable.rb4
-rw-r--r--app/models/concerns/protected_ref_access.rb8
-rw-r--r--app/models/concerns/storage/legacy_namespace.rb2
-rw-r--r--app/models/concerns/todoable.rb10
-rw-r--r--app/models/concerns/triggerable_hooks.rb4
-rw-r--r--app/models/container_expiration_policy.rb16
-rw-r--r--app/models/container_repository.rb6
-rw-r--r--app/models/custom_emoji.rb11
-rw-r--r--app/models/dependency_proxy.rb6
-rw-r--r--app/models/dependency_proxy/blob.rb21
-rw-r--r--app/models/dependency_proxy/group_setting.rb9
-rw-r--r--app/models/dependency_proxy/registry.rb30
-rw-r--r--app/models/deploy_key.rb13
-rw-r--r--app/models/deploy_keys_project.rb1
-rw-r--r--app/models/deploy_token.rb4
-rw-r--r--app/models/deployment.rb6
-rw-r--r--app/models/deployment_merge_request.rb5
-rw-r--r--app/models/design_management/design.rb11
-rw-r--r--app/models/design_management/version.rb5
-rw-r--r--app/models/diff_viewer/image.rb8
-rw-r--r--app/models/discussion.rb1
-rw-r--r--app/models/environment.rb6
-rw-r--r--app/models/experiment.rb19
-rw-r--r--app/models/experiment_user.rb6
-rw-r--r--app/models/group.rb26
-rw-r--r--app/models/hooks/project_hook.rb4
-rw-r--r--app/models/instance_metadata.rb10
-rw-r--r--app/models/internal_id.rb41
-rw-r--r--app/models/issue.rb3
-rw-r--r--app/models/issue_link.rb9
-rw-r--r--app/models/issues/csv_import.rb8
-rw-r--r--app/models/iteration.rb4
-rw-r--r--app/models/member.rb6
-rw-r--r--app/models/members/group_member.rb1
-rw-r--r--app/models/merge_request.rb14
-rw-r--r--app/models/merge_request/cleanup_schedule.rb14
-rw-r--r--app/models/merge_request_diff_file.rb4
-rw-r--r--app/models/milestone.rb4
-rw-r--r--app/models/namespace.rb18
-rw-r--r--app/models/namespace/root_storage_statistics.rb4
-rw-r--r--app/models/namespace_setting.rb8
-rw-r--r--app/models/note.rb4
-rw-r--r--app/models/operations/feature_flag.rb19
-rw-r--r--app/models/operations/feature_flags/user_list.rb7
-rw-r--r--app/models/packages/build_info.rb2
-rw-r--r--app/models/packages/event.rb17
-rw-r--r--app/models/packages/package.rb23
-rw-r--r--app/models/packages/package_file.rb2
-rw-r--r--app/models/packages/package_file_build_info.rb6
-rw-r--r--app/models/pages/lookup_path.rb36
-rw-r--r--app/models/pages_deployment.rb12
-rw-r--r--app/models/pages_domain.rb2
-rw-r--r--app/models/personal_access_token.rb1
-rw-r--r--app/models/project.rb54
-rw-r--r--app/models/project_repository_storage_move.rb26
-rw-r--r--app/models/project_services/alerts_service.rb10
-rw-r--r--app/models/project_services/jira_service.rb2
-rw-r--r--app/models/project_statistics.rb14
-rw-r--r--app/models/protected_branch/push_access_level.rb25
-rw-r--r--app/models/release.rb9
-rw-r--r--app/models/releases/link.rb10
-rw-r--r--app/models/releases/source.rb7
-rw-r--r--app/models/resource_timebox_event.rb4
-rw-r--r--app/models/route.rb1
-rw-r--r--app/models/service.rb52
-rw-r--r--app/models/snippet.rb6
-rw-r--r--app/models/terraform/state.rb25
-rw-r--r--app/models/terraform/state_version.rb1
-rw-r--r--app/models/user.rb49
-rw-r--r--app/models/user_callout.rb3
-rw-r--r--app/models/user_preference.rb4
-rw-r--r--app/models/user_status.rb2
-rw-r--r--app/models/vulnerability.rb21
-rw-r--r--app/policies/alert_management/http_integration_policy.rb7
-rw-r--r--app/policies/base_policy.rb2
-rw-r--r--app/policies/concerns/policy_actor.rb4
-rw-r--r--app/policies/container_registry/tag_policy.rb6
-rw-r--r--app/policies/custom_emoji_policy.rb5
-rw-r--r--app/policies/group_member_policy.rb5
-rw-r--r--app/policies/group_policy.rb20
-rw-r--r--app/policies/instance_metadata_policy.rb5
-rw-r--r--app/policies/issue_policy.rb4
-rw-r--r--app/policies/merge_request_policy.rb4
-rw-r--r--app/policies/note_policy.rb11
-rw-r--r--app/policies/project_policy.rb3
-rw-r--r--app/policies/service_policy.rb5
-rw-r--r--app/policies/terraform/state_version_policy.rb9
-rw-r--r--app/policies/user_policy.rb2
-rw-r--r--app/presenters/environment_presenter.rb4
-rw-r--r--app/presenters/invitation_presenter.rb5
-rw-r--r--app/presenters/issue_presenter.rb4
-rw-r--r--app/presenters/packages/detail/package_presenter.rb13
-rw-r--r--app/presenters/project_presenter.rb6
-rw-r--r--app/presenters/release_presenter.rb34
-rw-r--r--app/serializers/base_discussion_entity.rb55
-rw-r--r--app/serializers/diff_file_base_entity.rb2
-rw-r--r--app/serializers/diff_file_entity.rb16
-rw-r--r--app/serializers/diffs_entity.rb6
-rw-r--r--app/serializers/discussion_entity.rb48
-rw-r--r--app/serializers/environment_entity.rb5
-rw-r--r--app/serializers/merge_request_widget_entity.rb17
-rw-r--r--app/serializers/move_to_project_entity.rb1
-rw-r--r--app/serializers/note_entity.rb18
-rw-r--r--app/serializers/paginated_diff_entity.rb15
-rw-r--r--app/serializers/test_case_entity.rb1
-rw-r--r--app/serializers/test_suite_comparer_entity.rb27
-rw-r--r--app/services/admin/propagate_integration_service.rb47
-rw-r--r--app/services/alert_management/http_integrations/create_service.rb60
-rw-r--r--app/services/alert_management/http_integrations/destroy_service.rb44
-rw-r--r--app/services/alert_management/http_integrations/update_service.rb48
-rw-r--r--app/services/alert_management/process_prometheus_alert_service.rb15
-rw-r--r--app/services/alert_management/sync_alert_service_data_service.rb56
-rw-r--r--app/services/audit_event_service.rb8
-rw-r--r--app/services/bulk_create_integration_service.rb10
-rw-r--r--app/services/bulk_import_service.rb63
-rw-r--r--app/services/bulk_update_integration_service.rb4
-rw-r--r--app/services/ci/append_build_trace_service.rb65
-rw-r--r--app/services/ci/build_report_result_service.rb2
-rw-r--r--app/services/ci/compare_reports_base_service.rb8
-rw-r--r--app/services/ci/compare_test_reports_service.rb14
-rw-r--r--app/services/ci/create_pipeline_service.rb1
-rw-r--r--app/services/ci/daily_build_group_report_result_service.rb3
-rw-r--r--app/services/ci/destroy_expired_job_artifacts_service.rb73
-rw-r--r--app/services/ci/list_config_variables_service.rb5
-rw-r--r--app/services/ci/parse_dotenv_artifact_service.rb2
-rw-r--r--app/services/ci/test_cases_service.rb44
-rw-r--r--app/services/ci/update_build_state_service.rb10
-rw-r--r--app/services/clusters/aws/authorize_role_service.rb7
-rw-r--r--app/services/clusters/aws/fetch_credentials_service.rb7
-rw-r--r--app/services/clusters/kubernetes.rb2
-rw-r--r--app/services/clusters/kubernetes/create_or_update_service_account_service.rb12
-rw-r--r--app/services/concerns/admin/propagate_service.rb13
-rw-r--r--app/services/concerns/integrations/project_test_data.rb7
-rw-r--r--app/services/concerns/users/participable_service.rb3
-rw-r--r--app/services/container_expiration_policies/cleanup_service.rb53
-rw-r--r--app/services/container_expiration_policy_service.rb10
-rw-r--r--app/services/dependency_proxy/base_service.rb17
-rw-r--r--app/services/dependency_proxy/download_blob_service.rb48
-rw-r--r--app/services/dependency_proxy/find_or_create_blob_service.rb45
-rw-r--r--app/services/dependency_proxy/pull_manifest_service.rb29
-rw-r--r--app/services/dependency_proxy/request_token_service.rb29
-rw-r--r--app/services/deploy_keys/collect_keys_service.rb27
-rw-r--r--app/services/design_management/copy_design_collection/copy_service.rb30
-rw-r--r--app/services/discussions/capture_diff_note_position_service.rb3
-rw-r--r--app/services/feature_flags/update_service.rb4
-rw-r--r--app/services/git/branch_hooks_service.rb1
-rw-r--r--app/services/groups/create_service.rb2
-rw-r--r--app/services/import/github_service.rb23
-rw-r--r--app/services/integrations/test/project_service.rb2
-rw-r--r--app/services/issuable/common_system_notes_service.rb6
-rw-r--r--app/services/issuable/import_csv/base_service.rb84
-rw-r--r--app/services/issues/import_csv_service.rb61
-rw-r--r--app/services/issues/reopen_service.rb8
-rw-r--r--app/services/jira_connect/sync_service.rb4
-rw-r--r--app/services/jira_connect_subscriptions/create_service.rb17
-rw-r--r--app/services/jira_import/users_importer.rb8
-rw-r--r--app/services/jira_import/users_mapper_service.rb54
-rw-r--r--app/services/labels/promote_service.rb62
-rw-r--r--app/services/members/invite_service.rb103
-rw-r--r--app/services/merge_requests/cleanup_refs_service.rb16
-rw-r--r--app/services/merge_requests/refresh_service.rb6
-rw-r--r--app/services/merge_requests/reopen_service.rb2
-rw-r--r--app/services/notes/create_service.rb6
-rw-r--r--app/services/notes/destroy_service.rb7
-rw-r--r--app/services/notes/update_service.rb6
-rw-r--r--app/services/notification_service.rb12
-rw-r--r--app/services/packages/composer/create_package_service.rb4
-rw-r--r--app/services/packages/composer/version_parser_service.rb2
-rw-r--r--app/services/packages/create_event_service.rb28
-rw-r--r--app/services/packages/create_package_file_service.rb9
-rw-r--r--app/services/packages/debian/extract_deb_metadata_service.rb41
-rw-r--r--app/services/packages/debian/parse_debian822_service.rb66
-rw-r--r--app/services/packages/generic/create_package_file_service.rb3
-rw-r--r--app/services/packages/generic/find_or_create_package_service.rb2
-rw-r--r--app/services/packages/maven/create_package_service.rb7
-rw-r--r--app/services/packages/maven/find_or_create_package_service.rb5
-rw-r--r--app/services/packages/npm/create_package_service.rb5
-rw-r--r--app/services/pages/destroy_deployments_service.rb16
-rw-r--r--app/services/personal_access_tokens/create_service.rb27
-rw-r--r--app/services/personal_access_tokens/revoke_service.rb11
-rw-r--r--app/services/post_receive_service.rb20
-rw-r--r--app/services/projects/alerting/notify_service.rb27
-rw-r--r--app/services/projects/cleanup_service.rb22
-rw-r--r--app/services/projects/container_repository/delete_tags_service.rb6
-rw-r--r--app/services/projects/container_repository/gitlab/delete_tags_service.rb11
-rw-r--r--app/services/projects/hashed_storage/base_repository_service.rb11
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_service.rb2
-rw-r--r--app/services/projects/prometheus/alerts/notify_service.rb23
-rw-r--r--app/services/projects/update_pages_service.rb34
-rw-r--r--app/services/projects/update_repository_storage_service.rb16
-rw-r--r--app/services/releases/base_service.rb4
-rw-r--r--app/services/releases/create_service.rb2
-rw-r--r--app/services/releases/update_service.rb17
-rw-r--r--app/services/resource_access_tokens/create_service.rb19
-rw-r--r--app/services/search/global_service.rb1
-rw-r--r--app/services/search/group_service.rb1
-rw-r--r--app/services/search/project_service.rb1
-rw-r--r--app/services/search_service.rb8
-rw-r--r--app/services/snippets/create_service.rb2
-rw-r--r--app/services/snippets/update_service.rb2
-rw-r--r--app/services/system_note_service.rb8
-rw-r--r--app/services/system_notes/merge_requests_service.rb10
-rw-r--r--app/services/test_hooks/project_service.rb2
-rw-r--r--app/services/users/approve_service.rb8
-rw-r--r--app/services/users/set_status_service.rb5
-rw-r--r--app/services/web_hook_service.rb1
-rw-r--r--app/uploaders/dependency_proxy/file_uploader.rb23
-rw-r--r--app/uploaders/gitlab_uploader.rb8
-rw-r--r--app/validators/rsa_key_validator.rb27
-rw-r--r--app/views/admin/application_settings/_eks.html.haml2
-rw-r--r--app/views/admin/application_settings/_gitpod.html.haml4
-rw-r--r--app/views/admin/application_settings/_outbound.html.haml4
-rw-r--r--app/views/admin/application_settings/_package_registry.html.haml2
-rw-r--r--app/views/admin/application_settings/_plantuml.html.haml2
-rw-r--r--app/views/admin/application_settings/_signin.html.haml4
-rw-r--r--app/views/admin/application_settings/_signup.html.haml54
-rw-r--r--app/views/admin/application_settings/_snowplow.html.haml2
-rw-r--r--app/views/admin/application_settings/_third_party_offers.html.haml2
-rw-r--r--app/views/admin/application_settings/ci/_header.html.haml2
-rw-r--r--app/views/admin/application_settings/ci_cd.html.haml4
-rw-r--r--app/views/admin/application_settings/general.html.haml18
-rw-r--r--app/views/admin/application_settings/network.html.haml16
-rw-r--r--app/views/admin/dashboard/index.html.haml24
-rw-r--r--app/views/admin/dev_ops_report/_report.html.haml30
-rw-r--r--app/views/admin/dev_ops_report/show.html.haml32
-rw-r--r--app/views/admin/groups/show.html.haml10
-rw-r--r--app/views/admin/health_check/show.html.haml2
-rw-r--r--app/views/admin/identities/_identity.html.haml2
-rw-r--r--app/views/admin/jobs/index.html.haml6
-rw-r--r--app/views/admin/projects/show.html.haml19
-rw-r--r--app/views/admin/runners/index.html.haml4
-rw-r--r--app/views/admin/serverless/domains/_form.html.haml2
-rw-r--r--app/views/admin/system_info/show.html.haml4
-rw-r--r--app/views/admin/users/_block_user.html.haml7
-rw-r--r--app/views/admin/users/_modals.html.haml5
-rw-r--r--app/views/admin/users/_user.html.haml12
-rw-r--r--app/views/admin/users/_user_block_effects.html.haml11
-rw-r--r--app/views/admin/users/index.html.haml9
-rw-r--r--app/views/ci/runner/_how_to_setup_runner.html.haml4
-rw-r--r--app/views/ci/runner/_how_to_setup_runner_automatically.html.haml2
-rw-r--r--app/views/ci/variables/_index.html.haml48
-rw-r--r--app/views/clusters/clusters/_details.html.haml2
-rw-r--r--app/views/clusters/clusters/_empty_state.html.haml2
-rw-r--r--app/views/clusters/clusters/_provider_details_form.html.haml2
-rw-r--r--app/views/clusters/clusters/aws/_new.html.haml2
-rw-r--r--app/views/clusters/clusters/gcp/_form.html.haml4
-rw-r--r--app/views/clusters/clusters/user/_form.html.haml2
-rw-r--r--app/views/dashboard/_groups_head.html.haml2
-rw-r--r--app/views/dashboard/_projects_head.html.haml2
-rw-r--r--app/views/dashboard/_snippets_head.html.haml2
-rw-r--r--app/views/dashboard/groups/index.html.haml3
-rw-r--r--app/views/dashboard/todos/_todo.html.haml6
-rw-r--r--app/views/dashboard/todos/index.html.haml6
-rw-r--r--app/views/devise/confirmations/new.html.haml2
-rw-r--r--app/views/devise/mailer/user_admin_approval.html.haml8
-rw-r--r--app/views/devise/mailer/user_admin_approval.text.erb7
-rw-r--r--app/views/devise/passwords/edit.html.haml2
-rw-r--r--app/views/devise/registrations/new.html.haml25
-rw-r--r--app/views/devise/sessions/_new_base.html.haml2
-rw-r--r--app/views/devise/sessions/new.html.haml17
-rw-r--r--app/views/devise/sessions/two_factor.html.haml2
-rw-r--r--app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml39
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml2
-rw-r--r--app/views/devise/shared/_signin_box.html.haml5
-rw-r--r--app/views/devise/shared/_signup_box.html.haml68
-rw-r--r--app/views/devise/shared/_signup_omniauth_providers.haml (renamed from app/views/devise/shared/_experimental_separate_sign_up_flow_omniauth_box.haml)2
-rw-r--r--app/views/devise/shared/_tabs_ldap.html.haml8
-rw-r--r--app/views/devise/unlocks/new.html.haml2
-rw-r--r--app/views/discussions/_discussion.html.haml8
-rw-r--r--app/views/doorkeeper/applications/_delete_form.html.haml2
-rw-r--r--app/views/doorkeeper/applications/_form.html.haml2
-rw-r--r--app/views/doorkeeper/applications/index.html.haml2
-rw-r--r--app/views/doorkeeper/applications/show.html.haml6
-rw-r--r--app/views/doorkeeper/authorizations/new.html.haml4
-rw-r--r--app/views/errors/access_denied.html.haml2
-rw-r--r--app/views/errors/not_found.html.haml2
-rw-r--r--app/views/errors/omniauth_error.html.haml4
-rw-r--r--app/views/events/event/_note.html.haml2
-rw-r--r--app/views/explore/projects/_projects.html.haml3
-rw-r--r--app/views/explore/projects/index.html.haml1
-rw-r--r--app/views/explore/projects/page_out_of_bounds.html.haml2
-rw-r--r--app/views/explore/projects/trending.html.haml2
-rw-r--r--app/views/groups/_home_panel.html.haml39
-rw-r--r--app/views/groups/_invite_members_modal.html.haml5
-rw-r--r--app/views/groups/_invite_members_side_nav_link.html.haml2
-rw-r--r--app/views/groups/dependency_proxies/_url.html.haml12
-rw-r--r--app/views/groups/dependency_proxies/show.html.haml28
-rw-r--r--app/views/groups/group_members/index.html.haml29
-rw-r--r--app/views/groups/issues.html.haml3
-rw-r--r--app/views/groups/labels/index.html.haml2
-rw-r--r--app/views/groups/milestones/_form.html.haml8
-rw-r--r--app/views/groups/milestones/index.html.haml2
-rw-r--r--app/views/groups/runners/_group_runners.html.haml4
-rw-r--r--app/views/groups/settings/repository/_initial_branch_name.html.haml22
-rw-r--r--app/views/groups/settings/repository/show.html.haml1
-rw-r--r--app/views/groups/show.html.haml4
-rw-r--r--app/views/groups/sidebar/_packages.html.haml6
-rw-r--r--app/views/ide/_show.html.haml1
-rw-r--r--app/views/import/_project_status.html.haml11
-rw-r--r--app/views/import/google_code/status.html.haml2
-rw-r--r--app/views/import/shared/_new_project_form.html.haml4
-rw-r--r--app/views/invites/show.html.haml2
-rw-r--r--app/views/jira_connect/subscriptions/index.html.haml9
-rw-r--r--app/views/layouts/_flash.html.haml2
-rw-r--r--app/views/layouts/_google_tag_manager_body.html.haml4
-rw-r--r--app/views/layouts/_google_tag_manager_head.html.haml8
-rw-r--r--app/views/layouts/_head.html.haml13
-rw-r--r--app/views/layouts/_mailer.html.haml8
-rw-r--r--app/views/layouts/_page.html.haml5
-rw-r--r--app/views/layouts/_search.html.haml3
-rw-r--r--app/views/layouts/_startup_js.html.haml2
-rw-r--r--app/views/layouts/devise_experimental_onboarding_issues.html.haml2
-rw-r--r--app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml20
-rw-r--r--app/views/layouts/experiment_mailer.html.haml48
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml9
-rw-r--r--app/views/layouts/header/_default.html.haml3
-rw-r--r--app/views/layouts/header/_registration_enabled_callout.html.haml15
-rw-r--r--app/views/layouts/mailer.html.haml9
-rw-r--r--app/views/layouts/nav/_breadcrumbs.html.haml4
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml10
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml21
-rw-r--r--app/views/layouts/project.html.haml1
-rw-r--r--app/views/layouts/unknown_user_mailer.html.haml8
-rw-r--r--app/views/layouts/unknown_user_mailer.text.erb9
-rw-r--r--app/views/layouts/welcome.html.haml8
-rw-r--r--app/views/notify/_issuable_csv_export.html.haml2
-rw-r--r--app/views/notify/_note_email.html.haml2
-rw-r--r--app/views/notify/instance_access_request_email.html.haml10
-rw-r--r--app/views/notify/instance_access_request_email.text.erb8
-rw-r--r--app/views/notify/member_invited_email.html.haml20
-rw-r--r--app/views/notify/member_invited_email.text.erb11
-rw-r--r--app/views/notify/member_invited_email_experiment.html.haml12
-rw-r--r--app/views/notify/member_invited_email_experiment.text.erb10
-rw-r--r--app/views/notify/prometheus_alert_fired_email.html.haml6
-rw-r--r--app/views/notify/prometheus_alert_fired_email.text.erb5
-rw-r--r--app/views/profiles/_event_table.html.haml2
-rw-r--r--app/views/profiles/preferences/_gitpod.html.haml9
-rw-r--r--app/views/profiles/preferences/_integrations.html.haml18
-rw-r--r--app/views/profiles/preferences/_sourcegraph.html.haml10
-rw-r--r--app/views/profiles/preferences/show.html.haml255
-rw-r--r--app/views/profiles/show.html.haml10
-rw-r--r--app/views/projects/_find_file_link.html.haml2
-rw-r--r--app/views/projects/_home_panel.html.haml16
-rw-r--r--app/views/projects/_invite_members_modal.html.haml7
-rw-r--r--app/views/projects/_invite_members_side_nav_link.html.haml3
-rw-r--r--app/views/projects/_merge_request_merge_options_settings.html.haml1
-rw-r--r--app/views/projects/_remove.html.haml3
-rw-r--r--app/views/projects/_remove_fork.html.haml10
-rw-r--r--app/views/projects/_service_desk_settings.html.haml2
-rw-r--r--app/views/projects/_stat_anchor_list.html.haml2
-rw-r--r--app/views/projects/_transfer.html.haml16
-rw-r--r--app/views/projects/alert_management/details.html.haml1
-rw-r--r--app/views/projects/artifacts/_artifact.html.haml4
-rw-r--r--app/views/projects/artifacts/_tree_file.html.haml2
-rw-r--r--app/views/projects/artifacts/browse.html.haml2
-rw-r--r--app/views/projects/blob/_breadcrumb.html.haml8
-rw-r--r--app/views/projects/blob/_header.html.haml4
-rw-r--r--app/views/projects/blob/_header_content.html.haml2
-rw-r--r--app/views/projects/blob/_pipeline_tour_success.html.haml2
-rw-r--r--app/views/projects/blob/_upload.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_route_map.html.haml4
-rw-r--r--app/views/projects/branches/_branch.html.haml4
-rw-r--r--app/views/projects/branches/_delete_protected_modal.html.haml2
-rw-r--r--app/views/projects/branches/index.html.haml4
-rw-r--r--app/views/projects/branches/new.html.haml6
-rw-r--r--app/views/projects/buttons/_clone.html.haml2
-rw-r--r--app/views/projects/buttons/_download.html.haml2
-rw-r--r--app/views/projects/buttons/_download_links.html.haml2
-rw-r--r--app/views/projects/buttons/_xcode_link.html.haml2
-rw-r--r--app/views/projects/ci/lints/_create.html.haml51
-rw-r--r--app/views/projects/ci/lints/_lint_warnings.html.haml10
-rw-r--r--app/views/projects/ci/lints/show.html.haml30
-rw-r--r--app/views/projects/ci/pipeline_editor/show.html.haml6
-rw-r--r--app/views/projects/cleanup/_show.html.haml2
-rw-r--r--app/views/projects/commit/_change.html.haml4
-rw-r--r--app/views/projects/commit/diff_files.html.haml4
-rw-r--r--app/views/projects/commits/_commit.html.haml6
-rw-r--r--app/views/projects/commits/show.html.haml3
-rw-r--r--app/views/projects/confluences/show.html.haml2
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml2
-rw-r--r--app/views/projects/default_branch/_show.html.haml2
-rw-r--r--app/views/projects/deploy_keys/edit.html.haml2
-rw-r--r--app/views/projects/diffs/_diffs.html.haml2
-rw-r--r--app/views/projects/diffs/_file.html.haml7
-rw-r--r--app/views/projects/diffs/_file_header.html.haml17
-rw-r--r--app/views/projects/diffs/_line.html.haml29
-rw-r--r--app/views/projects/diffs/_stats.html.haml2
-rw-r--r--app/views/projects/edit.html.haml28
-rw-r--r--app/views/projects/environments/_external_url.html.haml2
-rw-r--r--app/views/projects/environments/_form.html.haml4
-rw-r--r--app/views/projects/environments/_metrics_button.html.haml2
-rw-r--r--app/views/projects/environments/_pin_button.html.haml2
-rw-r--r--app/views/projects/environments/_terminal_button.html.haml2
-rw-r--r--app/views/projects/environments/empty_metrics.html.haml2
-rw-r--r--app/views/projects/environments/show.html.haml11
-rw-r--r--app/views/projects/environments/terminal.html.haml2
-rw-r--r--app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml5
-rw-r--r--app/views/projects/hooks/index.html.haml2
-rw-r--r--app/views/projects/imports/new.html.haml2
-rw-r--r--app/views/projects/incidents/show.html.haml7
-rw-r--r--app/views/projects/issuable/_show.html.haml10
-rw-r--r--app/views/projects/issues/_design_management.html.haml2
-rw-r--r--app/views/projects/issues/_discussion.html.haml4
-rw-r--r--app/views/projects/issues/_issue.html.haml5
-rw-r--r--app/views/projects/issues/_issues.html.haml3
-rw-r--r--app/views/projects/issues/_nav_btns.html.haml8
-rw-r--r--app/views/projects/issues/_new_branch.html.haml4
-rw-r--r--app/views/projects/issues/_service_desk_empty_state.html.haml2
-rw-r--r--app/views/projects/issues/_service_desk_info_content.html.haml2
-rw-r--r--app/views/projects/issues/export_csv/_modal.html.haml22
-rw-r--r--app/views/projects/issues/import_csv/_modal.html.haml2
-rw-r--r--app/views/projects/issues/show.html.haml101
-rw-r--r--app/views/projects/jobs/index.html.haml1
-rw-r--r--app/views/projects/jobs/show.html.haml2
-rw-r--r--app/views/projects/labels/index.html.haml4
-rw-r--r--app/views/projects/logs/empty_logs.html.haml2
-rw-r--r--app/views/projects/mattermosts/_no_teams.html.haml4
-rw-r--r--app/views/projects/mattermosts/_team_selection.html.haml8
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml8
-rw-r--r--app/views/projects/merge_requests/_nav_btns.html.haml9
-rw-r--r--app/views/projects/merge_requests/conflicts/_submit_form.html.haml2
-rw-r--r--app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml5
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml2
-rw-r--r--app/views/projects/merge_requests/creations/_new_compare.html.haml3
-rw-r--r--app/views/projects/merge_requests/creations/new.html.haml1
-rw-r--r--app/views/projects/merge_requests/show.html.haml1
-rw-r--r--app/views/projects/milestones/_form.html.haml4
-rw-r--r--app/views/projects/milestones/index.html.haml2
-rw-r--r--app/views/projects/mirrors/_authentication_method.html.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml6
-rw-r--r--app/views/projects/mirrors/_mirror_repos_form.html.haml2
-rw-r--r--app/views/projects/mirrors/_ssh_host_keys.html.haml2
-rw-r--r--app/views/projects/new.html.haml2
-rw-r--r--app/views/projects/no_repo.html.haml9
-rw-r--r--app/views/projects/pages/_ssl_limitations_warning.html.haml2
-rw-r--r--app/views/projects/pages_domains/_dns.html.haml2
-rw-r--r--app/views/projects/pages_domains/_lets_encrypt_callout.html.haml2
-rw-r--r--app/views/projects/pages_domains/show.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/_form.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/edit.html.haml1
-rw-r--r--app/views/projects/pipeline_schedules/index.html.haml4
-rw-r--r--app/views/projects/pipeline_schedules/new.html.haml1
-rw-r--r--app/views/projects/pipelines/_info.html.haml9
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml4
-rw-r--r--app/views/projects/pipelines/index.html.haml1
-rw-r--r--app/views/projects/pipelines/new.html.haml4
-rw-r--r--app/views/projects/pipelines/show.html.haml3
-rw-r--r--app/views/projects/project_members/_team.html.haml10
-rw-r--r--app/views/projects/project_members/index.html.haml5
-rw-r--r--app/views/projects/runners/_runner.html.haml4
-rw-r--r--app/views/projects/runners/_specific_runners.html.haml7
-rw-r--r--app/views/projects/services/prometheus/_metrics.html.haml3
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml5
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml4
-rw-r--r--app/views/projects/settings/operations/_alert_management.html.haml1
-rw-r--r--app/views/projects/settings/operations/_error_tracking.html.haml2
-rw-r--r--app/views/projects/show.html.haml1
-rw-r--r--app/views/projects/tags/_tag.html.haml7
-rw-r--r--app/views/projects/terraform/index.html.haml4
-rw-r--r--app/views/projects/tree/_tree_header.html.haml2
-rw-r--r--app/views/projects/tree/show.html.haml4
-rw-r--r--app/views/registrations/welcome/show.html.haml (renamed from app/views/registrations/welcome.html.haml)4
-rw-r--r--app/views/search/_filter.html.haml19
-rw-r--r--app/views/search/_form.html.haml2
-rw-r--r--app/views/search/_results.html.haml75
-rw-r--r--app/views/search/_sort_dropdown.html.haml16
-rw-r--r--app/views/search/results/_blob_data.html.haml2
-rw-r--r--app/views/search/results/_empty.html.haml4
-rw-r--r--app/views/search/results/_filters.html.haml7
-rw-r--r--app/views/search/results/_issuable.html.haml10
-rw-r--r--app/views/search/results/_issue.html.haml14
-rw-r--r--app/views/search/results/_merge_request.html.haml15
-rw-r--r--app/views/search/results/_wiki_blob.html.haml7
-rw-r--r--app/views/sent_notifications/unsubscribe.html.haml4
-rw-r--r--app/views/shared/_broadcast_message.html.haml4
-rw-r--r--app/views/shared/_file_highlight.html.haml5
-rw-r--r--app/views/shared/_issuable_meta_data.html.haml2
-rw-r--r--app/views/shared/_label.html.haml5
-rw-r--r--app/views/shared/_label_row.html.haml6
-rw-r--r--app/views/shared/_ping_consent.html.haml20
-rw-r--r--app/views/shared/_remote_mirror_update_button.html.haml8
-rw-r--r--app/views/shared/access_tokens/_form.html.haml5
-rw-r--r--app/views/shared/boards/_show.html.haml5
-rw-r--r--app/views/shared/boards/components/sidebar/_assignee.html.haml2
-rw-r--r--app/views/shared/boards/components/sidebar/_due_date.html.haml2
-rw-r--r--app/views/shared/boards/components/sidebar/_labels.html.haml2
-rw-r--r--app/views/shared/boards/components/sidebar/_milestone.html.haml2
-rw-r--r--app/views/shared/form_elements/_apply_template_warning.html.haml5
-rw-r--r--app/views/shared/form_elements/_description.html.haml3
-rw-r--r--app/views/shared/groups/_empty_state.html.haml11
-rw-r--r--app/views/shared/groups/_search_form.html.haml2
-rw-r--r--app/views/shared/issuable/_close_reopen_button.html.haml5
-rw-r--r--app/views/shared/issuable/_close_reopen_report_toggle.html.haml4
-rw-r--r--app/views/shared/issuable/_form.html.haml2
-rw-r--r--app/views/shared/issuable/_label_dropdown.html.haml2
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml3
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml14
-rw-r--r--app/views/shared/issuable/_sort_dropdown.html.haml2
-rw-r--r--app/views/shared/issuable/csv_export/_button.html.haml (renamed from app/views/projects/issues/export_csv/_button.html.haml)2
-rw-r--r--app/views/shared/issuable/csv_export/_modal.html.haml29
-rw-r--r--app/views/shared/issuable/form/_type_selector.html.haml2
-rw-r--r--app/views/shared/issue_type/_details_content.html.haml31
-rw-r--r--app/views/shared/issue_type/_details_header.html.haml55
-rw-r--r--app/views/shared/issue_type/_emoji_block.html.haml9
-rw-r--r--app/views/shared/issue_type/_sentry_stack_trace.html.haml4
-rw-r--r--app/views/shared/members/_filter_2fa_dropdown.html.haml4
-rw-r--r--app/views/shared/members/_invite_group.html.haml2
-rw-r--r--app/views/shared/members/_invite_member.html.haml4
-rw-r--r--app/views/shared/members/_member.html.haml20
-rw-r--r--app/views/shared/members/_requests.html.haml9
-rw-r--r--app/views/shared/members/_search_field.html.haml2
-rw-r--r--app/views/shared/members/_sort_dropdown.html.haml10
-rw-r--r--app/views/shared/milestones/_delete_button.html.haml2
-rw-r--r--app/views/shared/milestones/_header.html.haml10
-rw-r--r--app/views/shared/milestones/_labels_tab.html.haml6
-rw-r--r--app/views/shared/milestones/_milestone.html.haml6
-rw-r--r--app/views/shared/notes/_comment_button.html.haml4
-rw-r--r--app/views/shared/notes/_hints.html.haml19
-rw-r--r--app/views/shared/notes/_note.html.haml2
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml2
-rw-r--r--app/views/shared/notifications/_custom_notifications.html.haml3
-rw-r--r--app/views/shared/notifications/_new_button.html.haml5
-rw-r--r--app/views/shared/projects/_search_form.html.haml1
-rw-r--r--app/views/shared/web_hooks/_form.html.haml12
-rw-r--r--app/views/shared/wikis/_sidebar.html.haml2
-rw-r--r--app/views/sherlock/transactions/_file_samples.html.haml2
-rw-r--r--app/views/sherlock/transactions/_queries.html.haml2
-rw-r--r--app/views/sherlock/transactions/index.html.haml4
-rw-r--r--app/views/snippets/notes/_actions.html.haml2
-rw-r--r--app/views/snippets/show.html.haml6
-rw-r--r--app/views/users/_groups.html.haml5
-rw-r--r--app/views/users/_overview.html.haml16
-rw-r--r--app/views/users/show.html.haml19
-rw-r--r--app/views/users/terms/index.html.haml6
-rw-r--r--app/workers/all_queues.yml58
-rw-r--r--app/workers/analytics/instance_statistics/counter_job_worker.rb10
-rw-r--r--app/workers/background_migration_worker.rb57
-rw-r--r--app/workers/build_finished_worker.rb5
-rw-r--r--app/workers/bulk_import_worker.rb15
-rw-r--r--app/workers/ci/build_trace_chunk_flush_worker.rb2
-rw-r--r--app/workers/ci/delete_objects_worker.rb8
-rw-r--r--app/workers/cleanup_container_repository_worker.rb5
-rw-r--r--app/workers/concerns/application_worker.rb2
-rw-r--r--app/workers/concerns/limited_capacity/worker.rb7
-rw-r--r--app/workers/concerns/reenqueuer.rb2
-rw-r--r--app/workers/container_expiration_policies/cleanup_container_repository_worker.rb96
-rw-r--r--app/workers/container_expiration_policy_worker.rb75
-rw-r--r--app/workers/destroy_pages_deployments_worker.rb19
-rw-r--r--app/workers/git_garbage_collect_worker.rb19
-rw-r--r--app/workers/jira_connect/sync_branch_worker.rb4
-rw-r--r--app/workers/jira_connect/sync_merge_request_worker.rb4
-rw-r--r--app/workers/jira_connect/sync_project_worker.rb30
-rw-r--r--app/workers/post_receive.rb2
-rw-r--r--app/workers/propagate_integration_inherit_descendant_worker.rb19
-rw-r--r--app/workers/propagate_integration_inherit_worker.rb4
-rw-r--r--app/workers/purge_dependency_proxy_cache_worker.rb27
-rw-r--r--app/workers/remove_expired_members_worker.rb10
-rw-r--r--app/workers/repository_cleanup_worker.rb5
-rw-r--r--app/workers/schedule_merge_request_cleanup_refs_worker.rb26
1683 files changed, 25353 insertions, 9535 deletions
diff --git a/app/assets/javascripts/access_tokens/components/expires_at_field.vue b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
index d0932ad80e1..1fec186f2fa 100644
--- a/app/assets/javascripts/access_tokens/components/expires_at_field.vue
+++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
@@ -1,14 +1,32 @@
<script>
-import { GlDatepicker } from '@gitlab/ui';
+import { GlDatepicker, GlFormInput } from '@gitlab/ui';
export default {
name: 'ExpiresAtField',
- components: { GlDatepicker },
+ components: { GlDatepicker, GlFormInput },
+ props: {
+ inputAttrs: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ minDate: new Date(),
+ };
+ },
};
</script>
<template>
- <gl-datepicker :target="null" :min-date="new Date()">
- <slot></slot>
+ <gl-datepicker :target="null" :min-date="minDate">
+ <gl-form-input
+ v-bind="inputAttrs"
+ class="datepicker gl-datepicker-input"
+ autocomplete="off"
+ inputmode="none"
+ data-qa-selector="expiry_date_field"
+ />
</gl-datepicker>
</template>
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index 9bdb2940956..319144193f1 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -1,11 +1,34 @@
import Vue from 'vue';
import ExpiresAtField from './components/expires_at_field.vue';
+const getInputAttrs = el => {
+ const input = el.querySelector('input');
+
+ return {
+ id: input.id,
+ name: input.name,
+ placeholder: input.placeholder,
+ };
+};
+
const initExpiresAtField = () => {
- // eslint-disable-next-line no-new
- new Vue({
- el: document.querySelector('.js-access-tokens-expires-at'),
- components: { ExpiresAtField },
+ const el = document.querySelector('.js-access-tokens-expires-at');
+
+ if (!el) {
+ return null;
+ }
+
+ const inputAttrs = getInputAttrs(el);
+
+ return new Vue({
+ el,
+ render(h) {
+ return h(ExpiresAtField, {
+ props: {
+ inputAttrs,
+ },
+ });
+ },
});
};
diff --git a/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js b/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js
new file mode 100644
index 00000000000..ae73033079d
--- /dev/null
+++ b/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js
@@ -0,0 +1,2 @@
+// EE-specific feature. Find the implementation in the `ee/`-folder
+export default () => {};
diff --git a/app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js b/app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js
new file mode 100644
index 00000000000..0cb8d9be0e4
--- /dev/null
+++ b/app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js
@@ -0,0 +1,27 @@
+import Vue from 'vue';
+import UserCallout from '~/user_callout';
+import UsagePingDisabled from './components/usage_ping_disabled.vue';
+
+export default () => {
+ // eslint-disable-next-line no-new
+ new UserCallout();
+
+ const emptyStateContainer = document.getElementById('js-devops-empty-state');
+
+ if (!emptyStateContainer) return false;
+
+ const { emptyStateSvgPath, enableUsagePingLink, docsLink, isAdmin } = emptyStateContainer.dataset;
+
+ return new Vue({
+ el: emptyStateContainer,
+ provide: {
+ isAdmin: Boolean(isAdmin),
+ svgPath: emptyStateSvgPath,
+ primaryButtonPath: enableUsagePingLink,
+ docsLink,
+ },
+ render(h) {
+ return h(UsagePingDisabled);
+ },
+ });
+};
diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue
index f7a5d31b835..1f3fdd5eef2 100644
--- a/app/assets/javascripts/alert_management/components/alert_details.vue
+++ b/app/assets/javascripts/alert_management/components/alert_details.vue
@@ -30,7 +30,6 @@ 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');
@@ -77,7 +76,6 @@ export default {
SystemNote,
AlertMetrics,
},
- mixins: [glFeatureFlagsMixin()],
inject: {
projectPath: {
default: '',
@@ -150,13 +148,10 @@ export default {
},
},
environmentName() {
- return this.shouldDisplayEnvironment && this.alert?.environment?.name;
+ return this.alert?.environment?.name;
},
environmentPath() {
- return this.shouldDisplayEnvironment && this.alert?.environment?.path;
- },
- shouldDisplayEnvironment() {
- return this.glFeatures.exposeEnvironmentPathInAlertDetails;
+ return this.alert?.environment?.path;
},
},
mounted() {
diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
new file mode 100644
index 00000000000..f6474efcc1f
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
@@ -0,0 +1,221 @@
+<script>
+import Vue from 'vue';
+import {
+ GlIcon,
+ GlFormInput,
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+// Mocks will be removed when integrating with BE is ready
+// data format is defined and will be the same as mocked (maybe with some minor changes)
+// feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171
+import gitlabFieldsMock from './mocks/gitlabFields.json';
+
+export const i18n = {
+ columns: {
+ gitlabKeyTitle: s__('AlertMappingBuilder|GitLab alert key'),
+ payloadKeyTitle: s__('AlertMappingBuilder|Payload alert key'),
+ fallbackKeyTitle: s__('AlertMappingBuilder|Define fallback'),
+ },
+ selectMappingKey: s__('AlertMappingBuilder|Select key'),
+ makeSelection: s__('AlertMappingBuilder|Make selection'),
+ fallbackTooltip: s__(
+ 'AlertMappingBuilder|Title is a required field for alerts in GitLab. Should the payload field you specified not be available, specifiy which field we should use instead. ',
+ ),
+ noResults: __('No matching results'),
+};
+
+export default {
+ i18n,
+ components: {
+ GlIcon,
+ GlFormInput,
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ },
+ directives: {
+ GlTooltip,
+ },
+ props: {
+ payloadFields: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ mapping: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ gitlabFields: this.gitlabAlertFields,
+ };
+ },
+ inject: {
+ gitlabAlertFields: {
+ default: gitlabFieldsMock,
+ },
+ },
+ computed: {
+ mappingData() {
+ return this.gitlabFields.map(gitlabField => {
+ const mappingFields = this.payloadFields.filter(({ type }) =>
+ type.some(t => gitlabField.compatibleTypes.includes(t)),
+ );
+
+ const foundMapping = this.mapping.find(
+ ({ alertFieldName }) => alertFieldName === gitlabField.name,
+ );
+
+ const { fallbackAlertPaths, payloadAlertPaths } = foundMapping || {};
+
+ return {
+ mapping: payloadAlertPaths,
+ fallback: fallbackAlertPaths,
+ searchTerm: '',
+ fallbackSearchTerm: '',
+ mappingFields,
+ ...gitlabField,
+ };
+ });
+ },
+ },
+ methods: {
+ setMapping(gitlabKey, mappingKey, valueKey) {
+ const fieldIndex = this.gitlabFields.findIndex(field => field.name === gitlabKey);
+ const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [valueKey]: mappingKey } };
+ Vue.set(this.gitlabFields, fieldIndex, updatedField);
+ },
+ setSearchTerm(search = '', searchFieldKey, gitlabKey) {
+ const fieldIndex = this.gitlabFields.findIndex(field => field.name === gitlabKey);
+ const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [searchFieldKey]: search } };
+ Vue.set(this.gitlabFields, fieldIndex, updatedField);
+ },
+ filterFields(searchTerm = '', fields) {
+ const search = searchTerm.toLowerCase();
+
+ return fields.filter(field => field.label.toLowerCase().includes(search));
+ },
+ isSelected(fieldValue, mapping) {
+ return fieldValue === mapping;
+ },
+ selectedValue(name) {
+ return (
+ this.payloadFields.find(item => item.name === name)?.label ||
+ this.$options.i18n.makeSelection
+ );
+ },
+ getFieldValue({ label, type }) {
+ return `${label} (${type.join(__(' or '))})`;
+ },
+ noResults(searchTerm, fields) {
+ return !this.filterFields(searchTerm, fields).length;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-table gl-w-full gl-mt-5">
+ <div class="gl-display-table-row">
+ <h5 id="gitlabFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3">
+ {{ $options.i18n.columns.gitlabKeyTitle }}
+ </h5>
+ <h5 class="gl-display-table-cell gl-py-3 gl-pr-3">&nbsp;</h5>
+ <h5 id="parsedFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3">
+ {{ $options.i18n.columns.payloadKeyTitle }}
+ </h5>
+ <h5 id="fallbackFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3">
+ {{ $options.i18n.columns.fallbackKeyTitle }}
+ <gl-icon
+ v-gl-tooltip
+ name="question"
+ class="gl-text-gray-500"
+ :title="$options.i18n.fallbackTooltip"
+ />
+ </h5>
+ </div>
+
+ <div
+ v-for="(gitlabField, index) in mappingData"
+ :key="gitlabField.name"
+ class="gl-display-table-row"
+ >
+ <div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p gl-vertical-align-middle">
+ <gl-form-input
+ aria-labelledby="gitlabFieldsHeader"
+ disabled
+ :value="getFieldValue(gitlabField)"
+ />
+ </div>
+
+ <div class="gl-display-table-cell gl-py-3 gl-pr-3">
+ <div class="right-arrow" :class="{ 'gl-vertical-align-middle': index === 0 }">
+ <i class="right-arrow-head"></i>
+ </div>
+ </div>
+
+ <div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p gl-vertical-align-middle">
+ <gl-dropdown
+ :disabled="!gitlabField.mappingFields.length"
+ aria-labelledby="parsedFieldsHeader"
+ :text="selectedValue(gitlabField.mapping)"
+ class="gl-w-full"
+ :header-text="$options.i18n.selectMappingKey"
+ >
+ <gl-search-box-by-type @input="setSearchTerm($event, 'searchTerm', gitlabField.name)" />
+ <gl-dropdown-item
+ v-for="mappingField in filterFields(gitlabField.searchTerm, gitlabField.mappingFields)"
+ :key="`${mappingField.name}__mapping`"
+ :is-checked="isSelected(gitlabField.mapping, mappingField.name)"
+ is-check-item
+ @click="setMapping(gitlabField.name, mappingField.name, 'mapping')"
+ >
+ {{ mappingField.label }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="noResults(gitlabField.searchTerm, gitlabField.mappingFields)">
+ {{ $options.i18n.noResults }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+
+ <div class="gl-display-table-cell gl-py-3 w-30p">
+ <gl-dropdown
+ v-if="Boolean(gitlabField.numberOfFallbacks)"
+ :disabled="!gitlabField.mappingFields.length"
+ aria-labelledby="fallbackFieldsHeader"
+ :text="selectedValue(gitlabField.fallback)"
+ class="gl-w-full"
+ :header-text="$options.i18n.selectMappingKey"
+ >
+ <gl-search-box-by-type
+ @input="setSearchTerm($event, 'fallbackSearchTerm', gitlabField.name)"
+ />
+ <gl-dropdown-item
+ v-for="mappingField in filterFields(
+ gitlabField.fallbackSearchTerm,
+ gitlabField.mappingFields,
+ )"
+ :key="`${mappingField.name}__fallback`"
+ :is-checked="isSelected(gitlabField.fallback, mappingField.name)"
+ is-check-item
+ @click="setMapping(gitlabField.name, mappingField.name, 'fallback')"
+ >
+ {{ mappingField.label }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="noResults(gitlabField.fallbackSearchTerm, gitlabField.mappingFields)"
+ >
+ {{ $options.i18n.noResults }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alerts_settings/components/alert_settings_form_help_block.vue b/app/assets/javascripts/alerts_settings/components/alert_settings_form_help_block.vue
new file mode 100644
index 00000000000..35b7fe84c5f
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/components/alert_settings_form_help_block.vue
@@ -0,0 +1,32 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ message: {
+ type: String,
+ required: true,
+ },
+ link: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-sprintf :message="message">
+ <template #link="{ content }">
+ <gl-link class="gl-display-inline-block" :href="link" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+</template>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
index 217442e6131..12c0409629f 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
@@ -1,8 +1,24 @@
<script>
-import { GlTable, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlButtonGroup,
+ GlButton,
+ GlIcon,
+ GlLoadingIcon,
+ GlModal,
+ GlModalDirective,
+ GlTable,
+ GlTooltipDirective,
+ GlSprintf,
+} from '@gitlab/ui';
import { s__, __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Tracking from '~/tracking';
-import { trackAlertIntergrationsViewsOptions } from '../constants';
+import {
+ trackAlertIntegrationsViewsOptions,
+ integrationToDeleteDefault,
+ typeSet,
+} from '../constants';
+import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
export const i18n = {
title: s__('AlertsIntegrations|Current integrations'),
@@ -24,23 +40,36 @@ const bodyTrClass =
export default {
i18n,
+ typeSet,
components: {
- GlTable,
+ GlButtonGroup,
+ GlButton,
GlIcon,
+ GlLoadingIcon,
+ GlModal,
+ GlTable,
+ GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
+ GlModal: GlModalDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
integrations: {
type: Array,
required: false,
default: () => [],
},
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
fields: [
{
- key: 'activated',
+ key: 'active',
label: __('Status'),
},
{
@@ -51,22 +80,56 @@ export default {
key: 'type',
label: __('Type'),
},
+ {
+ key: 'actions',
+ thClass: `gl-text-center`,
+ tdClass: `gl-text-center`,
+ label: __('Actions'),
+ },
],
- computed: {
- tbodyTrClass() {
- return {
- [bodyTrClass]: this.integrations.length,
- };
+ apollo: {
+ currentIntegration: {
+ query: getCurrentIntegrationQuery,
},
},
+ data() {
+ return {
+ integrationToDelete: integrationToDeleteDefault,
+ currentIntegration: null,
+ };
+ },
mounted() {
- this.trackPageViews();
+ const callback = entries => {
+ const isVisible = entries.some(entry => entry.isIntersecting);
+
+ if (isVisible) {
+ this.trackPageViews();
+ this.observer.disconnect();
+ }
+ };
+
+ this.observer = new IntersectionObserver(callback);
+ this.observer.observe(this.$el);
},
methods: {
+ tbodyTrClass(item) {
+ return {
+ [bodyTrClass]: this.integrations.length,
+ 'gl-bg-blue-50': (item !== null && item.id) === this.currentIntegration?.id,
+ };
+ },
trackPageViews() {
- const { category, action } = trackAlertIntergrationsViewsOptions;
+ const { category, action } = trackAlertIntegrationsViewsOptions;
Tracking.event(category, action);
},
+ setIntegrationToDelete({ name, id }) {
+ this.integrationToDelete.id = id;
+ this.integrationToDelete.name = name;
+ },
+ deleteIntegration() {
+ this.$emit('delete-integration', { id: this.integrationToDelete.id });
+ this.integrationToDelete = { ...integrationToDeleteDefault };
+ },
},
};
</script>
@@ -75,15 +138,16 @@ export default {
<div class="incident-management-list">
<h5 class="gl-font-lg">{{ $options.i18n.title }}</h5>
<gl-table
- :empty-text="$options.i18n.emptyState"
+ class="integration-list"
:items="integrations"
:fields="$options.fields"
+ :busy="loading"
stacked="md"
:tbody-tr-class="tbodyTrClass"
show-empty
>
- <template #cell(activated)="{ item }">
- <span v-if="item.activated" data-testid="integration-activated-status">
+ <template #cell(active)="{ item }">
+ <span v-if="item.active" data-testid="integration-activated-status">
<gl-icon
v-gl-tooltip
name="check-circle-filled"
@@ -104,6 +168,47 @@ export default {
{{ $options.i18n.status.disabled.name }}
</span>
</template>
+
+ <template #cell(actions)="{ item }">
+ <gl-button-group v-if="glFeatures.httpIntegrationsList" class="gl-ml-3">
+ <gl-button icon="pencil" @click="$emit('edit-integration', { id: item.id })" />
+ <gl-button
+ v-gl-modal.deleteIntegration
+ :disabled="item.type === $options.typeSet.prometheus"
+ icon="remove"
+ @click="setIntegrationToDelete(item)"
+ />
+ </gl-button-group>
+ </template>
+
+ <template #table-busy>
+ <gl-loading-icon size="lg" color="dark" class="mt-3" />
+ </template>
+
+ <template #empty>
+ <div
+ class="gl-border-t-solid gl-border-b-solid gl-border-1 gl-border gl-border-gray-100 mt-n3 gl-px-5"
+ >
+ <p class="gl-text-gray-400 gl-py-3 gl-my-3">{{ $options.i18n.emptyState }}</p>
+ </div>
+ </template>
</gl-table>
+ <gl-modal
+ modal-id="deleteIntegration"
+ :title="s__('AlertSettings|Delete integration')"
+ :ok-title="s__('AlertSettings|Delete integration')"
+ ok-variant="danger"
+ @ok="deleteIntegration"
+ >
+ <gl-sprintf
+ :message="
+ s__(
+ 'AlertsIntegrations|You have opted to delete the %{integrationName} integration. Do you want to proceed? It means you will no longer receive alerts from this endpoint in your alert list, and this action cannot be undone.',
+ )
+ "
+ >
+ <template #integrationName>{{ integrationToDelete.name }}</template>
+ </gl-sprintf>
+ </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue
new file mode 100644
index 00000000000..3656fc4d7ec
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue
@@ -0,0 +1,661 @@
+<script>
+import {
+ GlButton,
+ GlCollapse,
+ GlForm,
+ GlFormGroup,
+ GlFormSelect,
+ GlFormInput,
+ GlFormInputGroup,
+ GlFormTextarea,
+ GlModal,
+ GlModalDirective,
+ GlToggle,
+} from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { s__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import MappingBuilder from './alert_mapping_builder.vue';
+import AlertSettingsFormHelpBlock from './alert_settings_form_help_block.vue';
+import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
+import service from '../services';
+import {
+ integrationTypesNew,
+ JSON_VALIDATE_DELAY,
+ targetPrometheusUrlPlaceholder,
+ targetOpsgenieUrlPlaceholder,
+ typeSet,
+ sectionHash,
+} from '../constants';
+// Mocks will be removed when integrating with BE is ready
+// data format is defined and will be the same as mocked (maybe with some minor changes)
+// feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171
+import mockedCustomMapping from './mocks/parsedMapping.json';
+
+export default {
+ placeholders: {
+ prometheus: targetPrometheusUrlPlaceholder,
+ opsgenie: targetOpsgenieUrlPlaceholder,
+ },
+ JSON_VALIDATE_DELAY,
+ typeSet,
+ i18n: {
+ integrationFormSteps: {
+ step1: {
+ label: s__('AlertSettings|1. Select integration type'),
+ enterprise: s__(
+ 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.',
+ ),
+ },
+ step2: {
+ label: s__('AlertSettings|2. Name integration'),
+ placeholder: s__('AlertSettings|Enter integration name'),
+ },
+ step3: {
+ label: s__('AlertSettings|3. Set up webhook'),
+ help: s__(
+ "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.",
+ ),
+ prometheusHelp: s__(
+ 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.',
+ ),
+ info: s__('AlertSettings|Authorization key'),
+ reset: s__('AlertSettings|Reset Key'),
+ },
+ step4: {
+ label: s__('AlertSettings|4. Sample alert payload (optional)'),
+ help: s__(
+ 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional), or to test the integration (also optional).',
+ ),
+ prometheusHelp: s__(
+ 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional).',
+ ),
+ placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'),
+ resetHeader: s__('AlertSettings|Reset the mapping'),
+ resetBody: s__(
+ "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.",
+ ),
+ resetOk: s__('AlertSettings|Proceed with editing'),
+ editPayload: s__('AlertSettings|Edit payload'),
+ submitPayload: s__('AlertSettings|Submit payload'),
+ payloadParsedSucessMsg: s__(
+ 'AlertSettings|Sample payload has been parsed. You can now map the fields.',
+ ),
+ },
+ step5: {
+ label: s__('AlertSettings|5. Map fields (optional)'),
+ intro: s__(
+ "AlertSettings|If you've provided a sample alert payload, you can create a custom mapping for your endpoint. The default GitLab alert keys are listed below. Please define which payload key should map to the specified GitLab key.",
+ ),
+ },
+ prometheusFormUrl: {
+ label: s__('AlertSettings|Prometheus API base URL'),
+ help: s__('AlertSettings|URL cannot be blank and must start with http or https'),
+ },
+ restKeyInfo: {
+ label: s__(
+ 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
+ ),
+ },
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ opsgenie: {
+ label: s__('AlertSettings|2. Add link to your Opsgenie alert list'),
+ info: s__(
+ 'AlertSettings|Utilizing this option will link the GitLab Alerts navigation item to your existing Opsgenie instance. By selecting this option, you cannot receive alerts from any other source in GitLab; it will effectively be turning Alerts within GitLab off as a feature.',
+ ),
+ },
+ },
+ },
+ components: {
+ ClipboardButton,
+ GlButton,
+ GlCollapse,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormInputGroup,
+ GlFormTextarea,
+ GlFormSelect,
+ GlModal,
+ GlToggle,
+ AlertSettingsFormHelpBlock,
+ MappingBuilder,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ inject: {
+ generic: {
+ default: {},
+ },
+ prometheus: {
+ default: {},
+ },
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ opsgenie: {
+ default: {},
+ },
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ loading: {
+ type: Boolean,
+ required: true,
+ },
+ canAddIntegration: {
+ type: Boolean,
+ required: true,
+ },
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ canManageOpsgenie: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ apollo: {
+ currentIntegration: {
+ query: getCurrentIntegrationQuery,
+ },
+ },
+ data() {
+ return {
+ selectedIntegration: integrationTypesNew[0].value,
+ active: false,
+ formVisible: false,
+ integrationTestPayload: {
+ json: null,
+ error: null,
+ },
+ resetSamplePayloadConfirmed: false,
+ customMapping: null,
+ parsingPayload: false,
+ currentIntegration: null,
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ isManagingOpsgenie: false,
+ };
+ },
+ computed: {
+ isPrometheus() {
+ return this.selectedIntegration === this.$options.typeSet.prometheus;
+ },
+ jsonIsValid() {
+ return this.integrationTestPayload.error === null;
+ },
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ disabledIntegrations() {
+ const options = [];
+ if (this.opsgenie.active) {
+ options.push(typeSet.http, typeSet.prometheus);
+ } else if (!this.canManageOpsgenie) {
+ options.push(typeSet.opsgenie);
+ }
+
+ return options;
+ },
+ options() {
+ return integrationTypesNew.map(el => ({
+ ...el,
+ disabled: this.disabledIntegrations.includes(el.value),
+ }));
+ },
+ selectedIntegrationType() {
+ switch (this.selectedIntegration) {
+ case typeSet.http:
+ return this.generic;
+ case typeSet.prometheus:
+ return this.prometheus;
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ case typeSet.opsgenie:
+ return this.opsgenie;
+ default:
+ return {};
+ }
+ },
+ integrationForm() {
+ return {
+ name: this.currentIntegration?.name || '',
+ active: this.currentIntegration?.active || false,
+ token: this.currentIntegration?.token || this.selectedIntegrationType.token,
+ url: this.currentIntegration?.url || this.selectedIntegrationType.url,
+ apiUrl: this.currentIntegration?.apiUrl || '',
+ };
+ },
+ testAlertPayload() {
+ return {
+ data: this.integrationTestPayload.json,
+ endpoint: this.integrationForm.url,
+ token: this.integrationForm.token,
+ };
+ },
+ showMappingBuilder() {
+ return (
+ this.glFeatures.multipleHttpIntegrationsCustomMapping &&
+ this.selectedIntegration === typeSet.http
+ );
+ },
+ mappingBuilderFields() {
+ return this.customMapping?.samplePayload?.payloadAlerFields?.nodes;
+ },
+ mappingBuilderMapping() {
+ return this.customMapping?.storedMapping?.nodes;
+ },
+ hasSamplePayload() {
+ return Boolean(this.customMapping?.samplePayload);
+ },
+ canEditPayload() {
+ return this.hasSamplePayload && !this.resetSamplePayloadConfirmed;
+ },
+ isPayloadEditDisabled() {
+ return !this.active || this.canEditPayload;
+ },
+ },
+ watch: {
+ currentIntegration(val) {
+ if (val === null) {
+ return this.reset();
+ }
+ this.selectedIntegration = val.type;
+ this.active = val.active;
+ if (val.type === typeSet.http) this.getIntegrationMapping(val.id);
+ return this.integrationTypeSelect();
+ },
+ },
+ methods: {
+ integrationTypeSelect() {
+ if (this.selectedIntegration === integrationTypesNew[0].value) {
+ this.formVisible = false;
+ } else {
+ this.formVisible = true;
+ }
+
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ if (this.canManageOpsgenie && this.selectedIntegration === typeSet.opsgenie) {
+ this.isManagingOpsgenie = true;
+ this.active = this.opsgenie.active;
+ this.integrationForm.apiUrl = this.opsgenie.opsgenieMvcTargetUrl;
+ } else {
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ this.isManagingOpsgenie = false;
+ }
+ },
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ submitWithOpsgenie() {
+ return service
+ .updateGenericActive({
+ endpoint: this.opsgenie.formPath,
+ params: {
+ service: {
+ opsgenie_mvc_target_url: this.integrationForm.apiUrl,
+ opsgenie_mvc_enabled: this.active,
+ },
+ },
+ })
+ .then(() => {
+ window.location.hash = sectionHash;
+ window.location.reload();
+ });
+ },
+ submitWithTestPayload() {
+ return service
+ .updateTestAlert(this.testAlertPayload)
+ .then(() => {
+ this.submit();
+ })
+ .catch(() => {
+ this.$emit('test-payload-failure');
+ });
+ },
+ submit() {
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ if (this.isManagingOpsgenie) {
+ return this.submitWithOpsgenie();
+ }
+
+ const { name, apiUrl } = this.integrationForm;
+ const variables =
+ this.selectedIntegration === typeSet.http
+ ? { name, active: this.active }
+ : { apiUrl, active: this.active };
+ const integrationPayload = { type: this.selectedIntegration, variables };
+
+ if (this.currentIntegration) {
+ return this.$emit('update-integration', integrationPayload);
+ }
+
+ return this.$emit('create-new-integration', integrationPayload);
+ },
+ reset() {
+ this.selectedIntegration = integrationTypesNew[0].value;
+ this.integrationTypeSelect();
+
+ if (this.currentIntegration) {
+ return this.$emit('clear-current-integration');
+ }
+
+ return this.resetFormValues();
+ },
+ resetFormValues() {
+ this.integrationForm.name = '';
+ this.integrationForm.apiUrl = '';
+ this.integrationTestPayload = {
+ json: null,
+ error: null,
+ };
+ this.active = false;
+
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ this.isManagingOpsgenie = false;
+ },
+ resetAuthKey() {
+ if (!this.currentIntegration) {
+ return;
+ }
+
+ this.$emit('reset-token', {
+ type: this.selectedIntegration,
+ variables: { id: this.currentIntegration.id },
+ });
+ },
+ validateJson() {
+ this.integrationTestPayload.error = null;
+ if (this.integrationTestPayload.json === '') {
+ return;
+ }
+
+ try {
+ JSON.parse(this.integrationTestPayload.json);
+ } catch (e) {
+ this.integrationTestPayload.error = JSON.stringify(e.message);
+ }
+ },
+ parseMapping() {
+ // TODO: replace with real BE mutation when ready;
+ this.parsingPayload = true;
+
+ return new Promise(resolve => {
+ setTimeout(() => resolve(mockedCustomMapping), 1000);
+ })
+ .then(res => {
+ const mapping = { ...res };
+ delete mapping.storedMapping;
+ this.customMapping = res;
+ this.integrationTestPayload.json = res?.samplePayload.body;
+ this.resetSamplePayloadConfirmed = false;
+
+ this.$toast.show(this.$options.i18n.integrationFormSteps.step4.payloadParsedSucessMsg);
+ })
+ .finally(() => {
+ this.parsingPayload = false;
+ });
+ },
+ getIntegrationMapping() {
+ // TODO: replace with real BE mutation when ready;
+ return Promise.resolve(mockedCustomMapping).then(res => {
+ this.customMapping = res;
+ this.integrationTestPayload.json = res?.samplePayload.body;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form class="gl-mt-6" @submit.prevent="submit" @reset.prevent="reset">
+ <h5 class="gl-font-lg gl-my-5">{{ s__('AlertSettings|Add new integrations') }}</h5>
+ <gl-form-group
+ id="integration-type"
+ :label="$options.i18n.integrationFormSteps.step1.label"
+ label-for="integration-type"
+ >
+ <gl-form-select
+ v-model="selectedIntegration"
+ :disabled="currentIntegration !== null || !canAddIntegration"
+ :options="options"
+ @change="integrationTypeSelect"
+ />
+
+ <div v-if="!canAddIntegration" class="gl-my-4" data-testid="multi-integrations-not-supported">
+ <alert-settings-form-help-block
+ :message="$options.i18n.integrationFormSteps.step1.enterprise"
+ link="https://about.gitlab.com/pricing"
+ />
+ </div>
+ </gl-form-group>
+ <gl-collapse v-model="formVisible" class="gl-mt-3">
+ <!-- TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 -->
+ <div v-if="isManagingOpsgenie">
+ <gl-form-group
+ id="integration-webhook"
+ :label="$options.i18n.integrationFormSteps.opsgenie.label"
+ label-for="integration-webhook"
+ >
+ <span class="gl-my-4">
+ {{ $options.i18n.integrationFormSteps.opsgenie.info }}
+ </span>
+
+ <gl-toggle
+ v-model="active"
+ :is-loading="loading"
+ :label="__('Active')"
+ class="gl-my-4 gl-font-weight-normal"
+ />
+
+ <gl-form-input
+ id="opsgenie-opsgenieMvcTargetUrl"
+ v-model="integrationForm.apiUrl"
+ type="text"
+ :placeholder="$options.placeholders.opsgenie"
+ />
+
+ <span class="gl-text-gray-400 gl-my-1">
+ {{ $options.i18n.integrationFormSteps.prometheusFormUrl.help }}
+ </span>
+ </gl-form-group>
+ </div>
+ <div v-else>
+ <gl-form-group
+ id="name-integration"
+ :label="$options.i18n.integrationFormSteps.step2.label"
+ label-for="name-integration"
+ >
+ <gl-form-input
+ v-model="integrationForm.name"
+ type="text"
+ :placeholder="$options.i18n.integrationFormSteps.step2.placeholder"
+ />
+ </gl-form-group>
+ <gl-form-group
+ id="integration-webhook"
+ :label="$options.i18n.integrationFormSteps.step3.label"
+ label-for="integration-webhook"
+ >
+ <alert-settings-form-help-block
+ :message="
+ isPrometheus
+ ? $options.i18n.integrationFormSteps.step3.prometheusHelp
+ : $options.i18n.integrationFormSteps.step3.help
+ "
+ link="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html"
+ />
+
+ <gl-toggle
+ v-model="active"
+ :is-loading="loading"
+ :label="__('Active')"
+ class="gl-my-4 gl-font-weight-normal"
+ />
+
+ <div v-if="isPrometheus" class="gl-my-4">
+ <span class="gl-font-weight-bold">
+ {{ $options.i18n.integrationFormSteps.prometheusFormUrl.label }}
+ </span>
+
+ <gl-form-input
+ id="integration-apiUrl"
+ v-model="integrationForm.apiUrl"
+ type="text"
+ :placeholder="$options.placeholders.prometheus"
+ />
+
+ <span class="gl-text-gray-400">
+ {{ $options.i18n.integrationFormSteps.prometheusFormUrl.help }}
+ </span>
+ </div>
+
+ <div class="gl-my-4">
+ <span class="gl-font-weight-bold">
+ {{ s__('AlertSettings|Webhook URL') }}
+ </span>
+
+ <gl-form-input-group id="url" readonly :value="integrationForm.url">
+ <template #append>
+ <clipboard-button
+ :text="integrationForm.url || ''"
+ :title="__('Copy')"
+ class="gl-m-0!"
+ />
+ </template>
+ </gl-form-input-group>
+ </div>
+
+ <div class="gl-my-4">
+ <span class="gl-font-weight-bold">
+ {{ $options.i18n.integrationFormSteps.step3.info }}
+ </span>
+
+ <gl-form-input-group
+ id="authorization-key"
+ class="gl-mb-3"
+ readonly
+ :value="integrationForm.token"
+ >
+ <template #append>
+ <clipboard-button
+ :text="integrationForm.token || ''"
+ :title="__('Copy')"
+ class="gl-m-0!"
+ />
+ </template>
+ </gl-form-input-group>
+
+ <gl-button v-gl-modal.authKeyModal :disabled="!active">
+ {{ $options.i18n.integrationFormSteps.step3.reset }}
+ </gl-button>
+ <gl-modal
+ modal-id="authKeyModal"
+ :title="$options.i18n.integrationFormSteps.step3.reset"
+ :ok-title="$options.i18n.integrationFormSteps.step3.reset"
+ ok-variant="danger"
+ @ok="resetAuthKey"
+ >
+ {{ $options.i18n.integrationFormSteps.restKeyInfo.label }}
+ </gl-modal>
+ </div>
+ </gl-form-group>
+
+ <gl-form-group
+ id="test-integration"
+ :label="$options.i18n.integrationFormSteps.step4.label"
+ label-for="test-integration"
+ :class="{ 'gl-mb-0!': showMappingBuilder }"
+ :invalid-feedback="integrationTestPayload.error"
+ >
+ <alert-settings-form-help-block
+ :message="
+ isPrometheus || !showMappingBuilder
+ ? $options.i18n.integrationFormSteps.step4.prometheusHelp
+ : $options.i18n.integrationFormSteps.step4.help
+ "
+ :link="generic.alertsUsageUrl"
+ />
+
+ <gl-form-textarea
+ id="test-payload"
+ v-model.trim="integrationTestPayload.json"
+ :disabled="isPayloadEditDisabled"
+ :state="jsonIsValid"
+ :placeholder="$options.i18n.integrationFormSteps.step4.placeholder"
+ class="gl-my-3"
+ :debounce="$options.JSON_VALIDATE_DELAY"
+ rows="6"
+ max-rows="10"
+ @input="validateJson"
+ />
+ </gl-form-group>
+
+ <template v-if="showMappingBuilder">
+ <gl-button
+ v-if="canEditPayload"
+ v-gl-modal.resetPayloadModal
+ data-testid="payload-action-btn"
+ :disabled="!active"
+ class="gl-mt-3"
+ >
+ {{ $options.i18n.integrationFormSteps.step4.editPayload }}
+ </gl-button>
+
+ <gl-button
+ v-else
+ data-testid="payload-action-btn"
+ :class="{ 'gl-mt-3': integrationTestPayload.error }"
+ :disabled="!active"
+ :loading="parsingPayload"
+ @click="parseMapping"
+ >
+ {{ $options.i18n.integrationFormSteps.step4.submitPayload }}
+ </gl-button>
+ <gl-modal
+ modal-id="resetPayloadModal"
+ :title="$options.i18n.integrationFormSteps.step4.resetHeader"
+ :ok-title="$options.i18n.integrationFormSteps.step4.resetOk"
+ ok-variant="danger"
+ @ok="resetSamplePayloadConfirmed = true"
+ >
+ {{ $options.i18n.integrationFormSteps.step4.resetBody }}
+ </gl-modal>
+ </template>
+
+ <gl-form-group
+ v-if="showMappingBuilder"
+ id="mapping-builder"
+ class="gl-mt-5"
+ :label="$options.i18n.integrationFormSteps.step5.label"
+ label-for="mapping-builder"
+ >
+ <span>{{ $options.i18n.integrationFormSteps.step5.intro }}</span>
+ <mapping-builder
+ :payload-fields="mappingBuilderFields"
+ :mapping="mappingBuilderMapping"
+ />
+ </gl-form-group>
+ </div>
+ <div class="gl-display-flex gl-justify-content-start gl-py-3">
+ <gl-button
+ type="submit"
+ variant="success"
+ class="js-no-auto-disable"
+ data-testid="integration-form-submit"
+ >{{ s__('AlertSettings|Save integration') }}
+ </gl-button>
+ <!-- TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 -->
+ <gl-button
+ v-if="!isManagingOpsgenie"
+ data-testid="integration-test-and-submit"
+ :disabled="Boolean(integrationTestPayload.error)"
+ category="secondary"
+ variant="success"
+ class="gl-mx-3 js-no-auto-disable"
+ @click="submitWithTestPayload"
+ >{{ s__('AlertSettings|Save and test payload') }}</gl-button
+ >
+ <gl-button
+ type="reset"
+ class="js-no-auto-disable"
+ :class="{ 'gl-ml-3': isManagingOpsgenie }"
+ >{{ __('Cancel') }}</gl-button
+ >
+ </div>
+ </gl-collapse>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue
index f885afae378..0246315bdc5 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue
@@ -14,16 +14,14 @@ import {
GlFormSelect,
} from '@gitlab/ui';
import { debounce } from 'lodash';
-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 {
i18n,
- serviceOptions,
+ integrationTypes,
JSON_VALIDATE_DELAY,
targetPrometheusUrlPlaceholder,
targetOpsgenieUrlPlaceholder,
@@ -50,7 +48,6 @@ export default {
GlSprintf,
ClipboardButton,
ToggleButton,
- IntegrationsList,
},
directives: {
'gl-modal': GlModalDirective,
@@ -59,10 +56,10 @@ export default {
data() {
return {
loading: false,
- selectedEndpoint: serviceOptions[0].value,
- options: serviceOptions,
+ selectedIntegration: integrationTypes[0].value,
+ options: integrationTypes,
active: false,
- authKey: '',
+ token: '',
targetUrl: '',
feedback: {
variant: 'danger',
@@ -91,34 +88,34 @@ export default {
];
},
isPrometheus() {
- return this.selectedEndpoint === 'prometheus';
+ return this.selectedIntegration === 'PROMETHEUS';
},
isOpsgenie() {
- return this.selectedEndpoint === 'opsgenie';
+ return this.selectedIntegration === 'OPSGENIE';
},
- selectedService() {
- switch (this.selectedEndpoint) {
- case 'generic': {
+ selectedIntegrationType() {
+ switch (this.selectedIntegration) {
+ case 'HTTP': {
return {
url: this.generic.url,
- authKey: this.generic.authorizationKey,
- activated: this.generic.activated,
+ token: this.generic.token,
+ active: this.generic.active,
resetKey: this.resetKey.bind(this),
};
}
- case 'prometheus': {
+ case 'PROMETHEUS': {
return {
- url: this.prometheus.prometheusUrl,
- authKey: this.prometheus.authorizationKey,
- activated: this.prometheus.activated,
- resetKey: this.resetKey.bind(this, 'prometheus'),
+ url: this.prometheus.url,
+ token: this.prometheus.token,
+ active: this.prometheus.active,
+ resetKey: this.resetKey.bind(this, 'PROMETHEUS'),
targetUrl: this.prometheus.prometheusApiUrl,
};
}
- case 'opsgenie': {
+ case 'OPSGENIE': {
return {
targetUrl: this.opsgenie.opsgenieMvcTargetUrl,
- activated: this.opsgenie.activated,
+ active: this.opsgenie.active,
};
}
default: {
@@ -152,43 +149,25 @@ 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() {
this.validateJson();
}, JSON_VALIDATE_DELAY),
targetUrl(oldVal, newVal) {
- if (newVal && oldVal !== this.selectedService.targetUrl) {
+ if (newVal && oldVal !== this.selectedIntegrationType.targetUrl) {
this.canSaveForm = true;
}
},
},
mounted() {
- if (
- this.prometheus.activated ||
- this.generic.activated ||
- !this.opsgenie.opsgenieMvcIsAvailable
- ) {
+ if (this.prometheus.active || this.generic.active || !this.opsgenie.opsgenieMvcIsAvailable) {
this.removeOpsGenieOption();
- } else if (this.opsgenie.activated) {
+ } else if (this.opsgenie.active) {
this.setOpsgenieAsDefault();
}
- this.active = this.selectedService.activated;
- this.authKey = this.selectedService.authKey ?? '';
+ this.active = this.selectedIntegrationType.active;
+ this.token = this.selectedIntegrationType.token ?? '';
},
methods: {
createUserErrorMessage(errors = {}) {
@@ -200,19 +179,19 @@ export default {
},
setOpsgenieAsDefault() {
this.options = this.options.map(el => {
- if (el.value !== 'opsgenie') {
+ if (el.value !== 'OPSGENIE') {
return { ...el, disabled: true };
}
return { ...el, disabled: false };
});
- this.selectedEndpoint = this.options.find(({ value }) => value === 'opsgenie').value;
+ this.selectedIntegration = this.options.find(({ value }) => value === 'OPSGENIE').value;
if (this.targetUrl === null) {
- this.targetUrl = this.selectedService.targetUrl;
+ this.targetUrl = this.selectedIntegrationType.targetUrl;
}
},
removeOpsGenieOption() {
this.options = this.options.map(el => {
- if (el.value !== 'opsgenie') {
+ if (el.value !== 'OPSGENIE') {
return { ...el, disabled: false };
}
return { ...el, disabled: true };
@@ -220,8 +199,8 @@ export default {
},
resetFormValues() {
this.testAlert.json = null;
- this.targetUrl = this.selectedService.targetUrl;
- this.active = this.selectedService.activated;
+ this.targetUrl = this.selectedIntegrationType.targetUrl;
+ this.active = this.selectedIntegrationType.active;
},
dismissFeedback() {
this.serverError = null;
@@ -229,12 +208,12 @@ export default {
this.isFeedbackDismissed = false;
},
resetKey(key) {
- const fn = key === 'prometheus' ? this.resetPrometheusKey() : this.resetGenericKey();
+ const fn = key === 'PROMETHEUS' ? this.resetPrometheusKey() : this.resetGenericKey();
return fn
.then(({ data: { token } }) => {
- this.authKey = token;
- this.setFeedback({ feedbackMessage: this.$options.i18n.authKeyRest, variant: 'success' });
+ this.token = token;
+ this.setFeedback({ feedbackMessage: this.$options.i18n.tokenRest, variant: 'success' });
})
.catch(() => {
this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' });
@@ -259,9 +238,10 @@ export default {
},
toggleActivated(value) {
this.loading = true;
+ const path = this.isOpsgenie ? this.opsgenie.formPath : this.generic.formPath;
return service
.updateGenericActive({
- endpoint: this[this.selectedEndpoint].formPath,
+ endpoint: path,
params: this.isOpsgenie
? { service: { opsgenie_mvc_target_url: this.targetUrl, opsgenie_mvc_enabled: value } }
: { service: { active: value } },
@@ -331,9 +311,9 @@ export default {
this.validateJson();
return service
.updateTestAlert({
- endpoint: this.selectedService.url,
+ endpoint: this.selectedIntegrationType.url,
data: this.testAlert.json,
- authKey: this.selectedService.authKey,
+ token: this.selectedIntegrationType.token,
})
.then(() => {
this.setFeedback({
@@ -358,11 +338,11 @@ export default {
onReset() {
this.testAlert.json = null;
this.dismissFeedback();
- this.targetUrl = this.selectedService.targetUrl;
+ this.targetUrl = this.selectedIntegrationType.targetUrl;
if (this.canSaveForm) {
this.canSaveForm = false;
- this.active = this.selectedService.activated;
+ this.active = this.selectedIntegrationType.active;
}
},
},
@@ -370,153 +350,145 @@ export default {
</script>
<template>
- <div>
- <integrations-list :integrations="integrations" />
-
- <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset">
- <h5 class="gl-font-lg gl-my-5">{{ $options.i18n.integrationsLabel }}</h5>
+ <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset">
+ <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>
+ <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>
+ <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"
- />
+ <gl-form-group label-for="integration-type" :label="$options.i18n.integration">
+ <gl-form-select
+ id="integration-type"
+ v-model="selectedIntegration"
+ :options="options"
+ data-testid="alert-settings-select"
+ @change="resetFormValues"
+ />
+ <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/4390"
+ target="_blank"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </span>
+ </gl-form-group>
+ <gl-form-group :label="$options.i18n.activeLabel" label-for="active">
+ <toggle-button
+ id="active"
+ :disabled-input="loading"
+ :is-loading="loading"
+ :value="active"
+ @change="toggleService"
+ />
+ </gl-form-group>
+ <gl-form-group
+ v-if="isOpsgenie || isPrometheus"
+ :label="$options.i18n.apiBaseUrlLabel"
+ label-for="api-url"
+ >
+ <gl-form-input
+ id="api-url"
+ v-model="targetUrl"
+ type="url"
+ :placeholder="baseUrlPlaceholder"
+ :disabled="!active"
+ />
+ <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">
+ <gl-form-input-group id="url" readonly :value="selectedIntegrationType.url">
+ <template #append>
+ <clipboard-button
+ :text="selectedIntegrationType.url"
+ :title="$options.i18n.copyToClipboard"
+ class="gl-m-0!"
+ />
+ </template>
+ </gl-form-input-group>
<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/4390"
- target="_blank"
- >{{ content }}</gl-link
- >
- </template>
- </gl-sprintf>
+ {{ prometheusInfo }}
</span>
</gl-form-group>
- <gl-form-group :label="$options.i18n.activeLabel" label-for="activated">
- <toggle-button
- id="activated"
- :disabled-input="loading"
- :is-loading="loading"
- :value="active"
- @change="toggleService"
- />
+ <gl-form-group :label="$options.i18n.tokenLabel" label-for="authorization-key">
+ <gl-form-input-group id="authorization-key" class="gl-mb-2" readonly :value="token">
+ <template #append>
+ <clipboard-button
+ :text="token"
+ :title="$options.i18n.copyToClipboard"
+ class="gl-m-0!"
+ />
+ </template>
+ </gl-form-input-group>
+ <gl-button v-gl-modal.tokenModal :disabled="!active" class="gl-mt-3">{{
+ $options.i18n.resetKey
+ }}</gl-button>
+ <gl-modal
+ modal-id="tokenModal"
+ :title="$options.i18n.resetKey"
+ :ok-title="$options.i18n.resetKey"
+ ok-variant="danger"
+ @ok="selectedIntegrationType.resetKey"
+ >
+ {{ $options.i18n.restKeyInfo }}
+ </gl-modal>
</gl-form-group>
<gl-form-group
- v-if="isOpsgenie || isPrometheus"
- :label="$options.i18n.apiBaseUrlLabel"
- label-for="api-url"
+ :label="$options.i18n.alertJson"
+ label-for="alert-json"
+ :invalid-feedback="testAlert.error"
>
- <gl-form-input
- id="api-url"
- v-model="targetUrl"
- type="url"
- :placeholder="baseUrlPlaceholder"
+ <gl-form-textarea
+ id="alert-json"
+ v-model.trim="testAlert.json"
:disabled="!active"
+ :state="jsonIsValid"
+ :placeholder="$options.i18n.alertJsonPlaceholder"
+ rows="6"
+ max-rows="10"
/>
- <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">
- <gl-form-input-group id="url" readonly :value="selectedService.url">
- <template #append>
- <clipboard-button
- :text="selectedService.url"
- :title="$options.i18n.copyToClipboard"
- class="gl-m-0!"
- />
- </template>
- </gl-form-input-group>
- <span class="gl-text-gray-500">
- {{ prometheusInfo }}
- </span>
- </gl-form-group>
- <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
- :text="authKey"
- :title="$options.i18n.copyToClipboard"
- class="gl-m-0!"
- />
- </template>
- </gl-form-input-group>
- <gl-button v-gl-modal.authKeyModal :disabled="!active" class="gl-mt-3">{{
- $options.i18n.resetKey
- }}</gl-button>
- <gl-modal
- modal-id="authKeyModal"
- :title="$options.i18n.resetKey"
- :ok-title="$options.i18n.resetKey"
- ok-variant="danger"
- @ok="selectedService.resetKey"
- >
- {{ $options.i18n.restKeyInfo }}
- </gl-modal>
- </gl-form-group>
- <gl-form-group
- :label="$options.i18n.alertJson"
- label-for="alert-json"
- :invalid-feedback="testAlert.error"
- >
- <gl-form-textarea
- id="alert-json"
- v-model.trim="testAlert.json"
- :disabled="!active"
- :state="jsonIsValid"
- :placeholder="$options.i18n.alertJsonPlaceholder"
- rows="6"
- max-rows="10"
- />
- </gl-form-group>
- <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
- variant="success"
- category="primary"
- :disabled="!canSaveConfig"
- @click="onSubmit"
- >
- {{ __('Save changes') }}
- </gl-button>
- <gl-button category="primary" :disabled="!canSaveConfig" @click="onReset">
- {{ __('Cancel') }}
- </gl-button>
- </div>
- </gl-form>
- </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 variant="success" category="primary" :disabled="!canSaveConfig" @click="onSubmit">
+ {{ __('Save changes') }}
+ </gl-button>
+ <gl-button category="primary" :disabled="!canSaveConfig" @click="onReset">
+ {{ __('Cancel') }}
+ </gl-button>
+ </div>
+ </gl-form>
</template>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
new file mode 100644
index 00000000000..1ffc2f80148
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
@@ -0,0 +1,331 @@
+<script>
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { fetchPolicies } from '~/lib/graphql';
+import createFlash, { FLASH_TYPES } from '~/flash';
+import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql';
+import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
+import createHttpIntegrationMutation from '../graphql/mutations/create_http_integration.mutation.graphql';
+import createPrometheusIntegrationMutation from '../graphql/mutations/create_prometheus_integration.mutation.graphql';
+import updateHttpIntegrationMutation from '../graphql/mutations/update_http_integration.mutation.graphql';
+import updatePrometheusIntegrationMutation from '../graphql/mutations/update_prometheus_integration.mutation.graphql';
+import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_integration.mutation.graphql';
+import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutation.graphql';
+import resetPrometheusTokenMutation from '../graphql/mutations/reset_prometheus_token.mutation.graphql';
+import updateCurrentIntergrationMutation from '../graphql/mutations/update_current_intergration.mutation.graphql';
+import IntegrationsList from './alerts_integrations_list.vue';
+import SettingsFormOld from './alerts_settings_form_old.vue';
+import SettingsFormNew from './alerts_settings_form_new.vue';
+import { typeSet } from '../constants';
+import {
+ updateStoreAfterIntegrationDelete,
+ updateStoreAfterIntegrationAdd,
+} from '../utils/cache_updates';
+import {
+ DELETE_INTEGRATION_ERROR,
+ ADD_INTEGRATION_ERROR,
+ RESET_INTEGRATION_TOKEN_ERROR,
+ UPDATE_INTEGRATION_ERROR,
+ INTEGRATION_PAYLOAD_TEST_ERROR,
+} from '../utils/error_messages';
+
+export default {
+ typeSet,
+ i18n: {
+ changesSaved: s__(
+ 'AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list.',
+ ),
+ integrationRemoved: s__('AlertsIntegrations|The integration has been successfully removed.'),
+ },
+ components: {
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ GlAlert,
+ GlLink,
+ GlSprintf,
+ IntegrationsList,
+ SettingsFormOld,
+ SettingsFormNew,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ inject: {
+ generic: {
+ default: {},
+ },
+ prometheus: {
+ default: {},
+ },
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ opsgenie: {
+ default: {},
+ },
+ projectPath: {
+ default: '',
+ },
+ multiIntegrations: {
+ default: false,
+ },
+ },
+ apollo: {
+ integrations: {
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ query: getIntegrationsQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update(data) {
+ const { alertManagementIntegrations: { nodes: list = [] } = {} } = data.project || {};
+
+ return {
+ list,
+ };
+ },
+ error(err) {
+ createFlash({ message: err });
+ },
+ },
+ currentIntegration: {
+ query: getCurrentIntegrationQuery,
+ },
+ },
+ data() {
+ return {
+ isUpdating: false,
+ integrations: {},
+ currentIntegration: null,
+ };
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.integrations.loading;
+ },
+ integrationsOptionsOld() {
+ return [
+ {
+ name: s__('AlertSettings|HTTP endpoint'),
+ type: s__('AlertsIntegrations|HTTP endpoint'),
+ active: this.generic.active,
+ },
+ {
+ name: s__('AlertSettings|External Prometheus'),
+ type: s__('AlertsIntegrations|Prometheus'),
+ active: this.prometheus.active,
+ },
+ ];
+ },
+ canAddIntegration() {
+ return this.multiIntegrations || this.integrations?.list?.length < 2;
+ },
+ canManageOpsgenie() {
+ return (
+ this.integrations?.list?.every(({ active }) => active === false) ||
+ this.integrations?.list?.length === 0
+ );
+ },
+ },
+ methods: {
+ createNewIntegration({ type, variables }) {
+ const { projectPath } = this;
+
+ this.isUpdating = true;
+ this.$apollo
+ .mutate({
+ mutation:
+ type === this.$options.typeSet.http
+ ? createHttpIntegrationMutation
+ : createPrometheusIntegrationMutation,
+ variables: {
+ ...variables,
+ projectPath,
+ },
+ update(store, { data }) {
+ updateStoreAfterIntegrationAdd(store, getIntegrationsQuery, data, { projectPath });
+ },
+ })
+ .then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => {
+ const error = httpIntegrationCreate?.errors[0] || prometheusIntegrationCreate?.errors[0];
+ if (error) {
+ return createFlash({ message: error });
+ }
+ return createFlash({
+ message: this.$options.i18n.changesSaved,
+ type: FLASH_TYPES.SUCCESS,
+ });
+ })
+ .catch(() => {
+ createFlash({ message: ADD_INTEGRATION_ERROR });
+ })
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ updateIntegration({ type, variables }) {
+ this.isUpdating = true;
+ this.$apollo
+ .mutate({
+ mutation:
+ type === this.$options.typeSet.http
+ ? updateHttpIntegrationMutation
+ : updatePrometheusIntegrationMutation,
+ variables: {
+ ...variables,
+ id: this.currentIntegration.id,
+ },
+ })
+ .then(({ data: { httpIntegrationUpdate, prometheusIntegrationUpdate } = {} } = {}) => {
+ const error = httpIntegrationUpdate?.errors[0] || prometheusIntegrationUpdate?.errors[0];
+ if (error) {
+ return createFlash({ message: error });
+ }
+ return createFlash({
+ message: this.$options.i18n.changesSaved,
+ type: FLASH_TYPES.SUCCESS,
+ });
+ })
+ .catch(() => {
+ createFlash({ message: UPDATE_INTEGRATION_ERROR });
+ })
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ resetToken({ type, variables }) {
+ this.isUpdating = true;
+ this.$apollo
+ .mutate({
+ mutation:
+ type === this.$options.typeSet.http
+ ? resetHttpTokenMutation
+ : resetPrometheusTokenMutation,
+ variables,
+ })
+ .then(
+ ({ data: { httpIntegrationResetToken, prometheusIntegrationResetToken } = {} } = {}) => {
+ const error =
+ httpIntegrationResetToken?.errors[0] || prometheusIntegrationResetToken?.errors[0];
+ if (error) {
+ return createFlash({ message: error });
+ }
+
+ const integration =
+ httpIntegrationResetToken?.integration ||
+ prometheusIntegrationResetToken?.integration;
+ this.currentIntegration = integration;
+
+ return createFlash({
+ message: this.$options.i18n.changesSaved,
+ type: FLASH_TYPES.SUCCESS,
+ });
+ },
+ )
+ .catch(() => {
+ createFlash({ message: RESET_INTEGRATION_TOKEN_ERROR });
+ })
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ editIntegration({ id }) {
+ const currentIntegration = this.integrations.list.find(integration => integration.id === id);
+ this.$apollo.mutate({
+ mutation: updateCurrentIntergrationMutation,
+ variables: {
+ id: currentIntegration.id,
+ name: currentIntegration.name,
+ active: currentIntegration.active,
+ token: currentIntegration.token,
+ type: currentIntegration.type,
+ url: currentIntegration.url,
+ apiUrl: currentIntegration.apiUrl,
+ },
+ });
+ },
+ deleteIntegration({ id }) {
+ const { projectPath } = this;
+
+ this.isUpdating = true;
+ this.$apollo
+ .mutate({
+ mutation: destroyHttpIntegrationMutation,
+ variables: {
+ id,
+ },
+ update(store, { data }) {
+ updateStoreAfterIntegrationDelete(store, getIntegrationsQuery, data, { projectPath });
+ },
+ })
+ .then(({ data: { httpIntegrationDestroy } = {} } = {}) => {
+ const error = httpIntegrationDestroy?.errors[0];
+ if (error) {
+ return createFlash({ message: error });
+ }
+ this.clearCurrentIntegration();
+ return createFlash({
+ message: this.$options.i18n.integrationRemoved,
+ type: FLASH_TYPES.SUCCESS,
+ });
+ })
+ .catch(() => {
+ createFlash({ message: DELETE_INTEGRATION_ERROR });
+ })
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ clearCurrentIntegration() {
+ this.$apollo.mutate({
+ mutation: updateCurrentIntergrationMutation,
+ variables: {},
+ });
+ },
+ testPayloadFailure() {
+ createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <!-- TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 -->
+ <gl-alert v-if="opsgenie.active" :dismissible="false" variant="tip">
+ <gl-sprintf
+ :message="
+ s__(
+ 'AlertSettings|We will soon be introducing the ability to create multiple unique HTTP endpoints. When this functionality is live, you will be able to configure an integration with Opsgenie to surface Opsgenie alerts in GitLab. This will replace the current Opsgenie integration which will be deprecated. %{linkStart}More Information%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link
+ class="gl-display-inline-block"
+ href="https://gitlab.com/gitlab-org/gitlab/-/issues/273657"
+ target="_blank"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ <integrations-list
+ v-else
+ :integrations="glFeatures.httpIntegrationsList ? integrations.list : integrationsOptionsOld"
+ :loading="loading"
+ @edit-integration="editIntegration"
+ @delete-integration="deleteIntegration"
+ />
+ <settings-form-new
+ v-if="glFeatures.httpIntegrationsList"
+ :loading="isUpdating"
+ :can-add-integration="canAddIntegration"
+ :can-manage-opsgenie="canManageOpsgenie"
+ @create-new-integration="createNewIntegration"
+ @update-integration="updateIntegration"
+ @reset-token="resetToken"
+ @clear-current-integration="clearCurrentIntegration"
+ @test-payload-failure="testPayloadFailure"
+ />
+ <settings-form-old v-else />
+ </div>
+</template>
diff --git a/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json b/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json
new file mode 100644
index 00000000000..ac559a30eda
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json
@@ -0,0 +1,112 @@
+[
+ {
+ "name": "title",
+ "label": "Title",
+ "type": [
+ "String"
+ ],
+ "compatibleTypes": [
+ "String",
+ "Number",
+ "DateTime"
+ ],
+ "numberOfFallbacks": 1
+ },
+ {
+ "name": "description",
+ "label": "Description",
+ "type": [
+ "String"
+ ],
+ "compatibleTypes": [
+ "String",
+ "Number",
+ "DateTime"
+ ]
+ },
+ {
+ "name": "startTime",
+ "label": "Start time",
+ "type": [
+ "DateTime"
+ ],
+ "compatibleTypes": [
+ "Number",
+ "DateTime"
+ ]
+ },
+ {
+ "name": "service",
+ "label": "Service",
+ "type": [
+ "String"
+ ],
+ "compatibleTypes": [
+ "String",
+ "Number",
+ "DateTime"
+ ]
+ },
+ {
+ "name": "monitoringTool",
+ "label": "Monitoring tool",
+ "type": [
+ "String"
+ ],
+ "compatibleTypes": [
+ "String",
+ "Number",
+ "DateTime"
+ ]
+ },
+ {
+ "name": "hosts",
+ "label": "Hosts",
+ "type": [
+ "String",
+ "Array"
+ ],
+ "compatibleTypes": [
+ "String",
+ "Array",
+ "Number",
+ "DateTime"
+ ]
+ },
+ {
+ "name": "severity",
+ "label": "Severity",
+ "type": [
+ "String"
+ ],
+ "compatibleTypes": [
+ "String",
+ "Number",
+ "DateTime"
+ ]
+ },
+ {
+ "name": "fingerprint",
+ "label": "Fingerprint",
+ "type": [
+ "String"
+ ],
+ "compatibleTypes": [
+ "String",
+ "Number",
+ "DateTime"
+ ]
+ },
+ {
+ "name": "environment",
+ "label": "Environment",
+ "type": [
+ "String"
+ ],
+ "compatibleTypes": [
+ "String",
+ "Number",
+ "DateTime"
+ ]
+ }
+]
diff --git a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json
new file mode 100644
index 00000000000..5326678155d
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json
@@ -0,0 +1,121 @@
+{
+ "samplePayload": {
+ "body": "{\n \"dashboardId\":1,\n \"evalMatches\":[\n {\n \"value\":1,\n \"metric\":\"Count\",\n \"tags\":{}\n }\n ],\n \"imageUrl\":\"https://grafana.com/static/assets/img/blog/mixed_styles.png\",\n \"message\":\"Notification Message\",\n \"orgId\":1,\n \"panelId\":2,\n \"ruleId\":1,\n \"ruleName\":\"Panel Title alert\",\n \"ruleUrl\":\"http://localhost:3000/d/hZ7BuVbWz/test-dashboard?fullscreen\\u0026edit\\u0026tab=alert\\u0026panelId=2\\u0026orgId=1\",\n \"state\":\"alerting\",\n \"tags\":{\n \"tag name\":\"tag value\"\n },\n \"title\":\"[Alerting] Panel Title alert\"\n}\n",
+ "payloadAlerFields": {
+ "nodes": [
+ {
+ "name": "dashboardId",
+ "label": "Dashboard Id",
+ "type": [
+ "Number"
+ ]
+ },
+ {
+ "name": "evalMatches",
+ "label": "Eval Matches",
+ "type": [
+ "Array"
+ ]
+ },
+ {
+ "name": "createdAt",
+ "label": "Created At",
+ "type": [
+ "DateTime"
+ ]
+ },
+ {
+ "name": "imageUrl",
+ "label": "Image Url",
+ "type": [
+ "String"
+ ]
+ },
+ {
+ "name": "message",
+ "label": "Message",
+ "type": [
+ "String"
+ ]
+ },
+ {
+ "name": "orgId",
+ "label": "Org Id",
+ "type": [
+ "Number"
+ ]
+ },
+ {
+ "name": "panelId",
+ "label": "Panel Id",
+ "type": [
+ "String"
+ ]
+ },
+ {
+ "name": "ruleId",
+ "label": "Rule Id",
+ "type": [
+ "Number"
+ ]
+ },
+ {
+ "name": "ruleName",
+ "label": "Rule Name",
+ "type": [
+ "String"
+ ]
+ },
+ {
+ "name": "ruleUrl",
+ "label": "Rule Url",
+ "type": [
+ "String"
+ ]
+ },
+ {
+ "name": "state",
+ "label": "State",
+ "type": [
+ "String"
+ ]
+ },
+ {
+ "name": "title",
+ "label": "Title",
+ "type": [
+ "String"
+ ]
+ },
+ {
+ "name": "tags",
+ "label": "Tags",
+ "type": [
+ "Object"
+ ]
+ }
+ ]
+ }
+ },
+ "storedMapping": {
+ "nodes": [
+ {
+ "alertFieldName": "title",
+ "payloadAlertPaths": "title",
+ "fallbackAlertPaths": "ruleUrl"
+ },
+ {
+ "alertFieldName": "description",
+ "payloadAlertPaths": "message"
+ },
+ {
+ "alertFieldName": "hosts",
+ "payloadAlertPaths": "evalMatches"
+ },
+ {
+ "alertFieldName": "startTime",
+ "payloadAlertPaths": "createdAt"
+ }
+ ]
+ }
+}
diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js
index 4220dbde0c7..e30dc2ad553 100644
--- a/app/assets/javascripts/alerts_settings/constants.js
+++ b/app/assets/javascripts/alerts_settings/constants.js
@@ -1,5 +1,6 @@
import { s__ } from '~/locale';
+// TODO: Remove this as part of the form old removal
export const i18n = {
usageSection: s__(
'AlertSettings|You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.',
@@ -17,11 +18,10 @@ export const i18n = {
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 improvements for %{linkStart}integrations%{linkEnd}',
+ 'AlertSettings|Learn more about our our upcoming %{linkStart}integrations%{linkEnd}',
),
resetKey: s__('AlertSettings|Reset key'),
copyToClipboard: s__('AlertSettings|Copy'),
- integrationsLabel: s__('AlertSettings|Add new integrations'),
apiBaseUrlLabel: s__('AlertSettings|API URL'),
authKeyLabel: s__('AlertSettings|Authorization key'),
urlLabel: s__('AlertSettings|Webhook URL'),
@@ -40,12 +40,26 @@ export const i18n = {
integration: s__('AlertSettings|Integration'),
};
-export const serviceOptions = [
- { value: 'generic', text: s__('AlertSettings|HTTP Endpoint') },
- { value: 'prometheus', text: s__('AlertSettings|External Prometheus') },
- { value: 'opsgenie', text: s__('AlertSettings|Opsgenie') },
+// TODO: Delete as part of old form removal in 13.6
+export const integrationTypes = [
+ { value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') },
+ { value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') },
+ { value: 'OPSGENIE', text: s__('AlertSettings|Opsgenie') },
];
+export const integrationTypesNew = [
+ { value: '', text: s__('AlertSettings|Select integration type') },
+ ...integrationTypes,
+];
+
+export const typeSet = {
+ http: 'HTTP',
+ prometheus: 'PROMETHEUS',
+ opsgenie: 'OPSGENIE',
+};
+
+export const integrationToDeleteDefault = { id: null, name: '' };
+
export const JSON_VALIDATE_DELAY = 250;
export const targetPrometheusUrlPlaceholder = 'http://prometheus.example.com/';
@@ -56,9 +70,9 @@ export const sectionHash = 'js-alert-management-settings';
/* eslint-disable @gitlab/require-i18n-strings */
/**
- * Tracks snowplow event when user views alerts intergration list
+ * Tracks snowplow event when user views alerts integration list
*/
-export const trackAlertIntergrationsViewsOptions = {
- category: 'Alert Intergrations',
+export const trackAlertIntegrationsViewsOptions = {
+ category: 'Alert Integrations',
action: 'view_alert_integrations_list',
};
diff --git a/app/assets/javascripts/alerts_settings/graphql.js b/app/assets/javascripts/alerts_settings/graphql.js
new file mode 100644
index 00000000000..02c2def87fa
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql.js
@@ -0,0 +1,44 @@
+import Vue from 'vue';
+import produce from 'immer';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import getCurrentIntegrationQuery from './graphql/queries/get_current_integration.query.graphql';
+
+Vue.use(VueApollo);
+
+const resolvers = {
+ Mutation: {
+ updateCurrentIntegration: (
+ _,
+ { id = null, name, active, token, type, url, apiUrl },
+ { cache },
+ ) => {
+ const sourceData = cache.readQuery({ query: getCurrentIntegrationQuery });
+ const data = produce(sourceData, draftData => {
+ if (id === null) {
+ // eslint-disable-next-line no-param-reassign
+ draftData.currentIntegration = null;
+ } else {
+ // eslint-disable-next-line no-param-reassign
+ draftData.currentIntegration = {
+ id,
+ name,
+ active,
+ token,
+ type,
+ url,
+ apiUrl,
+ };
+ }
+ });
+ cache.writeQuery({ query: getCurrentIntegrationQuery, data });
+ },
+ },
+};
+
+export default new VueApollo({
+ defaultClient: createDefaultClient(resolvers, {
+ cacheConfig: {},
+ assumeImmutableResults: true,
+ }),
+});
diff --git a/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql b/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql
new file mode 100644
index 00000000000..6d9307959df
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql
@@ -0,0 +1,9 @@
+fragment IntegrationItem on AlertManagementIntegration {
+ id
+ type
+ active
+ name
+ url
+ token
+ apiUrl
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql
new file mode 100644
index 00000000000..d1dacbad40a
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/integration_item.fragment.graphql"
+
+mutation createHttpIntegration($projectPath: ID!, $name: String!, $active: Boolean!) {
+ httpIntegrationCreate(input: { projectPath: $projectPath, name: $name, active: $active }) {
+ errors
+ integration {
+ ...IntegrationItem
+ }
+ }
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql
new file mode 100644
index 00000000000..bb22795ddd5
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql
@@ -0,0 +1,12 @@
+#import "../fragments/integration_item.fragment.graphql"
+
+mutation createPrometheusIntegration($projectPath: ID!, $apiUrl: String!, $active: Boolean!) {
+ prometheusIntegrationCreate(
+ input: { projectPath: $projectPath, apiUrl: $apiUrl, active: $active }
+ ) {
+ errors
+ integration {
+ ...IntegrationItem
+ }
+ }
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql
new file mode 100644
index 00000000000..0a49c140e6a
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/integration_item.fragment.graphql"
+
+mutation destroyHttpIntegration($id: ID!) {
+ httpIntegrationDestroy(input: { id: $id }) {
+ errors
+ integration {
+ ...IntegrationItem
+ }
+ }
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql
new file mode 100644
index 00000000000..178d1e13047
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/integration_item.fragment.graphql"
+
+mutation resetHttpIntegrationToken($id: ID!) {
+ httpIntegrationResetToken(input: { id: $id }) {
+ errors
+ integration {
+ ...IntegrationItem
+ }
+ }
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql
new file mode 100644
index 00000000000..8f34521b9fd
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/integration_item.fragment.graphql"
+
+mutation resetPrometheusIntegrationToken($id: ID!) {
+ prometheusIntegrationResetToken(input: { id: $id }) {
+ errors
+ integration {
+ ...IntegrationItem
+ }
+ }
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql
new file mode 100644
index 00000000000..3505241309e
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql
@@ -0,0 +1,19 @@
+mutation updateCurrentIntegration(
+ $id: String
+ $name: String
+ $active: Boolean
+ $token: String
+ $type: String
+ $url: String
+ $apiUrl: String
+) {
+ updateCurrentIntegration(
+ id: $id
+ name: $name
+ active: $active
+ token: $token
+ type: $type
+ url: $url
+ apiUrl: $apiUrl
+ ) @client
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql
new file mode 100644
index 00000000000..bb5b334deeb
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/integration_item.fragment.graphql"
+
+mutation updateHttpIntegration($id: ID!, $name: String!, $active: Boolean!) {
+ httpIntegrationUpdate(input: { id: $id, name: $name, active: $active }) {
+ errors
+ integration {
+ ...IntegrationItem
+ }
+ }
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql
new file mode 100644
index 00000000000..62761730bd2
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/integration_item.fragment.graphql"
+
+mutation updatePrometheusIntegration($id: ID!, $apiUrl: String!, $active: Boolean!) {
+ prometheusIntegrationUpdate(input: { id: $id, apiUrl: $apiUrl, active: $active }) {
+ errors
+ integration {
+ ...IntegrationItem
+ }
+ }
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_current_integration.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_current_integration.query.graphql
new file mode 100644
index 00000000000..4f22849a618
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_current_integration.query.graphql
@@ -0,0 +1,3 @@
+query currentIntegration {
+ currentIntegration @client
+}
diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
new file mode 100644
index 00000000000..228dd5fb176
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
@@ -0,0 +1,11 @@
+#import "../fragments/integration_item.fragment.graphql"
+
+query getIntegrations($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ alertManagementIntegrations {
+ nodes {
+ ...IntegrationItem
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js
index 8d1d342d229..41b19a675c5 100644
--- a/app/assets/javascripts/alerts_settings/index.js
+++ b/app/assets/javascripts/alerts_settings/index.js
@@ -1,6 +1,15 @@
import Vue from 'vue';
+import { GlToast } from '@gitlab/ui';
import { parseBoolean } from '~/lib/utils/common_utils';
-import AlertSettingsForm from './components/alerts_settings_form.vue';
+import AlertSettingsWrapper from './components/alerts_settings_wrapper.vue';
+import apolloProvider from './graphql';
+
+apolloProvider.clients.defaultClient.cache.writeData({
+ data: {
+ currentIntegration: null,
+ },
+});
+Vue.use(GlToast);
export default el => {
if (!el) {
@@ -24,20 +33,17 @@ export default el => {
opsgenieMvcFormPath,
opsgenieMvcEnabled,
opsgenieMvcTargetUrl,
+ projectPath,
+ multiIntegrations,
} = el.dataset;
- const genericActivated = parseBoolean(activatedStr);
- const prometheusIsActivated = parseBoolean(prometheusActivated);
- const opsgenieMvcActivated = parseBoolean(opsgenieMvcEnabled);
- const opsgenieMvcIsAvailable = parseBoolean(opsgenieMvcAvailable);
-
return new Vue({
el,
provide: {
prometheus: {
- activated: prometheusIsActivated,
- prometheusUrl,
- authorizationKey: prometheusAuthorizationKey,
+ active: parseBoolean(prometheusActivated),
+ url: prometheusUrl,
+ token: prometheusAuthorizationKey,
prometheusFormPath,
prometheusResetKeyPath,
prometheusApiUrl,
@@ -45,23 +51,26 @@ export default el => {
generic: {
alertsSetupUrl,
alertsUsageUrl,
- activated: genericActivated,
+ active: parseBoolean(activatedStr),
formPath,
- authorizationKey,
+ token: authorizationKey,
url,
},
opsgenie: {
formPath: opsgenieMvcFormPath,
- activated: opsgenieMvcActivated,
+ active: parseBoolean(opsgenieMvcEnabled),
opsgenieMvcTargetUrl,
- opsgenieMvcIsAvailable,
+ opsgenieMvcIsAvailable: parseBoolean(opsgenieMvcAvailable),
},
+ projectPath,
+ multiIntegrations: parseBoolean(multiIntegrations),
},
+ apolloProvider,
components: {
- AlertSettingsForm,
+ AlertSettingsWrapper,
},
render(createElement) {
- return createElement('alert-settings-form');
+ return createElement('alert-settings-wrapper');
},
});
};
diff --git a/app/assets/javascripts/alerts_settings/services/index.js b/app/assets/javascripts/alerts_settings/services/index.js
index c49992d4f57..1835d6b46aa 100644
--- a/app/assets/javascripts/alerts_settings/services/index.js
+++ b/app/assets/javascripts/alerts_settings/services/index.js
@@ -2,6 +2,7 @@
import axios from '~/lib/utils/axios_utils';
export default {
+ // TODO: All this code save updateTestAlert will be deleted as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/255501
updateGenericKey({ endpoint, params }) {
return axios.put(endpoint, params);
},
@@ -25,11 +26,11 @@ export default {
},
});
},
- updateTestAlert({ endpoint, data, authKey }) {
+ updateTestAlert({ endpoint, data, token }) {
return axios.post(endpoint, data, {
headers: {
'Content-Type': 'application/json',
- Authorization: `Bearer ${authKey}`,
+ Authorization: `Bearer ${token}`,
},
});
},
diff --git a/app/assets/javascripts/alerts_settings/utils/cache_updates.js b/app/assets/javascripts/alerts_settings/utils/cache_updates.js
new file mode 100644
index 00000000000..18054b29fe9
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/utils/cache_updates.js
@@ -0,0 +1,84 @@
+import produce from 'immer';
+import createFlash from '~/flash';
+
+import { DELETE_INTEGRATION_ERROR, ADD_INTEGRATION_ERROR } from './error_messages';
+
+const deleteIntegrationFromStore = (store, query, { httpIntegrationDestroy }, variables) => {
+ const integration = httpIntegrationDestroy?.integration;
+ if (!integration) {
+ return;
+ }
+
+ const sourceData = store.readQuery({
+ query,
+ variables,
+ });
+
+ const data = produce(sourceData, draftData => {
+ // eslint-disable-next-line no-param-reassign
+ draftData.project.alertManagementIntegrations.nodes = draftData.project.alertManagementIntegrations.nodes.filter(
+ ({ id }) => id !== integration.id,
+ );
+ });
+
+ store.writeQuery({
+ query,
+ variables,
+ data,
+ });
+};
+
+const addIntegrationToStore = (
+ store,
+ query,
+ { httpIntegrationCreate, prometheusIntegrationCreate },
+ variables,
+) => {
+ const integration =
+ httpIntegrationCreate?.integration || prometheusIntegrationCreate?.integration;
+ if (!integration) {
+ return;
+ }
+
+ const sourceData = store.readQuery({
+ query,
+ variables,
+ });
+
+ const data = produce(sourceData, draftData => {
+ // eslint-disable-next-line no-param-reassign
+ draftData.project.alertManagementIntegrations.nodes = [
+ integration,
+ ...draftData.project.alertManagementIntegrations.nodes,
+ ];
+ });
+
+ store.writeQuery({
+ query,
+ variables,
+ data,
+ });
+};
+
+const onError = (data, message) => {
+ createFlash({ message });
+ throw new Error(data.errors);
+};
+
+export const hasErrors = ({ errors = [] }) => errors?.length;
+
+export const updateStoreAfterIntegrationDelete = (store, query, data, variables) => {
+ if (hasErrors(data)) {
+ onError(data, DELETE_INTEGRATION_ERROR);
+ } else {
+ deleteIntegrationFromStore(store, query, data, variables);
+ }
+};
+
+export const updateStoreAfterIntegrationAdd = (store, query, data, variables) => {
+ if (hasErrors(data)) {
+ onError(data, ADD_INTEGRATION_ERROR);
+ } else {
+ addIntegrationToStore(store, query, data, variables);
+ }
+};
diff --git a/app/assets/javascripts/alerts_settings/utils/error_messages.js b/app/assets/javascripts/alerts_settings/utils/error_messages.js
new file mode 100644
index 00000000000..979d1ca3ccc
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/utils/error_messages.js
@@ -0,0 +1,21 @@
+import { s__ } from '~/locale';
+
+export const DELETE_INTEGRATION_ERROR = s__(
+ 'AlertsIntegrations|The integration could not be deleted. Please try again.',
+);
+
+export const ADD_INTEGRATION_ERROR = s__(
+ 'AlertsIntegrations|The integration could not be added. Please try again.',
+);
+
+export const UPDATE_INTEGRATION_ERROR = s__(
+ 'AlertsIntegrations|The current integration could not be updated. Please try again.',
+);
+
+export const RESET_INTEGRATION_TOKEN_ERROR = s__(
+ 'AlertsIntegrations|The integration token could not be reset. Please try again.',
+);
+
+export const INTEGRATION_PAYLOAD_TEST_ERROR = s__(
+ 'AlertsIntegrations|Integration payload is invalid. You can still save your changes.',
+);
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/app.vue b/app/assets/javascripts/analytics/instance_statistics/components/app.vue
index 7aa5c98aa0b..8df4d2e2524 100644
--- a/app/assets/javascripts/analytics/instance_statistics/components/app.vue
+++ b/app/assets/javascripts/analytics/instance_statistics/components/app.vue
@@ -1,19 +1,23 @@
<script>
import InstanceCounts from './instance_counts.vue';
-import PipelinesChart from './pipelines_chart.vue';
+import InstanceStatisticsCountChart from './instance_statistics_count_chart.vue';
import UsersChart from './users_chart.vue';
+import ProjectsAndGroupsChart from './projects_and_groups_chart.vue';
+import ChartsConfig from './charts_config';
import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants';
export default {
name: 'InstanceStatisticsApp',
components: {
InstanceCounts,
- PipelinesChart,
+ InstanceStatisticsCountChart,
UsersChart,
+ ProjectsAndGroupsChart,
},
TOTAL_DAYS_TO_SHOW,
START_DATE,
TODAY,
+ configs: ChartsConfig,
};
</script>
@@ -25,6 +29,20 @@ export default {
:end-date="$options.TODAY"
:total-data-points="$options.TOTAL_DAYS_TO_SHOW"
/>
- <pipelines-chart />
+ <projects-and-groups-chart
+ :start-date="$options.START_DATE"
+ :end-date="$options.TODAY"
+ :total-data-points="$options.TOTAL_DAYS_TO_SHOW"
+ />
+ <instance-statistics-count-chart
+ v-for="chartOptions in $options.configs"
+ :key="chartOptions.chartTitle"
+ :queries="chartOptions.queries"
+ :x-axis-title="chartOptions.xAxisTitle"
+ :y-axis-title="chartOptions.yAxisTitle"
+ :load-chart-error-message="chartOptions.loadChartError"
+ :no-data-message="chartOptions.noDataMessage"
+ :chart-title="chartOptions.chartTitle"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js b/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js
new file mode 100644
index 00000000000..6fba3c56cfe
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js
@@ -0,0 +1,87 @@
+import { s__, __, sprintf } from '~/locale';
+import query from '../graphql/queries/instance_count.query.graphql';
+
+const noDataMessage = s__('InstanceStatistics|No data available.');
+
+export default [
+ {
+ loadChartError: sprintf(
+ s__(
+ 'InstanceStatistics|Could not load the pipelines chart. Please refresh the page to try again.',
+ ),
+ ),
+ noDataMessage,
+ chartTitle: s__('InstanceStatistics|Pipelines'),
+ yAxisTitle: s__('InstanceStatistics|Items'),
+ xAxisTitle: s__('InstanceStatistics|Month'),
+ queries: [
+ {
+ query,
+ title: s__('InstanceStatistics|Pipelines total'),
+ identifier: 'PIPELINES',
+ loadError: sprintf(
+ s__('InstanceStatistics|There was an error fetching the total pipelines'),
+ ),
+ },
+ {
+ query,
+ title: s__('InstanceStatistics|Pipelines succeeded'),
+ identifier: 'PIPELINES_SUCCEEDED',
+ loadError: sprintf(
+ s__('InstanceStatistics|There was an error fetching the successful pipelines'),
+ ),
+ },
+ {
+ query,
+ title: s__('InstanceStatistics|Pipelines failed'),
+ identifier: 'PIPELINES_FAILED',
+ loadError: sprintf(
+ s__('InstanceStatistics|There was an error fetching the failed pipelines'),
+ ),
+ },
+ {
+ query,
+ title: s__('InstanceStatistics|Pipelines canceled'),
+ identifier: 'PIPELINES_CANCELED',
+ loadError: sprintf(
+ s__('InstanceStatistics|There was an error fetching the cancelled pipelines'),
+ ),
+ },
+ {
+ query,
+ title: s__('InstanceStatistics|Pipelines skipped'),
+ identifier: 'PIPELINES_SKIPPED',
+ loadError: sprintf(
+ s__('InstanceStatistics|There was an error fetching the skipped pipelines'),
+ ),
+ },
+ ],
+ },
+ {
+ loadChartError: sprintf(
+ s__(
+ 'InstanceStatistics|Could not load the issues and merge requests chart. Please refresh the page to try again.',
+ ),
+ ),
+ noDataMessage,
+ chartTitle: s__('InstanceStatistics|Issues & Merge Requests'),
+ yAxisTitle: s__('InstanceStatistics|Items'),
+ xAxisTitle: s__('InstanceStatistics|Month'),
+ queries: [
+ {
+ query,
+ title: __('Issues'),
+ identifier: 'ISSUES',
+ loadError: sprintf(s__('InstanceStatistics|There was an error fetching the issues')),
+ },
+ {
+ query,
+ title: __('Merge requests'),
+ identifier: 'MERGE_REQUESTS',
+ loadError: sprintf(
+ s__('InstanceStatistics|There was an error fetching the merge requests'),
+ ),
+ },
+ ],
+ },
+];
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue
new file mode 100644
index 00000000000..a9bd1bb2f41
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue
@@ -0,0 +1,206 @@
+<script>
+import { GlLineChart } from '@gitlab/ui/dist/charts';
+import { GlAlert } from '@gitlab/ui';
+import { some, every } from 'lodash';
+import * as Sentry from '~/sentry/wrapper';
+import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
+import {
+ differenceInMonths,
+ formatDateAsMonth,
+ getDayDifference,
+} from '~/lib/utils/datetime_utility';
+import { getAverageByMonth, getEarliestDate, generateDataKeys } from '../utils';
+import { TODAY, START_DATE } from '../constants';
+
+const QUERY_DATA_KEY = 'instanceStatisticsMeasurements';
+
+export default {
+ name: 'InstanceStatisticsCountChart',
+ components: {
+ GlLineChart,
+ GlAlert,
+ ChartSkeletonLoader,
+ },
+ startDate: START_DATE,
+ endDate: TODAY,
+ props: {
+ chartTitle: {
+ type: String,
+ required: true,
+ },
+ loadChartErrorMessage: {
+ type: String,
+ required: true,
+ },
+ noDataMessage: {
+ type: String,
+ required: true,
+ },
+ xAxisTitle: {
+ type: String,
+ required: true,
+ },
+ yAxisTitle: {
+ type: String,
+ required: true,
+ },
+ queries: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ errors: { ...generateDataKeys(this.queries, '') },
+ ...generateDataKeys(this.queries, []),
+ };
+ },
+ computed: {
+ errorMessages() {
+ return Object.values(this.errors);
+ },
+ isLoading() {
+ return some(this.$apollo.queries, query => query?.loading);
+ },
+ allQueriesFailed() {
+ return every(this.errorMessages, message => message.length);
+ },
+ hasLoadingErrors() {
+ return some(this.errorMessages, message => message.length);
+ },
+ errorMessage() {
+ // show the generic loading message if all requests fail
+ return this.allQueriesFailed ? this.loadChartErrorMessage : this.errorMessages.join('\n\n');
+ },
+ hasEmptyDataSet() {
+ return this.chartData.every(({ data }) => data.length === 0);
+ },
+ totalDaysToShow() {
+ return getDayDifference(this.$options.startDate, this.$options.endDate);
+ },
+ chartData() {
+ const options = { shouldRound: true };
+ return this.queries.map(({ identifier, title }) => ({
+ name: title,
+ data: getAverageByMonth(this[identifier]?.nodes, options),
+ }));
+ },
+ range() {
+ return {
+ min: this.$options.startDate,
+ max: this.$options.endDate,
+ };
+ },
+ chartOptions() {
+ const { endDate, startDate } = this.$options;
+ return {
+ xAxis: {
+ ...this.range,
+ name: this.xAxisTitle,
+ type: 'time',
+ splitNumber: differenceInMonths(startDate, endDate) + 1,
+ axisLabel: {
+ interval: 0,
+ showMinLabel: false,
+ showMaxLabel: false,
+ align: 'right',
+ formatter: formatDateAsMonth,
+ },
+ },
+ yAxis: {
+ name: this.yAxisTitle,
+ },
+ };
+ },
+ },
+ created() {
+ this.queries.forEach(({ query, identifier, loadError }) => {
+ this.$apollo.addSmartQuery(identifier, {
+ query,
+ variables() {
+ return {
+ identifier,
+ first: this.totalDaysToShow,
+ after: null,
+ };
+ },
+ update(data) {
+ const { nodes = [], pageInfo } = data[QUERY_DATA_KEY] || {};
+ return {
+ nodes,
+ pageInfo,
+ };
+ },
+ result() {
+ const { pageInfo, nodes } = this[identifier];
+ if (pageInfo?.hasNextPage && this.calculateDaysToFetch(getEarliestDate(nodes)) > 0) {
+ this.fetchNextPage({
+ query: this.$apollo.queries[identifier],
+ errorMessage: loadError,
+ pageInfo,
+ identifier,
+ });
+ }
+ },
+ error(error) {
+ this.handleError({
+ message: loadError,
+ identifier,
+ error,
+ });
+ },
+ });
+ });
+ },
+ methods: {
+ calculateDaysToFetch(firstDataPointDate = null) {
+ return firstDataPointDate
+ ? Math.max(0, getDayDifference(this.$options.startDate, new Date(firstDataPointDate)))
+ : 0;
+ },
+ handleError({ identifier, error, message }) {
+ this.loadingError = true;
+ this.errors = { ...this.errors, [identifier]: message };
+ Sentry.captureException(error);
+ },
+ fetchNextPage({ query, pageInfo, identifier, errorMessage }) {
+ query
+ .fetchMore({
+ variables: {
+ identifier,
+ first: this.calculateDaysToFetch(getEarliestDate(this[identifier].nodes)),
+ after: pageInfo.endCursor,
+ },
+ updateQuery: (previousResult, { fetchMoreResult }) => {
+ const { nodes, ...rest } = fetchMoreResult[QUERY_DATA_KEY];
+ const { nodes: previousNodes } = previousResult[QUERY_DATA_KEY];
+ return {
+ [QUERY_DATA_KEY]: { ...rest, nodes: [...previousNodes, ...nodes] },
+ };
+ },
+ })
+ .catch(error => this.handleError({ identifier, error, message: errorMessage }));
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <h3>{{ chartTitle }}</h3>
+ <gl-alert v-if="hasLoadingErrors" variant="danger" :dismissible="false" class="gl-mt-3">
+ {{ errorMessage }}
+ </gl-alert>
+ <div v-if="!allQueriesFailed">
+ <chart-skeleton-loader v-if="isLoading" />
+ <gl-alert v-else-if="hasEmptyDataSet" variant="info" :dismissible="false" class="gl-mt-3">
+ {{ noDataMessage }}
+ </gl-alert>
+ <gl-line-chart
+ v-else
+ :option="chartOptions"
+ :include-legend-avg-max="true"
+ :data="chartData"
+ />
+ </div>
+ </div>
+</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
deleted file mode 100644
index b16d960402b..00000000000
--- a/app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue
+++ /dev/null
@@ -1,215 +0,0 @@
-<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/projects_and_groups_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue
new file mode 100644
index 00000000000..e8e35c22fe1
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue
@@ -0,0 +1,224 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { GlLineChart } 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 { s__, __ } from '~/locale';
+import { formatDateAsMonth } from '~/lib/utils/datetime_utility';
+import latestGroupsQuery from '../graphql/queries/groups.query.graphql';
+import latestProjectsQuery from '../graphql/queries/projects.query.graphql';
+import { getAverageByMonth } from '../utils';
+
+const sortByDate = data => sortBy(data, item => new Date(item[0]).getTime());
+
+const averageAndSortData = (data = [], maxDataPoints) => {
+ const averaged = getAverageByMonth(
+ data.length > maxDataPoints ? data.slice(0, maxDataPoints) : data,
+ { shouldRound: true },
+ );
+ return sortByDate(averaged);
+};
+
+export default {
+ name: 'ProjectsAndGroupsChart',
+ components: { GlAlert, GlLineChart, ChartSkeletonLoader },
+ props: {
+ startDate: {
+ type: Date,
+ required: true,
+ },
+ endDate: {
+ type: Date,
+ required: true,
+ },
+ totalDataPoints: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ loadingError: false,
+ errorMessage: '',
+ groups: [],
+ projects: [],
+ groupsPageInfo: null,
+ projectsPageInfo: null,
+ };
+ },
+ apollo: {
+ groups: {
+ query: latestGroupsQuery,
+ variables() {
+ return {
+ first: this.totalDataPoints,
+ after: null,
+ };
+ },
+ update(data) {
+ return data.groups?.nodes || [];
+ },
+ result({ data }) {
+ const {
+ groups: { pageInfo },
+ } = data;
+ this.groupsPageInfo = pageInfo;
+ this.fetchNextPage({
+ query: this.$apollo.queries.groups,
+ pageInfo: this.groupsPageInfo,
+ dataKey: 'groups',
+ errorMessage: this.$options.i18n.loadGroupsDataError,
+ });
+ },
+ error(error) {
+ this.handleError({
+ message: this.$options.i18n.loadGroupsDataError,
+ error,
+ dataKey: 'groups',
+ });
+ },
+ },
+ projects: {
+ query: latestProjectsQuery,
+ variables() {
+ return {
+ first: this.totalDataPoints,
+ after: null,
+ };
+ },
+ update(data) {
+ return data.projects?.nodes || [];
+ },
+ result({ data }) {
+ const {
+ projects: { pageInfo },
+ } = data;
+ this.projectsPageInfo = pageInfo;
+ this.fetchNextPage({
+ query: this.$apollo.queries.projects,
+ pageInfo: this.projectsPageInfo,
+ dataKey: 'projects',
+ errorMessage: this.$options.i18n.loadProjectsDataError,
+ });
+ },
+ error(error) {
+ this.handleError({
+ message: this.$options.i18n.loadProjectsDataError,
+ error,
+ dataKey: 'projects',
+ });
+ },
+ },
+ },
+ i18n: {
+ yAxisTitle: s__('InstanceStatistics|Total projects & groups'),
+ xAxisTitle: __('Month'),
+ loadChartError: s__(
+ 'InstanceStatistics|Could not load the projects and groups chart. Please refresh the page to try again.',
+ ),
+ loadProjectsDataError: s__('InstanceStatistics|There was an error while loading the projects'),
+ loadGroupsDataError: s__('InstanceStatistics|There was an error while loading the groups'),
+ noDataMessage: s__('InstanceStatistics|No data available.'),
+ },
+ computed: {
+ isLoadingGroups() {
+ return this.$apollo.queries.groups.loading || this.groupsPageInfo?.hasNextPage;
+ },
+ isLoadingProjects() {
+ return this.$apollo.queries.projects.loading || this.projectsPageInfo?.hasNextPage;
+ },
+ isLoading() {
+ return this.isLoadingProjects && this.isLoadingGroups;
+ },
+ groupChartData() {
+ return averageAndSortData(this.groups, this.totalDataPoints);
+ },
+ projectChartData() {
+ return averageAndSortData(this.projects, this.totalDataPoints);
+ },
+ hasNoData() {
+ const { projectChartData, groupChartData } = this;
+ return Boolean(!projectChartData.length && !groupChartData.length);
+ },
+ options() {
+ return {
+ xAxis: {
+ name: this.$options.i18n.xAxisTitle,
+ type: 'category',
+ axisLabel: {
+ formatter: value => {
+ return formatDateAsMonth(value);
+ },
+ },
+ },
+ yAxis: {
+ name: this.$options.i18n.yAxisTitle,
+ },
+ };
+ },
+ chartData() {
+ return [
+ {
+ name: s__('InstanceStatistics|Total projects'),
+ data: this.projectChartData,
+ },
+ {
+ name: s__('InstanceStatistics|Total groups'),
+ data: this.groupChartData,
+ },
+ ];
+ },
+ },
+ methods: {
+ handleError({ error, message = this.$options.i18n.loadChartError, dataKey = null }) {
+ this.loadingError = true;
+ this.errorMessage = message;
+ if (!dataKey) {
+ this.projects = [];
+ this.groups = [];
+ } else {
+ this[dataKey] = [];
+ }
+ Sentry.captureException(error);
+ },
+ fetchNextPage({ pageInfo, query, dataKey, errorMessage }) {
+ if (pageInfo?.hasNextPage) {
+ query
+ .fetchMore({
+ variables: { first: this.totalDataPoints, after: pageInfo.endCursor },
+ updateQuery: (previousResult, { fetchMoreResult }) => {
+ const results = produce(fetchMoreResult, newData => {
+ // eslint-disable-next-line no-param-reassign
+ newData[dataKey].nodes = [
+ ...previousResult[dataKey].nodes,
+ ...newData[dataKey].nodes,
+ ];
+ });
+ return results;
+ },
+ })
+ .catch(error => {
+ this.handleError({ error, message: errorMessage, dataKey });
+ });
+ }
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <h3>{{ $options.i18n.yAxisTitle }}</h3>
+ <chart-skeleton-loader v-if="isLoading" />
+ <gl-alert v-else-if="hasNoData" variant="info" :dismissible="false" class="gl-mt-3">
+ {{ $options.i18n.noDataMessage }}
+ </gl-alert>
+ <div v-else>
+ <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">{{
+ errorMessage
+ }}</gl-alert>
+ <gl-line-chart :option="options" :include-legend-avg-max="true" :data="chartData" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql
new file mode 100644
index 00000000000..ec56d91ffaa
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql
@@ -0,0 +1,13 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "../fragments/count.fragment.graphql"
+
+query getGroupsCount($first: Int, $after: String) {
+ groups: instanceStatisticsMeasurements(identifier: GROUPS, first: $first, after: $after) {
+ nodes {
+ ...Count
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+}
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql
new file mode 100644
index 00000000000..dd22a16cd51
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql
@@ -0,0 +1,13 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "../fragments/count.fragment.graphql"
+
+query getCount($identifier: MeasurementIdentifier!, $first: Int, $after: String) {
+ instanceStatisticsMeasurements(identifier: $identifier, first: $first, after: $after) {
+ nodes {
+ ...Count
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+}
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
deleted file mode 100644
index 3bf40403f91..00000000000
--- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql
+++ /dev/null
@@ -1,76 +0,0 @@
-#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/projects.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql
new file mode 100644
index 00000000000..0845b703435
--- /dev/null
+++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql
@@ -0,0 +1,13 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "../fragments/count.fragment.graphql"
+
+query getProjectsCount($first: Int, $after: String) {
+ projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: $first, after: $after) {
+ nodes {
+ ...Count
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+}
diff --git a/app/assets/javascripts/analytics/instance_statistics/utils.js b/app/assets/javascripts/analytics/instance_statistics/utils.js
index 907482c0c72..e1fa5d155a2 100644
--- a/app/assets/javascripts/analytics/instance_statistics/utils.js
+++ b/app/assets/javascripts/analytics/instance_statistics/utils.js
@@ -1,5 +1,5 @@
import { masks } from 'dateformat';
-import { mapKeys, mapValues, pick, sortBy } from 'lodash';
+import { get } from 'lodash';
import { formatDate } from '~/lib/utils/datetime_utility';
const { isoDate } = masks;
@@ -41,29 +41,28 @@ export function getAverageByMonth(items = [], options = {}) {
}
/**
- * 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
+ * Takes an array of instance counts and returns the last item in the list
+ * @param {Array} arr array of instance counts in the form { count: Number, recordedAt: date String }
+ * @return {String} the 'recordedAt' value of the earliest item
*/
-export function extractValues(data, dataKeys = [], replaceKey, nestedKey) {
- return mapKeys(pick(mapValues(data, nestedKey), dataKeys), (value, key) =>
- key.replace(replaceKey, nestedKey),
- );
-}
+export const getEarliestDate = (arr = []) => {
+ const len = arr.length;
+ return get(arr, `[${len - 1}].recordedAt`, null);
+};
/**
- * 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.
+ * Takes an array of queries and produces an object with the query identifier as key
+ * and a supplied defaultValue as its value
+ * @param {Array} queries array of chart query configs,
+ * see ./analytics/instance_statistics/components/charts_config.js
+ * @param {any} defaultValue value to set each identifier to
+ * @return {Object} key value pair of the form { queryIdentifier: defaultValue }
*/
-export function sortByDate(items = []) {
- return sortBy(items, ({ recordedAt }) => new Date(recordedAt).getTime());
-}
+export const generateDataKeys = (queries, defaultValue) =>
+ queries.reduce(
+ (acc, { identifier }) => ({
+ ...acc,
+ [identifier]: defaultValue,
+ }),
+ {},
+ );
diff --git a/app/assets/javascripts/analytics/product_analytics/components/activity_chart.vue b/app/assets/javascripts/analytics/product_analytics/components/activity_chart.vue
index a475ff8fd25..2be9ebda87a 100644
--- a/app/assets/javascripts/analytics/product_analytics/components/activity_chart.vue
+++ b/app/assets/javascripts/analytics/product_analytics/components/activity_chart.vue
@@ -17,10 +17,13 @@ export default {
},
},
computed: {
- seriesData() {
- return {
- full: this.formattedData.keys.map((val, idx) => [val, this.formattedData.values[idx]]),
- };
+ barSeriesData() {
+ return [
+ {
+ name: 'full',
+ data: this.formattedData.keys.map((val, idx) => [val, this.formattedData.values[idx]]),
+ },
+ ];
},
},
};
@@ -30,7 +33,7 @@ export default {
<div class="gl-xs-w-full">
<gl-column-chart
v-if="formattedData.keys"
- :data="seriesData"
+ :bars="barSeriesData"
:x-axis-title="__('Value')"
:y-axis-title="__('Number of events')"
:x-axis-type="'category'"
diff --git a/app/assets/javascripts/analytics/shared/components/metric_card.vue b/app/assets/javascripts/analytics/shared/components/metric_card.vue
index cee186c057c..e6e12821bec 100644
--- a/app/assets/javascripts/analytics/shared/components/metric_card.vue
+++ b/app/assets/javascripts/analytics/shared/components/metric_card.vue
@@ -43,7 +43,7 @@ export default {
};
</script>
<template>
- <gl-card>
+ <gl-card class="gl-mb-5">
<template #header>
<strong ref="title">{{ title }}</strong>
</template>
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 63b75cdb734..f469f49ce20 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -6,6 +6,7 @@ import { __ } from '~/locale';
const DEFAULT_PER_PAGE = 20;
const Api = {
+ DEFAULT_PER_PAGE,
groupsPath: '/api/:version/groups.json',
groupPath: '/api/:version/groups/:id',
groupMembersPath: '/api/:version/groups/:id/members',
@@ -22,6 +23,7 @@ const Api = {
projectLabelsPath: '/:namespace_path/:project_path/-/labels',
projectFileSchemaPath: '/:namespace_path/:project_path/-/schema/:ref/:filename',
projectUsersPath: '/api/:version/projects/:id/users',
+ projectMembersPath: '/api/:version/projects/:id/members',
projectMergeRequestsPath: '/api/:version/projects/:id/merge_requests',
projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid',
projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes',
@@ -34,6 +36,7 @@ const Api = {
mergeRequestsPath: '/api/:version/merge_requests',
groupLabelsPath: '/groups/:namespace_path/-/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
+ issuableTemplatesPath: '/:namespace_path/:project_path/templates/:type',
projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key',
projectTemplatesPath: '/api/:version/projects/:id/templates/:type',
userCountsPath: '/api/:version/user_counts',
@@ -70,6 +73,7 @@ const Api = {
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',
+ containerRegistryDetailsPath: '/api/:version/registry/repositories/:id/',
group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@@ -106,6 +110,11 @@ const Api = {
return axios.delete(url);
},
+ containerRegistryDetails(registryId, options = {}) {
+ const url = Api.buildUrl(this.containerRegistryDetailsPath).replace(':id', registryId);
+ return axios.get(url, options);
+ },
+
groupMembers(id, options) {
const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
@@ -207,6 +216,12 @@ const Api = {
.then(({ data }) => data);
},
+ inviteProjectMembers(id, data) {
+ const url = Api.buildUrl(this.projectMembersPath).replace(':id', encodeURIComponent(id));
+
+ return axios.post(url, data);
+ },
+
// Return single project
project(projectPath) {
const url = Api.buildUrl(Api.projectPath).replace(':id', encodeURIComponent(projectPath));
@@ -454,17 +469,38 @@ const Api = {
},
issueTemplate(namespacePath, projectPath, key, type, callback) {
- const url = Api.buildUrl(Api.issuableTemplatePath)
- .replace(':key', encodeURIComponent(key))
- .replace(':type', type)
- .replace(':project_path', projectPath)
- .replace(':namespace_path', namespacePath);
+ const url = this.buildIssueTemplateUrl(
+ Api.issuableTemplatePath,
+ type,
+ projectPath,
+ namespacePath,
+ ).replace(':key', encodeURIComponent(key));
+ return axios
+ .get(url)
+ .then(({ data }) => callback(null, data))
+ .catch(callback);
+ },
+
+ issueTemplates(namespacePath, projectPath, type, callback) {
+ const url = this.buildIssueTemplateUrl(
+ Api.issuableTemplatesPath,
+ type,
+ projectPath,
+ namespacePath,
+ );
return axios
.get(url)
.then(({ data }) => callback(null, data))
.catch(callback);
},
+ buildIssueTemplateUrl(path, type, projectPath, namespacePath) {
+ return Api.buildUrl(path)
+ .replace(':type', type)
+ .replace(':project_path', projectPath)
+ .replace(':namespace_path', namespacePath);
+ },
+
users(query, options) {
const url = Api.buildUrl(this.usersPath);
return axios.get(url, {
@@ -530,12 +566,13 @@ const Api = {
});
},
- postUserStatus({ emoji, message }) {
+ postUserStatus({ emoji, message, availability }) {
const url = Api.buildUrl(this.userPostStatusPath);
return axios.put(url, {
emoji,
message,
+ availability,
});
},
@@ -610,12 +647,12 @@ const Api = {
return axios.get(url);
},
- pipelineJobs(projectId, pipelineId) {
+ pipelineJobs(projectId, pipelineId, params) {
const url = Api.buildUrl(this.pipelineJobsPath)
.replace(':id', encodeURIComponent(projectId))
.replace(':pipeline_id', encodeURIComponent(pipelineId));
- return axios.get(url);
+ return axios.get(url, { params });
},
// Return all pipelines for a project or filter by query params
@@ -737,6 +774,12 @@ const Api = {
return axios.get(url, { params: { page } });
},
+ searchFeatureFlagUserLists(id, search) {
+ const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id);
+
+ return axios.get(url, { params: { search } });
+ },
+
createFeatureFlagUserList(id, list) {
const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id);
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 7055cd42978..17e6255700a 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -5,11 +5,11 @@ import { uniq } from 'lodash';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Cookies from 'js-cookie';
import { __ } from './locale';
-import { updateTooltipTitle } from './lib/utils/common_utils';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import { deprecatedCreateFlash as flash } from './flash';
import axios from './lib/utils/axios_utils';
import * as Emoji from '~/emoji';
+import { dispose, fixTitle } from '~/tooltips';
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
@@ -374,7 +374,7 @@ export class AwardsHandler {
counter.text(counterNumber - 1);
this.removeYouFromUserList($emojiButton);
} else if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
- $emojiButton.tooltip('dispose');
+ dispose($emojiButton);
counter.text('0');
this.removeYouFromUserList($emojiButton);
if ($emojiButton.parents('.note').length) {
@@ -387,7 +387,8 @@ export class AwardsHandler {
}
removeEmoji($emojiButton) {
- $emojiButton.tooltip('dispose');
+ dispose($emojiButton);
+
$emojiButton.remove();
const $votesBlock = this.getVotesBlock();
if ($votesBlock.find('.js-emoji-btn').length === 0) {
@@ -415,13 +416,17 @@ export class AwardsHandler {
const originalTitle = this.getAwardTooltip(awardBlock);
const authors = originalTitle.split(FROM_SENTENCE_REGEX);
authors.splice(authors.indexOf('You'), 1);
- return awardBlock
+
+ awardBlock
.closest('.js-emoji-btn')
.removeData('title')
.removeAttr('data-title')
.removeAttr('data-original-title')
- .attr('title', this.toSentence(authors))
- .tooltip('_fixTitle');
+ .attr('title', this.toSentence(authors));
+
+ fixTitle(awardBlock);
+
+ return awardBlock;
}
addYouToUserList(votesBlock, emoji) {
@@ -432,7 +437,12 @@ export class AwardsHandler {
users = origTitle.trim().split(FROM_SENTENCE_REGEX);
}
users.unshift('You');
- return awardBlock.attr('title', this.toSentence(users)).tooltip('_fixTitle');
+
+ awardBlock.attr('title', this.toSentence(users));
+
+ fixTitle(awardBlock);
+
+ return awardBlock;
}
createAwardButtonForVotesBlock(votesBlock, emojiName) {
@@ -448,7 +458,7 @@ export class AwardsHandler {
.find('.emoji-icon')
.data('name', emojiName);
this.animateEmoji($emojiButton);
- $('.award-control').tooltip();
+
votesBlock.removeClass('current');
}
@@ -487,17 +497,6 @@ export class AwardsHandler {
return votesBlock.find(`.js-emoji-btn [data-name="${emoji}"]`);
}
- userAuthored($emojiButton) {
- const oldTitle = this.getAwardTooltip($emojiButton);
- const newTitle = 'You cannot vote on your own issue, MR and note';
- updateTooltipTitle($emojiButton, newTitle).tooltip('show');
- // Restore tooltip back to award list
- return setTimeout(() => {
- $emojiButton.tooltip('hide');
- updateTooltipTitle($emojiButton, oldTitle);
- }, 2800);
- }
-
scrollToAwards() {
const options = {
scrollTop: $('.awards').offset().top - 110,
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
index 3dd05f73841..0b8c6aff219 100644
--- a/app/assets/javascripts/badges/components/badge.vue
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlTooltipDirective, GlIcon, GlButton } from '@gitlab/ui';
export default {
// name: 'Badge' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
@@ -8,6 +8,7 @@ export default {
components: {
GlIcon,
GlLoadingIcon,
+ GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -90,15 +91,16 @@ export default {
</div>
</div>
- <button
+ <gl-button
v-show="hasError"
v-gl-tooltip.hover
:title="s__('Badges|Reload badge image')"
- class="btn btn-transparent btn-sm text-primary"
+ category="tertiary"
+ variant="success"
type="button"
+ icon="retry"
+ size="small"
@click="reloadImage"
- >
- <gl-icon :size="16" name="retry" />
- </button>
+ />
</div>
</template>
diff --git a/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue b/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue
deleted file mode 100644
index 385725cd109..00000000000
--- a/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue
+++ /dev/null
@@ -1,32 +0,0 @@
-<script>
-import DraftNote from './draft_note.vue';
-
-export default {
- components: {
- DraftNote,
- },
- props: {
- draft: {
- type: Object,
- required: true,
- },
- diffFile: {
- type: Object,
- required: true,
- },
- line: {
- type: Object,
- required: false,
- default: null,
- },
- },
-};
-</script>
-
-<template>
- <tr class="notes_holder js-temp-notes-holder">
- <td class="notes-content" colspan="4">
- <div class="content"><draft-note :draft="draft" :diff-file="diffFile" :line="line" /></div>
- </td>
- </tr>
-</template>
diff --git a/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue b/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue
deleted file mode 100644
index b0916623cd2..00000000000
--- a/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue
+++ /dev/null
@@ -1,49 +0,0 @@
-<script>
-import { mapGetters } from 'vuex';
-import DraftNote from './draft_note.vue';
-
-export default {
- components: {
- DraftNote,
- },
- props: {
- line: {
- type: Object,
- required: true,
- },
- diffFileContentSha: {
- type: String,
- required: true,
- },
- },
- computed: {
- ...mapGetters('batchComments', ['draftForLine']),
- className() {
- return this.leftDraft > 0 || this.rightDraft > 0 ? '' : 'js-temp-notes-holder';
- },
- leftDraft() {
- return this.draftForLine(this.diffFileContentSha, this.line, 'left');
- },
- rightDraft() {
- return this.draftForLine(this.diffFileContentSha, this.line, 'right');
- },
- },
-};
-</script>
-
-<template>
- <tr :class="className" class="notes_holder">
- <td class="notes_line old"></td>
- <td class="notes-content parallel old" colspan="2">
- <div v-if="leftDraft.isDraft" class="content">
- <draft-note :draft="leftDraft" :line="line.left" />
- </div>
- </td>
- <td class="notes_line new"></td>
- <td class="notes-content parallel new" colspan="2">
- <div v-if="rightDraft.isDraft" class="content">
- <draft-note :draft="rightDraft" :line="line.right" />
- </div>
- </td>
- </tr>
-</template>
diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
index 2517fb198f0..0b085da1ff9 100644
--- a/app/assets/javascripts/batch_comments/mixins/resolved_status.js
+++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
@@ -1,7 +1,9 @@
import { mapGetters } from 'vuex';
import { sprintf, s__, __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
+ mixins: [glFeatureFlagsMixin()],
props: {
discussionId: {
type: String,
@@ -54,6 +56,10 @@ export default {
let title = __('Mark as resolved');
+ if (this.glFeatures.removeResolveNote) {
+ title = __('Resolve thread');
+ }
+
if (this.resolvedBy) {
title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name });
}
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
index 430a8c38387..e822072d669 100644
--- a/app/assets/javascripts/behaviors/copy_to_clipboard.js
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -79,3 +79,33 @@ export default function initCopyToClipboard() {
clipboardData.setData('text/x-gfm', json.gfm);
});
}
+
+/**
+ * Programmatically triggers a click event on a
+ * "copy to clipboard" button, causing its
+ * contents to be copied. Handles some of the messiniess
+ * around managing the button's tooltip.
+ * @param {HTMLElement} btnElement
+ */
+export function clickCopyToClipboardButton(btnElement) {
+ const $btnElement = $(btnElement);
+
+ // Ensure the button has already been tooltip'd.
+ // If the use hasn't yet interacted (i.e. hovered or clicked)
+ // with the button, Bootstrap hasn't yet initialized
+ // the tooltip, and its `data-original-title` will be `undefined`.
+ // This value is used in the functions above.
+ $btnElement.tooltip();
+ btnElement.dispatchEvent(new MouseEvent('mouseover'));
+
+ btnElement.click();
+
+ // Manually trigger the necessary events to hide the
+ // button's tooltip and allow the button to perform its
+ // tooltip cleanup (updating the title from "Copied" back
+ // to its original title, "Copy branch name").
+ setTimeout(() => {
+ btnElement.dispatchEvent(new MouseEvent('mouseout'));
+ $btnElement.tooltip('hide');
+ }, 2000);
+}
diff --git a/app/assets/javascripts/behaviors/details_behavior.js b/app/assets/javascripts/behaviors/details_behavior.js
deleted file mode 100644
index 9bdfc21c7e4..00000000000
--- a/app/assets/javascripts/behaviors/details_behavior.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import $ from 'jquery';
-
-$(() => {
- $('body').on('click', '.js-details-target', function target() {
- $(this)
- .closest('.js-details-container')
- .toggleClass('open');
- });
-
- // Show details content. Hides link after click.
- //
- // %div
- // %a.js-details-expand
- // %div.js-details-content
- //
- $('body').on('click', '.js-details-expand', function expand(e) {
- e.preventDefault();
- $(this)
- .next('.js-details-content')
- .removeClass('hide');
- $(this).hide();
-
- const truncatedItem = $(this).siblings('.js-details-short');
- if (truncatedItem.length) {
- truncatedItem.addClass('hide');
- }
- });
-});
diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js
index 613309a1c5a..75659bbf685 100644
--- a/app/assets/javascripts/behaviors/index.js
+++ b/app/assets/javascripts/behaviors/index.js
@@ -4,7 +4,6 @@ import './bind_in_out';
import './markdown/render_gfm';
import initCopyAsGFM from './markdown/copy_as_gfm';
import initCopyToClipboard from './copy_to_clipboard';
-import './details_behavior';
import installGlEmojiElement from './gl_emoji';
import './quick_submit';
import './requires_input';
diff --git a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
index 83f2ca0bdc2..d712c90242c 100644
--- a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
+++ b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
@@ -14,6 +14,7 @@ export default function initGFMInput($els) {
milestones: enableGFM,
mergeRequests: enableGFM,
labels: enableGFM,
+ vulnerabilities: enableGFM,
});
});
}
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index cb0e6345059..233c5f84340 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -25,7 +25,7 @@ function importMermaidModule() {
return import(/* webpackChunkName: 'mermaid' */ 'mermaid')
.then(mermaid => {
let theme = 'neutral';
- const ideDarkThemes = ['dark', 'solarized-dark'];
+ const ideDarkThemes = ['dark', 'solarized-dark', 'monokai'];
if (
ideDarkThemes.includes(window.gon?.user_color_scheme) &&
diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js
index 49eab3e4f09..907cfc06e28 100644
--- a/app/assets/javascripts/behaviors/quick_submit.js
+++ b/app/assets/javascripts/behaviors/quick_submit.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import '../commons/bootstrap';
import { isInIssuePage } from '../lib/utils/common_utils';
import { __ } from '~/locale';
+import { add, show, hide } from '~/tooltips';
// Quick Submit behavior
//
@@ -65,18 +66,17 @@ $(document).on(
return;
}
- const $this = $(this);
+ const $el = $(this);
const title = isMac()
- ? __('You can also press &#8984;-Enter')
+ ? __('You can also press \u{2318}-Enter')
: __('You can also press Ctrl-Enter');
- $this.tooltip({
- container: 'body',
- html: true,
- placement: 'top',
+ add($el, {
+ triggers: 'manual',
+ show: true,
title,
- trigger: 'manual',
});
- $this.tooltip('show').one('blur click', () => $this.tooltip('hide'));
+ $el.one('blur click', () => hide($el));
+ show($el);
},
);
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index f7b327b2af1..5a5a67334d3 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -4,6 +4,8 @@ import Sidebar from '../../right_sidebar';
import Shortcuts from './shortcuts';
import { CopyAsGFM } from '../markdown/copy_as_gfm';
import { getSelectedFragment } from '~/lib/utils/common_utils';
+import { isElementVisible } from '~/lib/utils/dom_utils';
+import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard';
export default class ShortcutsIssuable extends Shortcuts {
constructor() {
@@ -14,6 +16,7 @@ export default class ShortcutsIssuable extends Shortcuts {
Mousetrap.bind('l', () => ShortcutsIssuable.openSidebarDropdown('labels'));
Mousetrap.bind('r', ShortcutsIssuable.replyWithSelectedText);
Mousetrap.bind('e', ShortcutsIssuable.editIssue);
+ Mousetrap.bind('b', ShortcutsIssuable.copyBranchName);
}
static replyWithSelectedText() {
@@ -98,4 +101,18 @@ export default class ShortcutsIssuable extends Shortcuts {
Sidebar.instance.openDropdown(name);
return false;
}
+
+ static copyBranchName() {
+ // There are two buttons - one that is shown when the sidebar
+ // is expanded, and one that is shown when it's collapsed.
+ const allCopyBtns = Array.from(document.querySelectorAll('.sidebar-source-branch button'));
+
+ // Select whichever button is currently visible so that
+ // the "Copied" tooltip is shown when a click is simulated.
+ const visibleBtn = allCopyBtns.find(isElementVisible);
+
+ if (visibleBtn) {
+ clickCopyToClipboardButton(visibleBtn);
+ }
+ }
}
diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js
index ef8b8788abf..4b63143c4ba 100644
--- a/app/assets/javascripts/behaviors/toggler_behavior.js
+++ b/app/assets/javascripts/behaviors/toggler_behavior.js
@@ -12,11 +12,19 @@ import { getLocationHash } from '../lib/utils/url_utility';
$(() => {
function toggleContainer(container, toggleState) {
const $container = $(container);
-
- $container
- .find('.js-toggle-button .fa-chevron-up, .js-toggle-button .fa-chevron-down')
- .toggleClass('fa-chevron-up', toggleState)
- .toggleClass('fa-chevron-down', toggleState !== undefined ? !toggleState : undefined);
+ const isExpanded = $container.data('is-expanded');
+ const $collapseIcon = $container.find('.js-sidebar-collapse');
+ const $expandIcon = $container.find('.js-sidebar-expand');
+
+ if (isExpanded && !toggleState) {
+ $container.data('is-expanded', false);
+ $collapseIcon.addClass('hidden');
+ $expandIcon.removeClass('hidden');
+ } else {
+ $container.data('is-expanded', true);
+ $expandIcon.addClass('hidden');
+ $collapseIcon.removeClass('hidden');
+ }
$container.find('.js-toggle-content').toggle(toggleState);
}
diff --git a/app/assets/javascripts/blob/components/blob_edit_content.vue b/app/assets/javascripts/blob/components/blob_edit_content.vue
index a013d637c1d..73ccc3289b9 100644
--- a/app/assets/javascripts/blob/components/blob_edit_content.vue
+++ b/app/assets/javascripts/blob/components/blob_edit_content.vue
@@ -1,7 +1,7 @@
<script>
import { debounce } from 'lodash';
import { initEditorLite } from '~/blob/utils';
-import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance_constants';
+import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance/constants';
import eventHub from './eventhub';
diff --git a/app/assets/javascripts/blob/components/blob_edit_header.vue b/app/assets/javascripts/blob/components/blob_edit_header.vue
index 2cbbbddceeb..5715635fd13 100644
--- a/app/assets/javascripts/blob/components/blob_edit_header.vue
+++ b/app/assets/javascripts/blob/components/blob_edit_header.vue
@@ -50,6 +50,7 @@ export default {
variant="danger"
category="secondary"
:disabled="!canDelete"
+ data-qa-selector="delete_file_button"
@click="$emit('delete')"
>{{ s__('Snippets|Delete file') }}</gl-button
>
diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue
index fd40c51fec1..a4a43b7a94e 100644
--- a/app/assets/javascripts/blob/components/blob_header.vue
+++ b/app/assets/javascripts/blob/components/blob_header.vue
@@ -66,7 +66,7 @@ export default {
<template>
<div class="js-file-title file-title-flex-parent">
<blob-filepath :blob="blob">
- <template #filepathPrepend>
+ <template #filepath-prepend>
<slot name="prepend"></slot>
</template>
</blob-filepath>
diff --git a/app/assets/javascripts/blob/components/blob_header_default_actions.vue b/app/assets/javascripts/blob/components/blob_header_default_actions.vue
index daade611651..6eddec31166 100644
--- a/app/assets/javascripts/blob/components/blob_header_default_actions.vue
+++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue
@@ -32,6 +32,7 @@ export default {
default: false,
},
},
+ inject: ['blobHash'],
computed: {
downloadUrl() {
return `${this.rawPath}?inline=false`;
@@ -39,6 +40,9 @@ export default {
copyDisabled() {
return this.activeViewer === RICH_BLOB_VIEWER;
},
+ getBlobHashTarget() {
+ return `[data-blob-hash="${this.blobHash}"]`;
+ },
},
BTN_COPY_CONTENTS_TITLE,
BTN_DOWNLOAD_TITLE,
@@ -53,7 +57,7 @@ export default {
:aria-label="$options.BTN_COPY_CONTENTS_TITLE"
:title="$options.BTN_COPY_CONTENTS_TITLE"
:disabled="copyDisabled"
- data-clipboard-target="#blob-code-content"
+ :data-clipboard-target="getBlobHashTarget"
data-testid="copyContentsButton"
icon="copy-to-clipboard"
category="primary"
diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue
index f99ecba2324..eb8068a8ad7 100644
--- a/app/assets/javascripts/blob/components/blob_header_filepath.vue
+++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue
@@ -26,7 +26,7 @@ export default {
</script>
<template>
<div class="file-header-content d-flex align-items-center lh-100">
- <slot name="filepathPrepend"></slot>
+ <slot name="filepath-prepend"></slot>
<template v-if="blob.path">
<file-icon :file-name="blob.path" :size="18" aria-hidden="true" css-classes="mr-2" />
diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
index 1412e49836d..02a522dda9d 100644
--- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
+++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
@@ -9,8 +9,6 @@ const trackingMixin = Tracking.mixin();
export default {
beginnerLink:
'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',
goToTrackValuePipelines: 10,
goToTrackValueMergeRequest: 20,
trackEvent: 'click_button',
@@ -39,6 +37,14 @@ export default {
type: String,
required: true,
},
+ exampleLink: {
+ type: String,
+ required: true,
+ },
+ codeQualityLink: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -93,7 +99,7 @@ export default {
<p>
<gl-sprintf :message="$options.i18n.bodyMessage">
<template #codeQualityLink="{content}">
- <gl-link :href="$options.codeQualityLink" target="_blank" class="font-size-inherit">{{
+ <gl-link :href="codeQualityLink" target="_blank" class="font-size-inherit">{{
content
}}</gl-link>
</template>
@@ -106,7 +112,7 @@ export default {
</gl-link>
</template>
<template #exampleLink="{content}">
- <gl-link :href="$options.exampleLink" target="_blank">
+ <gl-link :href="exampleLink" target="_blank">
{{ content }}
</gl-link>
</template>
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 2d426ee663a..f84e39baa53 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -8,11 +8,40 @@ import initPopover from '~/blob/suggest_gitlab_ci_yml';
import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils';
import Tracking from '~/tracking';
+const initPopovers = () => {
+ const suggestEl = document.querySelector('.js-suggest-gitlab-ci-yml');
+
+ if (suggestEl) {
+ const commitButton = document.querySelector('#commit-changes');
+
+ initPopover(suggestEl);
+
+ if (commitButton) {
+ const { dismissKey, humanAccess } = suggestEl.dataset;
+ const urlParams = new URLSearchParams(window.location.search);
+ const mergeRequestPath = urlParams.get('mr_path') || true;
+
+ const commitCookieName = `suggest_gitlab_ci_yml_commit_${dismissKey}`;
+ const commitTrackLabel = 'suggest_gitlab_ci_yml_commit_changes';
+ const commitTrackValue = '20';
+
+ commitButton.addEventListener('click', () => {
+ setCookie(commitCookieName, mergeRequestPath);
+
+ Tracking.event(undefined, 'click_button', {
+ label: commitTrackLabel,
+ property: humanAccess,
+ value: commitTrackValue,
+ });
+ });
+ }
+ }
+};
+
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');
if (editBlobForm.length) {
const urlRoot = editBlobForm.data('relativeUrlRoot');
@@ -33,6 +62,7 @@ export default () => {
projectId,
isMarkdown,
});
+ initPopovers();
})
.catch(e => createFlash(e));
@@ -62,30 +92,4 @@ export default () => {
if (deleteBlobForm.length) {
new NewCommitForm(deleteBlobForm);
}
-
- if (suggestEl) {
- const commitButton = document.querySelector('#commit-changes');
-
- initPopover(suggestEl);
-
- if (commitButton) {
- const { dismissKey, humanAccess } = suggestEl.dataset;
- const urlParams = new URLSearchParams(window.location.search);
- const mergeRequestPath = urlParams.get('mr_path') || true;
-
- const commitCookieName = `suggest_gitlab_ci_yml_commit_${dismissKey}`;
- const commitTrackLabel = 'suggest_gitlab_ci_yml_commit_changes';
- const commitTrackValue = '20';
-
- commitButton.addEventListener('click', () => {
- setCookie(commitCookieName, mergeRequestPath);
-
- Tracking.event(undefined, 'click_button', {
- label: commitTrackLabel,
- property: humanAccess,
- value: commitTrackValue,
- });
- });
- }
- }
};
diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue
new file mode 100644
index 00000000000..c81f171af2b
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue
@@ -0,0 +1,178 @@
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import {
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlAvatarLabeled,
+ GlAvatarLink,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import { __, n__ } from '~/locale';
+import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
+import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
+import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
+import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql';
+import searchUsers from '~/boards/queries/users_search.query.graphql';
+
+export default {
+ noSearchDelay: 0,
+ searchDelay: 250,
+ i18n: {
+ unassigned: __('Unassigned'),
+ assignee: __('Assignee'),
+ assignees: __('Assignees'),
+ assignTo: __('Assign to'),
+ },
+ components: {
+ BoardEditableItem,
+ IssuableAssignees,
+ MultiSelectDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlAvatarLabeled,
+ GlAvatarLink,
+ GlSearchBoxByType,
+ },
+ data() {
+ return {
+ search: '',
+ participants: [],
+ selected: this.$store.getters.activeIssue.assignees,
+ };
+ },
+ apollo: {
+ participants: {
+ query() {
+ return this.isSearchEmpty ? getIssueParticipants : searchUsers;
+ },
+ variables() {
+ if (this.isSearchEmpty) {
+ return {
+ id: `gid://gitlab/Issue/${this.activeIssue.iid}`,
+ };
+ }
+
+ return {
+ search: this.search,
+ };
+ },
+ update(data) {
+ if (this.isSearchEmpty) {
+ return data.issue?.participants?.nodes || [];
+ }
+
+ return data.users?.nodes || [];
+ },
+ debounce() {
+ const { noSearchDelay, searchDelay } = this.$options;
+
+ return this.isSearchEmpty ? noSearchDelay : searchDelay;
+ },
+ },
+ },
+ computed: {
+ ...mapGetters(['activeIssue']),
+ assigneeText() {
+ return n__('Assignee', '%d Assignees', this.selected.length);
+ },
+ unSelectedFiltered() {
+ return this.participants.filter(({ username }) => {
+ return !this.selectedUserNames.includes(username);
+ });
+ },
+ selectedIsEmpty() {
+ return this.selected.length === 0;
+ },
+ selectedUserNames() {
+ return this.selected.map(({ username }) => username);
+ },
+ isSearchEmpty() {
+ return this.search === '';
+ },
+ },
+ methods: {
+ ...mapActions(['setAssignees']),
+ clearSelected() {
+ this.selected = [];
+ },
+ selectAssignee(name) {
+ if (name === undefined) {
+ this.clearSelected();
+ return;
+ }
+
+ this.selected = this.selected.concat(name);
+ },
+ unselect(name) {
+ this.selected = this.selected.filter(user => user.username !== name);
+ },
+ saveAssignees() {
+ this.setAssignees(this.selectedUserNames);
+ },
+ isChecked(id) {
+ return this.selectedUserNames.includes(id);
+ },
+ },
+};
+</script>
+
+<template>
+ <board-editable-item :title="assigneeText" @close="saveAssignees">
+ <template #collapsed>
+ <issuable-assignees :users="activeIssue.assignees" />
+ </template>
+
+ <template #default>
+ <multi-select-dropdown
+ class="w-100"
+ :text="$options.i18n.assignees"
+ :header-text="$options.i18n.assignTo"
+ >
+ <template #search>
+ <gl-search-box-by-type v-model.trim="search" />
+ </template>
+ <template #items>
+ <gl-dropdown-item
+ :is-checked="selectedIsEmpty"
+ data-testid="unassign"
+ class="mt-2"
+ @click="selectAssignee()"
+ >{{ $options.i18n.unassigned }}</gl-dropdown-item
+ >
+ <gl-dropdown-divider data-testid="unassign-divider" />
+ <gl-dropdown-item
+ v-for="item in selected"
+ :key="item.id"
+ :is-checked="isChecked(item.username)"
+ @click="unselect(item.username)"
+ >
+ <gl-avatar-link>
+ <gl-avatar-labeled
+ :size="32"
+ :label="item.name"
+ :sub-label="item.username"
+ :src="item.avatarUrl || item.avatar"
+ />
+ </gl-avatar-link>
+ </gl-dropdown-item>
+ <gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" />
+ <gl-dropdown-item
+ v-for="unselectedUser in unSelectedFiltered"
+ :key="unselectedUser.id"
+ :data-testid="`item_${unselectedUser.name}`"
+ @click="selectAssignee(unselectedUser)"
+ >
+ <gl-avatar-link>
+ <gl-avatar-labeled
+ :size="32"
+ :label="unselectedUser.name"
+ :sub-label="unselectedUser.username"
+ :src="unselectedUser.avatarUrl || unselectedUser.avatar"
+ />
+ </gl-avatar-link>
+ </gl-dropdown-item>
+ </template>
+ </multi-select-dropdown>
+ </template>
+ </board-editable-item>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue
index 072dd87861a..f796acd2303 100644
--- a/app/assets/javascripts/boards/components/board_card_layout.vue
+++ b/app/assets/javascripts/boards/components/board_card_layout.vue
@@ -44,9 +44,6 @@ export default {
multiSelectVisible() {
return this.multiSelect.list.findIndex(issue => issue.id === this.issue.id) > -1;
},
- canMultiSelect() {
- return gon.features && gon.features.multiSelectBoard;
- },
},
methods: {
mouseDown() {
@@ -59,7 +56,7 @@ export default {
// Don't do anything if this happened on a no trigger element
if (e.target.classList.contains('js-no-trigger')) return;
- const isMultiSelect = this.canMultiSelect && (e.ctrlKey || e.metaKey);
+ const isMultiSelect = e.ctrlKey || e.metaKey;
if (this.showDetail || isMultiSelect) {
this.showDetail = false;
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index 9295065b7b7..cb93340bcf8 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -1,14 +1,10 @@
<script>
-import { mapGetters, mapActions } from 'vuex';
+// This component is being replaced in favor of './board_column_new.vue' for GraphQL boards
import Sortable from 'sortablejs';
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 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';
import { ListType } from '../constants';
@@ -16,12 +12,8 @@ export default {
components: {
BoardPromotionState: EmptyComponent,
BoardListHeader,
- BoardList: gon.features?.graphqlBoardLists ? BoardListNew : BoardList,
+ BoardList,
},
- directives: {
- Tooltip,
- },
- mixins: [glFeatureFlagMixin()],
props: {
list: {
type: Object,
@@ -50,44 +42,25 @@ export default {
};
},
computed: {
- ...mapGetters(['getIssues']),
showBoardListAndBoardInfo() {
return this.list.type !== ListType.promotion;
},
- uniqueKey() {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- return `boards.${this.boardId}.${this.list.type}.${this.list.id}`;
- },
listIssues() {
- if (!this.glFeatures.graphqlBoardLists) {
- return this.list.issues;
- }
- return this.getIssues(this.list.id);
- },
- shouldFetchIssues() {
- return this.glFeatures.graphqlBoardLists && this.list.type !== ListType.blank;
+ return this.list.issues;
},
},
watch: {
filter: {
handler() {
- if (this.shouldFetchIssues) {
- this.fetchIssuesForList({ listId: this.list.id });
- } else {
- this.list.page = 1;
- this.list.getIssues(true).catch(() => {
- // TODO: handle request error
- });
- }
+ this.list.page = 1;
+ this.list.getIssues(true).catch(() => {
+ // TODO: handle request error
+ });
},
deep: true,
},
},
mounted() {
- if (this.shouldFetchIssues) {
- this.fetchIssuesForList({ listId: this.list.id });
- }
-
const instance = this;
const sortableOptions = getBoardSortableDefaultOptions({
@@ -113,12 +86,6 @@ export default {
Sortable.create(this.$el.parentNode, sortableOptions);
},
- methods: {
- ...mapActions(['fetchIssuesForList']),
- showListNewIssueForm(listId) {
- eventHub.$emit('showForm', listId);
- },
- },
};
</script>
@@ -131,7 +98,7 @@ export default {
'board-type-assignee': list.type === 'assignee',
}"
:data-id="list.id"
- class="board gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal"
+ class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal"
data-qa-selector="board_list"
>
<div
diff --git a/app/assets/javascripts/boards/components/board_column_new.vue b/app/assets/javascripts/boards/components/board_column_new.vue
new file mode 100644
index 00000000000..8a59355eb83
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_column_new.vue
@@ -0,0 +1,94 @@
+<script>
+import { mapGetters, mapActions, mapState } from 'vuex';
+import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_new.vue';
+import BoardPromotionState from 'ee_else_ce/boards/components/board_promotion_state';
+import BoardList from './board_list_new.vue';
+import { ListType } from '../constants';
+
+export default {
+ components: {
+ BoardPromotionState,
+ BoardListHeader,
+ BoardList,
+ },
+ props: {
+ list: {
+ type: Object,
+ default: () => ({}),
+ required: false,
+ },
+ disabled: {
+ type: Boolean,
+ required: true,
+ },
+ canAdminList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ inject: {
+ boardId: {
+ default: '',
+ },
+ },
+ computed: {
+ ...mapState(['filterParams']),
+ ...mapGetters(['getIssuesByList']),
+ showBoardListAndBoardInfo() {
+ return this.list.type !== ListType.promotion;
+ },
+ listIssues() {
+ return this.getIssuesByList(this.list.id);
+ },
+ shouldFetchIssues() {
+ return this.list.type !== ListType.blank;
+ },
+ },
+ watch: {
+ filterParams: {
+ handler() {
+ if (this.shouldFetchIssues) {
+ this.fetchIssuesForList({ listId: this.list.id });
+ }
+ },
+ deep: true,
+ immediate: true,
+ },
+ },
+ methods: {
+ ...mapActions(['fetchIssuesForList']),
+ // TODO: Reordering of lists https://gitlab.com/gitlab-org/gitlab/-/issues/280515
+ },
+};
+</script>
+
+<template>
+ <div
+ :class="{
+ 'is-draggable': !list.preset,
+ 'is-expandable': list.isExpandable,
+ 'is-collapsed': !list.isExpanded,
+ 'board-type-assignee': list.type === 'assignee',
+ }"
+ :data-id="list.id"
+ class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal"
+ data-qa-selector="board_list"
+ >
+ <div
+ class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base"
+ >
+ <board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" />
+ <board-list
+ v-if="showBoardListAndBoardInfo"
+ ref="board-list"
+ :disabled="disabled"
+ :issues="listIssues"
+ :list="list"
+ />
+
+ <!-- Will be only available in EE -->
+ <board-promotion-state v-if="list.id === 'promotion'" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_configuration_options.vue b/app/assets/javascripts/boards/components/board_configuration_options.vue
index ad3d653b905..754b00b54e0 100644
--- a/app/assets/javascripts/boards/components/board_configuration_options.vue
+++ b/app/assets/javascripts/boards/components/board_configuration_options.vue
@@ -43,7 +43,7 @@ export default {
<template>
<div class="append-bottom-20">
- <label class="form-section-title label-bold" for="board-new-name">
+ <label class="label-bold gl-font-lg" for="board-new-name">
{{ __('List options') }}
</label>
<p class="text-secondary gl-mb-3">
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 2515f471379..92976574efb 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -1,13 +1,14 @@
<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 BoardColumn from 'ee_else_ce/boards/components/board_column.vue';
+import BoardColumnNew from './board_column_new.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
- BoardColumn,
+ BoardColumn: gon.features?.graphqlBoardLists ? BoardColumnNew : BoardColumn,
BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'),
EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
GlAlert,
@@ -38,12 +39,11 @@ export default {
},
mounted() {
if (this.glFeatures.graphqlBoardLists) {
- this.fetchLists();
this.showPromotionList();
}
},
methods: {
- ...mapActions(['fetchLists', 'showPromotionList']),
+ ...mapActions(['showPromotionList']),
},
};
</script>
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index 793c594cf16..e4ef3600ff9 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -196,9 +196,7 @@ export default {
<p v-if="isDeleteForm">{{ __('Are you sure you want to delete this board?') }}</p>
<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">{{
- __('Title')
- }}</label>
+ <label class="label-bold gl-font-lg" for="board-new-name">{{ __('Title') }}</label>
<input
id="board-new-name"
ref="name"
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index d01df44e7e4..53989e2d9de 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -16,9 +16,7 @@ import {
// 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());
-}
+Sortable.mount(new MultiDrag());
export default {
name: 'BoardList',
@@ -100,12 +98,11 @@ export default {
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;
- multiSelectOpts.selectedClass = 'js-multi-select';
- multiSelectOpts.animation = 500;
- }
+ const multiSelectOpts = {
+ multiDrag: true,
+ selectedClass: 'js-multi-select',
+ animation: 500,
+ };
const options = getBoardSortableDefaultOptions({
scroll: true,
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index bb9a1b79d91..d85ba2038a7 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -17,7 +17,6 @@ import eventHub from '../eventhub';
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: {
@@ -32,7 +31,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagMixin()],
props: {
list: {
type: Object,
@@ -121,12 +119,9 @@ export default {
collapsedTooltipTitle() {
return this.listTitle || this.listAssignee;
},
- shouldDisplaySwimlanes() {
- return this.glFeatures.boardsWithSwimlanes && this.isSwimlanesOn;
- },
},
methods: {
- ...mapActions(['updateList', 'setActiveId']),
+ ...mapActions(['setActiveId']),
openSidebarSettings() {
if (this.activeId === inactiveId) {
sidebarEventHub.$emit('sidebar.closeAll');
@@ -160,11 +155,7 @@ export default {
}
},
updateListFunction() {
- if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) {
- this.updateList({ listId: this.list.id, collapsed: !this.list.isExpanded });
- } else {
- this.list.update();
- }
+ this.list.update();
},
},
};
@@ -188,8 +179,9 @@ export default {
'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader,
'gl-border-b-0': !list.isExpanded || isSwimlanesHeader,
'gl-py-2': !list.isExpanded && isSwimlanesHeader,
+ 'gl-flex-direction-column': !list.isExpanded,
}"
- class="board-title gl-m-0 gl-display-flex js-board-handle"
+ class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle"
>
<gl-button
v-if="list.isExpandable"
@@ -202,7 +194,15 @@ export default {
@click="toggleExpanded"
/>
<!-- The following is only true in EE and if it is a milestone -->
- <span v-if="showMilestoneListDetails" aria-hidden="true" class="gl-mr-2 milestone-icon">
+ <span
+ v-if="showMilestoneListDetails"
+ aria-hidden="true"
+ class="milestone-icon"
+ :class="{
+ 'gl-mt-3 gl-rotate-90': !list.isExpanded,
+ 'gl-mr-2': list.isExpanded,
+ }"
+ >
<gl-icon name="timer" />
</span>
@@ -210,6 +210,9 @@ export default {
v-if="showAssigneeListDetails"
:href="list.assignee.path"
class="user-avatar-link js-no-trigger"
+ :class="{
+ 'gl-mt-3 gl-rotate-90': !list.isExpanded,
+ }"
>
<img
v-gl-tooltip.hover.bottom
@@ -223,20 +226,28 @@ export default {
</a>
<div
class="board-title-text"
- :class="{ 'gl-display-none': !list.isExpanded && isSwimlanesHeader }"
+ :class="{
+ 'gl-display-none': !list.isExpanded && isSwimlanesHeader,
+ 'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded,
+ 'gl-flex-grow-1': list.isExpanded,
+ }"
>
<span
v-if="list.type !== 'label'"
v-gl-tooltip.hover
:class="{
- 'gl-display-inline-block': list.type === 'milestone',
+ 'gl-display-block': !list.isExpanded || list.type === 'milestone',
}"
:title="listTitle"
- class="board-title-main-text block-truncated"
+ class="board-title-main-text gl-text-truncate"
>
{{ list.title }}
</span>
- <span v-if="list.type === 'assignee'" class="board-title-sub-text gl-ml-2">
+ <span
+ v-if="list.type === 'assignee'"
+ class="gl-ml-2 gl-font-weight-normal gl-text-gray-500"
+ :class="{ 'gl-display-none': !list.isExpanded }"
+ >
@{{ listAssignee }}
</span>
<gl-label
@@ -279,7 +290,10 @@ export default {
<div
v-if="showBoardListAndBoardInfo"
class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary"
- :class="{ 'gl-display-none!': !list.isExpanded && isSwimlanesHeader }"
+ :class="{
+ 'gl-display-none!': !list.isExpanded && isSwimlanesHeader,
+ 'gl-p-0': !list.isExpanded,
+ }"
>
<span class="gl-display-inline-flex">
<gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" />
diff --git a/app/assets/javascripts/boards/components/board_list_header_new.vue b/app/assets/javascripts/boards/components/board_list_header_new.vue
new file mode 100644
index 00000000000..99347a4cd4d
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_list_header_new.vue
@@ -0,0 +1,358 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import {
+ GlButton,
+ GlButtonGroup,
+ GlLabel,
+ GlTooltip,
+ GlIcon,
+ GlSprintf,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import { n__, s__ } from '~/locale';
+import AccessorUtilities from '../../lib/utils/accessor';
+import IssueCount from './issue_count.vue';
+import eventHub from '../eventhub';
+import sidebarEventHub from '~/sidebar/event_hub';
+import { inactiveId, LIST, ListType } from '../constants';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+
+export default {
+ components: {
+ GlButtonGroup,
+ GlButton,
+ GlLabel,
+ GlTooltip,
+ GlIcon,
+ GlSprintf,
+ IssueCount,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ list: {
+ type: Object,
+ default: () => ({}),
+ required: false,
+ },
+ disabled: {
+ type: Boolean,
+ required: true,
+ },
+ isSwimlanesHeader: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ inject: {
+ boardId: {
+ default: '',
+ },
+ weightFeatureAvailable: {
+ default: false,
+ },
+ scopedLabelsAvailable: {
+ default: false,
+ },
+ currentUserId: {
+ default: null,
+ },
+ },
+ computed: {
+ ...mapState(['activeId']),
+ isLoggedIn() {
+ return Boolean(this.currentUserId);
+ },
+ listType() {
+ return this.list.type;
+ },
+ listAssignee() {
+ return this.list?.assignee?.username || '';
+ },
+ listTitle() {
+ return this.list?.label?.description || this.list.title || '';
+ },
+ showListHeaderButton() {
+ return (
+ !this.disabled &&
+ this.listType !== ListType.closed &&
+ this.listType !== ListType.blank &&
+ this.listType !== ListType.promotion
+ );
+ },
+ showMilestoneListDetails() {
+ return (
+ this.list.type === ListType.milestone &&
+ this.list.milestone &&
+ (this.list.isExpanded || !this.isSwimlanesHeader)
+ );
+ },
+ showAssigneeListDetails() {
+ return (
+ this.list.type === ListType.assignee && (this.list.isExpanded || !this.isSwimlanesHeader)
+ );
+ },
+ issuesCount() {
+ return this.list.issuesSize;
+ },
+ issuesTooltipLabel() {
+ return n__(`%d issue`, `%d issues`, this.issuesCount);
+ },
+ chevronTooltip() {
+ return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
+ },
+ chevronIcon() {
+ return this.list.isExpanded ? 'chevron-right' : 'chevron-down';
+ },
+ isNewIssueShown() {
+ return this.listType === ListType.backlog || this.showListHeaderButton;
+ },
+ isSettingsShown() {
+ return (
+ this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded
+ );
+ },
+ showBoardListAndBoardInfo() {
+ return this.listType !== ListType.blank && this.listType !== ListType.promotion;
+ },
+ uniqueKey() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `boards.${this.boardId}.${this.listType}.${this.list.id}`;
+ },
+ collapsedTooltipTitle() {
+ return this.listTitle || this.listAssignee;
+ },
+ headerStyle() {
+ return { borderTopColor: this.list?.label?.color };
+ },
+ },
+ methods: {
+ ...mapActions(['updateList', 'setActiveId']),
+ openSidebarSettings() {
+ if (this.activeId === inactiveId) {
+ sidebarEventHub.$emit('sidebar.closeAll');
+ }
+
+ this.setActiveId({ id: this.list.id, sidebarType: LIST });
+ },
+ showScopedLabels(label) {
+ return this.scopedLabelsAvailable && isScopedLabel(label);
+ },
+
+ showNewIssueForm() {
+ eventHub.$emit(`toggle-issue-form-${this.list.id}`);
+ },
+ toggleExpanded() {
+ this.list.isExpanded = !this.list.isExpanded;
+
+ if (!this.isLoggedIn) {
+ this.addToLocalStorage();
+ } else {
+ this.updateListFunction();
+ }
+
+ // When expanding/collapsing, the tooltip on the caret button sometimes stays open.
+ // Close all tooltips manually to prevent dangling tooltips.
+ this.$root.$emit('bv::hide::tooltip');
+ },
+ addToLocalStorage() {
+ if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
+ }
+ },
+ updateListFunction() {
+ this.updateList({ listId: this.list.id, collapsed: !this.list.isExpanded });
+ },
+ },
+};
+</script>
+
+<template>
+ <header
+ :class="{
+ 'has-border': list.label && list.label.color,
+ 'gl-h-full': !list.isExpanded,
+ 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader,
+ }"
+ :style="headerStyle"
+ class="board-header gl-relative"
+ data-qa-selector="board_list_header"
+ data-testid="board-list-header"
+ >
+ <h3
+ :class="{
+ 'user-can-drag': !disabled && !list.preset,
+ 'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader,
+ 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader,
+ 'gl-py-2': !list.isExpanded && isSwimlanesHeader,
+ 'gl-flex-direction-column': !list.isExpanded,
+ }"
+ class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle"
+ >
+ <gl-button
+ v-if="list.isExpandable"
+ v-gl-tooltip.hover
+ :aria-label="chevronTooltip"
+ :title="chevronTooltip"
+ :icon="chevronIcon"
+ class="board-title-caret no-drag gl-cursor-pointer"
+ variant="link"
+ @click="toggleExpanded"
+ />
+ <!-- EE start -->
+ <span
+ v-if="showMilestoneListDetails"
+ aria-hidden="true"
+ class="milestone-icon"
+ :class="{
+ 'gl-mt-3 gl-rotate-90': !list.isExpanded,
+ 'gl-mr-2': list.isExpanded,
+ }"
+ >
+ <gl-icon name="timer" />
+ </span>
+
+ <a
+ v-if="showAssigneeListDetails"
+ :href="list.assignee.path"
+ class="user-avatar-link js-no-trigger"
+ :class="{
+ 'gl-mt-3 gl-rotate-90': !list.isExpanded,
+ }"
+ >
+ <img
+ v-gl-tooltip.hover.bottom
+ :title="listAssignee"
+ :alt="list.assignee.name"
+ :src="list.assignee.avatar"
+ class="avatar s20"
+ height="20"
+ width="20"
+ />
+ </a>
+ <!-- EE end -->
+ <div
+ class="board-title-text"
+ :class="{
+ 'gl-display-none': !list.isExpanded && isSwimlanesHeader,
+ 'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded,
+ 'gl-flex-grow-1': list.isExpanded,
+ }"
+ >
+ <!-- EE start -->
+ <span
+ v-if="listType !== 'label'"
+ v-gl-tooltip.hover
+ :class="{
+ 'gl-display-block': !list.isExpanded || listType === 'milestone',
+ }"
+ :title="listTitle"
+ class="board-title-main-text gl-text-truncate"
+ >
+ {{ list.title }}
+ </span>
+ <span
+ v-if="listType === 'assignee'"
+ v-show="list.isExpanded"
+ class="gl-ml-2 gl-font-weight-normal gl-text-gray-500"
+ >
+ @{{ listAssignee }}
+ </span>
+ <!-- EE end -->
+ <gl-label
+ v-if="listType === 'label'"
+ v-gl-tooltip.hover.bottom
+ :background-color="list.label.color"
+ :description="list.label.description"
+ :scoped="showScopedLabels(list.label)"
+ :size="!list.isExpanded ? 'sm' : ''"
+ :title="list.label.title"
+ />
+ </div>
+
+ <!-- EE start -->
+ <span
+ v-if="isSwimlanesHeader && !list.isExpanded"
+ ref="collapsedInfo"
+ aria-hidden="true"
+ class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-500"
+ >
+ <gl-icon name="information" />
+ </span>
+ <gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo">
+ <div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div>
+ <div v-if="list.maxIssueCount !== 0">
+ •
+ <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
+ <template #issuesSize>{{ issuesTooltipLabel }}</template>
+ <template #maxIssueCount>{{ list.maxIssueCount }}</template>
+ </gl-sprintf>
+ </div>
+ <div v-else>• {{ issuesTooltipLabel }}</div>
+ <div v-if="weightFeatureAvailable">
+ •
+ <gl-sprintf :message="__('%{totalWeight} total weight')">
+ <template #totalWeight>{{ list.totalWeight }}</template>
+ </gl-sprintf>
+ </div>
+ </gl-tooltip>
+ <!-- EE end -->
+
+ <div
+ v-if="showBoardListAndBoardInfo"
+ class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500"
+ :class="{
+ 'gl-display-none!': !list.isExpanded && isSwimlanesHeader,
+ 'gl-p-0': !list.isExpanded,
+ }"
+ >
+ <span class="gl-display-inline-flex">
+ <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" />
+ <span ref="issueCount" class="issue-count-badge-count">
+ <gl-icon class="gl-mr-2" name="issues" />
+ <issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" />
+ </span>
+ <!-- EE start -->
+ <template v-if="weightFeatureAvailable">
+ <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
+ <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
+ <gl-icon class="gl-mr-2" name="weight" />
+ {{ list.totalWeight }}
+ </span>
+ </template>
+ <!-- EE end -->
+ </span>
+ </div>
+ <gl-button-group
+ v-if="isNewIssueShown || isSettingsShown"
+ class="board-list-button-group pl-2"
+ >
+ <gl-button
+ v-if="isNewIssueShown"
+ v-show="list.isExpanded"
+ ref="newIssueBtn"
+ v-gl-tooltip.hover
+ :aria-label="__('New issue')"
+ :title="__('New issue')"
+ class="issue-count-badge-add-button no-drag"
+ icon="plus"
+ @click="showNewIssueForm"
+ />
+
+ <gl-button
+ v-if="isSettingsShown"
+ ref="settingsBtn"
+ v-gl-tooltip.hover
+ :aria-label="__('List settings')"
+ class="no-drag js-board-settings-button"
+ :title="__('List settings')"
+ icon="settings"
+ @click="openSidebarSettings"
+ />
+ <gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip>
+ </gl-button-group>
+ </h3>
+ </header>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_list_new.vue b/app/assets/javascripts/boards/components/board_list_new.vue
index 0a495d05122..396aedcc557 100644
--- a/app/assets/javascripts/boards/components/board_list_new.vue
+++ b/app/assets/javascripts/boards/components/board_list_new.vue
@@ -1,7 +1,7 @@
<script>
import { mapActions, mapState } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
-import BoardNewIssue from './board_new_issue.vue';
+import BoardNewIssue from './board_new_issue_new.vue';
import BoardCard from './board_card.vue';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index 0a665b82880..a9e6d768656 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -1,6 +1,4 @@
<script>
-import $ from 'jquery';
-import { mapActions, mapGetters } from 'vuex';
import { GlButton } from '@gitlab/ui';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
import ListIssue from 'ee_else_ce/boards/models/issue';
@@ -9,6 +7,8 @@ import ProjectSelect from './project_select.vue';
import boardsStore from '../stores/boards_store';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+// This component is being replaced in favor of './board_new_issue_new.vue' for GraphQL boards
+
export default {
name: 'BoardNewIssue',
components: {
@@ -31,23 +31,18 @@ export default {
};
},
computed: {
- ...mapGetters(['isSwimlanesOn']),
disabled() {
if (this.groupId) {
return this.title === '' || !this.selectedProject.name;
}
return this.title === '';
},
- shouldDisplaySwimlanes() {
- return this.glFeatures.boardsWithSwimlanes && this.isSwimlanesOn;
- },
},
mounted() {
this.$refs.input.focus();
eventHub.$on('setSelectedProject', this.setSelectedProject);
},
methods: {
- ...mapActions(['addListIssue', 'addListIssueFailure']),
submit(e) {
e.preventDefault();
if (this.title.trim() === '') return Promise.resolve();
@@ -74,31 +69,14 @@ export default {
eventHub.$emit(`scroll-board-list-${this.list.id}`);
this.cancel();
- if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) {
- this.addListIssue({ list: this.list, issue, position: 0 });
- }
-
return this.list
.newIssue(issue)
.then(() => {
- // Need this because our jQuery very kindly disables buttons on ALL form submissions
- $(this.$refs.submitButton).enable();
-
- if (!this.shouldDisplaySwimlanes && !this.glFeatures.graphqlBoardLists) {
- boardsStore.setIssueDetail(issue);
- boardsStore.setListDetail(this.list);
- }
+ boardsStore.setIssueDetail(issue);
+ boardsStore.setListDetail(this.list);
})
.catch(() => {
- // Need this because our jQuery very kindly disables buttons on ALL form submissions
- $(this.$refs.submitButton).enable();
-
- // Remove the issue
- if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) {
- this.addListIssueFailure({ list: this.list, issue });
- } else {
- this.list.removeIssue(issue);
- }
+ this.list.removeIssue(issue);
// Show error message
this.error = true;
@@ -137,7 +115,7 @@ export default {
<gl-button
ref="submitButton"
:disabled="disabled"
- class="float-left"
+ class="float-left js-no-auto-disable"
variant="success"
category="primary"
type="submit"
diff --git a/app/assets/javascripts/boards/components/board_new_issue_new.vue b/app/assets/javascripts/boards/components/board_new_issue_new.vue
new file mode 100644
index 00000000000..969c84ddb59
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_new_issue_new.vue
@@ -0,0 +1,129 @@
+<script>
+import { mapActions } from 'vuex';
+import { GlButton } from '@gitlab/ui';
+import { getMilestone } from 'ee_else_ce/boards/boards_util';
+import eventHub from '../eventhub';
+import ProjectSelect from './project_select.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { __ } from '~/locale';
+
+export default {
+ name: 'BoardNewIssue',
+ i18n: {
+ submit: __('Submit issue'),
+ cancel: __('Cancel'),
+ },
+ components: {
+ ProjectSelect,
+ GlButton,
+ },
+ mixins: [glFeatureFlagMixin()],
+ props: {
+ list: {
+ type: Object,
+ required: true,
+ },
+ },
+ inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'],
+ data() {
+ return {
+ title: '',
+ selectedProject: {},
+ };
+ },
+ computed: {
+ disabled() {
+ if (this.groupId) {
+ return this.title === '' || !this.selectedProject.name;
+ }
+ return this.title === '';
+ },
+ inputFieldId() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `${this.list.id}-title`;
+ },
+ },
+ mounted() {
+ this.$refs.input.focus();
+ eventHub.$on('setSelectedProject', this.setSelectedProject);
+ },
+ methods: {
+ ...mapActions(['addListNewIssue']),
+ submit(e) {
+ e.preventDefault();
+
+ const labels = this.list.label ? [this.list.label] : [];
+ const assignees = this.list.assignee ? [this.list.assignee] : [];
+ const milestone = getMilestone(this.list);
+
+ const weight = this.weightFeatureAvailable ? this.boardWeight : undefined;
+
+ const { title } = this;
+
+ eventHub.$emit(`scroll-board-list-${this.list.id}`);
+
+ return this.addListNewIssue({
+ issueInput: {
+ title,
+ labelIds: labels?.map(l => l.id),
+ assigneeIds: assignees?.map(a => a?.id),
+ milestoneId: milestone?.id,
+ projectPath: this.selectedProject.path,
+ weight: weight >= 0 ? weight : null,
+ },
+ list: this.list,
+ }).then(() => {
+ this.reset();
+ });
+ },
+ reset() {
+ this.title = '';
+ eventHub.$emit(`toggle-issue-form-${this.list.id}`);
+ },
+ setSelectedProject(selectedProject) {
+ this.selectedProject = selectedProject;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="board-new-issue-form">
+ <div class="board-card position-relative p-3 rounded">
+ <form ref="submitForm" @submit="submit">
+ <label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label>
+ <input
+ :id="inputFieldId"
+ ref="input"
+ v-model="title"
+ class="form-control"
+ type="text"
+ name="issue_title"
+ autocomplete="off"
+ />
+ <project-select v-if="groupId" :group-id="groupId" :list="list" />
+ <div class="clearfix gl-mt-3">
+ <gl-button
+ ref="submitButton"
+ :disabled="disabled"
+ class="float-left js-no-auto-disable"
+ variant="success"
+ category="primary"
+ type="submit"
+ >
+ {{ $options.i18n.submit }}
+ </gl-button>
+ <gl-button
+ ref="cancelButton"
+ class="float-right"
+ type="button"
+ variant="default"
+ @click="reset"
+ >
+ {{ $options.i18n.cancel }}
+ </gl-button>
+ </div>
+ </form>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_promotion_state.js b/app/assets/javascripts/boards/components/board_promotion_state.js
new file mode 100644
index 00000000000..ff8b4c56321
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_promotion_state.js
@@ -0,0 +1 @@
+export default {};
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index 392e056dcbf..80070b25bd0 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -36,6 +36,9 @@ export default {
computed: {
...mapGetters(['isSidebarOpen', 'shouldUseGraphQL']),
...mapState(['activeId', 'sidebarType', 'boardLists']),
+ isWipLimitsOn() {
+ return this.glFeatures.wipLimits;
+ },
activeList() {
/*
Warning: Though a computed property it is not reactive because we are
@@ -66,14 +69,18 @@ export default {
eventHub.$off('sidebar.closeAll', this.unsetActiveId);
},
methods: {
- ...mapActions(['unsetActiveId']),
+ ...mapActions(['unsetActiveId', 'removeList']),
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();
+ if (window.confirm(__('Are you sure you want to remove this list?'))) {
+ if (this.shouldUseGraphQL) {
+ this.removeList(this.activeId);
+ } else {
+ this.activeList.destroy();
+ }
this.unsetActiveId();
}
},
@@ -105,7 +112,10 @@ export default {
:active-list="activeList"
:board-list-type="boardListType"
/>
- <board-settings-sidebar-wip-limit :max-issue-count="activeList.maxIssueCount" />
+ <board-settings-sidebar-wip-limit
+ v-if="isWipLimitsOn"
+ :max-issue-count="activeList.maxIssueCount"
+ />
<div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-m-4">
<gl-button
variant="danger"
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 271e1fc4b5f..0b079c78209 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -261,7 +261,7 @@ export default {
>
<gl-deprecated-dropdown-item
v-show="filteredBoards.length === 0"
- class="no-pointer-events text-secondary"
+ class="gl-pointer-events-none text-secondary"
>
{{ s__('IssueBoards|No matching boards found') }}
</gl-deprecated-dropdown-item>
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index a181ea51c4a..45ce1e51489 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -3,7 +3,7 @@ import { sortBy } from 'lodash';
import { mapState } from 'vuex';
import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner';
-import { sprintf, __ } from '~/locale';
+import { sprintf, __, n__ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import IssueDueDate from './issue_due_date.vue';
@@ -89,6 +89,12 @@ export default {
orderedLabels() {
return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title');
},
+ blockedLabel() {
+ if (this.issue.blockedByCount) {
+ return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.issue.blockedByCount);
+ }
+ return __('Blocked issue');
+ },
},
methods: {
isIndexLessThanlimit(index) {
@@ -133,15 +139,16 @@ export default {
</script>
<template>
<div>
- <div class="d-flex board-card-header" dir="auto">
+ <div class="gl-display-flex" dir="auto">
<h4 class="board-card-title gl-mb-0 gl-mt-0">
<gl-icon
v-if="issue.blocked"
v-gl-tooltip
name="issue-block"
- :title="__('Blocked issue')"
+ :title="blockedLabel"
class="issue-blocked-icon gl-mr-2"
- :aria-label="__('Blocked issue')"
+ :aria-label="blockedLabel"
+ data-testid="issue-blocked-icon"
/>
<gl-icon
v-if="issue.confidential"
@@ -156,7 +163,7 @@ export default {
}}</a>
</h4>
</div>
- <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 d-flex flex-wrap">
+ <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap">
<template v-for="label in orderedLabels">
<gl-label
:key="label.id"
@@ -169,24 +176,26 @@ export default {
/>
</template>
</div>
- <div class="board-card-footer d-flex justify-content-between align-items-end">
+ <div
+ class="board-card-footer gl-display-flex gl-justify-content-space-between gl-align-items-flex-end"
+ >
<div
- class="d-flex align-items-start flex-wrap-reverse board-card-number-container overflow-hidden js-board-card-number-container"
+ class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container"
>
<span
v-if="issue.referencePath"
- class="board-card-number overflow-hidden d-flex gl-mr-3 gl-mt-3"
+ class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3"
>
<tooltip-on-truncate
v-if="issueReferencePath"
:title="issueReferencePath"
placement="bottom"
- class="board-issue-path block-truncated bold"
+ class="board-issue-path gl-text-truncate gl-font-weight-bold"
>{{ issueReferencePath }}</tooltip-on-truncate
>
#{{ issue.iid }}
</span>
- <span class="board-info-items gl-mt-3 d-inline-block">
+ <span class="board-info-items gl-mt-3 gl-display-inline-block">
<issue-due-date v-if="issue.dueDate" :date="issue.dueDate" :closed="issue.closed" />
<issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" />
<issue-card-weight
@@ -196,20 +205,20 @@ export default {
/>
</span>
</div>
- <div class="board-card-assignee d-flex">
+ <div class="board-card-assignee gl-display-flex">
<user-avatar-link
v-for="(assignee, index) in issue.assignees"
v-if="shouldRenderAssignee(index)"
:key="assignee.id"
:link-href="assigneeUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
- :img-src="assignee.avatar || assignee.avatar_url"
+ :img-src="assignee.avatarUrl || assignee.avatar || assignee.avatar_url"
:img-size="24"
class="js-no-trigger"
tooltip-placement="bottom"
>
<span class="js-assignee-tooltip">
- <span class="bold d-block">{{ __('Assignee') }}</span>
+ <span class="gl-font-weight-bold gl-display-block">{{ __('Assignee') }}</span>
{{ assignee.name }}
<span class="text-white-50">@{{ assignee.username }}</span>
</span>
diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue
index cd4512f320f..eb2db260717 100644
--- a/app/assets/javascripts/boards/components/modal/empty_state.vue
+++ b/app/assets/javascripts/boards/components/modal/empty_state.vue
@@ -1,13 +1,13 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlButton } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
+import { GlButton, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
import ModalStore from '../../stores/modal_store';
import modalMixin from '../../mixins/modal_mixins';
export default {
components: {
GlButton,
+ GlSprintf,
},
mixins: [modalMixin],
props: {
@@ -34,11 +34,8 @@ export default {
if (this.activeTab === 'selected') {
obj.title = __("You haven't selected any issues yet");
- obj.content = sprintf(
- __(
- 'Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board.',
- ),
- { startTag: '<strong>', endTag: '</strong>' },
+ obj.content = __(
+ 'Go back to %{tagStart}Open issues%{tagEnd} and select some issues to add to your board.',
);
}
@@ -57,7 +54,13 @@ export default {
<div class="col-12 col-md-6 order-md-first">
<div class="text-content">
<h4>{{ contents.title }}</h4>
- <p v-html="contents.content"></p>
+ <p>
+ <gl-sprintf :message="contents.content">
+ <template #tag="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
<gl-button
v-if="activeTab === 'all'"
:href="newIssuePath"
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index c8926c5ef2a..47eee5306da 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -7,6 +7,7 @@ import { deprecatedCreateFlash as flash } from '~/flash';
import CreateLabelDropdown from '../../create_label';
import boardsStore from '../stores/boards_store';
import { fullLabelId } from '../boards_util';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import store from '~/boards/stores';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
@@ -61,7 +62,7 @@ export default function initNewListDropdown() {
const active = boardsStore.findListByLabelId(label.id);
const $li = $('<li />');
const $a = $('<a />', {
- class: active ? `is-active js-board-list-${active.id}` : '',
+ class: active ? `is-active js-board-list-${getIdFromGraphQLId(active.id)}` : '',
text: label.title,
href: '#',
});
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
index 566c0081b9d..f90fe582566 100644
--- a/app/assets/javascripts/boards/components/project_select.vue
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -44,6 +44,7 @@ export default {
this.selectedProject = {
id: $el.data('project-id'),
name: $el.data('project-name'),
+ path: $el.data('project-path'),
};
eventHub.$emit('setSelectedProject', this.selectedProject);
},
@@ -75,11 +76,12 @@ export default {
renderRow(project) {
return `
<li>
- <a href='#' class='dropdown-menu-link' data-project-id="${
- project.id
- }" data-project-name="${project.name}" data-project-name-with-namespace="${
- project.name_with_namespace
- }">
+ <a href='#' class='dropdown-menu-link'
+ data-project-id="${project.id}"
+ data-project-name="${project.name}"
+ data-project-name-with-namespace="${project.name_with_namespace}"
+ data-project-path="${project.path_with_namespace}"
+ >
${escape(project.name_with_namespace)}
</a>
</li>
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue
new file mode 100644
index 00000000000..6935ead2706
--- /dev/null
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue
@@ -0,0 +1,111 @@
+<script>
+import { mapGetters, mapActions } from 'vuex';
+import { GlButton, GlDatepicker } from '@gitlab/ui';
+import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
+import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ BoardEditableItem,
+ GlButton,
+ GlDatepicker,
+ },
+ data() {
+ return {
+ loading: false,
+ };
+ },
+ computed: {
+ ...mapGetters({ issue: 'activeIssue', projectPathForActiveIssue: 'projectPathForActiveIssue' }),
+ hasDueDate() {
+ return this.issue.dueDate != null;
+ },
+ parsedDueDate() {
+ if (!this.hasDueDate) {
+ return null;
+ }
+
+ return parsePikadayDate(this.issue.dueDate);
+ },
+ formattedDueDate() {
+ if (!this.hasDueDate) {
+ return '';
+ }
+
+ return dateInWords(this.parsedDueDate, true);
+ },
+ },
+ methods: {
+ ...mapActions(['setActiveIssueDueDate']),
+ async openDatePicker() {
+ await this.$nextTick();
+ this.$refs.datePicker.calendar.show();
+ },
+ async setDueDate(date) {
+ this.loading = true;
+ this.$refs.sidebarItem.collapse();
+
+ try {
+ const dueDate = date ? formatDate(date, 'yyyy-mm-dd') : null;
+ await this.setActiveIssueDueDate({ dueDate, projectPath: this.projectPathForActiveIssue });
+ } catch (e) {
+ createFlash({ message: this.$options.i18n.updateDueDateError });
+ } finally {
+ this.loading = false;
+ }
+ },
+ },
+ i18n: {
+ dueDate: __('Due date'),
+ removeDueDate: __('remove due date'),
+ updateDueDateError: __('An error occurred when updating the issue due date'),
+ },
+};
+</script>
+
+<template>
+ <board-editable-item
+ ref="sidebarItem"
+ class="board-sidebar-due-date"
+ :title="$options.i18n.dueDate"
+ :loading="loading"
+ @open="openDatePicker"
+ >
+ <template v-if="hasDueDate" #collapsed>
+ <div class="gl-display-flex gl-align-items-center">
+ <strong class="gl-text-gray-900">{{ formattedDueDate }}</strong>
+ <span class="gl-mx-2">-</span>
+ <gl-button
+ variant="link"
+ class="gl-text-gray-400!"
+ data-testid="reset-button"
+ :disabled="loading"
+ @click="setDueDate(null)"
+ >
+ {{ $options.i18n.removeDueDate }}
+ </gl-button>
+ </div>
+ </template>
+ <template>
+ <gl-datepicker
+ ref="datePicker"
+ :value="parsedDueDate"
+ show-clear-button
+ @input="setDueDate"
+ @clear="setDueDate(null)"
+ />
+ </template>
+ </board-editable-item>
+</template>
+<style>
+/*
+ * This can be removed after closing:
+ * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1048
+ */
+.board-sidebar-due-date .gl-datepicker,
+.board-sidebar-due-date .gl-datepicker-input {
+ width: 100%;
+}
+</style>
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
index 0f063c7582e..9d537a4ef2c 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue
@@ -21,9 +21,9 @@ export default {
},
inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'],
computed: {
- ...mapGetters({ issue: 'getActiveIssue' }),
+ ...mapGetters(['activeIssue', 'projectPathForActiveIssue']),
selectedLabels() {
- const { labels = [] } = this.issue;
+ const { labels = [] } = this.activeIssue;
return labels.map(label => ({
...label,
@@ -31,17 +31,13 @@ export default {
}));
},
issueLabels() {
- const { labels = [] } = this.issue;
+ const { labels = [] } = this.activeIssue;
return labels.map(label => ({
...label,
scoped: isScopedLabel(label),
}));
},
- projectPath() {
- const { referencePath = '' } = this.issue;
- return referencePath.slice(0, referencePath.indexOf('#'));
- },
},
methods: {
...mapActions(['setActiveIssueLabels']),
@@ -55,7 +51,7 @@ export default {
.filter(label => !payload.find(selected => selected.id === label.id))
.map(label => label.id);
- const input = { addLabelIds, removeLabelIds, projectPath: this.projectPath };
+ const input = { addLabelIds, removeLabelIds, projectPath: this.projectPathForActiveIssue };
await this.setActiveIssueLabels(input);
} catch (e) {
createFlash({ message: __('An error occurred while updating labels.') });
@@ -68,7 +64,7 @@ export default {
try {
const removeLabelIds = [getIdFromGraphQLId(id)];
- const input = { removeLabelIds, projectPath: this.projectPath };
+ const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue };
await this.setActiveIssueLabels(input);
} catch (e) {
createFlash({ message: __('An error occurred when removing the label.') });
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
new file mode 100644
index 00000000000..ed069cea630
--- /dev/null
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
@@ -0,0 +1,71 @@
+<script>
+import { mapGetters, mapActions } from 'vuex';
+import { GlToggle } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { __, s__ } from '~/locale';
+
+export default {
+ i18n: {
+ header: {
+ title: __('Notifications'),
+ /* Any change to subscribeDisabledDescription
+ must be reflected in app/helpers/notifications_helper.rb */
+ subscribeDisabledDescription: __(
+ 'Notifications have been disabled by the project or group owner',
+ ),
+ },
+ updateSubscribedErrorMessage: s__(
+ 'IssueBoards|An error occurred while setting notifications status.',
+ ),
+ },
+ components: {
+ GlToggle,
+ },
+ data() {
+ return {
+ loading: false,
+ };
+ },
+ computed: {
+ ...mapGetters(['activeIssue', 'projectPathForActiveIssue']),
+ notificationText() {
+ return this.activeIssue.emailsDisabled
+ ? this.$options.i18n.header.subscribeDisabledDescription
+ : this.$options.i18n.header.title;
+ },
+ },
+ methods: {
+ ...mapActions(['setActiveIssueSubscribed']),
+ async handleToggleSubscription() {
+ this.loading = true;
+
+ try {
+ await this.setActiveIssueSubscribed({
+ subscribed: !this.activeIssue.subscribed,
+ projectPath: this.projectPathForActiveIssue,
+ });
+ } catch (error) {
+ createFlash({ message: this.$options.i18n.updateSubscribedErrorMessage });
+ } finally {
+ this.loading = false;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-space-between"
+ data-testid="sidebar-notifications"
+ >
+ <span data-testid="notification-header-text"> {{ notificationText }} </span>
+ <gl-toggle
+ v-if="!activeIssue.emailsDisabled"
+ :value="activeIssue.subscribed"
+ :is-loading="loading"
+ data-testid="notification-subscribe-toggle"
+ @change="handleToggleSubscription"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 2f64014a949..49cb560594c 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -18,7 +18,11 @@ export const inactiveId = 0;
export const ISSUABLE = 'issuable';
export const LIST = 'list';
+/* eslint-disable-next-line @gitlab/require-i18n-strings */
+export const DEFAULT_LABELS = ['to do', 'doing'];
+
export default {
BoardType,
ListType,
+ DEFAULT_LABELS,
};
diff --git a/app/assets/javascripts/boards/graphql/mutations/issue_set_subscription.mutation.graphql b/app/assets/javascripts/boards/graphql/mutations/issue_set_subscription.mutation.graphql
new file mode 100644
index 00000000000..1f383245ac2
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/mutations/issue_set_subscription.mutation.graphql
@@ -0,0 +1,8 @@
+mutation issueSetSubscription($input: IssueSetSubscriptionInput!) {
+ issueSetSubscription(input: $input) {
+ issue {
+ subscribed
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 887abe79059..d3e40299d8d 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import 'ee_else_ce/boards/models/issue';
import 'ee_else_ce/boards/models/list';
@@ -86,10 +86,17 @@ export default () => {
boardId: $boardApp.dataset.boardId,
groupId: Number($boardApp.dataset.groupId),
rootPath: $boardApp.dataset.rootPath,
+ currentUserId: gon.current_user_id || null,
canUpdate: $boardApp.dataset.canUpdate,
labelsFetchPath: $boardApp.dataset.labelsFetchPath,
labelsManagePath: $boardApp.dataset.labelsManagePath,
labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath,
+ timeTrackingLimitToHours: parseBoolean($boardApp.dataset.timeTrackingLimitToHours),
+ weightFeatureAvailable: parseBoolean($boardApp.dataset.weightFeatureAvailable),
+ boardWeight: $boardApp.dataset.boardWeight
+ ? parseInt($boardApp.dataset.boardWeight, 10)
+ : null,
+ scopedLabelsAvailable: parseBoolean($boardApp.dataset.scopedLabels),
},
store,
apolloProvider,
@@ -108,6 +115,7 @@ export default () => {
},
computed: {
...mapState(['isShowingEpicsSwimlanes']),
+ ...mapGetters(['shouldUseGraphQL']),
detailIssueVisible() {
return Object.keys(this.detailIssue.issue).length;
},
@@ -153,7 +161,7 @@ export default () => {
boardsStore.disabled = this.disabled;
- if (!gon.features.graphqlBoardLists) {
+ if (!this.shouldUseGraphQL) {
this.initialBoardLoad();
}
},
diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js
index fceb8c9d48e..f02c92e4230 100644
--- a/app/assets/javascripts/boards/mixins/sortable_default_options.js
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js
@@ -1,17 +1,12 @@
/* global DocumentTouch */
-import $ from 'jquery';
import sortableConfig from 'ee_else_ce/sortable/sortable_config';
export function sortableStart() {
- $('.has-tooltip')
- .tooltip('hide')
- .tooltip('disable');
document.body.classList.add('is-dragging');
}
export function sortableEnd() {
- $('.has-tooltip').tooltip('enable');
document.body.classList.remove('is-dragging');
}
diff --git a/app/assets/javascripts/boards/queries/board_labels.query.graphql b/app/assets/javascripts/boards/queries/board_labels.query.graphql
new file mode 100644
index 00000000000..42a94419a97
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/board_labels.query.graphql
@@ -0,0 +1,23 @@
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
+query BoardLabels(
+ $fullPath: ID!
+ $searchTerm: String
+ $isGroup: Boolean = false
+ $isProject: Boolean = false
+) {
+ group(fullPath: $fullPath) @include(if: $isGroup) {
+ labels(searchTerm: $searchTerm) {
+ nodes {
+ ...Label
+ }
+ }
+ }
+ project(fullPath: $fullPath) @include(if: $isProject) {
+ labels(searchTerm: $searchTerm) {
+ nodes {
+ ...Label
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql b/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql
new file mode 100644
index 00000000000..ef3fd36e980
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql
@@ -0,0 +1,5 @@
+mutation DestroyBoardList($listId: ID!) {
+ destroyBoardList(input: { listId: $listId }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/boards/queries/issue_create.mutation.graphql b/app/assets/javascripts/boards/queries/issue_create.mutation.graphql
new file mode 100644
index 00000000000..65be147be07
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/issue_create.mutation.graphql
@@ -0,0 +1,10 @@
+#import "ee_else_ce/boards/queries/issue.fragment.graphql"
+
+mutation CreateIssue($input: CreateIssueInput!) {
+ createIssue(input: $input) {
+ issue {
+ ...IssueNode
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/boards/queries/issue_set_due_date.mutation.graphql b/app/assets/javascripts/boards/queries/issue_set_due_date.mutation.graphql
new file mode 100644
index 00000000000..bbea248cf85
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/issue_set_due_date.mutation.graphql
@@ -0,0 +1,8 @@
+mutation issueSetDueDate($input: UpdateIssueInput!) {
+ updateIssue(input: $input) {
+ issue {
+ dueDate
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/boards/queries/users_search.query.graphql b/app/assets/javascripts/boards/queries/users_search.query.graphql
new file mode 100644
index 00000000000..ca016495d79
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/users_search.query.graphql
@@ -0,0 +1,11 @@
+query usersSearch($search: String!) {
+ users(search: $search) {
+ nodes {
+ username
+ name
+ webUrl
+ avatarUrl
+ id
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 1fed1228106..dd950a45076 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -1,26 +1,30 @@
-import Cookies from 'js-cookie';
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 createGqClient, { fetchPolicies } from '~/lib/graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { BoardType, ListType, inactiveId } from '~/boards/constants';
+import { BoardType, ListType, inactiveId, DEFAULT_LABELS } from '~/boards/constants';
import * as types from './mutation_types';
import {
formatBoardLists,
formatListIssues,
fullBoardId,
formatListsPageInfo,
+ formatIssue,
} from '../boards_util';
import boardStore from '~/boards/stores/boards_store';
+import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import listsIssuesQuery from '../queries/lists_issues.query.graphql';
+import boardLabelsQuery from '../queries/board_labels.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 destroyBoardListMutation from '../queries/board_list_destroy.mutation.graphql';
+import issueCreateMutation from '../queries/issue_create.mutation.graphql';
import issueSetLabels from '../queries/issue_set_labels.mutation.graphql';
+import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql';
+import issueSetSubscriptionMutation from '../graphql/mutations/issue_set_subscription.mutation.graphql';
const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
@@ -83,7 +87,7 @@ export default {
if (!lists.nodes.find(l => l.listType === ListType.backlog) && !hideBacklogList) {
dispatch('createList', { backlog: true });
}
- dispatch('showWelcomeList');
+ dispatch('generateDefaultLists');
})
.catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE));
},
@@ -121,7 +125,32 @@ export default {
);
},
- showWelcomeList: ({ state, dispatch }) => {
+ showPromotionList: () => {},
+
+ fetchLabels: ({ state, commit }, searchTerm) => {
+ const { endpoints, boardType } = state;
+ const { fullPath } = endpoints;
+
+ const variables = {
+ fullPath,
+ searchTerm,
+ isGroup: boardType === BoardType.group,
+ isProject: boardType === BoardType.project,
+ };
+
+ return gqlClient
+ .query({
+ query: boardLabelsQuery,
+ variables,
+ })
+ .then(({ data }) => {
+ const labels = data[boardType]?.labels;
+ return labels.nodes;
+ })
+ .catch(() => commit(types.RECEIVE_LABELS_FAILURE));
+ },
+
+ generateDefaultLists: async ({ state, commit, dispatch }) => {
if (state.disabled) {
return;
}
@@ -132,22 +161,18 @@ export default {
) {
return;
}
- if (parseBoolean(Cookies.get('issue_board_welcome_hidden'))) {
- return;
- }
- dispatch('addList', {
- id: 'blank',
- listType: ListType.blank,
- title: __('Welcome to your issue board!'),
- position: 0,
- });
- },
-
- showPromotionList: () => {},
+ const fetchLabelsAndCreateList = label => {
+ return dispatch('fetchLabels', label)
+ .then(res => {
+ if (res.length > 0) {
+ dispatch('createList', { labelId: res[0].id });
+ }
+ })
+ .catch(() => commit(types.GENERATE_DEFAULT_LISTS_FAILURE));
+ };
- generateDefaultLists: () => {
- notImplemented();
+ await Promise.all(DEFAULT_LABELS.map(fetchLabelsAndCreateList));
},
moveList: (
@@ -191,8 +216,26 @@ export default {
});
},
- deleteList: () => {
- notImplemented();
+ removeList: ({ state, commit }, listId) => {
+ const listsBackup = { ...state.boardLists };
+
+ commit(types.REMOVE_LIST, listId);
+
+ return gqlClient
+ .mutate({
+ mutation: destroyBoardListMutation,
+ variables: {
+ listId,
+ },
+ })
+ .then(({ data: { destroyBoardList: { errors } } }) => {
+ if (errors.length > 0) {
+ commit(types.REMOVE_LIST_FAILURE, listsBackup);
+ }
+ })
+ .catch(() => {
+ commit(types.REMOVE_LIST_FAILURE, listsBackup);
+ });
},
fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => {
@@ -271,20 +314,69 @@ export default {
);
},
- createNewIssue: () => {
- notImplemented();
+ setAssignees: ({ commit, getters }, assigneeUsernames) => {
+ return gqlClient
+ .mutate({
+ mutation: updateAssignees,
+ variables: {
+ iid: getters.activeIssue.iid,
+ projectPath: getters.activeIssue.referencePath.split('#')[0],
+ assigneeUsernames,
+ },
+ })
+ .then(({ data }) => {
+ commit('UPDATE_ISSUE_BY_ID', {
+ issueId: getters.activeIssue.id,
+ prop: 'assignees',
+ value: data.issueSetAssignees.issue.assignees.nodes,
+ });
+ });
+ },
+
+ createNewIssue: ({ commit, state }, issueInput) => {
+ const input = issueInput;
+ const { boardType, endpoints } = state;
+ if (boardType === BoardType.project) {
+ input.projectPath = endpoints.fullPath;
+ }
+
+ return gqlClient
+ .mutate({
+ mutation: issueCreateMutation,
+ variables: { input },
+ })
+ .then(({ data }) => {
+ if (data.createIssue.errors.length) {
+ commit(types.CREATE_ISSUE_FAILURE);
+ } else {
+ return data.createIssue?.issue;
+ }
+ return null;
+ })
+ .catch(() => commit(types.CREATE_ISSUE_FAILURE));
},
addListIssue: ({ commit }, { list, issue, position }) => {
commit(types.ADD_ISSUE_TO_LIST, { list, issue, position });
},
- addListIssueFailure: ({ commit }, { list, issue }) => {
- commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issue });
+ addListNewIssue: ({ commit, dispatch }, { issueInput, list }) => {
+ const issue = formatIssue({ ...issueInput, id: 'tmp' });
+ commit(types.ADD_ISSUE_TO_LIST, { list, issue, position: 0 });
+
+ dispatch('createNewIssue', issueInput)
+ .then(res => {
+ commit(types.ADD_ISSUE_TO_LIST, {
+ list,
+ issue: formatIssue({ ...res, id: getIdFromGraphQLId(res.id) }),
+ });
+ commit(types.REMOVE_ISSUE_FROM_LIST, { list, issue });
+ })
+ .catch(() => commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issueId: issueInput.id }));
},
setActiveIssueLabels: async ({ commit, getters }, input) => {
- const activeIssue = getters.getActiveIssue;
+ const { activeIssue } = getters;
const { data } = await gqlClient.mutate({
mutation: issueSetLabels,
variables: {
@@ -308,6 +400,53 @@ export default {
});
},
+ setActiveIssueDueDate: async ({ commit, getters }, input) => {
+ const { activeIssue } = getters;
+ const { data } = await gqlClient.mutate({
+ mutation: issueSetDueDate,
+ variables: {
+ input: {
+ iid: String(activeIssue.iid),
+ projectPath: input.projectPath,
+ dueDate: input.dueDate,
+ },
+ },
+ });
+
+ if (data.updateIssue?.errors?.length > 0) {
+ throw new Error(data.updateIssue.errors);
+ }
+
+ commit(types.UPDATE_ISSUE_BY_ID, {
+ issueId: activeIssue.id,
+ prop: 'dueDate',
+ value: data.updateIssue.issue.dueDate,
+ });
+ },
+
+ setActiveIssueSubscribed: async ({ commit, getters }, input) => {
+ const { data } = await gqlClient.mutate({
+ mutation: issueSetSubscriptionMutation,
+ variables: {
+ input: {
+ iid: String(getters.activeIssue.iid),
+ projectPath: input.projectPath,
+ subscribedState: input.subscribed,
+ },
+ },
+ });
+
+ if (data.issueSetSubscription?.errors?.length > 0) {
+ throw new Error(data.issueSetSubscription.errors);
+ }
+
+ commit(types.UPDATE_ISSUE_BY_ID, {
+ issueId: getters.activeIssue.id,
+ prop: 'subscribed',
+ value: data.issueSetSubscription.issue.subscribed,
+ });
+ },
+
fetchBacklog: () => {
notImplemented();
},
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index d1a5db1bcc5..337b2897fe9 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -1,7 +1,6 @@
/* eslint-disable no-shadow, no-param-reassign,consistent-return */
/* global List */
/* global ListIssue */
-import $ from 'jquery';
import { sortBy, pick } from 'lodash';
import Vue from 'vue';
import Cookies from 'js-cookie';
@@ -119,8 +118,12 @@ const boardsStore = {
// https://gitlab.com/gitlab-org/gitlab-foss/issues/30821
});
},
+
updateNewListDropdown(listId) {
- $(`.js-board-list-${listId}`).removeClass('is-active');
+ // eslint-disable-next-line no-unused-expressions
+ document
+ .querySelector(`.js-board-list-${getIdFromGraphQLId(listId)}`)
+ ?.classList.remove('is-active');
},
shouldAddBlankState() {
// Decide whether to add the blank state
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
index 89a3b14b262..cd28b4a0ff7 100644
--- a/app/assets/javascripts/boards/stores/getters.js
+++ b/app/assets/javascripts/boards/stores/getters.js
@@ -2,7 +2,7 @@ import { find } from 'lodash';
import { inactiveId } from '../constants';
export default {
- getLabelToggleState: state => (state.isShowingLabels ? 'on' : 'off'),
+ labelToggleState: state => (state.isShowingLabels ? 'on' : 'off'),
isSidebarOpen: state => state.activeId !== inactiveId,
isSwimlanesOn: state => {
if (!gon?.features?.boardsWithSwimlanes && !gon?.features?.swimlanes) {
@@ -15,15 +15,20 @@ export default {
return state.issues[id] || {};
},
- getIssues: (state, getters) => listId => {
+ getIssuesByList: (state, getters) => listId => {
const listIssueIds = state.issuesByListId[listId] || [];
return listIssueIds.map(id => getters.getIssueById(id));
},
- getActiveIssue: state => {
+ activeIssue: state => {
return state.issues[state.activeId] || {};
},
+ projectPathForActiveIssue: (_, getters) => {
+ const referencePath = getters.activeIssue.referencePath || '';
+ return referencePath.slice(0, referencePath.indexOf('#'));
+ },
+
getListByLabelId: state => labelId => {
return find(state.boardLists, l => l.label?.id === labelId);
},
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index 09ab08062df..3a57cb9b5e1 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -2,6 +2,8 @@ export const SET_INITIAL_BOARD_DATA = 'SET_INITIAL_BOARD_DATA';
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_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE';
+export const GENERATE_DEFAULT_LISTS_FAILURE = 'GENERATE_DEFAULT_LISTS_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';
@@ -10,12 +12,12 @@ export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS';
export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR';
export const MOVE_LIST = 'MOVE_LIST';
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 REMOVE_LIST = 'REMOVE_LIST';
+export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE';
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 CREATE_ISSUE_FAILURE = 'CREATE_ISSUE_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';
@@ -27,6 +29,7 @@ export const RECEIVE_UPDATE_ISSUE_SUCCESS = 'RECEIVE_UPDATE_ISSUE_SUCCESS';
export const RECEIVE_UPDATE_ISSUE_ERROR = 'RECEIVE_UPDATE_ISSUE_ERROR';
export const ADD_ISSUE_TO_LIST = 'ADD_ISSUE_TO_LIST';
export const ADD_ISSUE_TO_LIST_FAILURE = 'ADD_ISSUE_TO_LIST_FAILURE';
+export const REMOVE_ISSUE_FROM_LIST = 'REMOVE_ISSUE_FROM_LIST';
export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE';
export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE';
export const SET_ACTIVE_ID = 'SET_ACTIVE_ID';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 0c7dbc0d2ef..bb083158c8f 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -62,6 +62,14 @@ export default {
state.error = s__('Boards|An error occurred while creating the list. Please try again.');
},
+ [mutationTypes.RECEIVE_LABELS_FAILURE]: state => {
+ state.error = s__('Boards|An error occurred while fetching labels. Please reload the page.');
+ },
+
+ [mutationTypes.GENERATE_DEFAULT_LISTS_FAILURE]: state => {
+ state.error = s__('Boards|An error occurred while generating lists. Please reload the page.');
+ },
+
[mutationTypes.REQUEST_ADD_LIST]: () => {
notImplemented();
},
@@ -85,16 +93,13 @@ export default {
Vue.set(state, 'boardLists', backupList);
},
- [mutationTypes.REQUEST_REMOVE_LIST]: () => {
- notImplemented();
+ [mutationTypes.REMOVE_LIST]: (state, listId) => {
+ Vue.delete(state.boardLists, listId);
},
- [mutationTypes.RECEIVE_REMOVE_LIST_SUCCESS]: () => {
- notImplemented();
- },
-
- [mutationTypes.RECEIVE_REMOVE_LIST_ERROR]: () => {
- notImplemented();
+ [mutationTypes.REMOVE_LIST_FAILURE](state, listsBackup) {
+ state.error = s__('Boards|An error occurred while removing the list. Please try again.');
+ state.boardLists = listsBackup;
},
[mutationTypes.REQUEST_ISSUES_FOR_LIST]: (state, { listId, fetchNext }) => {
@@ -196,16 +201,28 @@ export default {
notImplemented();
},
+ [mutationTypes.CREATE_ISSUE_FAILURE]: state => {
+ state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
+ },
+
[mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => {
- const listIssues = state.issuesByListId[list.id];
- listIssues.splice(position, 0, issue.id);
- Vue.set(state.issuesByListId, list.id, listIssues);
+ addIssueToList({
+ state,
+ listId: list.id,
+ issueId: issue.id,
+ atIndex: position,
+ });
Vue.set(state.issues, issue.id, issue);
},
- [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issue }) => {
+ [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => {
state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
+ removeIssueFromList({ state, listId: list.id, issueId });
+ },
+
+ [mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => {
removeIssueFromList({ state, listId: list.id, issueId: issue.id });
+ Vue.delete(state.issues, issue.id);
},
[mutationTypes.SET_CURRENT_PAGE]: () => {
diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js
index fa13d3a9e3c..347deb81846 100644
--- a/app/assets/javascripts/boards/toggle_focus.js
+++ b/app/assets/javascripts/boards/toggle_focus.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import Vue from 'vue';
import { GlIcon } from '@gitlab/ui';
+import { hide } from '~/tooltips';
export default (ModalStore, boardsStore) => {
const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board');
@@ -17,7 +18,9 @@ export default (ModalStore, boardsStore) => {
},
methods: {
toggleFocusMode() {
- $(this.$refs.toggleFocusModeButton).tooltip('hide');
+ const $el = $(this.$refs.toggleFocusModeButton);
+ hide($el);
+
issueBoardsContent.classList.toggle('is-focused');
this.isFullscreen = !this.isFullscreen;
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue
index 2532f4b86d2..def45026b35 100644
--- a/app/assets/javascripts/ci_lint/components/ci_lint.vue
+++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue
@@ -19,7 +19,11 @@ export default {
type: String,
required: true,
},
- helpPagePath: {
+ lintHelpPagePath: {
+ type: String,
+ required: true,
+ },
+ pipelineSimulationHelpPagePath: {
type: String,
required: true,
},
@@ -27,6 +31,7 @@ export default {
data() {
return {
content: '',
+ loading: false,
valid: false,
errors: null,
warnings: null,
@@ -44,6 +49,7 @@ export default {
},
methods: {
async lint() {
+ this.loading = true;
try {
const {
data: {
@@ -62,6 +68,8 @@ export default {
} catch (error) {
this.apiError = error;
this.isErrorDismissed = false;
+ } finally {
+ this.loading = false;
}
},
clear() {
@@ -93,6 +101,7 @@ export default {
<div class="gl-display-flex gl-align-items-center">
<gl-button
class="gl-mr-4"
+ :loading="loading"
category="primary"
variant="success"
data-testid="ci-lint-validate"
@@ -101,7 +110,7 @@ export default {
>
<gl-form-checkbox v-model="dryRun"
>{{ __('Simulate a pipeline created for the default branch') }}
- <gl-link :href="helpPagePath" target="_blank"
+ <gl-link :href="pipelineSimulationHelpPagePath" target="_blank"
><gl-icon class="gl-text-blue-600" name="question-o"/></gl-link
></gl-form-checkbox>
</div>
@@ -115,6 +124,7 @@ export default {
:errors="errors"
:warnings="warnings"
:dry-run="dryRun"
+ :lint-help-page-path="lintHelpPagePath"
/>
</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 28b2a028b29..8b37c94de19 100644
--- a/app/assets/javascripts/ci_lint/components/ci_lint_results.vue
+++ b/app/assets/javascripts/ci_lint/components/ci_lint_results.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlTable } from '@gitlab/ui';
+import { GlAlert, GlLink, GlSprintf, 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';
@@ -8,8 +8,17 @@ import { __ } from '~/locale';
const thBorderColor = 'gl-border-gray-100!';
export default {
- correct: { variant: 'success', text: __('syntax is correct') },
- incorrect: { variant: 'danger', text: __('syntax is incorrect') },
+ correct: {
+ variant: 'success',
+ text: __('syntax is correct.'),
+ },
+ incorrect: {
+ variant: 'danger',
+ text: __('syntax is incorrect.'),
+ },
+ includesText: __(
+ 'CI configuration validated, including all configuration added with the %{codeStart}includes%{codeEnd} keyword. %{link}',
+ ),
warningTitle: __('The form contains the following warning:'),
fields: [
{
@@ -25,6 +34,8 @@ export default {
],
components: {
GlAlert,
+ GlLink,
+ GlSprintf,
GlTable,
CiLintWarnings,
CiLintResultsValue,
@@ -51,6 +62,10 @@ export default {
type: Boolean,
required: true,
},
+ lintHelpPagePath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -82,8 +97,20 @@ export default {
:title="__('Status:')"
:dismissible="false"
data-testid="ci-lint-status"
- >{{ status.text }}</gl-alert
- >
+ >{{ status.text }}
+ <gl-sprintf :message="$options.includesText">
+ <template #code="{content}">
+ <code>
+ {{ content }}
+ </code>
+ </template>
+ <template #link>
+ <gl-link :href="lintHelpPagePath" target="_blank">
+ {{ __('More information') }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
<pre
v-if="shouldShowError"
diff --git a/app/assets/javascripts/ci_lint/graphql/resolvers.js b/app/assets/javascripts/ci_lint/graphql/resolvers.js
new file mode 100644
index 00000000000..126b4c664b2
--- /dev/null
+++ b/app/assets/javascripts/ci_lint/graphql/resolvers.js
@@ -0,0 +1,34 @@
+import axios from '~/lib/utils/axios_utils';
+
+const resolvers = {
+ Mutation: {
+ lintCI: (_, { endpoint, content, dry_run }) => {
+ return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({
+ valid: data.valid,
+ errors: data.errors,
+ warnings: data.warnings,
+ jobs: data.jobs.map(job => {
+ const only = job.only ? { refs: job.only.refs, __typename: 'CiLintJobOnlyPolicy' } : null;
+
+ return {
+ 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,
+ except: job.except,
+ __typename: 'CiLintJob',
+ };
+ }),
+ __typename: 'CiLintContent',
+ }));
+ },
+ },
+};
+
+export default resolvers;
diff --git a/app/assets/javascripts/ci_lint/index.js b/app/assets/javascripts/ci_lint/index.js
index c41e6d47d75..e4cda4cb369 100644
--- a/app/assets/javascripts/ci_lint/index.js
+++ b/app/assets/javascripts/ci_lint/index.js
@@ -1,48 +1,18 @@
import Vue from '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';
+import resolvers from './graphql/resolvers';
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, helpPagePath } = containerEl.dataset;
+ const { endpoint, lintHelpPagePath, pipelineSimulationHelpPagePath } = containerEl.dataset;
return new Vue({
el: containerEl,
@@ -51,7 +21,8 @@ export default (containerId = '#js-ci-lint') => {
return createElement(CiLint, {
props: {
endpoint,
- helpPagePath,
+ lintHelpPagePath,
+ pipelineSimulationHelpPagePath,
},
});
},
diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
deleted file mode 100644
index b8bf363fc9d..00000000000
--- a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
+++ /dev/null
@@ -1,128 +0,0 @@
-import { escape } from 'lodash';
-import axios from '../lib/utils/axios_utils';
-import { s__ } from '../locale';
-import { deprecatedCreateFlash as Flash } from '../flash';
-import { parseBoolean } from '../lib/utils/common_utils';
-import statusCodes from '../lib/utils/http_status';
-import VariableList from './ci_variable_list';
-
-function generateErrorBoxContent(errors) {
- const errorList = [].concat(errors).map(
- errorString => `
- <li>
- ${escape(errorString)}
- </li>
- `,
- );
-
- return `
- <p>
- ${s__('CiVariable|Validation failed')}
- </p>
- <ul>
- ${errorList.join('')}
- </ul>
- `;
-}
-
-// Used for the variable list on CI/CD projects/groups settings page
-export default class AjaxVariableList {
- constructor({
- container,
- saveButton,
- errorBox,
- formField = 'variables',
- saveEndpoint,
- maskableRegex,
- }) {
- this.container = container;
- this.saveButton = saveButton;
- this.errorBox = errorBox;
- this.saveEndpoint = saveEndpoint;
- this.maskableRegex = maskableRegex;
-
- this.variableList = new VariableList({
- container: this.container,
- formField,
- maskableRegex,
- });
-
- this.bindEvents();
- this.variableList.init();
- }
-
- bindEvents() {
- this.saveButton.addEventListener('click', this.onSaveClicked.bind(this));
- }
-
- onSaveClicked() {
- const loadingIcon = this.saveButton.querySelector('.js-ci-variables-save-loading-icon');
- loadingIcon.classList.toggle('hide', false);
- this.errorBox.classList.toggle('hide', true);
- // We use this to prevent a user from changing a key before we have a chance
- // to match it up in `updateRowsWithPersistedVariables`
- this.variableList.toggleEnableRow(false);
-
- return axios
- .patch(
- this.saveEndpoint,
- {
- variables_attributes: this.variableList.getAllData(),
- },
- {
- // We want to be able to process the `res.data` from a 400 error response
- // and print the validation messages such as duplicate variable keys
- validateStatus: status =>
- (status >= statusCodes.OK && status < statusCodes.MULTIPLE_CHOICES) ||
- status === statusCodes.BAD_REQUEST,
- },
- )
- .then(res => {
- loadingIcon.classList.toggle('hide', true);
- this.variableList.toggleEnableRow(true);
-
- if (res.status === statusCodes.OK && res.data) {
- this.updateRowsWithPersistedVariables(res.data.variables);
- this.variableList.hideValues();
- } else if (res.status === statusCodes.BAD_REQUEST) {
- // Validation failed
- this.errorBox.innerHTML = generateErrorBoxContent(res.data);
- this.errorBox.classList.toggle('hide', false);
- }
- })
- .catch(() => {
- loadingIcon.classList.toggle('hide', true);
- this.variableList.toggleEnableRow(true);
- Flash(s__('CiVariable|Error occurred while saving variables'));
- });
- }
-
- updateRowsWithPersistedVariables(persistedVariables = []) {
- const persistedVariableMap = [].concat(persistedVariables).reduce(
- (variableMap, variable) => ({
- ...variableMap,
- [variable.key]: variable,
- }),
- {},
- );
-
- this.container.querySelectorAll('.js-row').forEach(row => {
- // If we submitted a row that was destroyed, remove it so we don't try
- // to destroy it again which would cause a BE error
- const destroyInput = row.querySelector('.js-ci-variable-input-destroy');
- if (parseBoolean(destroyInput.value)) {
- row.remove();
- // Update the ID input so any future edits and `_destroy` will apply on the BE
- } else {
- const key = row.querySelector('.js-ci-variable-input-key').value;
- const persistedVariable = persistedVariableMap[key];
-
- if (persistedVariable) {
- // eslint-disable-next-line no-param-reassign
- row.querySelector('.js-ci-variable-input-id').value = persistedVariable.id;
- row.setAttribute('data-is-persisted', 'true');
- }
- }
- });
- }
-}
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 ceb94b1f0f8..83e9717041f 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
@@ -60,7 +60,7 @@ export default {
</script>
<template>
<gl-dropdown :text="value">
- <gl-search-box-by-type v-model.trim="searchTerm" />
+ <gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" />
<gl-dropdown-item
v-for="environment in filteredResults"
:key="environment"
@@ -75,7 +75,7 @@ export default {
}}</gl-dropdown-item>
<template v-if="shouldRenderCreateButton">
<gl-dropdown-divider />
- <gl-dropdown-item @click="createClicked">
+ <gl-dropdown-item data-testid="create-wildcard-button" @click="createClicked">
{{ composedCreateButtonLabel }}
</gl-dropdown-item>
</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 a2f4bea2f61..da816f85466 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
@@ -236,6 +236,7 @@ export default {
:label="__('Environment scope')"
label-for="ci-variable-env"
class="w-50"
+ data-testid="environment-scope"
>
<ci-environments-dropdown
class="w-100"
@@ -247,7 +248,11 @@ export default {
</div>
<gl-form-group :label="__('Flags')" label-for="ci-variable-flags">
- <gl-form-checkbox v-model="protected_variable" class="mb-0">
+ <gl-form-checkbox
+ v-model="protected_variable"
+ class="mb-0"
+ data-testid="ci-variable-protected-checkbox"
+ >
{{ __('Protect variable') }}
<gl-link target="_blank" :href="protectedEnvironmentVariablesLink">
<gl-icon name="question" :size="12" />
@@ -261,6 +266,7 @@ export default {
ref="masked-ci-variable"
v-model="masked"
data-qa-selector="ci_variable_masked_checkbox"
+ data-testid="ci-variable-masked-checkbox"
>
{{ __('Mask variable') }}
<gl-link target="_blank" :href="maskedEnvironmentVariablesLink">
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index 412260da958..471c1a0b4a2 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -343,7 +343,7 @@ export default {
>
<span v-else class="js-cluster-application-title">{{ title }}</span>
</strong>
- <slot name="installedVia"></slot>
+ <slot name="installed-via"></slot>
<div>
<slot name="description"></slot>
</div>
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index b03cf6fc31b..271d862afab 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -467,6 +467,17 @@ export default {
notebooks to a class of students, a corporate data science group,
or a scientific research group.`)
}}
+ <gl-sprintf
+ :message="
+ s__(
+ 'ClusterIntegration|%{boldStart}Note:%{boldEnd} Requires Ingress to be installed.',
+ )
+ "
+ >
+ <template #bold="{ content }">
+ <b>{{ content }}</b>
+ </template>
+ </gl-sprintf>
</p>
<template v-if="ingressExternalEndpoint">
@@ -549,8 +560,8 @@ export default {
@set="setKnativeDomain"
/>
</template>
- <template v-if="cloudRun" #installedVia>
- <span data-testid="installedVia">
+ <template v-if="cloudRun" #installed-via>
+ <span data-testid="installed-via">
<gl-sprintf
:message="s__('ClusterIntegration|installed via %{linkStart}Cloud Run%{linkEnd}')"
>
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index f8fb58cdca2..08fd7db40a1 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -1,17 +1,17 @@
<script>
import { mapState, mapActions } from 'vuex';
import {
- GlDeprecatedBadge as GlBadge,
+ GlBadge,
GlLink,
GlLoadingIcon,
GlPagination,
GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlSprintf,
GlTable,
+ GlTooltipDirective,
} 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';
@@ -30,7 +30,7 @@ export default {
NodeErrorHelpText,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
computed: {
...mapState([
@@ -227,7 +227,7 @@ export default {
<gl-loading-icon
v-if="item.status === 'deleting' || item.status === 'creating'"
- v-tooltip
+ v-gl-tooltip
:title="statusTitle(item.status)"
size="sm"
/>
@@ -294,7 +294,7 @@ export default {
</template>
<template #cell(cluster_type)="{value}">
- <gl-badge variant="light">
+ <gl-badge variant="muted">
{{ value }}
</gl-badge>
</template>
diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js
index 45ac1bafd61..1c1f0664885 100644
--- a/app/assets/javascripts/compare_autocomplete.js
+++ b/app/assets/javascripts/compare_autocomplete.js
@@ -5,6 +5,7 @@ import { __ } from './locale';
import axios from './lib/utils/axios_utils';
import { deprecatedCreateFlash as flash } from './flash';
import { capitalizeFirstCharacter } from './lib/utils/text_utility';
+import { fixTitle } from '~/tooltips';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
export default function initCompareAutocomplete(limitTo = null, clickHandler = () => {}) {
@@ -76,7 +77,7 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = (
$dropdownContainer.on('click', '.dropdown-content a', e => {
$dropdown.prop('title', e.target.text.replace(/_+?/g, '-'));
if ($dropdown.hasClass('has-tooltip')) {
- $dropdown.tooltip('_fixTitle');
+ fixTitle($dropdown);
}
});
});
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 d403f370f9d..2858561e033 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,15 +1,12 @@
<script>
import { createNamespacedHelpers, mapState, mapActions, mapGetters } from 'vuex';
-import { GlFormInput, GlFormCheckbox, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlFormGroup, 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';
const { mapState: mapRolesState, mapActions: mapRolesActions } = createNamespacedHelpers('roles');
-const { mapState: mapRegionsState, mapActions: mapRegionsActions } = createNamespacedHelpers(
- 'regions',
-);
const { mapState: mapKeyPairsState, mapActions: mapKeyPairsActions } = createNamespacedHelpers(
'keyPairs',
);
@@ -27,6 +24,7 @@ export default {
components: {
ClusterFormDropdown,
GlFormCheckbox,
+ GlFormGroup,
GlFormInput,
GlIcon,
GlLink,
@@ -60,11 +58,10 @@ export default {
),
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}.',
+ regionInputLabel: s__('ClusterIntegration|Cluster Region'),
+ regionHelpText: s__(
+ 'ClusterIntegration|The region the new cluster will be created in. You must reauthenticate to change regions.',
),
- 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}.',
),
@@ -117,11 +114,6 @@ export default {
isLoadingRoles: 'isLoadingItems',
loadingRolesError: 'loadingItemsError',
}),
- ...mapRegionsState({
- regions: 'items',
- isLoadingRegions: 'isLoadingItems',
- loadingRegionsError: 'loadingItemsError',
- }),
...mapKeyPairsState({
keyPairs: 'items',
isLoadingKeyPairs: 'isLoadingItems',
@@ -195,8 +187,8 @@ export default {
},
},
mounted() {
- this.fetchRegions();
this.fetchRoles();
+ this.setRegionAndFetchVpcsAndKeyPairs();
},
methods: {
...mapActions([
@@ -215,20 +207,18 @@ export default {
'setGitlabManagedCluster',
'setNamespacePerEnvironment',
]),
- ...mapRegionsActions({ fetchRegions: 'fetchItems' }),
...mapVpcActions({ fetchVpcs: 'fetchItems' }),
...mapSubnetActions({ fetchSubnets: 'fetchItems' }),
...mapRolesActions({ fetchRoles: 'fetchItems' }),
...mapKeyPairsActions({ fetchKeyPairs: 'fetchItems' }),
...mapSecurityGroupsActions({ fetchSecurityGroups: 'fetchItems' }),
- setRegionAndFetchVpcsAndKeyPairs(region) {
- this.setRegion({ region });
+ setRegionAndFetchVpcsAndKeyPairs() {
this.setVpc({ vpc: null });
this.setKeyPair({ keyPair: null });
this.setSubnet({ subnet: [] });
this.setSecurityGroup({ securityGroup: null });
- this.fetchVpcs({ region });
- this.fetchKeyPairs({ region });
+ this.fetchVpcs({ region: this.selectedRegion });
+ this.fetchKeyPairs({ region: this.selectedRegion });
},
setVpcAndFetchSubnets(vpc) {
this.setVpc({ vpc });
@@ -314,33 +304,12 @@ export default {
</gl-sprintf>
</p>
</div>
- <div class="form-group">
- <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Region') }}</label>
- <cluster-form-dropdown
- field-id="eks-region"
- field-name="eks-region"
- :value="selectedRegion"
- :items="regions"
- :loading="isLoadingRegions"
- :loading-text="s__('ClusterIntegration|Loading Regions')"
- :placeholder="s__('ClusterIntergation|Select a region')"
- :search-field-placeholder="s__('ClusterIntegration|Search regions')"
- :empty-text="s__('ClusterIntegration|No region found')"
- :has-errors="Boolean(loadingRegionsError)"
- :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')"
- @input="setRegionAndFetchVpcsAndKeyPairs($event)"
- />
- <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>
+ <gl-form-group
+ :label="$options.i18n.regionInputLabel"
+ :description="$options.i18n.regionHelpText"
+ >
+ <gl-form-input id="eks-region" :value="selectedRegion" type="text" readonly />
+ </gl-form-group>
<div class="form-group">
<label class="label-bold" for="eks-key-pair">{{
s__('ClusterIntegration|Key pair name')
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
index 5c13cbb2775..a3f76241bf2 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
@@ -1,15 +1,20 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlFormInput, GlButton } from '@gitlab/ui';
+import { GlButton, GlFormGroup, GlFormInput, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import { escape } from 'lodash';
import { mapState, mapActions } from 'vuex';
+import { DEFAULT_REGION } from '../constants';
import { sprintf, s__, __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
export default {
components: {
- GlFormInput,
GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlIcon,
+ GlLink,
+ GlSprintf,
ClipboardButton,
},
props: {
@@ -26,9 +31,18 @@ export default {
required: true,
},
},
+ i18n: {
+ regionInputLabel: s__('ClusterIntegration|Cluster Region'),
+ regionHelpPath: 'https://aws.amazon.com/about-aws/global-infrastructure/regions_az/',
+ regionHelpText: s__(
+ 'ClusterIntegration|Select the region you want to create the new cluster in. Make sure you have access to this region for your role to be able to authenticate. If no region is selected, we will use %{codeStart}DEFAULT_REGION%{codeEnd}. Learn more about %{linkStart}Regions%{linkEnd}.',
+ ),
+ regionHelpTextDefaultRegion: DEFAULT_REGION,
+ },
data() {
return {
roleArn: this.$store.state.roleArn,
+ selectedRegion: this.$store.state.selectedRegion,
};
},
computed: {
@@ -130,13 +144,33 @@ export default {
<gl-form-input id="eks-provision-role-arn" v-model="roleArn" />
<p class="form-text text-muted" v-html="provisionRoleArnHelpText"></p>
</div>
+
+ <gl-form-group :label="$options.i18n.regionInputLabel">
+ <gl-form-input id="eks-region" v-model="selectedRegion" type="text" />
+
+ <template #description>
+ <gl-sprintf :message="$options.i18n.regionHelpText">
+ <template #code>
+ <code>{{ $options.i18n.regionHelpTextDefaultRegion }}</code>
+ </template>
+
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.regionHelpPath" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-form-group>
+
<gl-button
variant="success"
category="primary"
type="submit"
:disabled="submitButtonDisabled"
:loading="isCreatingRole"
- @click.prevent="createRole({ roleArn, externalId })"
+ @click.prevent="createRole({ roleArn, selectedRegion, externalId })"
>
{{ submitButtonLabel }}
</gl-button>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js
index 471d6e1f0aa..0f0db2090c1 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/constants.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js
@@ -1,3 +1,5 @@
+export const DEFAULT_REGION = 'us-east-2';
+
export const KUBERNETES_VERSIONS = [
{ name: '1.14', value: '1.14' },
{ name: '1.15', value: '1.15' },
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js
index 601ff6f9adc..58568b5dedb 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js
@@ -8,13 +8,8 @@ const lookupVpcName = ({ Tags: tags, VpcId: id }) => {
return nameTag ? nameTag.Value : id;
};
-export const DEFAULT_REGION = 'us-east-2';
-
export const setAWSConfig = ({ awsCredentials }) => {
- AWS.config = {
- ...awsCredentials,
- region: DEFAULT_REGION,
- };
+ AWS.config = awsCredentials;
};
export const fetchRoles = () => {
@@ -26,20 +21,6 @@ export const fetchRoles = () => {
.then(({ Roles: roles }) => roles.map(({ RoleName: name, Arn: value }) => ({ name, value })));
};
-export const fetchRegions = () => {
- const ec2 = new EC2();
-
- return ec2
- .describeRegions()
- .promise()
- .then(({ Regions: regions }) =>
- regions.map(({ RegionName: name }) => ({
- name,
- value: name,
- })),
- );
-};
-
export const fetchKeyPairs = ({ region }) => {
const ec2 = new EC2({ region });
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 48c85ff627f..f3950a3343a 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
@@ -1,4 +1,5 @@
import * as types from './mutation_types';
+import { DEFAULT_REGION } from '../constants';
import { setAWSConfig } from '../services/aws_services_facade';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as createFlash } from '~/flash';
@@ -25,12 +26,22 @@ export const setKubernetesVersion = ({ commit }, payload) => {
export const createRole = ({ dispatch, state: { createRolePath } }, payload) => {
dispatch('requestCreateRole');
+ const region = payload.selectedRegion || DEFAULT_REGION;
+
return axios
.post(createRolePath, {
role_arn: payload.roleArn,
role_external_id: payload.externalId,
+ region,
+ })
+ .then(({ data }) => {
+ const awsData = {
+ ...convertObjectPropsToCamelCase(data),
+ region,
+ };
+
+ dispatch('createRoleSuccess', awsData);
})
- .then(({ data }) => dispatch('createRoleSuccess', convertObjectPropsToCamelCase(data)))
.catch(error => dispatch('createRoleError', { error }));
};
@@ -38,7 +49,8 @@ export const requestCreateRole = ({ commit }) => {
commit(types.REQUEST_CREATE_ROLE);
};
-export const createRoleSuccess = ({ commit }, awsCredentials) => {
+export const createRoleSuccess = ({ dispatch, commit }, awsCredentials) => {
+ dispatch('setRegion', { region: awsCredentials.region });
setAWSConfig({ awsCredentials });
commit(types.CREATE_ROLE_SUCCESS);
};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
index 8dc55506dc2..262bbb3167a 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
@@ -8,7 +8,6 @@ import clusterDropdownStore from '~/create_cluster/store/cluster_dropdown';
import {
fetchRoles,
- fetchRegions,
fetchKeyPairs,
fetchVpcs,
fetchSubnets,
@@ -26,10 +25,6 @@ const createStore = ({ initialState }) =>
namespaced: true,
...clusterDropdownStore({ fetchFn: fetchRoles }),
},
- regions: {
- namespaced: true,
- ...clusterDropdownStore({ fetchFn: fetchRegions }),
- },
keyPairs: {
namespaced: true,
...clusterDropdownStore({ fetchFn: fetchKeyPairs }),
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
index 979628d683d..85d9f0d66ab 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
@@ -1,6 +1,6 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
-import { GlSprintf, GlLink } from '@gitlab/ui';
+import { GlSprintf, GlLink, GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import gkeDropdownMixin from './gke_dropdown_mixin';
@@ -10,6 +10,7 @@ export default {
components: {
GlSprintf,
GlLink,
+ GlIcon,
},
mixins: [gkeDropdownMixin],
props: {
@@ -178,14 +179,14 @@ export default {
'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral'
"
target="_blank"
- >{{ content }} <i class="fa fa-external-link" aria-hidden="true"></i
- ></gl-link>
+ >{{ content }} <gl-icon name="external-link" aria-hidden="true"
+ /></gl-link>
</template>
<template #docsLink="{ content }">
<gl-link :href="docsUrl" target="_blank"
- >{{ content }} <i class="fa fa-external-link" aria-hidden="true"></i
- ></gl-link>
+ >{{ content }} <gl-icon name="external-link" aria-hidden="true"
+ /></gl-link>
</template>
<template #error>
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index 010a6b073f9..49091f5f140 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -168,9 +168,6 @@ export default class CreateMergeRequestDropdown {
disable() {
this.disableCreateAction();
-
- this.dropdownToggle.classList.add('disabled');
- this.dropdownToggle.setAttribute('disabled', 'disabled');
}
disableCreateAction() {
@@ -189,9 +186,6 @@ export default class CreateMergeRequestDropdown {
this.createTargetButton.classList.remove('disabled');
this.createTargetButton.removeAttribute('disabled');
-
- this.dropdownToggle.classList.remove('disabled');
- this.dropdownToggle.removeAttribute('disabled');
}
static findByValue(objects, ref, returnFirstMatch = false) {
diff --git a/app/assets/javascripts/dependency_proxy.js b/app/assets/javascripts/dependency_proxy.js
new file mode 100644
index 00000000000..ddf5703b28f
--- /dev/null
+++ b/app/assets/javascripts/dependency_proxy.js
@@ -0,0 +1,5 @@
+import setupToggleButtons from '~/toggle_buttons';
+
+export default () => {
+ setupToggleButtons(document.querySelector('.js-dependency-proxy-toggle-area'));
+};
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index 5b41d23bd27..16eee094108 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -1,8 +1,7 @@
<script>
import { head, tail } from 'lodash';
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import actionBtn from './action_btn.vue';
@@ -13,7 +12,7 @@ export default {
GlIcon,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
props: {
@@ -125,7 +124,7 @@ export default {
<div class="table-mobile-content deploy-project-list">
<template v-if="projects.length > 0">
<a
- v-tooltip
+ v-gl-tooltip
:title="projectTooltipTitle(firstProject)"
class="label deploy-project-label"
>
@@ -134,7 +133,7 @@ export default {
</a>
<a
v-if="isExpandable"
- v-tooltip
+ v-gl-tooltip
:title="restProjectsTooltip"
class="label deploy-project-label"
@click="toggleExpanded"
@@ -145,7 +144,7 @@ export default {
v-for="deployKeysProject in restProjects"
v-else-if="isExpanded"
:key="deployKeysProject.project.full_path"
- v-tooltip
+ v-gl-tooltip
:href="deployKeysProject.project.full_path"
:title="projectTooltipTitle(deployKeysProject)"
class="label deploy-project-label"
@@ -160,7 +159,7 @@ export default {
<div class="table-section section-15 text-right">
<div role="rowheader" class="table-mobile-header">{{ __('Created') }}</div>
<div class="table-mobile-content text-secondary key-created-at">
- <span v-tooltip :title="tooltipTitle(deployKey.created_at)">
+ <span v-gl-tooltip :title="tooltipTitle(deployKey.created_at)">
<gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.created_at) }}</span>
</span>
</div>
@@ -172,7 +171,7 @@ export default {
</action-btn>
<a
v-if="deployKey.can_edit"
- v-tooltip
+ v-gl-tooltip
:href="editDeployKeyPath"
:title="__('Edit')"
class="btn btn-default text-secondary"
@@ -182,7 +181,7 @@ export default {
</a>
<action-btn
v-if="isRemovable"
- v-tooltip
+ v-gl-tooltip
:deploy-key="deployKey"
:title="__('Remove')"
btn-css-class="btn-danger"
@@ -193,7 +192,7 @@ export default {
</action-btn>
<action-btn
v-else-if="isEnabled"
- v-tooltip
+ v-gl-tooltip
:deploy-key="deployKey"
:title="__('Disable')"
btn-css-class="btn-warning"
diff --git a/app/assets/javascripts/design_management/components/design_destroyer.vue b/app/assets/javascripts/design_management/components/design_destroyer.vue
index 7ae569216f0..5d32bfd4a73 100644
--- a/app/assets/javascripts/design_management/components/design_destroyer.vue
+++ b/app/assets/javascripts/design_management/components/design_destroyer.vue
@@ -1,6 +1,6 @@
<script>
import { ApolloMutation } from 'vue-apollo';
-import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
+import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
import destroyDesignMutation from '../graphql/mutations/destroy_design.mutation.graphql';
import { updateStoreAfterDesignsDelete } from '../utils/cache_update';
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index 845f1aec8cf..6aab4bf423e 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -210,7 +210,7 @@ export default {
:class="{ 'gl-bg-blue-50': isDiscussionActive }"
@error="$emit('update-note-error', $event)"
>
- <template v-if="discussion.resolvable" #resolveDiscussion>
+ <template v-if="discussion.resolvable" #resolve-discussion>
<button
v-gl-tooltip
:class="{ 'is-active': discussion.resolved }"
@@ -224,7 +224,7 @@ export default {
<gl-loading-icon v-else inline />
</button>
</template>
- <template v-if="discussion.resolved" #resolvedStatus>
+ <template v-if="discussion.resolved" #resolved-status>
<p class="gl-text-gray-500 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message">
{{ __('Resolved by') }}
<gl-link
@@ -277,7 +277,7 @@ export default {
@submit-form="mutate"
@cancel-form="hideForm"
>
- <template v-if="discussion.resolvable" #resolveCheckbox>
+ <template v-if="discussion.resolvable" #resolve-checkbox>
<label data-testid="resolve-checkbox">
<input v-model="shouldChangeResolvedStatus" type="checkbox" />
{{ resolveCheckboxText }}
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
index 7f4b3b31024..421a4dc274a 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
@@ -108,7 +108,7 @@ export default {
</span>
</div>
<div class="gl-display-flex gl-align-items-baseline">
- <slot name="resolveDiscussion"></slot>
+ <slot name="resolve-discussion"></slot>
<button
v-if="isEditButtonVisible"
v-gl-tooltip
@@ -127,7 +127,7 @@ export default {
class="note-text js-note-text md"
data-qa-selector="note_content"
></div>
- <slot name="resolvedStatus"></slot>
+ <slot name="resolved-status"></slot>
</template>
<apollo-mutation
v-else
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
index 3754e1dbbc1..7aaac58a1ce 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
@@ -110,7 +110,7 @@ export default {
</textarea>
</template>
</markdown-field>
- <slot name="resolveCheckbox"></slot>
+ <slot name="resolve-checkbox"></slot>
<div class="note-form-actions gl-display-flex gl-justify-content-space-between">
<gl-button
ref="submitButton"
diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue
index 88f3ce0b8ea..3c2ce693bc0 100644
--- a/app/assets/javascripts/design_management/components/design_overlay.vue
+++ b/app/assets/javascripts/design_management/components/design_overlay.vue
@@ -112,9 +112,9 @@ export default {
},
canMoveNote(note) {
const { userPermissions } = note;
- const { adminNote } = userPermissions || {};
+ const { repositionNote } = userPermissions || {};
- return Boolean(adminNote);
+ return Boolean(repositionNote);
},
isPositionInOverlay(position) {
const { top, left } = this.getNoteRelativePosition(position);
diff --git a/app/assets/javascripts/design_management/components/design_scaler.vue b/app/assets/javascripts/design_management/components/design_scaler.vue
index 8d26f84641e..85c6bd4d79e 100644
--- a/app/assets/javascripts/design_management/components/design_scaler.vue
+++ b/app/assets/javascripts/design_management/components/design_scaler.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlButtonGroup, GlButton } from '@gitlab/ui';
const SCALE_STEP_SIZE = 0.2;
const DEFAULT_SCALE = 1;
@@ -8,7 +8,8 @@ const MAX_SCALE = 2;
export default {
components: {
- GlIcon,
+ GlButtonGroup,
+ GlButton,
},
data() {
return {
@@ -49,17 +50,9 @@ export default {
</script>
<template>
- <div class="design-scaler btn-group" role="group">
- <button class="btn" :disabled="disableDecrease" @click="decrementScale">
- <span class="gl-display-flex gl-justify-content-center gl-align-items-center gl-icon s16">
- –
- </span>
- </button>
- <button class="btn" :disabled="disableReset" @click="resetScale">
- <gl-icon name="redo" />
- </button>
- <button class="btn" :disabled="disableIncrease" @click="incrementScale">
- <gl-icon name="plus" />
- </button>
- </div>
+ <gl-button-group class="gl-z-index-1">
+ <gl-button icon="dash" :disabled="disableDecrease" @click="decrementScale" />
+ <gl-button icon="redo" :disabled="disableReset" @click="resetScale" />
+ <gl-button icon="plus" :disabled="disableIncrease" @click="incrementScale" />
+ </gl-button-group>
</template>
diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue
index fb8e74c8c4c..41dcec38abe 100644
--- a/app/assets/javascripts/design_management/components/design_sidebar.vue
+++ b/app/assets/javascripts/design_management/components/design_sidebar.vue
@@ -207,6 +207,6 @@ export default {
/>
</gl-collapse>
</template>
- <slot name="replyForm"></slot>
+ <slot name="reply-form"></slot>
</div>
</template>
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 2719d701c12..4edc2e410c7 100644
--- a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
@@ -1,7 +1,7 @@
<script>
/* global Mousetrap */
import 'mousetrap';
-import { GlButton, GlButtonGroup } from '@gitlab/ui';
+import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import allDesignsMixin from '../../mixins/all_designs';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
@@ -11,6 +11,9 @@ export default {
GlButton,
GlButtonGroup,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
mixins: [allDesignsMixin],
props: {
id: {
@@ -68,6 +71,7 @@ export default {
{{ paginationText }}
<gl-button-group class="gl-mx-5">
<gl-button
+ v-gl-tooltip.bottom
:disabled="!previousDesign"
:title="s__('DesignManagement|Go to previous design')"
icon="angle-left"
@@ -75,6 +79,7 @@ export default {
@click="navigateToDesign(previousDesign)"
/>
<gl-button
+ v-gl-tooltip.bottom
:disabled="!nextDesign"
:title="s__('DesignManagement|Go to next design')"
icon="angle-right"
diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue
index 8d25d467d59..4caee863df8 100644
--- a/app/assets/javascripts/design_management/components/toolbar/index.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/index.vue
@@ -1,10 +1,10 @@
<script>
-import { GlButton, GlIcon } from '@gitlab/ui';
+import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import DesignNavigation from './design_navigation.vue';
import DeleteButton from '../delete_button.vue';
-import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql';
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
export default {
@@ -14,6 +14,9 @@ export default {
DesignNavigation,
DeleteButton,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
mixins: [timeagoMixin],
props: {
id: {
@@ -112,14 +115,21 @@ export default {
</div>
</div>
<design-navigation :id="id" class="gl-ml-auto gl-flex-shrink-0" />
- <gl-button :href="image" icon="download" />
+ <gl-button
+ v-gl-tooltip.bottom
+ :href="image"
+ icon="download"
+ :title="s__('DesignManagement|Download design')"
+ />
<delete-button
v-if="isLatestVersion && canDeleteDesign"
+ v-gl-tooltip.bottom
class="gl-ml-3"
:is-deleting="isDeleting"
button-variant="warning"
button-icon="archive"
button-category="secondary"
+ :title="s__('DesignManagement|Archive design')"
@deleteSelectedDesigns="$emit('delete')"
/>
</header>
diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue
index c76041c74a8..d7b287f663b 100644
--- a/app/assets/javascripts/design_management/components/upload/button.vue
+++ b/app/assets/javascripts/design_management/components/upload/button.vue
@@ -1,11 +1,10 @@
<script>
-import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
export default {
components: {
GlButton,
- GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -38,12 +37,12 @@ export default {
)
"
:disabled="isSaving"
+ :loading="isSaving"
variant="default"
size="small"
@click="openFileUpload"
>
{{ s__('DesignManagement|Upload designs') }}
- <gl-loading-icon v-if="isSaving" inline class="ml-1" />
</gl-button>
<input
diff --git a/app/assets/javascripts/design_management/constants.js b/app/assets/javascripts/design_management/constants.js
index 63a92ef5ec0..92928ca429f 100644
--- a/app/assets/javascripts/design_management/constants.js
+++ b/app/assets/javascripts/design_management/constants.js
@@ -5,9 +5,6 @@ export const VALID_DESIGN_FILE_MIMETYPE = {
regex: /image\/.+/,
};
-// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
-export const VALID_DATA_TRANSFER_TYPE = 'Files';
-
export const ACTIVE_DISCUSSION_SOURCE_TYPES = {
pin: 'pin',
discussion: 'discussion',
diff --git a/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql
index c243e39f3d3..e599ab19c2d 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql
@@ -1,3 +1,4 @@
fragment DesignNotePermissions on NotePermissions {
adminNote
+ repositionNote
}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/reposition_image_diff_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/reposition_image_diff_note.mutation.graphql
new file mode 100644
index 00000000000..78fbcf1c3c7
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/reposition_image_diff_note.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/design_note.fragment.graphql"
+
+mutation repositionImageDiffNote($input: RepositionImageDiffNoteInput!) {
+ repositionImageDiffNote(input: $input) {
+ errors
+ note {
+ ...DesignNote
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_image_diff_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_image_diff_note.mutation.graphql
deleted file mode 100644
index 5562ca9d89f..00000000000
--- a/app/assets/javascripts/design_management/graphql/mutations/update_image_diff_note.mutation.graphql
+++ /dev/null
@@ -1,10 +0,0 @@
-#import "../fragments/design_note.fragment.graphql"
-
-mutation updateImageDiffNote($input: UpdateImageDiffNoteInput!) {
- updateImageDiffNote(input: $input) {
- errors
- note {
- ...DesignNote
- }
- }
-}
diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql
index 96869a404b1..99a61191c6e 100644
--- a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql
+++ b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql
@@ -1,7 +1,12 @@
#import "../fragments/design.fragment.graphql"
#import "~/graphql_shared/fragments/author.fragment.graphql"
-query getDesign($fullPath: ID!, $iid: String!, $atVersion: ID, $filenames: [String!]) {
+query getDesign(
+ $fullPath: ID!
+ $iid: String!
+ $atVersion: DesignManagementVersionID
+ $filenames: [String!]
+) {
project(fullPath: $fullPath) {
id
issue(iid: $iid) {
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
deleted file mode 100644
index efa61edf51a..00000000000
--- a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql
+++ /dev/null
@@ -1,23 +0,0 @@
-#import "../fragments/design_list.fragment.graphql"
-#import "../fragments/version.fragment.graphql"
-
-query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) {
- project(fullPath: $fullPath) {
- id
- issue(iid: $iid) {
- designCollection {
- copyState
- designs(atVersion: $atVersion) {
- nodes {
- ...DesignListItem
- }
- }
- versions {
- nodes {
- ...VersionListItem
- }
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js
index 62bcf216add..466f61e21fa 100644
--- a/app/assets/javascripts/design_management/mixins/all_designs.js
+++ b/app/assets/javascripts/design_management/mixins/all_designs.js
@@ -1,7 +1,7 @@
import { propertyOf } from 'lodash';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
+import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
+import createFlash, { FLASH_TYPES } from '~/flash';
import { s__ } from '~/locale';
-import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import allVersionsMixin from './all_versions';
import { DESIGNS_ROUTE_NAME } from '../router/constants';
@@ -36,20 +36,20 @@ export default {
},
result() {
if (this.$route.query.version && !this.hasValidVersion) {
- createFlash(
- s__(
+ createFlash({
+ message: s__(
'DesignManagement|Requested design version does not exist. Showing latest version instead',
),
- );
+ });
this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } });
}
if (this.designCollection.copyState === 'ERROR') {
- createFlash(
- s__(
+ createFlash({
+ message: s__(
'DesignManagement|There was an error moving your designs. Please upload your designs below.',
),
- 'warning',
- );
+ type: FLASH_TYPES.WARNING,
+ });
}
},
},
diff --git a/app/assets/javascripts/design_management/mixins/all_versions.js b/app/assets/javascripts/design_management/mixins/all_versions.js
index 7a094f23378..07cd0fc92bd 100644
--- a/app/assets/javascripts/design_management/mixins/all_versions.js
+++ b/app/assets/javascripts/design_management/mixins/all_versions.js
@@ -1,4 +1,4 @@
-import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
+import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
import { findVersionId } from '../utils/design_management_utils';
export default {
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index 6a96b06dcd8..e07279ba39d 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -2,7 +2,7 @@
import Mousetrap from 'mousetrap';
import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { ApolloMutation } from 'vue-apollo';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
+import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import allVersionsMixin from '../../mixins/all_versions';
import Toolbar from '../../components/toolbar/index.vue';
@@ -13,18 +13,19 @@ import DesignReplyForm from '../../components/design_notes/design_reply_form.vue
import DesignSidebar from '../../components/design_sidebar.vue';
import getDesignQuery from '../../graphql/queries/get_design.query.graphql';
import createImageDiffNoteMutation from '../../graphql/mutations/create_image_diff_note.mutation.graphql';
-import updateImageDiffNoteMutation from '../../graphql/mutations/update_image_diff_note.mutation.graphql';
+import repositionImageDiffNoteMutation from '../../graphql/mutations/reposition_image_diff_note.mutation.graphql';
import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql';
import {
extractDiscussions,
extractDesign,
- updateImageDiffNoteOptimisticResponse,
+ repositionImageDiffNoteOptimisticResponse,
toDiffNoteGid,
extractDesignNoteId,
+ getPageLayoutElement,
} from '../../utils/design_management_utils';
import {
updateStoreAfterAddImageDiffNote,
- updateStoreAfterUpdateImageDiffNote,
+ updateStoreAfterRepositionImageDiffNote,
} from '../../utils/cache_update';
import {
ADD_DISCUSSION_COMMENT_ERROR,
@@ -38,7 +39,7 @@ import {
} from '../../utils/error_messages';
import { trackDesignDetailView } from '../../utils/tracking';
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
-import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES, DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../../constants';
const DEFAULT_SCALE = 1;
@@ -181,12 +182,12 @@ export default {
updateImageDiffNoteInStore(
store,
{
- data: { updateImageDiffNote },
+ data: { repositionImageDiffNote },
},
) {
- return updateStoreAfterUpdateImageDiffNote(
+ return updateStoreAfterRepositionImageDiffNote(
store,
- updateImageDiffNote,
+ repositionImageDiffNote,
getDesignQuery,
this.designVariables,
);
@@ -198,7 +199,7 @@ export default {
);
const mutationPayload = {
- optimisticResponse: updateImageDiffNoteOptimisticResponse(note, {
+ optimisticResponse: repositionImageDiffNoteOptimisticResponse(note, {
position,
}),
variables: {
@@ -207,7 +208,7 @@ export default {
position,
},
},
- mutation: updateImageDiffNoteMutation,
+ mutation: repositionImageDiffNoteMutation,
update: this.updateImageDiffNoteInStore,
};
@@ -229,7 +230,7 @@ export default {
onQueryError(message) {
// because we redirect user to /designs (the issue page),
// we want to create these flashes on the issue page
- createFlash(message);
+ createFlash({ message });
this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME });
},
onError(message, e) {
@@ -300,6 +301,22 @@ export default {
this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded;
},
},
+ beforeRouteEnter(to, from, next) {
+ const pageEl = getPageLayoutElement();
+ if (pageEl) {
+ pageEl.classList.add(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
+ }
+
+ next();
+ },
+ beforeRouteLeave(to, from, next) {
+ const pageEl = getPageLayoutElement();
+ if (pageEl) {
+ pageEl.classList.remove(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
+ }
+
+ next();
+ },
createImageDiffNoteMutation,
DESIGNS_ROUTE_NAME,
};
@@ -366,7 +383,7 @@ export default {
@toggleResolvedComments="toggleResolvedComments"
@todoError="onTodoError"
>
- <template #replyForm>
+ <template #reply-form>
<apollo-mutation
v-if="isAnnotating"
#default="{ mutate, loading }"
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index 6e71dca41e9..ea404692840 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -1,25 +1,26 @@
<script>
-import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton, GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import VueDraggable from 'vuedraggable';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
-import { s__, sprintf } from '~/locale';
+import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
+import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
+import createFlash, { FLASH_TYPES } 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';
import DesignDestroyer from '../components/design_destroyer.vue';
import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue';
-import DesignDropzone from '../components/upload/design_dropzone.vue';
+import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql';
import moveDesignMutation from '../graphql/mutations/move_design.mutation.graphql';
-import permissionsQuery from '../graphql/queries/design_permissions.query.graphql';
-import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import allDesignsMixin from '../mixins/all_designs';
import {
UPLOAD_DESIGN_ERROR,
EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
MOVE_DESIGN_ERROR,
+ UPLOAD_DESIGN_INVALID_FILETYPE_ERROR,
designUploadSkippedWarning,
designDeletionError,
} from '../utils/error_messages';
@@ -34,6 +35,7 @@ import {
} from '../utils/design_management_utils';
import { trackDesignCreate, trackDesignUpdate } from '../utils/tracking';
import { DESIGNS_ROUTE_NAME } from '../router/constants';
+import { VALID_DESIGN_FILE_MIMETYPE } from '../constants';
const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
@@ -42,6 +44,8 @@ export default {
GlLoadingIcon,
GlAlert,
GlButton,
+ GlSprintf,
+ GlLink,
UploadButton,
Design,
DesignDestroyer,
@@ -50,6 +54,11 @@ export default {
DesignDropzone,
VueDraggable,
},
+ dropzoneProps: {
+ dropToStartMessage: __('Drop your designs to start your upload.'),
+ isFileValid: isValidDesignFile,
+ validFileMimetypes: [VALID_DESIGN_FILE_MIMETYPE.mimetype],
+ },
mixins: [allDesignsMixin],
apollo: {
permissions: {
@@ -139,8 +148,8 @@ export default {
if (!this.canCreateDesign) return false;
if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) {
- createFlash(
- sprintf(
+ createFlash({
+ message: sprintf(
s__(
'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.',
),
@@ -148,7 +157,7 @@ export default {
upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
},
),
- );
+ });
return false;
}
@@ -191,7 +200,7 @@ export default {
const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || [];
const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles);
if (skippedWarningMessage) {
- createFlash(skippedWarningMessage, 'warning');
+ createFlash({ message: skippedWarningMessage, types: FLASH_TYPES.WARNING });
}
// if this upload resulted in a new version being created, redirect user to the latest version
@@ -214,7 +223,7 @@ export default {
},
onUploadDesignError() {
this.resetFilesToBeSaved();
- createFlash(UPLOAD_DESIGN_ERROR);
+ createFlash({ message: UPLOAD_DESIGN_ERROR });
},
changeSelectedDesigns(filename) {
if (this.isDesignSelected(filename)) {
@@ -245,18 +254,21 @@ export default {
},
onDesignDeleteError() {
const errorMessage = designDeletionError({ singular: this.selectedDesigns.length === 1 });
- createFlash(errorMessage);
+ createFlash({ message: errorMessage });
+ },
+ onDesignDropzoneError() {
+ createFlash({ message: UPLOAD_DESIGN_INVALID_FILETYPE_ERROR });
},
onExistingDesignDropzoneChange(files, existingDesignFilename) {
const filesArr = Array.from(files);
if (filesArr.length > 1) {
- createFlash(EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE);
+ createFlash({ message: EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE });
return;
}
if (!filesArr.some(({ name }) => existingDesignFilename === name)) {
- createFlash(EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE);
+ createFlash({ message: EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE });
return;
}
@@ -307,7 +319,7 @@ export default {
optimisticResponse: moveDesignOptimisticResponse(this.reorderedDesigns),
})
.catch(() => {
- createFlash(MOVE_DESIGN_ERROR);
+ createFlash({ message: MOVE_DESIGN_ERROR });
})
.finally(() => {
this.isReorderingInProgress = false;
@@ -325,6 +337,9 @@ export default {
animation: 200,
ghostClass: 'gl-visibility-hidden',
},
+ i18n: {
+ dropzoneDescriptionText: __('Drop or %{linkStart}upload%{linkEnd} designs to attach'),
+ },
};
</script>
@@ -335,7 +350,11 @@ export default {
@mouseenter="toggleOnPasteListener"
@mouseleave="toggleOffPasteListener"
>
- <header v-if="showToolbar" class="row-content-block gl-border-t-0 gl-p-3 gl-display-flex">
+ <header
+ v-if="showToolbar"
+ class="row-content-block gl-border-t-0 gl-p-3 gl-display-flex"
+ data-testid="design-toolbar-wrapper"
+ >
<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>
@@ -371,7 +390,12 @@ export default {
{{ s__('DesignManagement|Archive selected') }}
</delete-button>
</design-destroyer>
- <upload-button v-if="canCreateDesign" :is-saving="isSaving" @upload="onUploadDesign" />
+ <upload-button
+ v-if="canCreateDesign"
+ :is-saving="isSaving"
+ data-testid="design-upload-button"
+ @upload="onUploadDesign"
+ />
</div>
</div>
</header>
@@ -414,15 +438,26 @@ export default {
class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
>
<design-dropzone
- :has-designs="hasDesigns"
- :is-dragging-design="isDraggingDesign"
+ :display-as-card="hasDesigns"
+ :enable-drag-behavior="isDraggingDesign"
+ v-bind="$options.dropzoneProps"
@change="onExistingDesignDropzoneChange($event, design.filename)"
+ @error="onDesignDropzoneError"
>
<design
v-bind="design"
:is-uploading="isDesignToBeSaved(design.filename)"
class="gl-bg-white"
/>
+ <template #upload-text="{ openFileUpload }">
+ <gl-sprintf :message="$options.i18n.dropzoneDescriptionText">
+ <template #link="{ content }">
+ <gl-link @click.stop="openFileUpload">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
</design-dropzone>
<input
@@ -438,12 +473,24 @@ export default {
<template #header>
<li :class="designDropzoneWrapperClass" data-testid="design-dropzone-wrapper">
<design-dropzone
- :is-dragging-design="isDraggingDesign"
+ :enable-drag-behavior="isDraggingDesign"
:class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }"
- :has-designs="hasDesigns"
+ :display-as-card="hasDesigns"
+ v-bind="$options.dropzoneProps"
data-qa-selector="design_dropzone_content"
@change="onUploadDesign"
- />
+ @error="onDesignDropzoneError"
+ >
+ <template #upload-text="{ openFileUpload }">
+ <gl-sprintf :message="$options.i18n.dropzoneDescriptionText">
+ <template #link="{ content }">
+ <gl-link @click.stop="openFileUpload">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </design-dropzone>
</li>
</template>
</vue-draggable>
diff --git a/app/assets/javascripts/design_management/router/index.js b/app/assets/javascripts/design_management/router/index.js
index cbeb2f7ce42..12692612bbc 100644
--- a/app/assets/javascripts/design_management/router/index.js
+++ b/app/assets/javascripts/design_management/router/index.js
@@ -1,9 +1,6 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
import routes from './routes';
-import { DESIGN_ROUTE_NAME } from './constants';
-import { getPageLayoutElement } from '~/design_management/utils/design_management_utils';
-import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../constants';
Vue.use(VueRouter);
@@ -13,20 +10,6 @@ export default function createRouter(base) {
mode: 'history',
routes,
});
- const pageEl = getPageLayoutElement();
-
- router.beforeEach(({ name }, _, next) => {
- // apply a fullscreen layout style in Design View (a.k.a design detail)
- if (pageEl) {
- if (name === DESIGN_ROUTE_NAME) {
- pageEl.classList.add(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
- } else {
- pageEl.classList.remove(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
- }
- }
-
- next();
- });
return router;
}
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
index fc0530ff977..5bd0288d037 100644
--- a/app/assets/javascripts/design_management/utils/cache_update.js
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -2,7 +2,7 @@
import { differenceBy } from 'lodash';
import produce from 'immer';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
+import createFlash from '~/flash';
import { extractCurrentDiscussion, extractDesign, extractDesigns } from './design_management_utils';
import {
ADD_IMAGE_DIFF_NOTE_ERROR,
@@ -101,7 +101,7 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) =
});
};
-const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables) => {
+const updateImageDiffNoteInStore = (store, repositionImageDiffNote, query, variables) => {
const sourceData = store.readQuery({
query,
variables,
@@ -111,12 +111,12 @@ const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables
const design = extractDesign(draftData);
const discussion = extractCurrentDiscussion(
design.discussions,
- updateImageDiffNote.note.discussion.id,
+ repositionImageDiffNote.note.discussion.id,
);
discussion.notes = {
...discussion.notes,
- nodes: [updateImageDiffNote.note, ...discussion.notes.nodes.slice(1)],
+ nodes: [repositionImageDiffNote.note, ...discussion.notes.nodes.slice(1)],
};
});
@@ -237,7 +237,7 @@ export const deletePendingTodoFromStore = (store, todoMarkDone, query, queryVari
};
const onError = (data, message) => {
- createFlash(message);
+ createFlash({ message });
throw new Error(data.errors);
};
@@ -268,7 +268,7 @@ export const updateStoreAfterAddImageDiffNote = (store, data, query, queryVariab
}
};
-export const updateStoreAfterUpdateImageDiffNote = (store, data, query, queryVariables) => {
+export const updateStoreAfterRepositionImageDiffNote = (store, data, query, queryVariables) => {
if (hasErrors(data)) {
onError(data, UPDATE_IMAGE_DIFF_NOTE_ERROR);
} else {
@@ -286,7 +286,7 @@ export const updateStoreAfterUploadDesign = (store, data, query) => {
export const updateDesignsOnStoreAfterReorder = (store, data, query) => {
if (hasErrors(data)) {
- createFlash(data.errors[0]);
+ createFlash({ message: data.errors[0] });
} else {
moveDesignInStore(store, data, query);
}
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 687e793d3df..a905230811c 100644
--- a/app/assets/javascripts/design_management/utils/design_management_utils.js
+++ b/app/assets/javascripts/design_management/utils/design_management_utils.js
@@ -107,12 +107,12 @@ export const designUploadOptimisticResponse = files => {
* @param {Object} note
* @param {Object} position
*/
-export const updateImageDiffNoteOptimisticResponse = (note, { position }) => ({
+export const repositionImageDiffNoteOptimisticResponse = (note, { position }) => ({
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Mutation',
- updateImageDiffNote: {
- __typename: 'UpdateImageDiffNotePayload',
+ repositionImageDiffNote: {
+ __typename: 'RepositionImageDiffNotePayload',
note: {
...note,
position: {
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 085f951147f..9d8d184a3f6 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -20,6 +20,8 @@ import HiddenFilesWarning from './hidden_files_warning.vue';
import MergeConflictWarning from './merge_conflict_warning.vue';
import CollapsedFilesWarning from './collapsed_files_warning.vue';
+import { diffsApp } from '../utils/performance';
+
import {
TREE_LIST_WIDTH_STORAGE_KEY,
INITIAL_TREE_WIDTH,
@@ -124,7 +126,6 @@ export default {
return {
treeWidth,
diffFilesLength: 0,
- collapsedWarningDismissed: false,
};
},
computed: {
@@ -153,7 +154,7 @@ export default {
'canMerge',
'hasConflicts',
]),
- ...mapGetters('diffs', ['hasCollapsedFile', 'isParallelView', 'currentDiffIndex']),
+ ...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
diffs() {
if (!this.viewDiffsFileByFile) {
@@ -206,11 +207,7 @@ export default {
visible = this.$options.alerts.ALERT_OVERFLOW_HIDDEN;
} else if (this.isDiffHead && this.hasConflicts) {
visible = this.$options.alerts.ALERT_MERGE_CONFLICT;
- } else if (
- this.hasCollapsedFile &&
- !this.collapsedWarningDismissed &&
- !this.viewDiffsFileByFile
- ) {
+ } else if (this.whichCollapsedTypes.automatic && !this.viewDiffsFileByFile) {
visible = this.$options.alerts.ALERT_COLLAPSED_FILES;
}
@@ -277,8 +274,12 @@ export default {
);
}
},
+ beforeCreate() {
+ diffsApp.instrument();
+ },
created() {
this.adjustView();
+
eventHub.$once('fetchDiffData', this.fetchData);
eventHub.$on('refetchDiffData', this.refetchDiffData);
this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES;
@@ -299,6 +300,8 @@ export default {
);
},
beforeDestroy() {
+ diffsApp.deinstrument();
+
eventHub.$off('fetchDiffData', this.fetchData);
eventHub.$off('refetchDiffData', this.refetchDiffData);
this.removeEventListeners();
@@ -429,9 +432,6 @@ export default {
this.toggleShowTreeList(false);
}
},
- dismissCollapsedWarning() {
- this.collapsedWarningDismissed = true;
- },
},
minTreeWidth: MIN_TREE_WIDTH,
maxTreeWidth: MAX_TREE_WIDTH,
@@ -464,7 +464,6 @@ export default {
<collapsed-files-warning
v-if="visibleWarning == $options.alerts.ALERT_COLLAPSED_FILES"
:limited="isLimitedContainer"
- @dismiss="dismissCollapsedWarning"
/>
<div
@@ -496,9 +495,11 @@ export default {
<div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div>
<template v-else-if="renderDiffFiles">
<diff-file
- v-for="file in diffs"
+ v-for="(file, index) in diffs"
:key="file.newPath"
:file="file"
+ :is-first-file="index === 0"
+ :is-last-file="index === diffs.length - 1"
:help-page-path="helpPagePath"
:can-current-user-fork="canCurrentUserFork"
:view-diffs-file-by-file="viewDiffsFileByFile"
diff --git a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
index 270bbfb99b7..0cf1cdb17f8 100644
--- a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
+++ b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
@@ -1,9 +1,8 @@
<script>
-import { mapActions } from 'vuex';
-
import { GlAlert, GlButton } from '@gitlab/ui';
-import { CENTERED_LIMITED_CONTAINER_CLASSES } from '../constants';
+import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants';
+import eventHub from '../event_hub';
export default {
components: {
@@ -36,13 +35,12 @@ export default {
},
methods: {
- ...mapActions('diffs', ['expandAllFiles']),
dismiss() {
this.isDismissed = true;
this.$emit('dismiss');
},
expand() {
- this.expandAllFiles();
+ eventHub.$emit(EVT_EXPAND_ALL_FILES);
this.dismiss();
},
},
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index b1ebd8e6ebc..700d5ec86c8 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -6,7 +6,8 @@ import { polyfillSticky } from '~/lib/utils/sticky';
import CompareDropdownLayout from './compare_dropdown_layout.vue';
import SettingsDropdown from './settings_dropdown.vue';
import DiffStats from './diff_stats.vue';
-import { CENTERED_LIMITED_CONTAINER_CLASSES } from '../constants';
+import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants';
+import eventHub from '../event_hub';
export default {
components: {
@@ -38,7 +39,7 @@ export default {
},
computed: {
...mapGetters('diffs', [
- 'hasCollapsedFile',
+ 'whichCollapsedTypes',
'diffCompareDropdownTargetVersions',
'diffCompareDropdownSourceVersions',
]),
@@ -67,9 +68,11 @@ export default {
...mapActions('diffs', [
'setInlineDiffViewType',
'setParallelDiffViewType',
- 'expandAllFiles',
'toggleShowTreeList',
]),
+ expandAllFiles() {
+ eventHub.$emit(EVT_EXPAND_ALL_FILES);
+ },
},
};
</script>
@@ -129,7 +132,7 @@ export default {
{{ __('Show latest version') }}
</gl-button>
<gl-button
- v-show="hasCollapsedFile"
+ v-show="whichCollapsedTypes.any"
variant="default"
class="gl-mr-3"
@click="expandAllFiles"
diff --git a/app/assets/javascripts/diffs/components/diff_comment_cell.vue b/app/assets/javascripts/diffs/components/diff_comment_cell.vue
new file mode 100644
index 00000000000..4b0b603f5a5
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_comment_cell.vue
@@ -0,0 +1,69 @@
+<script>
+import { mapActions } from 'vuex';
+import DiffDiscussions from './diff_discussions.vue';
+import DiffLineNoteForm from './diff_line_note_form.vue';
+import DiffDiscussionReply from './diff_discussion_reply.vue';
+
+export default {
+ components: {
+ DiffDiscussions,
+ DiffLineNoteForm,
+ DiffDiscussionReply,
+ },
+ props: {
+ line: {
+ type: Object,
+ required: true,
+ },
+ diffFileHash: {
+ type: String,
+ required: true,
+ },
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ hasDraft: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ linePosition: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ methods: {
+ ...mapActions('diffs', ['showCommentForm']),
+ },
+};
+</script>
+
+<template>
+ <div class="content">
+ <diff-discussions
+ v-if="line.renderDiscussion"
+ :line="line"
+ :discussions="line.discussions"
+ :help-page-path="helpPagePath"
+ />
+ <diff-discussion-reply
+ v-if="!hasDraft"
+ :has-form="line.hasCommentForm"
+ :render-reply-placeholder="Boolean(line.discussions.length)"
+ @showNewDiscussionForm="showCommentForm({ lineCode: line.line_code, fileHash: diffFileHash })"
+ >
+ <template #form>
+ <diff-line-note-form
+ :diff-file-hash="diffFileHash"
+ :line="line"
+ :note-target-line="line"
+ :help-page-path="helpPagePath"
+ :line-position="linePosition"
+ />
+ </template>
+ </diff-discussion-reply>
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index e68260b3e62..401064fb18f 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -10,6 +10,7 @@ import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_prev
import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue';
import InlineDiffView from './inline_diff_view.vue';
import ParallelDiffView from './parallel_diff_view.vue';
+import DiffView from './diff_view.vue';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import NoteForm from '../../notes/components/note_form.vue';
import ImageDiffOverlay from './image_diff_overlay.vue';
@@ -18,12 +19,14 @@ import eventHub from '../../notes/event_hub';
import { IMAGE_DIFF_POSITION_TYPE } from '../constants';
import { getDiffMode } from '../store/utils';
import { diffViewerModes } from '~/ide/constants';
+import { mapInline, mapParallel } from './diff_row_utils';
export default {
components: {
GlLoadingIcon,
InlineDiffView,
ParallelDiffView,
+ DiffView,
DiffViewer,
NoteForm,
DiffDiscussions,
@@ -83,6 +86,19 @@ export default {
author() {
return this.getUserData;
},
+ mappedLines() {
+ if (this.glFeatures.unifiedDiffLines && this.glFeatures.unifiedDiffComponents) {
+ return this.diffLines(this.diffFile, true).map(mapParallel(this)) || [];
+ }
+
+ // TODO: Everything below this line can be deleted when unifiedDiffComponents FF is removed
+ if (this.isInlineView) {
+ return this.diffFile.highlighted_diff_lines.map(mapInline(this));
+ }
+ return this.glFeatures.unifiedDiffLines
+ ? this.diffLines(this.diffFile).map(mapParallel(this))
+ : this.diffFile.parallel_diff_lines.map(mapParallel(this)) || [];
+ },
},
updated() {
this.$nextTick(() => {
@@ -113,19 +129,28 @@ export default {
<template>
<div class="diff-content">
<div class="diff-viewer">
- <template v-if="isTextFile">
+ <template
+ v-if="isTextFile && glFeatures.unifiedDiffLines && glFeatures.unifiedDiffComponents"
+ >
+ <diff-view
+ :diff-file="diffFile"
+ :diff-lines="mappedLines"
+ :help-page-path="helpPagePath"
+ :inline="isInlineView"
+ />
+ <gl-loading-icon v-if="diffFile.renderingLines" size="md" class="mt-3" />
+ </template>
+ <template v-else-if="isTextFile">
<inline-diff-view
v-if="isInlineView"
:diff-file="diffFile"
- :diff-lines="diffFile.highlighted_diff_lines"
+ :diff-lines="mappedLines"
:help-page-path="helpPagePath"
/>
<parallel-diff-view
v-else-if="isParallelView"
:diff-file="diffFile"
- :diff-lines="
- glFeatures.unifiedDiffLines ? diffLines(diffFile) : diffFile.parallel_diff_lines || []
- "
+ :diff-lines="mappedLines"
:help-page-path="helpPagePath"
/>
<gl-loading-icon v-if="diffFile.renderingLines" size="md" class="mt-3" />
diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
index 0094b4f8707..4c49dfb5de9 100644
--- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
@@ -6,7 +6,6 @@ import { s__, sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { UNFOLD_COUNT, INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '../constants';
import * as utils from '../store/utils';
-import tooltip from '../../vue_shared/directives/tooltip';
const EXPAND_ALL = 0;
const EXPAND_UP = 1;
@@ -28,9 +27,6 @@ const i18n = {
export default {
i18n,
- directives: {
- tooltip,
- },
components: {
GlIcon,
},
@@ -58,11 +54,6 @@ export default {
required: false,
default: false,
},
- colspan: {
- type: Number,
- required: false,
- default: 4,
- },
},
computed: {
...mapState({
@@ -235,28 +226,26 @@ export default {
</script>
<template>
- <td :colspan="colspan" class="text-center gl-font-regular">
- <div class="content js-line-expansion-content">
- <a
- v-if="canExpandDown"
- class="gl-mx-2 gl-cursor-pointer js-unfold-down gl-display-inline-block gl-py-4"
- @click="handleExpandLines(EXPAND_DOWN)"
- >
- <gl-icon :size="12" name="expand-down" aria-hidden="true" />
- <span>{{ $options.i18n.showMore }}</span>
- </a>
- <a class="gl-mx-2 cursor-pointer js-unfold-all" @click="handleExpandLines()">
- <gl-icon :size="12" name="expand" aria-hidden="true" />
- <span>{{ $options.i18n.showAll }}</span>
- </a>
- <a
- v-if="canExpandUp"
- class="gl-mx-2 gl-cursor-pointer js-unfold gl-display-inline-block gl-py-4"
- @click="handleExpandLines(EXPAND_UP)"
- >
- <gl-icon :size="12" name="expand-up" aria-hidden="true" />
- <span>{{ $options.i18n.showMore }}</span>
- </a>
- </div>
- </td>
+ <div class="content js-line-expansion-content">
+ <a
+ v-if="canExpandDown"
+ class="gl-mx-2 gl-cursor-pointer js-unfold-down gl-display-inline-block gl-py-4"
+ @click="handleExpandLines(EXPAND_DOWN)"
+ >
+ <gl-icon :size="12" name="expand-down" aria-hidden="true" />
+ <span>{{ $options.i18n.showMore }}</span>
+ </a>
+ <a class="gl-mx-2 cursor-pointer js-unfold-all" @click="handleExpandLines()">
+ <gl-icon :size="12" name="expand" aria-hidden="true" />
+ <span>{{ $options.i18n.showAll }}</span>
+ </a>
+ <a
+ v-if="canExpandUp"
+ class="gl-mx-2 gl-cursor-pointer js-unfold gl-display-inline-block gl-py-4"
+ @click="handleExpandLines(EXPAND_UP)"
+ >
+ <gl-icon :size="12" name="expand-up" aria-hidden="true" />
+ <span>{{ $options.i18n.showMore }}</span>
+ </a>
+ </div>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 529723a349d..32191d7e309 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -1,20 +1,31 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { escape } from 'lodash';
-import { GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { __, sprintf } from '~/locale';
+import { sprintf } from '~/locale';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { hasDiff } from '~/helpers/diffs_helper';
-import eventHub from '../../notes/event_hub';
+import notesEventHub from '../../notes/event_hub';
import DiffFileHeader from './diff_file_header.vue';
import DiffContent from './diff_content.vue';
import { diffViewerErrors } from '~/ide/constants';
+import { collapsedType, isCollapsed } from '../diff_file';
+import {
+ DIFF_FILE_AUTOMATIC_COLLAPSE,
+ DIFF_FILE_MANUAL_COLLAPSE,
+ EVT_EXPAND_ALL_FILES,
+ EVT_PERF_MARK_DIFF_FILES_END,
+ EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN,
+} from '../constants';
+import { DIFF_FILE, GENERIC_ERROR } from '../i18n';
+import eventHub from '../event_hub';
export default {
components: {
DiffFileHeader,
DiffContent,
+ GlButton,
GlLoadingIcon,
},
directives: {
@@ -26,6 +37,16 @@ export default {
type: Object,
required: true,
},
+ isFirstFile: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isLastFile: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
canCurrentUserFork: {
type: Boolean,
required: true,
@@ -44,16 +65,20 @@ export default {
return {
isLoadingCollapsedDiff: false,
forkMessageVisible: false,
- isCollapsed: this.file.viewer.automaticallyCollapsed || false,
+ isCollapsed: isCollapsed(this.file),
};
},
+ i18n: {
+ ...DIFF_FILE,
+ genericError: GENERIC_ERROR,
+ },
computed: {
...mapState('diffs', ['currentDiffFileId']),
...mapGetters(['isNotesFetched']),
...mapGetters('diffs', ['getDiffFileDiscussions']),
viewBlobLink() {
return sprintf(
- __('You can %{linkStart}view the blob%{linkEnd} instead.'),
+ this.$options.i18n.blobView,
{
linkStart: `<a href="${escape(this.file.view_path)}">`,
linkEnd: '</a>',
@@ -71,13 +96,11 @@ export default {
return this.file.viewer.error === diffViewerErrors.too_large;
},
errorMessage() {
- return this.file.viewer.error_message;
+ return !this.manuallyCollapsed ? this.file.viewer.error_message : '';
},
forkMessage() {
return sprintf(
- __(
- "You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.",
- ),
+ this.$options.i18n.editInFork,
{
tag_start: '<span class="js-file-fork-suggestion-section-action">',
tag_end: '</span>',
@@ -85,62 +108,131 @@ export default {
false,
);
},
- },
- watch: {
- isCollapsed: function fileCollapsedWatch(newVal, oldVal) {
- if (!newVal && oldVal && !this.hasDiff) {
- this.handleLoadCollapsedDiff();
+ hasBodyClasses() {
+ const domParts = {
+ header: 'gl-rounded-base!',
+ contentByHash: '',
+ content: '',
+ };
+
+ if (this.showBody) {
+ domParts.header = 'gl-rounded-bottom-left-none gl-rounded-bottom-right-none';
+ domParts.contentByHash =
+ 'gl-rounded-none gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-border-1 gl-border-t-0! gl-border-solid gl-border-gray-100';
+ domParts.content = 'gl-rounded-bottom-left-base gl-rounded-bottom-right-base';
}
- this.setFileCollapsed({ filePath: this.file.file_path, collapsed: newVal });
+ return domParts;
},
+ automaticallyCollapsed() {
+ return collapsedType(this.file) === DIFF_FILE_AUTOMATIC_COLLAPSE;
+ },
+ manuallyCollapsed() {
+ return collapsedType(this.file) === DIFF_FILE_MANUAL_COLLAPSE;
+ },
+ showBody() {
+ return !this.isCollapsed || this.automaticallyCollapsed;
+ },
+ showWarning() {
+ return this.isCollapsed && (this.automaticallyCollapsed && !this.viewDiffsFileByFile);
+ },
+ showContent() {
+ return !this.isCollapsed && !this.isFileTooLarge;
+ },
+ },
+ watch: {
'file.file_hash': {
- handler: function watchFileHash() {
- if (this.viewDiffsFileByFile && this.file.viewer.automaticallyCollapsed) {
- this.isCollapsed = false;
- this.handleLoadCollapsedDiff();
- } else {
- this.isCollapsed = this.file.viewer.automaticallyCollapsed || false;
+ handler: function hashChangeWatch(newHash, oldHash) {
+ this.isCollapsed = isCollapsed(this.file);
+
+ if (newHash && oldHash && !this.hasDiff) {
+ this.requestDiff();
}
},
immediate: true,
},
- 'file.viewer.automaticallyCollapsed': function setIsCollapsed(newVal) {
- if (!this.viewDiffsFileByFile) {
- this.isCollapsed = newVal;
- }
+ 'file.viewer.automaticallyCollapsed': {
+ handler: function autoChangeWatch(automaticValue) {
+ if (collapsedType(this.file) !== DIFF_FILE_MANUAL_COLLAPSE) {
+ this.isCollapsed = this.viewDiffsFileByFile ? false : automaticValue;
+ }
+ },
+ immediate: true,
+ },
+ 'file.viewer.manuallyCollapsed': {
+ handler: function manualChangeWatch(manualValue) {
+ if (manualValue !== null) {
+ this.isCollapsed = manualValue;
+ }
+ },
+ immediate: true,
},
},
created() {
- eventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.handleLoadCollapsedDiff);
+ notesEventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.requestDiff);
+ eventHub.$on(EVT_EXPAND_ALL_FILES, this.expandAllListener);
+ },
+ mounted() {
+ if (this.hasDiff) {
+ this.postRender();
+ }
+ },
+ beforeDestroy() {
+ eventHub.$off(EVT_EXPAND_ALL_FILES, this.expandAllListener);
},
methods: {
...mapActions('diffs', [
'loadCollapsedDiff',
'assignDiscussionsToDiff',
'setRenderIt',
- 'setFileCollapsed',
+ 'setFileCollapsedByUser',
]),
+ expandAllListener() {
+ if (this.isCollapsed) {
+ this.handleToggle();
+ }
+ },
+ async postRender() {
+ const eventsForThisFile = [];
+
+ if (this.isFirstFile) {
+ eventsForThisFile.push(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN);
+ }
+
+ if (this.isLastFile) {
+ eventsForThisFile.push(EVT_PERF_MARK_DIFF_FILES_END);
+ }
+
+ await this.$nextTick();
+
+ eventsForThisFile.forEach(event => {
+ eventHub.$emit(event);
+ });
+ },
handleToggle() {
- if (!this.hasDiff) {
- this.handleLoadCollapsedDiff();
- } else {
- this.isCollapsed = !this.isCollapsed;
- this.setRenderIt(this.file);
+ const currentCollapsedFlag = this.isCollapsed;
+
+ this.setFileCollapsedByUser({
+ filePath: this.file.file_path,
+ collapsed: !currentCollapsedFlag,
+ });
+
+ if (!this.hasDiff && currentCollapsedFlag) {
+ this.requestDiff();
}
},
- handleLoadCollapsedDiff() {
+ requestDiff() {
this.isLoadingCollapsedDiff = true;
this.loadCollapsedDiff(this.file)
.then(() => {
this.isLoadingCollapsedDiff = false;
- this.isCollapsed = false;
this.setRenderIt(this.file);
})
.then(() => {
requestIdleCallback(
() => {
+ this.postRender();
this.assignDiscussionsToDiff(this.getDiffFileDiscussions(this.file));
},
{ timeout: 1000 },
@@ -148,7 +240,7 @@ export default {
})
.catch(() => {
this.isLoadingCollapsedDiff = false;
- createFlash(__('Something went wrong on our end. Please try again!'));
+ createFlash(this.$options.i18n.genericError);
});
},
showForkMessage() {
@@ -167,9 +259,10 @@ export default {
:class="{
'is-active': currentDiffFileId === file.file_hash,
'comments-disabled': Boolean(file.brokenSymlink),
+ 'has-body': showBody,
}"
:data-path="file.new_path"
- class="diff-file file-holder"
+ class="diff-file file-holder gl-border-none"
>
<diff-file-header
:can-current-user-fork="canCurrentUserFork"
@@ -178,7 +271,8 @@ export default {
:expanded="!isCollapsed"
:add-merge-request-buttons="true"
:view-diffs-file-by-file="viewDiffsFileByFile"
- class="js-file-title file-title"
+ class="js-file-title file-title gl-border-1 gl-border-solid gl-border-gray-100"
+ :class="hasBodyClasses.header"
@toggleFile="handleToggle"
@showForkMessage="showForkMessage"
/>
@@ -188,31 +282,50 @@ export default {
<a
:href="file.fork_path"
class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success"
- >{{ __('Fork') }}</a
+ >{{ $options.i18n.fork }}</a
>
<button
class="js-cancel-fork-suggestion-button btn btn-grouped"
type="button"
@click="hideForkMessage"
>
- {{ __('Cancel') }}
+ {{ $options.i18n.cancel }}
</button>
</div>
- <gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" />
<template v-else>
- <div :id="`diff-content-${file.file_hash}`">
- <div v-if="errorMessage" class="diff-viewer">
+ <div
+ :id="`diff-content-${file.file_hash}`"
+ :class="hasBodyClasses.contentByHash"
+ data-testid="content-area"
+ >
+ <gl-loading-icon
+ v-if="showLoadingIcon"
+ class="diff-content loading gl-my-0 gl-pt-3"
+ data-testid="loader-icon"
+ />
+ <div v-else-if="errorMessage" class="diff-viewer">
<div v-safe-html="errorMessage" class="nothing-here-block"></div>
</div>
<template v-else>
- <div v-show="isCollapsed" class="nothing-here-block diff-collapsed">
- {{ __('This diff is collapsed.') }}
- <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{
- __('Click to expand it.')
- }}</a>
+ <div
+ v-show="showWarning"
+ class="collapsed-file-warning gl-p-7 gl-bg-orange-50 gl-text-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
+ >
+ <p class="gl-mb-8">
+ {{ $options.i18n.autoCollapsed }}
+ </p>
+ <gl-button
+ data-testid="expand-button"
+ category="secondary"
+ variant="warning"
+ @click.prevent="handleToggle"
+ >
+ {{ $options.i18n.expand }}
+ </gl-button>
</div>
<diff-content
- v-show="!isCollapsed && !isFileTooLarge"
+ v-show="showContent"
+ :class="hasBodyClasses.content"
:diff-file="file"
:help-page-path="helpPagePath"
/>
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 9f451cd759a..0d99a2e8a60 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -10,6 +10,7 @@ import {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
+ GlLoadingIcon,
} from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
@@ -18,6 +19,7 @@ import { __, s__, sprintf } from '~/locale';
import { diffViewerModes } from '~/ide/constants';
import DiffStats from './diff_stats.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
+import { isCollapsed } from '../diff_file';
import { DIFF_FILE_HEADER } from '../i18n';
export default {
@@ -31,6 +33,7 @@ export default {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
+ GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -125,6 +128,9 @@ export default {
isUsingLfs() {
return this.diffFile.stored_externally && this.diffFile.external_storage === 'lfs';
},
+ isCollapsed() {
+ return isCollapsed(this.diffFile, { fileByFile: this.viewDiffsFileByFile });
+ },
collapseIcon() {
return this.expanded ? 'chevron-down' : 'chevron-right';
},
@@ -209,7 +215,7 @@ export default {
class="js-file-title file-title file-title-flex-parent"
@click.self="handleToggleFile"
>
- <div class="file-header-content gl-display-flex gl-align-items-center gl-pr-0!">
+ <div class="file-header-content">
<gl-icon
v-if="collapsible"
ref="collapseIcon"
@@ -222,11 +228,17 @@ export default {
<a
ref="titleWrapper"
:v-once="!viewDiffsFileByFile"
- class="gl-mr-2 gl-text-decoration-none! gl-text-truncate"
+ class="gl-mr-2 gl-text-decoration-none! gl-word-break-all"
:href="titleLink"
@click="handleFileNameClick"
>
- <file-icon :file-name="filePath" :size="18" aria-hidden="true" css-classes="gl-mr-2" />
+ <file-icon
+ :file-name="filePath"
+ :size="18"
+ aria-hidden="true"
+ css-classes="gl-mr-2"
+ :submodule="diffFile.submodule"
+ />
<span v-if="isFileRenamed">
<strong
v-gl-tooltip
@@ -270,12 +282,12 @@ export default {
{{ diffFile.a_mode }} → {{ diffFile.b_mode }}
</small>
- <span v-if="isUsingLfs" class="label label-lfs gl-mr-2"> {{ __('LFS') }} </span>
+ <span v-if="isUsingLfs" class="badge label label-lfs gl-mr-2"> {{ __('LFS') }} </span>
</div>
<div
v-if="!diffFile.submodule && addMergeRequestButtons"
- class="file-actions d-flex align-items-center flex-wrap"
+ class="file-actions d-flex align-items-center gl-ml-auto gl-align-self-start"
>
<diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" />
<gl-button-group class="gl-pt-0!">
@@ -334,7 +346,7 @@ export default {
</gl-dropdown-item>
</template>
- <template v-if="!diffFile.viewer.automaticallyCollapsed">
+ <template v-if="!isCollapsed">
<gl-dropdown-divider
v-if="!diffFile.is_fully_expanded || diffHasDiscussions(diffFile)"
/>
@@ -355,8 +367,10 @@ export default {
<gl-dropdown-item
v-if="!diffFile.is_fully_expanded"
ref="expandDiffToFullFileButton"
+ :disabled="diffFile.isLoadingFullFile"
@click="toggleFullDiff(diffFile.file_path)"
>
+ <gl-loading-icon v-if="diffFile.isLoadingFullFile" inline />
{{ expandDiffToFullFileTitle }}
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_file_row.vue b/app/assets/javascripts/diffs/components/diff_file_row.vue
index 2856e6ae8eb..3888eb781fb 100644
--- a/app/assets/javascripts/diffs/components/diff_file_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_row.vue
@@ -62,6 +62,7 @@ export default {
v-bind="$attrs"
:class="{ 'is-active': isActive }"
class="diff-file-row"
+ truncate-middle
:file-classes="fileClasses"
v-on="$listeners"
>
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index 700e6302102..55f5a736cdf 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -7,7 +7,7 @@ import noteForm from '../../notes/components/note_form.vue';
import MultilineCommentForm from '../../notes/components/multiline_comment_form.vue';
import autosave from '../../notes/mixins/autosave';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
-import { DIFF_NOTE_TYPE } from '../constants';
+import { DIFF_NOTE_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '../constants';
import {
commentLineOptions,
formatLineRange,
@@ -60,7 +60,7 @@ export default {
diffViewType: state => state.diffs.diffViewType,
}),
...mapState('diffs', ['showSuggestPopover']),
- ...mapGetters('diffs', ['getDiffFileByHash']),
+ ...mapGetters('diffs', ['getDiffFileByHash', 'diffLines']),
...mapGetters([
'isLoggedIn',
'noteableType',
@@ -88,16 +88,30 @@ export default {
commentLineOptions() {
const combineSides = (acc, { left, right }) => {
// ignore null values match lines
- if (left && left.type !== 'match') acc.push(left);
+ if (left) acc.push(left);
// if the line_codes are identically, return to avoid duplicates
- if (left?.line_code === right?.line_code) return acc;
+ if (
+ left?.line_code === right?.line_code ||
+ left?.type === 'old-nonewline' ||
+ right?.type === 'new-nonewline'
+ ) {
+ return acc;
+ }
if (right && right.type !== 'match') acc.push(right);
return acc;
};
+ const getDiffLines = () => {
+ if (this.diffViewType === PARALLEL_DIFF_VIEW_TYPE) {
+ return (this.glFeatures.unifiedDiffLines
+ ? this.diffLines(this.diffFile)
+ : this.diffFile.parallel_diff_lines
+ ).reduce(combineSides, []);
+ }
+
+ return this.diffFile.highlighted_diff_lines;
+ };
const side = this.line.type === 'new' ? 'right' : 'left';
- const lines = this.diffFile.highlighted_diff_lines.length
- ? this.diffFile.highlighted_diff_lines
- : this.diffFile.parallel_diff_lines.reduce(combineSides, []);
+ const lines = getDiffLines();
return commentLineOptions(lines, this.line, this.line.line_code, side);
},
},
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
new file mode 100644
index 00000000000..77a97c67f3b
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -0,0 +1,271 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+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: {
+ GlIcon,
+ DiffGutterAvatars,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ SafeHtml,
+ },
+ props: {
+ fileHash: {
+ type: String,
+ required: true,
+ },
+ filePath: {
+ type: String,
+ required: true,
+ },
+ line: {
+ type: Object,
+ required: true,
+ },
+ isCommented: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapGetters('diffs', ['fileLineCoverage']),
+ ...mapGetters(['isLoggedIn']),
+ ...mapState({
+ isHighlighted(state) {
+ const line = this.line.left?.line_code ? this.line.left : this.line.right;
+ return utils.isHighlighted(state, line, this.isCommented);
+ },
+ }),
+ classNameMap() {
+ return {
+ [CONTEXT_LINE_CLASS_NAME]: this.line.isContextLineLeft,
+ [PARALLEL_DIFF_VIEW_TYPE]: true,
+ };
+ },
+ parallelViewLeftLineType() {
+ return utils.parallelViewLeftLineType(this.line, this.isHighlighted);
+ },
+ coverageState() {
+ return this.fileLineCoverage(this.filePath, this.line.right.new_line);
+ },
+ classNameMapCellLeft() {
+ return utils.classNameMapCell(this.line.left, this.isHighlighted, this.isLoggedIn);
+ },
+ classNameMapCellRight() {
+ return utils.classNameMapCell(this.line.right, this.isHighlighted, this.isLoggedIn);
+ },
+ addCommentTooltipLeft() {
+ return utils.addCommentTooltip(this.line.left);
+ },
+ addCommentTooltipRight() {
+ return utils.addCommentTooltip(this.line.right);
+ },
+ shouldRenderCommentButton() {
+ return (
+ this.isLoggedIn &&
+ !this.line.isContextLineLeft &&
+ !this.line.isMetaLineLeft &&
+ !this.line.hasDiscussionsLeft &&
+ !this.line.hasDiscussionsRight
+ );
+ },
+ },
+ mounted() {
+ this.scrollToLineIfNeededParallel(this.line);
+ },
+ methods: {
+ ...mapActions('diffs', [
+ 'scrollToLineIfNeededParallel',
+ 'showCommentForm',
+ 'setHighlightedRow',
+ 'toggleLineDiscussions',
+ ]),
+ // Prevent text selecting on both sides of parallel diff view
+ // Backport of the same code from legacy diff notes.
+ handleParallelLineMouseDown(e) {
+ const line = e.currentTarget;
+ const table = line.closest('.diff-table');
+
+ table.classList.remove('left-side-selected', 'right-side-selected');
+ const [lineClass] = ['left-side', 'right-side'].filter(name => line.classList.contains(name));
+
+ if (lineClass) {
+ table.classList.add(`${lineClass}-selected`);
+ }
+ },
+ handleCommentButton(line) {
+ this.showCommentForm({ lineCode: line.line_code, fileHash: this.fileHash });
+ },
+ },
+};
+</script>
+
+<template>
+ <div :class="classNameMap" class="diff-grid-row diff-tr line_holder">
+ <div class="diff-grid-left left-side">
+ <template v-if="line.left">
+ <div
+ :class="classNameMapCellLeft"
+ data-testid="leftLineNumber"
+ class="diff-td diff-line-num old_line"
+ >
+ <span
+ v-if="shouldRenderCommentButton"
+ v-gl-tooltip
+ data-testid="leftCommentButton"
+ class="add-diff-note tooltip-wrapper"
+ :title="addCommentTooltipLeft"
+ >
+ <button
+ type="button"
+ class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
+ :disabled="line.left.commentsDisabled"
+ @click="handleCommentButton(line.left)"
+ >
+ <gl-icon :size="12" name="comment" />
+ </button>
+ </span>
+ <a
+ v-if="line.left.old_line"
+ :data-linenumber="line.left.old_line"
+ :href="line.lineHrefOld"
+ @click="setHighlightedRow(line.lineCode)"
+ >
+ </a>
+ <diff-gutter-avatars
+ v-if="line.hasDiscussionsLeft"
+ :discussions="line.left.discussions"
+ :discussions-expanded="line.left.discussionsExpanded"
+ data-testid="leftDiscussions"
+ @toggleLineDiscussions="
+ toggleLineDiscussions({
+ lineCode: line.left.line_code,
+ fileHash,
+ expanded: !line.left.discussionsExpanded,
+ })
+ "
+ />
+ </div>
+ <div :class="classNameMapCellLeft" class="diff-td diff-line-num old_line">
+ <a
+ v-if="line.left.old_line"
+ :data-linenumber="line.left.old_line"
+ :href="line.lineHrefOld"
+ @click="setHighlightedRow(line.lineCode)"
+ >
+ </a>
+ </div>
+ <div :class="parallelViewLeftLineType" class="diff-td line-coverage left-side"></div>
+ <div
+ :id="line.left.line_code"
+ :key="line.left.line_code"
+ v-safe-html="line.left.rich_text"
+ :class="parallelViewLeftLineType"
+ class="diff-td line_content with-coverage parallel left-side"
+ data-testid="leftContent"
+ @mousedown="handleParallelLineMouseDown"
+ ></div>
+ </template>
+ <template v-else>
+ <div data-testid="leftEmptyCell" class="diff-td diff-line-num old_line empty-cell"></div>
+ <div class="diff-td diff-line-num old_line empty-cell"></div>
+ <div class="diff-td line-coverage left-side empty-cell"></div>
+ <div class="diff-td line_content with-coverage parallel left-side empty-cell"></div>
+ </template>
+ </div>
+ <div
+ v-if="!inline || (line.right && Boolean(line.right.type))"
+ class="diff-grid-right right-side"
+ >
+ <template v-if="line.right">
+ <div
+ :class="classNameMapCellRight"
+ data-testid="rightLineNumber"
+ class="diff-td diff-line-num new_line"
+ >
+ <span
+ v-if="shouldRenderCommentButton"
+ v-gl-tooltip
+ data-testid="rightCommentButton"
+ class="add-diff-note tooltip-wrapper"
+ :title="addCommentTooltipRight"
+ >
+ <button
+ type="button"
+ class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
+ :disabled="line.right.commentsDisabled"
+ @click="handleCommentButton(line.right)"
+ >
+ <gl-icon :size="12" name="comment" />
+ </button>
+ </span>
+ <a
+ v-if="line.right.new_line"
+ :data-linenumber="line.right.new_line"
+ :href="line.lineHrefNew"
+ @click="setHighlightedRow(line.lineCode)"
+ >
+ </a>
+ <diff-gutter-avatars
+ v-if="line.hasDiscussionsRight"
+ :discussions="line.right.discussions"
+ :discussions-expanded="line.right.discussionsExpanded"
+ data-testid="rightDiscussions"
+ @toggleLineDiscussions="
+ toggleLineDiscussions({
+ lineCode: line.right.line_code,
+ fileHash,
+ expanded: !line.right.discussionsExpanded,
+ })
+ "
+ />
+ </div>
+ <div :class="classNameMapCellRight" class="diff-td diff-line-num new_line">
+ <a
+ v-if="line.right.new_line"
+ :data-linenumber="line.right.new_line"
+ :href="line.lineHrefNew"
+ @click="setHighlightedRow(line.lineCode)"
+ >
+ </a>
+ </div>
+ <div
+ v-gl-tooltip.hover
+ :title="coverageState.text"
+ :class="[line.right.type, coverageState.class, { hll: isHighlighted }]"
+ class="diff-td line-coverage right-side"
+ ></div>
+ <div
+ :id="line.right.line_code"
+ :key="line.right.rich_text"
+ v-safe-html="line.right.rich_text"
+ :class="[
+ line.right.type,
+ {
+ hll: isHighlighted,
+ },
+ ]"
+ class="diff-td line_content with-coverage parallel right-side"
+ @mousedown="handleParallelLineMouseDown"
+ ></div>
+ </template>
+ <template v-else>
+ <div data-testid="rightEmptyCell" class="diff-td diff-line-num old_line empty-cell"></div>
+ <div class="diff-td diff-line-num old_line empty-cell"></div>
+ <div class="diff-td line-coverage right-side empty-cell"></div>
+ <div class="diff-td line_content with-coverage parallel right-side empty-cell"></div>
+ </template>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js
index 08b87a4bade..d5491d3cd56 100644
--- a/app/assets/javascripts/diffs/components/diff_row_utils.js
+++ b/app/assets/javascripts/diffs/components/diff_row_utils.js
@@ -83,3 +83,76 @@ export const parallelViewLeftLineType = (line, hll) => {
export const shouldShowCommentButton = (hover, context, meta, discussions) => {
return hover && !context && !meta && !discussions;
};
+
+export const mapParallel = content => line => {
+ let { left, right } = line;
+
+ // Dicussions/Comments
+ const hasExpandedDiscussionOnLeft =
+ left?.discussions?.length > 0 ? left?.discussionsExpanded : false;
+ const hasExpandedDiscussionOnRight =
+ right?.discussions?.length > 0 ? right?.discussionsExpanded : false;
+
+ const renderCommentRow =
+ hasExpandedDiscussionOnLeft || hasExpandedDiscussionOnRight || left?.hasForm || right?.hasForm;
+
+ if (left) {
+ left = {
+ ...left,
+ renderDiscussion: hasExpandedDiscussionOnLeft,
+ hasDraft: content.hasParallelDraftLeft(content.diffFile.file_hash, line),
+ lineDraft: content.draftForLine(content.diffFile.file_hash, line, 'left'),
+ hasCommentForm: left.hasForm,
+ };
+ }
+ if (right) {
+ right = {
+ ...right,
+ renderDiscussion: Boolean(hasExpandedDiscussionOnRight && right.type),
+ hasDraft: content.hasParallelDraftRight(content.diffFile.file_hash, line),
+ lineDraft: content.draftForLine(content.diffFile.file_hash, line, 'right'),
+ hasCommentForm: Boolean(right.hasForm && right.type),
+ };
+ }
+
+ return {
+ ...line,
+ left,
+ right,
+ isMatchLineLeft: isMatchLine(left?.type),
+ isMatchLineRight: isMatchLine(right?.type),
+ isContextLineLeft: isContextLine(left?.type),
+ isContextLineRight: isContextLine(right?.type),
+ hasDiscussionsLeft: hasDiscussions(left),
+ hasDiscussionsRight: hasDiscussions(right),
+ lineHrefOld: lineHref(left),
+ lineHrefNew: lineHref(right),
+ lineCode: lineCode(line),
+ isMetaLineLeft: isMetaLine(left?.type),
+ isMetaLineRight: isMetaLine(right?.type),
+ draftRowClasses: left?.lineDraft > 0 || right?.lineDraft > 0 ? '' : 'js-temp-notes-holder',
+ renderCommentRow,
+ commentRowClasses: hasDiscussions(left) || hasDiscussions(right) ? '' : 'js-temp-notes-holder',
+ };
+};
+
+// TODO: Delete this function when unifiedDiffComponents FF is removed
+export const mapInline = content => line => {
+ // Discussions/Comments
+ const renderCommentRow = line.hasForm || (line.discussions?.length && line.discussionsExpanded);
+
+ return {
+ ...line,
+ renderDiscussion: Boolean(line.discussions?.length),
+ isMatchLine: isMatchLine(line.type),
+ commentRowClasses: line.discussions?.length ? '' : 'js-temp-notes-holder',
+ renderCommentRow,
+ hasDraft: content.shouldRenderDraftRow(content.diffFile.file_hash, line),
+ hasCommentForm: line.hasForm,
+ isMetaLine: isMetaLine(line.type),
+ isContextLine: isContextLine(line.type),
+ hasDiscussions: hasDiscussions(line),
+ lineHref: lineHref(line),
+ lineCode: lineCode(line),
+ };
+};
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
new file mode 100644
index 00000000000..84429f62a1c
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -0,0 +1,151 @@
+<script>
+import { mapGetters, mapState } from 'vuex';
+import draftCommentsMixin from '~/diffs/mixins/draft_comments';
+import DraftNote from '~/batch_comments/components/draft_note.vue';
+import DiffRow from './diff_row.vue';
+import DiffCommentCell from './diff_comment_cell.vue';
+import DiffExpansionCell from './diff_expansion_cell.vue';
+import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
+
+export default {
+ components: {
+ DiffExpansionCell,
+ DiffRow,
+ DiffCommentCell,
+ DraftNote,
+ },
+ mixins: [draftCommentsMixin],
+ props: {
+ diffFile: {
+ type: Object,
+ required: true,
+ },
+ diffLines: {
+ type: Array,
+ required: true,
+ },
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapGetters('diffs', ['commitId']),
+ ...mapState({
+ selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition,
+ selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover,
+ }),
+ diffLinesLength() {
+ return this.diffLines.length;
+ },
+ commentedLines() {
+ return getCommentedLines(
+ this.selectedCommentPosition || this.selectedCommentPositionHover,
+ this.diffLines,
+ );
+ },
+ },
+ methods: {
+ showCommentLeft(line) {
+ return !this.inline || line.left;
+ },
+ showCommentRight(line) {
+ return !this.inline || (line.right && !line.left);
+ },
+ },
+ userColorScheme: window.gon.user_color_scheme,
+};
+</script>
+
+<template>
+ <div
+ :class="[$options.userColorScheme, { inline }]"
+ :data-commit-id="commitId"
+ class="diff-grid diff-table code diff-wrap-lines js-syntax-highlight text-file"
+ >
+ <template v-for="(line, index) in diffLines">
+ <div
+ v-if="line.isMatchLineLeft || line.isMatchLineRight"
+ :key="`expand-${index}`"
+ class="diff-tr line_expansion match"
+ >
+ <div class="diff-td text-center gl-font-regular">
+ <diff-expansion-cell
+ :file-hash="diffFile.file_hash"
+ :context-lines-path="diffFile.context_lines_path"
+ :line="line.left"
+ :is-top="index === 0"
+ :is-bottom="index + 1 === diffLinesLength"
+ />
+ </div>
+ </div>
+ <diff-row
+ v-if="!line.isMatchLineLeft && !line.isMatchLineRight"
+ :key="line.line_code"
+ :file-hash="diffFile.file_hash"
+ :file-path="diffFile.file_path"
+ :line="line"
+ :is-bottom="index + 1 === diffLinesLength"
+ :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine"
+ :inline="inline"
+ />
+ <div
+ v-if="line.renderCommentRow"
+ :key="`dcr-${line.line_code || index}`"
+ :class="line.commentRowClasses"
+ class="diff-grid-comments diff-tr notes_holder"
+ >
+ <div v-if="showCommentLeft(line)" class="diff-td notes-content parallel old">
+ <diff-comment-cell
+ v-if="line.left"
+ :line="line.left"
+ :diff-file-hash="diffFile.file_hash"
+ :help-page-path="helpPagePath"
+ :has-draft="line.left.hasDraft"
+ line-position="left"
+ />
+ </div>
+ <div v-if="showCommentRight(line)" class="diff-td notes-content parallel new">
+ <diff-comment-cell
+ v-if="line.right"
+ :line="line.right"
+ :diff-file-hash="diffFile.file_hash"
+ :line-index="index"
+ :help-page-path="helpPagePath"
+ :has-draft="line.right.hasDraft"
+ line-position="right"
+ />
+ </div>
+ </div>
+ <div
+ v-if="shouldRenderParallelDraftRow(diffFile.file_hash, line)"
+ :key="`drafts-${index}`"
+ :class="line.draftRowClasses"
+ class="diff-grid-drafts diff-tr notes_holder"
+ >
+ <div
+ v-if="!inline || (line.left && line.left.lineDraft.isDraft)"
+ class="diff-td notes-content parallel old"
+ >
+ <div v-if="line.left && line.left.lineDraft.isDraft" class="content">
+ <draft-note :draft="line.left.lineDraft" :line="line.left" />
+ </div>
+ </div>
+ <div
+ v-if="!inline || (line.right && line.right.lineDraft.isDraft)"
+ class="diff-td notes-content parallel new"
+ >
+ <div v-if="line.right && line.right.lineDraft.isDraft" class="content">
+ <draft-note :draft="line.right.lineDraft" :line="line.right" />
+ </div>
+ </div>
+ </div>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
deleted file mode 100644
index 87f0396cf72..00000000000
--- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
+++ /dev/null
@@ -1,82 +0,0 @@
-<script>
-import { mapActions } from 'vuex';
-import DiffDiscussions from './diff_discussions.vue';
-import DiffLineNoteForm from './diff_line_note_form.vue';
-import DiffDiscussionReply from './diff_discussion_reply.vue';
-
-export default {
- components: {
- DiffDiscussions,
- DiffLineNoteForm,
- DiffDiscussionReply,
- },
- props: {
- line: {
- type: Object,
- required: true,
- },
- diffFileHash: {
- type: String,
- required: true,
- },
- helpPagePath: {
- type: String,
- required: false,
- default: '',
- },
- hasDraft: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- className() {
- return this.line.discussions.length ? '' : 'js-temp-notes-holder';
- },
- shouldRender() {
- if (this.line.hasForm) return true;
-
- if (!this.line.discussions || !this.line.discussions.length) {
- return false;
- }
- return this.line.discussionsExpanded;
- },
- },
- methods: {
- ...mapActions('diffs', ['showCommentForm']),
- },
-};
-</script>
-
-<template>
- <tr v-if="shouldRender" :class="className" class="notes_holder">
- <td class="notes-content" colspan="4">
- <div class="content">
- <diff-discussions
- v-if="line.discussions.length"
- :line="line"
- :discussions="line.discussions"
- :help-page-path="helpPagePath"
- />
- <diff-discussion-reply
- v-if="!hasDraft"
- :has-form="line.hasForm"
- :render-reply-placeholder="Boolean(line.discussions.length)"
- @showNewDiscussionForm="
- showCommentForm({ lineCode: line.line_code, fileHash: diffFileHash })
- "
- >
- <template #form>
- <diff-line-note-form
- :diff-file-hash="diffFileHash"
- :line="line"
- :note-target-line="line"
- :help-page-path="helpPagePath"
- />
- </template>
- </diff-discussion-reply>
- </div>
- </td>
- </tr>
-</template>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue b/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue
deleted file mode 100644
index 071a988d789..00000000000
--- a/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue
+++ /dev/null
@@ -1,51 +0,0 @@
-<script>
-import DiffExpansionCell from './diff_expansion_cell.vue';
-import { MATCH_LINE_TYPE } from '../constants';
-
-export default {
- components: {
- DiffExpansionCell,
- },
- props: {
- fileHash: {
- type: String,
- required: true,
- },
- contextLinesPath: {
- type: String,
- required: true,
- },
- line: {
- type: Object,
- required: true,
- },
- isTop: {
- type: Boolean,
- required: false,
- default: false,
- },
- isBottom: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- isMatchLine() {
- return this.line.type === MATCH_LINE_TYPE;
- },
- },
-};
-</script>
-
-<template>
- <tr v-if="isMatchLine" class="line_expansion match">
- <diff-expansion-cell
- :file-hash="fileHash"
- :context-lines-path="contextLinesPath"
- :line="line"
- :is-top="isTop"
- :is-bottom="isBottom"
- />
- </tr>
-</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 99cf79a70d4..2d8ffb047ca 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -3,7 +3,13 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { CONTEXT_LINE_CLASS_NAME } from '../constants';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
-import * as utils from './diff_row_utils';
+import {
+ isHighlighted,
+ shouldShowCommentButton,
+ shouldRenderCommentButton,
+ classNameMapCell,
+ addCommentTooltip,
+} from './diff_row_utils';
export default {
components: {
@@ -48,60 +54,42 @@ export default {
...mapGetters('diffs', ['fileLineCoverage']),
...mapState({
isHighlighted(state) {
- return utils.isHighlighted(state, this.line, this.isCommented);
+ return isHighlighted(state, this.line, this.isCommented);
},
}),
- isContextLine() {
- return utils.isContextLine(this.line.type);
- },
classNameMap() {
return [
this.line.type,
{
- [CONTEXT_LINE_CLASS_NAME]: this.isContextLine,
+ [CONTEXT_LINE_CLASS_NAME]: this.line.isContextLine,
},
];
},
inlineRowId() {
return this.line.line_code || `${this.fileHash}_${this.line.old_line}_${this.line.new_line}`;
},
- isMatchLine() {
- return utils.isMatchLine(this.line.type);
- },
coverageState() {
return this.fileLineCoverage(this.filePath, this.line.new_line);
},
- isMetaLine() {
- return utils.isMetaLine(this.line.type);
- },
classNameMapCell() {
- return utils.classNameMapCell(this.line, this.isHighlighted, this.isLoggedIn, this.isHover);
+ return classNameMapCell(this.line, this.isHighlighted, this.isLoggedIn, this.isHover);
},
addCommentTooltip() {
- return utils.addCommentTooltip(this.line);
+ return addCommentTooltip(this.line);
},
shouldRenderCommentButton() {
- return utils.shouldRenderCommentButton(this.isLoggedIn, true);
+ return shouldRenderCommentButton(this.isLoggedIn, true);
},
shouldShowCommentButton() {
- return utils.shouldShowCommentButton(
+ return shouldShowCommentButton(
this.isHover,
- this.isContextLine,
- this.isMetaLine,
- this.hasDiscussions,
+ this.line.isContextLine,
+ this.line.isMetaLine,
+ this.line.hasDiscussions,
);
},
- hasDiscussions() {
- return utils.hasDiscussions(this.line);
- },
- lineHref() {
- return utils.lineHref(this.line);
- },
- lineCode() {
- return utils.lineCode(this.line);
- },
shouldShowAvatarsOnGutter() {
- return this.hasDiscussions;
+ return this.line.hasDiscussions;
},
},
mounted() {
@@ -128,7 +116,6 @@ export default {
<template>
<tr
- v-if="!isMatchLine"
:id="inlineRowId"
:class="classNameMap"
class="line_holder"
@@ -158,8 +145,8 @@ export default {
v-if="line.old_line"
ref="lineNumberRefOld"
:data-linenumber="line.old_line"
- :href="lineHref"
- @click="setHighlightedRow(lineCode)"
+ :href="line.lineHref"
+ @click="setHighlightedRow(line.lineCode)"
>
</a>
<diff-gutter-avatars
@@ -167,7 +154,11 @@ export default {
:discussions="line.discussions"
:discussions-expanded="line.discussionsExpanded"
@toggleLineDiscussions="
- toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded })
+ toggleLineDiscussions({
+ lineCode: line.lineCode,
+ fileHash,
+ expanded: !line.discussionsExpanded,
+ })
"
/>
</td>
@@ -176,8 +167,8 @@ export default {
v-if="line.new_line"
ref="lineNumberRefNew"
:data-linenumber="line.new_line"
- :href="lineHref"
- @click="setHighlightedRow(lineCode)"
+ :href="line.lineHref"
+ @click="setHighlightedRow(line.lineCode)"
>
</a>
</td>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue
index 13805910648..05f5461054f 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue
@@ -2,18 +2,18 @@
import { mapGetters, mapState } from 'vuex';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
-import InlineDraftCommentRow from '~/batch_comments/components/inline_draft_comment_row.vue';
+import DraftNote from '~/batch_comments/components/draft_note.vue';
import inlineDiffTableRow from './inline_diff_table_row.vue';
-import inlineDiffCommentRow from './inline_diff_comment_row.vue';
-import inlineDiffExpansionRow from './inline_diff_expansion_row.vue';
+import DiffCommentCell from './diff_comment_cell.vue';
+import DiffExpansionCell from './diff_expansion_cell.vue';
import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
export default {
components: {
- inlineDiffCommentRow,
+ DiffCommentCell,
inlineDiffTableRow,
- InlineDraftCommentRow,
- inlineDiffExpansionRow,
+ DraftNote,
+ DiffExpansionCell,
},
mixins: [draftCommentsMixin, glFeatureFlagsMixin()],
props: {
@@ -65,15 +65,19 @@ export default {
</colgroup>
<tbody>
<template v-for="(line, index) in diffLines">
- <inline-diff-expansion-row
- :key="`expand-${index}`"
- :file-hash="diffFile.file_hash"
- :context-lines-path="diffFile.context_lines_path"
- :line="line"
- :is-top="index === 0"
- :is-bottom="index + 1 === diffLinesLength"
- />
+ <tr v-if="line.isMatchLine" :key="`expand-${index}`" class="line_expansion match">
+ <td colspan="4" class="text-center gl-font-regular">
+ <diff-expansion-cell
+ :file-hash="diffFile.file_hash"
+ :context-lines-path="diffFile.context_lines_path"
+ :line="line"
+ :is-top="index === 0"
+ :is-bottom="index + 1 === diffLinesLength"
+ />
+ </td>
+ </tr>
<inline-diff-table-row
+ v-if="!line.isMatchLine"
:key="`${line.line_code || index}`"
:file-hash="diffFile.file_hash"
:file-path="diffFile.file_path"
@@ -81,20 +85,32 @@ export default {
:is-bottom="index + 1 === diffLinesLength"
:is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine"
/>
- <inline-diff-comment-row
+ <tr
+ v-if="line.renderCommentRow"
:key="`icr-${line.line_code || index}`"
- :diff-file-hash="diffFile.file_hash"
- :line="line"
- :help-page-path="helpPagePath"
- :has-draft="shouldRenderDraftRow(diffFile.file_hash, line) || false"
- />
- <inline-draft-comment-row
- v-if="shouldRenderDraftRow(diffFile.file_hash, line)"
- :key="`draft_${index}`"
- :draft="draftForLine(diffFile.file_hash, line)"
- :diff-file="diffFile"
- :line="line"
- />
+ :class="line.commentRowClasses"
+ class="notes_holder"
+ >
+ <td class="notes-content" colspan="4">
+ <diff-comment-cell
+ :diff-file-hash="diffFile.file_hash"
+ :line="line"
+ :help-page-path="helpPagePath"
+ :has-draft="line.hasDraft"
+ />
+ </td>
+ </tr>
+ <tr v-if="line.hasDraft" :key="`draft_${index}`" class="notes_holder js-temp-notes-holder">
+ <td class="notes-content" colspan="4">
+ <div class="content">
+ <draft-note
+ :draft="draftForLine(diffFile.file_hash, line)"
+ :diff-file="diffFile"
+ :line="line"
+ />
+ </div>
+ </td>
+ </tr>
</template>
</tbody>
</table>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
deleted file mode 100644
index 127e3f214cf..00000000000
--- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
+++ /dev/null
@@ -1,175 +0,0 @@
-<script>
-import { mapActions } from 'vuex';
-import DiffDiscussions from './diff_discussions.vue';
-import DiffLineNoteForm from './diff_line_note_form.vue';
-import DiffDiscussionReply from './diff_discussion_reply.vue';
-
-export default {
- components: {
- DiffDiscussions,
- DiffLineNoteForm,
- DiffDiscussionReply,
- },
- props: {
- line: {
- type: Object,
- required: true,
- },
- diffFileHash: {
- type: String,
- required: true,
- },
- lineIndex: {
- type: Number,
- required: true,
- },
- helpPagePath: {
- type: String,
- required: false,
- default: '',
- },
- hasDraftLeft: {
- type: Boolean,
- required: false,
- default: false,
- },
- hasDraftRight: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- hasExpandedDiscussionOnLeft() {
- return this.line.left && this.line.left.discussions.length
- ? this.line.left.discussionsExpanded
- : false;
- },
- hasExpandedDiscussionOnRight() {
- return this.line.right && this.line.right.discussions.length
- ? this.line.right.discussionsExpanded
- : false;
- },
- hasAnyExpandedDiscussion() {
- return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight;
- },
- shouldRenderDiscussionsOnLeft() {
- return (
- this.line.left &&
- this.line.left.discussions &&
- this.line.left.discussions.length &&
- this.hasExpandedDiscussionOnLeft
- );
- },
- shouldRenderDiscussionsOnRight() {
- return (
- this.line.right &&
- this.line.right.discussions &&
- this.line.right.discussions.length &&
- this.hasExpandedDiscussionOnRight &&
- this.line.right.type
- );
- },
- showRightSideCommentForm() {
- return this.line.right && this.line.right.type && this.line.right.hasForm;
- },
- showLeftSideCommentForm() {
- return this.line.left && this.line.left.hasForm;
- },
- className() {
- return (this.left && this.line.left.discussions.length > 0) ||
- (this.right && this.line.right.discussions.length > 0)
- ? ''
- : 'js-temp-notes-holder';
- },
- shouldRender() {
- const { line } = this;
- const hasDiscussion =
- (line.left && line.left.discussions && line.left.discussions.length) ||
- (line.right && line.right.discussions && line.right.discussions.length);
-
- if (
- hasDiscussion &&
- (this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight)
- ) {
- return true;
- }
-
- const hasCommentFormOnLeft = line.left && line.left.hasForm;
- const hasCommentFormOnRight = line.right && line.right.hasForm;
-
- return hasCommentFormOnLeft || hasCommentFormOnRight;
- },
- shouldRenderReplyPlaceholderOnLeft() {
- return Boolean(
- this.line.left && this.line.left.discussions && this.line.left.discussions.length,
- );
- },
- shouldRenderReplyPlaceholderOnRight() {
- return Boolean(
- this.line.right && this.line.right.discussions && this.line.right.discussions.length,
- );
- },
- },
- methods: {
- ...mapActions('diffs', ['showCommentForm']),
- showNewDiscussionForm(lineCode) {
- this.showCommentForm({ lineCode, fileHash: this.diffFileHash });
- },
- },
-};
-</script>
-
-<template>
- <tr v-if="shouldRender" :class="className" class="notes_holder">
- <td class="notes-content parallel old" colspan="3">
- <div v-if="shouldRenderDiscussionsOnLeft" class="content">
- <diff-discussions
- :discussions="line.left.discussions"
- :line="line.left"
- :help-page-path="helpPagePath"
- />
- </div>
- <diff-discussion-reply
- v-if="!hasDraftLeft"
- :has-form="showLeftSideCommentForm"
- :render-reply-placeholder="shouldRenderReplyPlaceholderOnLeft"
- @showNewDiscussionForm="showNewDiscussionForm(line.left.line_code)"
- >
- <template #form>
- <diff-line-note-form
- :diff-file-hash="diffFileHash"
- :line="line.left"
- :note-target-line="line.left"
- :help-page-path="helpPagePath"
- line-position="left"
- />
- </template>
- </diff-discussion-reply>
- </td>
- <td class="notes-content parallel new" colspan="3">
- <div v-if="shouldRenderDiscussionsOnRight" class="content">
- <diff-discussions
- :discussions="line.right.discussions"
- :line="line.right"
- :help-page-path="helpPagePath"
- />
- </div>
- <diff-discussion-reply
- v-if="!hasDraftRight"
- :has-form="showRightSideCommentForm"
- :render-reply-placeholder="shouldRenderReplyPlaceholderOnRight"
- @showNewDiscussionForm="showNewDiscussionForm(line.right.line_code)"
- >
- <template #form>
- <diff-line-note-form
- :diff-file-hash="diffFileHash"
- :line="line.right"
- :note-target-line="line.right"
- line-position="right"
- />
- </template>
- </diff-discussion-reply>
- </td>
- </tr>
-</template>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_expansion_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_expansion_row.vue
deleted file mode 100644
index 0a80107ced4..00000000000
--- a/app/assets/javascripts/diffs/components/parallel_diff_expansion_row.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-<script>
-import { MATCH_LINE_TYPE } from '../constants';
-import DiffExpansionCell from './diff_expansion_cell.vue';
-
-export default {
- components: {
- DiffExpansionCell,
- },
- props: {
- fileHash: {
- type: String,
- required: true,
- },
- contextLinesPath: {
- type: String,
- required: true,
- },
- line: {
- type: Object,
- required: true,
- },
- isTop: {
- type: Boolean,
- required: false,
- default: false,
- },
- isBottom: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- isMatchLineLeft() {
- return this.line.left && this.line.left.type === MATCH_LINE_TYPE;
- },
- isMatchLineRight() {
- return this.line.right && this.line.right.type === MATCH_LINE_TYPE;
- },
- },
-};
-</script>
-<template>
- <tr class="line_expansion match">
- <template v-if="isMatchLineLeft || isMatchLineRight">
- <diff-expansion-cell
- :file-hash="fileHash"
- :context-lines-path="contextLinesPath"
- :line="line.left"
- :is-top="isTop"
- :is-bottom="isBottom"
- :colspan="6"
- />
- </template>
- </tr>
-</template>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
index cdc6db791f0..13cd0651ff2 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
@@ -55,27 +55,15 @@ export default {
return utils.isHighlighted(state, line, this.isCommented);
},
}),
- isContextLineLeft() {
- return utils.isContextLine(this.line.left?.type);
- },
- isContextLineRight() {
- return utils.isContextLine(this.line.right?.type);
- },
classNameMap() {
return {
- [CONTEXT_LINE_CLASS_NAME]: this.isContextLineLeft,
+ [CONTEXT_LINE_CLASS_NAME]: this.line.isContextLineLeft,
[PARALLEL_DIFF_VIEW_TYPE]: true,
};
},
parallelViewLeftLineType() {
return utils.parallelViewLeftLineType(this.line, this.isHighlighted);
},
- isMatchLineLeft() {
- return utils.isMatchLine(this.line.left?.type);
- },
- isMatchLineRight() {
- return utils.isMatchLine(this.line.right?.type);
- },
coverageState() {
return this.fileLineCoverage(this.filePath, this.line.right.new_line);
},
@@ -107,40 +95,19 @@ export default {
shouldShowCommentButtonLeft() {
return utils.shouldShowCommentButton(
this.isLeftHover,
- this.isContextLineLeft,
- this.isMetaLineLeft,
- this.hasDiscussionsLeft,
+ this.line.isContextLineLeft,
+ this.line.isMetaLineLeft,
+ this.line.hasDiscussionsLeft,
);
},
shouldShowCommentButtonRight() {
return utils.shouldShowCommentButton(
this.isRightHover,
- this.isContextLineRight,
- this.isMetaLineRight,
- this.hasDiscussionsRight,
+ this.line.isContextLineRight,
+ this.line.isMetaLineRight,
+ this.line.hasDiscussionsRight,
);
},
- hasDiscussionsLeft() {
- return utils.hasDiscussions(this.line.left);
- },
- hasDiscussionsRight() {
- return utils.hasDiscussions(this.line.right);
- },
- lineHrefOld() {
- return utils.lineHref(this.line.left);
- },
- lineHrefNew() {
- return utils.lineHref(this.line.right);
- },
- lineCode() {
- return utils.lineCode(this.line);
- },
- isMetaLineLeft() {
- return utils.isMetaLine(this.line.left?.type);
- },
- isMetaLineRight() {
- return utils.isMetaLine(this.line.right?.type);
- },
},
mounted() {
this.scrollToLineIfNeededParallel(this.line);
@@ -203,7 +170,7 @@ export default {
@mouseover="handleMouseMove"
@mouseout="handleMouseMove"
>
- <template v-if="line.left && !isMatchLineLeft">
+ <template v-if="line.left && !line.isMatchLineLeft">
<td ref="oldTd" :class="classNameMapCellLeft" class="diff-line-num old_line">
<span
v-if="shouldRenderCommentButton"
@@ -227,12 +194,12 @@ export default {
v-if="line.left.old_line"
ref="lineNumberRefOld"
:data-linenumber="line.left.old_line"
- :href="lineHrefOld"
- @click="setHighlightedRow(lineCode)"
+ :href="line.lineHrefOld"
+ @click="setHighlightedRow(line.lineCode)"
>
</a>
<diff-gutter-avatars
- v-if="hasDiscussionsLeft"
+ v-if="line.hasDiscussionsLeft"
:discussions="line.left.discussions"
:discussions-expanded="line.left.discussionsExpanded"
@toggleLineDiscussions="
@@ -259,7 +226,7 @@ export default {
<td class="line-coverage left-side empty-cell"></td>
<td class="line_content with-coverage parallel left-side empty-cell"></td>
</template>
- <template v-if="line.right && !isMatchLineRight">
+ <template v-if="line.right && !line.isMatchLineRight">
<td ref="newTd" :class="classNameMapCellRight" class="diff-line-num new_line">
<span
v-if="shouldRenderCommentButton"
@@ -283,12 +250,12 @@ export default {
v-if="line.right.new_line"
ref="lineNumberRefNew"
:data-linenumber="line.right.new_line"
- :href="lineHrefNew"
- @click="setHighlightedRow(lineCode)"
+ :href="line.lineHrefNew"
+ @click="setHighlightedRow(line.lineCode)"
>
</a>
<diff-gutter-avatars
- v-if="hasDiscussionsRight"
+ v-if="line.hasDiscussionsRight"
:discussions="line.right.discussions"
:discussions-expanded="line.right.discussionsExpanded"
@toggleLineDiscussions="
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
index 46a691ad22d..67b599fe163 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
@@ -1,18 +1,18 @@
<script>
import { mapGetters, mapState } from 'vuex';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
-import ParallelDraftCommentRow from '~/batch_comments/components/parallel_draft_comment_row.vue';
+import DraftNote from '~/batch_comments/components/draft_note.vue';
import parallelDiffTableRow from './parallel_diff_table_row.vue';
-import parallelDiffCommentRow from './parallel_diff_comment_row.vue';
-import parallelDiffExpansionRow from './parallel_diff_expansion_row.vue';
+import DiffCommentCell from './diff_comment_cell.vue';
+import DiffExpansionCell from './diff_expansion_cell.vue';
import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
export default {
components: {
- parallelDiffExpansionRow,
+ DiffExpansionCell,
parallelDiffTableRow,
- parallelDiffCommentRow,
- ParallelDraftCommentRow,
+ DiffCommentCell,
+ DraftNote,
},
mixins: [draftCommentsMixin],
props: {
@@ -66,14 +66,21 @@ export default {
</colgroup>
<tbody>
<template v-for="(line, index) in diffLines">
- <parallel-diff-expansion-row
+ <tr
+ v-if="line.isMatchLineLeft || line.isMatchLineRight"
:key="`expand-${index}`"
- :file-hash="diffFile.file_hash"
- :context-lines-path="diffFile.context_lines_path"
- :line="line"
- :is-top="index === 0"
- :is-bottom="index + 1 === diffLinesLength"
- />
+ class="line_expansion match"
+ >
+ <td colspan="6" class="text-center gl-font-regular">
+ <diff-expansion-cell
+ :file-hash="diffFile.file_hash"
+ :context-lines-path="diffFile.context_lines_path"
+ :line="line.left"
+ :is-top="index === 0"
+ :is-bottom="index + 1 === diffLinesLength"
+ />
+ </td>
+ </tr>
<parallel-diff-table-row
:key="line.line_code"
:file-hash="diffFile.file_hash"
@@ -82,21 +89,53 @@ export default {
:is-bottom="index + 1 === diffLinesLength"
:is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine"
/>
- <parallel-diff-comment-row
+ <tr
+ v-if="line.renderCommentRow"
:key="`dcr-${line.line_code || index}`"
- :line="line"
- :diff-file-hash="diffFile.file_hash"
- :line-index="index"
- :help-page-path="helpPagePath"
- :has-draft-left="hasParallelDraftLeft(diffFile.file_hash, line) || false"
- :has-draft-right="hasParallelDraftRight(diffFile.file_hash, line) || false"
- />
- <parallel-draft-comment-row
+ :class="line.commentRowClasses"
+ class="notes_holder"
+ >
+ <td class="notes-content parallel old" colspan="3">
+ <diff-comment-cell
+ v-if="line.left"
+ :line="line.left"
+ :diff-file-hash="diffFile.file_hash"
+ :help-page-path="helpPagePath"
+ :has-draft="line.left.hasDraft"
+ line-position="left"
+ />
+ </td>
+ <td class="notes-content parallel new" colspan="3">
+ <diff-comment-cell
+ v-if="line.right"
+ :line="line.right"
+ :diff-file-hash="diffFile.file_hash"
+ :line-index="index"
+ :help-page-path="helpPagePath"
+ :has-draft="line.right.hasDraft"
+ line-position="right"
+ />
+ </td>
+ </tr>
+ <tr
v-if="shouldRenderParallelDraftRow(diffFile.file_hash, line)"
:key="`drafts-${index}`"
- :line="line"
- :diff-file-content-sha="diffFile.file_hash"
- />
+ :class="line.draftRowClasses"
+ class="notes_holder"
+ >
+ <td class="notes_line old"></td>
+ <td class="notes-content parallel old" colspan="2">
+ <div v-if="line.left && line.left.lineDraft.isDraft" class="content">
+ <draft-note :draft="line.left.lineDraft" :line="line.left" />
+ </div>
+ </td>
+ <td class="notes_line new"></td>
+ <td class="notes-content parallel new" colspan="2">
+ <div v-if="line.right && line.right.lineDraft.isDraft" class="content">
+ <draft-note :draft="line.right.lineDraft" :line="line.right" />
+ </div>
+ </td>
+ </tr>
</template>
</tbody>
</table>
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index dc97d9993da..79f8c08e389 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -73,6 +73,10 @@ export const ALERT_OVERFLOW_HIDDEN = 'overflow';
export const ALERT_MERGE_CONFLICT = 'merge-conflict';
export const ALERT_COLLAPSED_FILES = 'collapsed';
+// Diff File collapse types
+export const DIFF_FILE_AUTOMATIC_COLLAPSE = 'automatic';
+export const DIFF_FILE_MANUAL_COLLAPSE = 'manual';
+
// State machine states
export const STATE_IDLING = 'idle';
export const STATE_LOADING = 'loading';
@@ -91,3 +95,11 @@ export const RENAMED_DIFF_TRANSITIONS = {
[`${STATE_ERRORED}:${TRANSITION_LOAD_START}`]: STATE_LOADING,
[`${STATE_ERRORED}:${TRANSITION_ACKNOWLEDGE_ERROR}`]: STATE_IDLING,
};
+
+// MR Diffs known events
+export const EVT_EXPAND_ALL_FILES = 'mr:diffs:expandAllFiles';
+export const EVT_PERF_MARK_FILE_TREE_START = 'mr:diffs:perf:fileTreeStart';
+export const EVT_PERF_MARK_FILE_TREE_END = 'mr:diffs:perf:fileTreeEnd';
+export const EVT_PERF_MARK_DIFF_FILES_START = 'mr:diffs:perf:filesStart';
+export const EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN = 'mr:diffs:perf:firstFileShown';
+export const EVT_PERF_MARK_DIFF_FILES_END = 'mr:diffs:perf:filesEnd';
diff --git a/app/assets/javascripts/diffs/diff_file.js b/app/assets/javascripts/diffs/diff_file.js
index 933197a2c7f..a14a30b41a9 100644
--- a/app/assets/javascripts/diffs/diff_file.js
+++ b/app/assets/javascripts/diffs/diff_file.js
@@ -1,4 +1,9 @@
-import { DIFF_FILE_SYMLINK_MODE, DIFF_FILE_DELETED_MODE } from './constants';
+import {
+ DIFF_FILE_SYMLINK_MODE,
+ DIFF_FILE_DELETED_MODE,
+ DIFF_FILE_MANUAL_COLLAPSE,
+ DIFF_FILE_AUTOMATIC_COLLAPSE,
+} from './constants';
function fileSymlinkInformation(file, fileList) {
const duplicates = fileList.filter(iteratedFile => iteratedFile.file_hash === file.file_hash);
@@ -23,6 +28,7 @@ function collapsed(file) {
return {
automaticallyCollapsed: viewer.automaticallyCollapsed || viewer.collapsed || false,
+ manuallyCollapsed: null,
};
}
@@ -37,3 +43,19 @@ export function prepareRawDiffFile({ file, allFiles }) {
return file;
}
+
+export function collapsedType(file) {
+ const isManual = typeof file.viewer?.manuallyCollapsed === 'boolean';
+
+ return isManual ? DIFF_FILE_MANUAL_COLLAPSE : DIFF_FILE_AUTOMATIC_COLLAPSE;
+}
+
+export function isCollapsed(file) {
+ const type = collapsedType(file);
+ const collapsedStates = {
+ [DIFF_FILE_AUTOMATIC_COLLAPSE]: file.viewer?.automaticallyCollapsed || false,
+ [DIFF_FILE_MANUAL_COLLAPSE]: file.viewer?.manuallyCollapsed,
+ };
+
+ return collapsedStates[type];
+}
diff --git a/app/assets/javascripts/diffs/event_hub.js b/app/assets/javascripts/diffs/event_hub.js
new file mode 100644
index 00000000000..3e0c313f5e8
--- /dev/null
+++ b/app/assets/javascripts/diffs/event_hub.js
@@ -0,0 +1,3 @@
+import eventHubFactory from '~/helpers/event_hub_factory';
+
+export default eventHubFactory();
diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js
index 8699cd88a18..4ec24d452bf 100644
--- a/app/assets/javascripts/diffs/i18n.js
+++ b/app/assets/javascripts/diffs/i18n.js
@@ -1,5 +1,18 @@
import { __ } from '~/locale';
+export const GENERIC_ERROR = __('Something went wrong on our end. Please try again!');
+
export const DIFF_FILE_HEADER = {
optionsDropdownTitle: __('Options'),
};
+
+export const DIFF_FILE = {
+ blobView: __('You can %{linkStart}view the blob%{linkEnd} instead.'),
+ editInFork: __(
+ "You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.",
+ ),
+ fork: __('Fork'),
+ cancel: __('Cancel'),
+ autoCollapsed: __('Files with large changes are collapsed by default.'),
+ expand: __('Expand file'),
+};
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 966b706fc31..91c4c51487f 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -8,7 +8,8 @@ import { __, s__ } from '~/locale';
import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
import TreeWorker from '../workers/tree_worker';
-import eventHub from '../../notes/event_hub';
+import notesEventHub from '../../notes/event_hub';
+import eventHub from '../event_hub';
import {
getDiffPositionByLineCode,
getNoteFormData,
@@ -40,8 +41,14 @@ import {
DIFF_WHITESPACE_COOKIE_NAME,
SHOW_WHITESPACE,
NO_SHOW_WHITESPACE,
+ DIFF_FILE_MANUAL_COLLAPSE,
+ DIFF_FILE_AUTOMATIC_COLLAPSE,
+ EVT_PERF_MARK_FILE_TREE_START,
+ EVT_PERF_MARK_FILE_TREE_END,
+ EVT_PERF_MARK_DIFF_FILES_START,
} from '../constants';
import { diffViewerModes } from '~/ide/constants';
+import { isCollapsed } from '../diff_file';
export const setBaseConfig = ({ commit }, options) => {
const {
@@ -75,6 +82,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
commit(types.SET_BATCH_LOADING, true);
commit(types.SET_RETRIEVING_BATCHES, true);
+ eventHub.$emit(EVT_PERF_MARK_DIFF_FILES_START);
const getBatch = (page = 1) =>
axios
@@ -136,9 +144,11 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
};
commit(types.SET_LOADING, true);
+ eventHub.$emit(EVT_PERF_MARK_FILE_TREE_START);
worker.addEventListener('message', ({ data }) => {
commit(types.SET_TREE_DATA, data);
+ eventHub.$emit(EVT_PERF_MARK_FILE_TREE_END);
worker.terminate();
});
@@ -212,7 +222,7 @@ export const assignDiscussionsToDiff = (
}
Vue.nextTick(() => {
- eventHub.$emit('scrollToDiscussion');
+ notesEventHub.$emit('scrollToDiscussion');
});
};
@@ -237,10 +247,17 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi
}
if (file.viewer.automaticallyCollapsed) {
- eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`);
+ notesEventHub.$emit(`loadCollapsedDiff/${file.file_hash}`);
scrollToElement(document.getElementById(file.file_hash));
+ } else if (file.viewer.manuallyCollapsed) {
+ commit(types.SET_FILE_COLLAPSED, {
+ filePath: file.file_path,
+ collapsed: false,
+ trigger: DIFF_FILE_AUTOMATIC_COLLAPSE,
+ });
+ notesEventHub.$emit('scrollToDiscussion');
} else {
- eventHub.$emit('scrollToDiscussion');
+ notesEventHub.$emit('scrollToDiscussion');
}
}
}
@@ -252,8 +269,7 @@ export const startRenderDiffsQueue = ({ state, commit }) => {
const nextFile = state.diffFiles.find(
file =>
!file.renderIt &&
- (file.viewer &&
- (!file.viewer.automaticallyCollapsed || file.viewer.name !== diffViewerModes.text)),
+ (file.viewer && (!isCollapsed(file) || file.viewer.name !== diffViewerModes.text)),
);
if (nextFile) {
@@ -355,10 +371,6 @@ export const loadCollapsedDiff = ({ commit, getters, state }, file) =>
});
});
-export const expandAllFiles = ({ commit }) => {
- commit(types.EXPAND_ALL_FILES);
-};
-
/**
* Toggles the file discussions after user clicked on the toggle discussions button.
*
@@ -480,7 +492,7 @@ export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = fals
historyPushState(mergeUrlParams({ w }, window.location.href));
}
- eventHub.$emit('refetchDiffData');
+ notesEventHub.$emit('refetchDiffData');
};
export const toggleFileFinder = ({ commit }, visible) => {
@@ -531,15 +543,20 @@ export const setExpandedDiffLines = ({ commit, state }, { file, data }) => {
}),
}),
};
+ const unifiedDiffLinesEnabled = window.gon?.features?.unifiedDiffLines;
const currentDiffLinesKey =
- state.diffViewType === INLINE_DIFF_VIEW_TYPE ? INLINE_DIFF_LINES_KEY : PARALLEL_DIFF_LINES_KEY;
+ state.diffViewType === INLINE_DIFF_VIEW_TYPE || unifiedDiffLinesEnabled
+ ? INLINE_DIFF_LINES_KEY
+ : PARALLEL_DIFF_LINES_KEY;
const hiddenDiffLinesKey =
state.diffViewType === INLINE_DIFF_VIEW_TYPE ? PARALLEL_DIFF_LINES_KEY : INLINE_DIFF_LINES_KEY;
- commit(types.SET_HIDDEN_VIEW_DIFF_FILE_LINES, {
- filePath: file.file_path,
- lines: expandedDiffLines[hiddenDiffLinesKey],
- });
+ if (!unifiedDiffLinesEnabled) {
+ commit(types.SET_HIDDEN_VIEW_DIFF_FILE_LINES, {
+ filePath: file.file_path,
+ lines: expandedDiffLines[hiddenDiffLinesKey],
+ });
+ }
if (expandedDiffLines[currentDiffLinesKey].length > MAX_RENDERING_DIFF_LINES) {
let index = START_RENDERING_INDEX;
@@ -621,7 +638,7 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d
.then(({ data }) => {
const lines = data.map((line, index) =>
prepareLineForRenamedFile({
- diffViewType: state.diffViewType,
+ diffViewType: window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType,
line,
diffFile,
index,
@@ -633,6 +650,7 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d
viewer: {
...diffFile.alternate_viewer,
automaticallyCollapsed: false,
+ manuallyCollapsed: false,
},
});
commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: diffFile.file_path, lines });
@@ -641,8 +659,9 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d
});
}
-export const setFileCollapsed = ({ commit }, { filePath, collapsed }) =>
- commit(types.SET_FILE_COLLAPSED, { filePath, collapsed });
+export const setFileCollapsedByUser = ({ commit }, { filePath, collapsed }) => {
+ commit(types.SET_FILE_COLLAPSED, { filePath, collapsed, trigger: DIFF_FILE_MANUAL_COLLAPSE });
+};
export const setSuggestPopoverDismissed = ({ commit, state }) =>
axios
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index 91425c7825b..9ee73998177 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -8,8 +8,16 @@ 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.automaticallyCollapsed);
+export const whichCollapsedTypes = state => {
+ const automatic = state.diffFiles.some(file => file.viewer?.automaticallyCollapsed);
+ const manual = state.diffFiles.some(file => file.viewer?.manuallyCollapsed);
+
+ return {
+ any: automatic || manual,
+ automatic,
+ manual,
+ };
+};
export const commitId = state => (state.commit && state.commit.id ? state.commit.id : null);
@@ -157,10 +165,13 @@ export const fileLineCoverage = state => (file, line) => {
export const currentDiffIndex = state =>
Math.max(0, state.diffFiles.findIndex(diff => diff.file_hash === state.currentDiffFileId));
-export const diffLines = state => file => {
- if (state.diffViewType === INLINE_DIFF_VIEW_TYPE) {
+export const diffLines = state => (file, unifiedDiffComponents) => {
+ if (!unifiedDiffComponents && state.diffViewType === INLINE_DIFF_VIEW_TYPE) {
return null;
}
- return parallelizeDiffLines(file.highlighted_diff_lines || []);
+ return parallelizeDiffLines(
+ file.highlighted_diff_lines || [],
+ state.diffViewType === INLINE_DIFF_VIEW_TYPE,
+ );
};
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index 5dba2e9d10d..19a9e65edc9 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -13,7 +13,6 @@ export const SET_MERGE_REQUEST_DIFFS = 'SET_MERGE_REQUEST_DIFFS';
export const TOGGLE_LINE_HAS_FORM = 'TOGGLE_LINE_HAS_FORM';
export const ADD_CONTEXT_LINES = 'ADD_CONTEXT_LINES';
export const ADD_COLLAPSED_DIFFS = 'ADD_COLLAPSED_DIFFS';
-export const EXPAND_ALL_FILES = 'EXPAND_ALL_FILES';
export const RENDER_FILE = 'RENDER_FILE';
export const SET_LINE_DISCUSSIONS_FOR_FILE = 'SET_LINE_DISCUSSIONS_FOR_FILE';
export const REMOVE_LINE_DISCUSSIONS_FOR_FILE = 'REMOVE_LINE_DISCUSSIONS_FOR_FILE';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 13ecf6a997d..096c4f69439 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -1,6 +1,10 @@
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { INLINE_DIFF_VIEW_TYPE } from '../constants';
+import {
+ DIFF_FILE_MANUAL_COLLAPSE,
+ DIFF_FILE_AUTOMATIC_COLLAPSE,
+ INLINE_DIFF_VIEW_TYPE,
+} from '../constants';
import {
findDiffFile,
addLineReferences,
@@ -16,6 +20,12 @@ function updateDiffFilesInState(state, files) {
return Object.assign(state, { diffFiles: files });
}
+function renderFile(file) {
+ Object.assign(file, {
+ renderIt: true,
+ });
+}
+
export default {
[types.SET_BASE_CONFIG](state, options) {
const {
@@ -81,9 +91,7 @@ export default {
},
[types.RENDER_FILE](state, file) {
- Object.assign(file, {
- renderIt: true,
- });
+ renderFile(file);
},
[types.SET_MERGE_REQUEST_DIFFS](state, mergeRequestDiffs) {
@@ -168,16 +176,6 @@ export default {
Object.assign(selectedFile, { ...newFileData });
},
- [types.EXPAND_ALL_FILES](state) {
- state.diffFiles.forEach(file => {
- Object.assign(file, {
- viewer: Object.assign(file.viewer, {
- automaticallyCollapsed: false,
- }),
- });
- });
- },
-
[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) {
const { latestDiff } = state;
@@ -351,11 +349,24 @@ export default {
file.isShowingFullFile = true;
file.isLoadingFullFile = false;
},
- [types.SET_FILE_COLLAPSED](state, { filePath, collapsed }) {
+ [types.SET_FILE_COLLAPSED](
+ state,
+ { filePath, collapsed, trigger = DIFF_FILE_AUTOMATIC_COLLAPSE },
+ ) {
const file = state.diffFiles.find(f => f.file_path === filePath);
if (file && file.viewer) {
- file.viewer.automaticallyCollapsed = collapsed;
+ if (trigger === DIFF_FILE_MANUAL_COLLAPSE) {
+ file.viewer.automaticallyCollapsed = false;
+ file.viewer.manuallyCollapsed = collapsed;
+ } else if (trigger === DIFF_FILE_AUTOMATIC_COLLAPSE) {
+ file.viewer.automaticallyCollapsed = collapsed;
+ file.viewer.manuallyCollapsed = null;
+ }
+ }
+
+ if (file && !collapsed) {
+ renderFile(file);
}
},
[types.SET_HIDDEN_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) {
@@ -367,8 +378,13 @@ export default {
},
[types.SET_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) {
const file = state.diffFiles.find(f => f.file_path === filePath);
- const currentDiffLinesKey =
- state.diffViewType === 'inline' ? 'highlighted_diff_lines' : 'parallel_diff_lines';
+ let currentDiffLinesKey;
+
+ if (window.gon?.features?.unifiedDiffLines || state.diffViewType === 'inline') {
+ currentDiffLinesKey = 'highlighted_diff_lines';
+ } else {
+ currentDiffLinesKey = 'parallel_diff_lines';
+ }
file[currentDiffLinesKey] = lines;
},
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 69330ffae2f..f87f57c32c3 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -36,9 +36,12 @@ export const isMeta = line => ['match', 'new-nonewline', 'old-nonewline'].includ
*
* @param {Object[]} diffLines - inline diff lines
*
+ * @param {Boolean} inline - is inline context or not
+ *
* @returns {Object[]} parallel lines
*/
-export const parallelizeDiffLines = (diffLines = []) => {
+
+export const parallelizeDiffLines = (diffLines, inline) => {
let freeRightIndex = null;
const lines = [];
@@ -57,7 +60,7 @@ export const parallelizeDiffLines = (diffLines = []) => {
}
index += 1;
} else if (isAdded(line)) {
- if (freeRightIndex !== null) {
+ if (freeRightIndex !== null && !inline) {
// If an old line came before this without a line on the right, this
// line can be put to the right of it.
lines[freeRightIndex].right = line;
@@ -664,6 +667,7 @@ export const generateTreeList = files => {
addedLines: file.added_lines,
removedLines: file.removed_lines,
parentPath: parent ? `${parent.path}/` : '/',
+ submodule: file.submodule,
});
} else {
Object.assign(entry, {
diff --git a/app/assets/javascripts/diffs/utils/performance.js b/app/assets/javascripts/diffs/utils/performance.js
new file mode 100644
index 00000000000..dcde6f4ecc4
--- /dev/null
+++ b/app/assets/javascripts/diffs/utils/performance.js
@@ -0,0 +1,80 @@
+import { performanceMarkAndMeasure } from '~/performance/utils';
+import {
+ MR_DIFFS_MARK_FILE_TREE_START,
+ MR_DIFFS_MARK_FILE_TREE_END,
+ MR_DIFFS_MARK_DIFF_FILES_START,
+ MR_DIFFS_MARK_FIRST_DIFF_FILE_SHOWN,
+ MR_DIFFS_MARK_DIFF_FILES_END,
+ MR_DIFFS_MEASURE_FILE_TREE_DONE,
+ MR_DIFFS_MEASURE_DIFF_FILES_DONE,
+} from '../../performance/constants';
+
+import eventHub from '../event_hub';
+import {
+ EVT_PERF_MARK_FILE_TREE_START,
+ EVT_PERF_MARK_FILE_TREE_END,
+ EVT_PERF_MARK_DIFF_FILES_START,
+ EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN,
+ EVT_PERF_MARK_DIFF_FILES_END,
+} from '../constants';
+
+function treeStart() {
+ performanceMarkAndMeasure({
+ mark: MR_DIFFS_MARK_FILE_TREE_START,
+ });
+}
+
+function treeEnd() {
+ performanceMarkAndMeasure({
+ mark: MR_DIFFS_MARK_FILE_TREE_END,
+ measures: [
+ {
+ name: MR_DIFFS_MEASURE_FILE_TREE_DONE,
+ start: MR_DIFFS_MARK_FILE_TREE_START,
+ end: MR_DIFFS_MARK_FILE_TREE_END,
+ },
+ ],
+ });
+}
+
+function filesStart() {
+ performanceMarkAndMeasure({
+ mark: MR_DIFFS_MARK_DIFF_FILES_START,
+ });
+}
+
+function filesEnd() {
+ performanceMarkAndMeasure({
+ mark: MR_DIFFS_MARK_DIFF_FILES_END,
+ measures: [
+ {
+ name: MR_DIFFS_MEASURE_DIFF_FILES_DONE,
+ start: MR_DIFFS_MARK_DIFF_FILES_START,
+ end: MR_DIFFS_MARK_DIFF_FILES_END,
+ },
+ ],
+ });
+}
+
+function firstFile() {
+ performanceMarkAndMeasure({
+ mark: MR_DIFFS_MARK_FIRST_DIFF_FILE_SHOWN,
+ });
+}
+
+export const diffsApp = {
+ instrument() {
+ eventHub.$on(EVT_PERF_MARK_FILE_TREE_START, treeStart);
+ eventHub.$on(EVT_PERF_MARK_FILE_TREE_END, treeEnd);
+ eventHub.$on(EVT_PERF_MARK_DIFF_FILES_START, filesStart);
+ eventHub.$on(EVT_PERF_MARK_DIFF_FILES_END, filesEnd);
+ eventHub.$on(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, firstFile);
+ },
+ deinstrument() {
+ eventHub.$off(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, firstFile);
+ eventHub.$off(EVT_PERF_MARK_DIFF_FILES_END, filesEnd);
+ eventHub.$off(EVT_PERF_MARK_DIFF_FILES_START, filesStart);
+ eventHub.$off(EVT_PERF_MARK_FILE_TREE_END, treeEnd);
+ eventHub.$off(EVT_PERF_MARK_FILE_TREE_START, treeStart);
+ },
+};
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index f65e22a31c5..69961d2e07a 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -7,6 +7,7 @@ import csrf from './lib/utils/csrf';
import axios from './lib/utils/axios_utils';
import { n__, __ } from '~/locale';
import { getFilename } from '~/lib/utils/file_upload';
+import { spriteIcon } from '~/lib/utils/common_utils';
Dropzone.autoDiscover = false;
@@ -25,7 +26,7 @@ function getErrorMessage(res) {
export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
const divHover = '<div class="div-dropzone-hover"></div>';
- const iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>';
+ const iconPaperclip = spriteIcon('paperclip', 'div-dropzone-icon s24');
const $attachButton = form.find('.button-attach-file');
const $attachingFileMessage = form.find('.attaching-file-message');
const $cancelButton = form.find('.button-cancel-uploading-files');
diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js
index e52e64d4c2d..e7535c211db 100644
--- a/app/assets/javascripts/editor/editor_lite.js
+++ b/app/assets/javascripts/editor/editor_lite.js
@@ -6,6 +6,7 @@ import { registerLanguages } from '~/ide/utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { clearDomElement } from './utils';
import { EDITOR_LITE_INSTANCE_ERROR_NO_EL, URI_PREFIX } from './constants';
+import { uuids } from '~/diffs/utils/uuids';
export default class Editor {
constructor(options = {}) {
@@ -72,7 +73,7 @@ export default class Editor {
el = undefined,
blobPath = '',
blobContent = '',
- blobGlobalId = '',
+ blobGlobalId = uuids()[0],
extensions = [],
...instanceOptions
} = {}) {
diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue
index 4c6d233c4d2..e7697f14802 100644
--- a/app/assets/javascripts/environments/components/container.vue
+++ b/app/assets/javascripts/environments/components/container.vue
@@ -69,7 +69,7 @@ export default {
<div class="environments-container">
<gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" label="Loading environments" />
- <slot name="emptyState"></slot>
+ <slot name="empty-state"></slot>
<div v-if="!isLoading && environments.length > 0" class="table-holder">
<environment-table
diff --git a/app/assets/javascripts/environments/components/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue
index 29aab268fd3..2eb2be351b3 100644
--- a/app/assets/javascripts/environments/components/delete_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue
@@ -1,29 +1,35 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import { GlTooltipDirective, GlModal } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
export default {
id: 'delete-environment-modal',
name: 'DeleteEnvironmentModal',
-
components: {
GlModal,
},
-
directives: {
GlTooltip: GlTooltipDirective,
},
-
props: {
environment: {
type: Object,
required: true,
},
},
-
computed: {
+ primaryProps() {
+ return {
+ text: s__('Environments|Delete environment'),
+ attributes: [{ variant: 'danger' }],
+ };
+ },
+ cancelProps() {
+ return {
+ text: s__('Cancel'),
+ };
+ },
confirmDeleteMessage() {
return sprintf(
s__(
@@ -35,8 +41,12 @@ export default {
false,
);
},
+ modalTitle() {
+ return sprintf(s__(`Environments|Delete '%{environmentName}'?`), {
+ environmentName: this.environment.name,
+ });
+ },
},
-
methods: {
onSubmit() {
eventHub.$emit('deleteEnvironment', this.environment);
@@ -47,20 +57,12 @@ export default {
<template>
<gl-modal
- :id="$options.id"
- :footer-primary-button-text="s__('Environments|Delete environment')"
- footer-primary-button-variant="danger"
- @submit="onSubmit"
+ :modal-id="$options.id"
+ :action-primary="primaryProps"
+ :action-cancel="cancelProps"
+ :title="modalTitle"
+ @primary="onSubmit"
>
- <template #header>
- <h4 class="modal-title d-flex mw-100">
- {{ __('Delete') }}
- <span v-gl-tooltip :title="environment.name" class="text-truncate mx-1 flex-fill">
- {{ environment.name }}?
- </span>
- </h4>
- </template>
-
<p>{{ confirmDeleteMessage }}</p>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_delete.vue b/app/assets/javascripts/environments/components/environment_delete.vue
index 039b40a3596..75d92d3295d 100644
--- a/app/assets/javascripts/environments/components/environment_delete.vue
+++ b/app/assets/javascripts/environments/components/environment_delete.vue
@@ -1,21 +1,20 @@
<script>
/**
* Renders the delete button that allows deleting a stopped environment.
- * Used in the environments table and the environment detail view.
+ * Used in the environments table.
*/
-import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
-import LoadingButton from '../../vue_shared/components/loading_button.vue';
export default {
components: {
- GlIcon,
- LoadingButton,
+ GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
+ GlModalDirective,
},
props: {
environment: {
@@ -54,16 +53,16 @@ export default {
};
</script>
<template>
- <loading-button
+ <gl-button
v-gl-tooltip="{ id: $options.deleteEnvironmentTooltipId }"
+ v-gl-modal-directive="'delete-environment-modal'"
:loading="isLoading"
:title="title"
:aria-label="title"
- container-class="btn btn-danger d-none d-md-block"
- data-toggle="modal"
- data-target="#delete-environment-modal"
+ class="gl-display-none gl-display-md-block"
+ variant="danger"
+ category="primary"
+ icon="remove"
@click="onClick"
- >
- <gl-icon name="remove" />
- </loading-button>
+ />
</template>
diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue
index ff74f81c98e..8e100623199 100644
--- a/app/assets/javascripts/environments/components/environment_stop.vue
+++ b/app/assets/javascripts/environments/components/environment_stop.vue
@@ -4,7 +4,7 @@
* Used in environments table.
*/
-import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
@@ -14,6 +14,7 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
+ GlModalDirective,
},
props: {
environment: {
@@ -54,14 +55,13 @@ export default {
<template>
<gl-button
v-gl-tooltip="{ id: $options.stopEnvironmentTooltipId }"
+ v-gl-modal-directive="'stop-environment-modal'"
:loading="isLoading"
:title="title"
:aria-label="title"
icon="stop"
category="primary"
variant="danger"
- data-toggle="modal"
- data-target="#stop-environment-modal"
@click="onClick"
/>
</template>
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index 9bafc7ed153..c1b9ba755a6 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -228,7 +228,7 @@ export default {
:deploy-boards-help-path="deployBoardsHelpPath"
@onChangePage="onChangePage"
>
- <template v-if="!isLoading && state.environments.length === 0" #emptyState>
+ <template v-if="!isLoading && state.environments.length === 0" #empty-state>
<empty-state :help-path="helpPagePath" />
</template>
</container>
diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue
index f0dafe0620e..0832822520d 100644
--- a/app/assets/javascripts/environments/components/stop_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue
@@ -1,15 +1,14 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
-import { GlSprintf, GlTooltipDirective } from '@gitlab/ui';
-import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
+import { GlSprintf, GlTooltipDirective, GlModal } from '@gitlab/ui';
import eventHub from '../event_hub';
+import { __, s__ } from '~/locale';
export default {
id: 'stop-environment-modal',
name: 'StopEnvironmentModal',
components: {
- GlModal: DeprecatedModal2,
+ GlModal,
GlSprintf,
},
@@ -24,6 +23,20 @@ export default {
},
},
+ computed: {
+ primaryProps() {
+ return {
+ text: s__('Environments|Stop environment'),
+ attributes: [{ variant: 'danger' }],
+ };
+ },
+ cancelProps() {
+ return {
+ text: __('Cancel'),
+ };
+ },
+ },
+
methods: {
onSubmit() {
eventHub.$emit('stopEnvironment', this.environment);
@@ -34,18 +47,23 @@ export default {
<template>
<gl-modal
- :id="$options.id"
- :footer-primary-button-text="s__('Environments|Stop environment')"
- footer-primary-button-variant="danger"
- @submit="onSubmit"
+ :modal-id="$options.id"
+ :action-primary="primaryProps"
+ :action-cancel="cancelProps"
+ @primary="onSubmit"
>
- <template #header>
- <h4 class="modal-title d-flex mw-100">
- Stopping
- <span v-gl-tooltip :title="environment.name" class="text-truncate ml-1 mr-1 flex-fill">
- {{ environment.name }}?
- </span>
- </h4>
+ <template #modal-title>
+ <gl-sprintf :message="s__('Environments|Stopping %{environmentName}')">
+ <template #environmentName>
+ <span
+ v-gl-tooltip
+ :title="environment.name"
+ class="gl-text-truncate gl-ml-2 gl-mr-2 gl-flex-fill"
+ >
+ {{ environment.name }}?
+ </span>
+ </template>
+ </gl-sprintf>
</template>
<p>{{ s__('Environments|Are you sure you want to stop this environment?') }}</p>
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index 5dee3ef3ffe..8272260705b 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -8,9 +8,9 @@ import {
GlBadge,
GlAlert,
GlSprintf,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
- GlDeprecatedDropdownDivider,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
GlIcon,
} from '@gitlab/ui';
import { deprecatedCreateFlash as createFlash } from '~/flash';
@@ -43,9 +43,9 @@ export default {
GlBadge,
GlAlert,
GlSprintf,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
- GlDeprecatedDropdownDivider,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
TimeAgoTooltip,
},
directives: {
@@ -331,38 +331,38 @@ export default {
</gl-button>
</form>
</div>
- <gl-deprecated-dropdown
+ <gl-dropdown
text="Options"
class="error-details-options d-md-none"
right
:disabled="issueUpdateInProgress"
>
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
data-qa-selector="update_ignore_status_button"
@click="onIgnoreStatusUpdate"
- >{{ ignoreBtnLabel }}</gl-deprecated-dropdown-item
+ >{{ ignoreBtnLabel }}</gl-dropdown-item
>
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
data-qa-selector="update_resolve_status_button"
@click="onResolveStatusUpdate"
- >{{ resolveBtnLabel }}</gl-deprecated-dropdown-item
+ >{{ resolveBtnLabel }}</gl-dropdown-item
>
- <gl-deprecated-dropdown-divider />
- <gl-deprecated-dropdown-item
+ <gl-dropdown-divider />
+ <gl-dropdown-item
v-if="error.gitlabIssuePath"
data-qa-selector="view_issue_button"
:href="error.gitlabIssuePath"
variant="success"
- >{{ __('View issue') }}</gl-deprecated-dropdown-item
+ >{{ __('View issue') }}</gl-dropdown-item
>
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
v-if="!error.gitlabIssuePath"
:loading="issueCreationInProgress"
data-qa-selector="create_issue_button"
@click="createIssue"
- >{{ __('Create issue') }}</gl-deprecated-dropdown-item
+ >{{ __('Create issue') }}</gl-dropdown-item
>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
</div>
</div>
<div>
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index da41dc4c9d9..7ccb6253508 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -8,9 +8,9 @@ import {
GlLoadingIcon,
GlTable,
GlFormInput,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
- GlDeprecatedDropdownDivider,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
GlTooltipDirective,
GlPagination,
} from '@gitlab/ui';
@@ -72,9 +72,9 @@ export default {
components: {
GlEmptyState,
GlButton,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
- GlDeprecatedDropdownDivider,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
GlIcon,
GlLink,
GlLoadingIcon,
@@ -233,30 +233,30 @@ export default {
>
<div class="search-box flex-fill mb-1 mb-md-0">
<div class="filtered-search-box mb-0">
- <gl-deprecated-dropdown
+ <gl-dropdown
:text="__('Recent searches')"
class="filtered-search-history-dropdown-wrapper"
- toggle-class="filtered-search-history-dropdown-toggle-button"
+ toggle-class="filtered-search-history-dropdown-toggle-button gl-shadow-none! gl-border-r-gray-200! gl-border-1! gl-rounded-0!"
:disabled="loading"
>
<div v-if="!$options.hasLocalStorage" class="px-3">
{{ __('This feature requires local storage to be enabled') }}
</div>
<template v-else-if="recentSearches.length > 0">
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
v-for="searchQuery in recentSearches"
:key="searchQuery"
@click="setSearchText(searchQuery)"
>{{ searchQuery }}
- </gl-deprecated-dropdown-item>
- <gl-deprecated-dropdown-divider />
- <gl-deprecated-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches"
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches"
>{{ __('Clear recent searches') }}
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
</template>
<div v-else class="px-3">{{ __("You don't have any recent searches") }}</div>
- </gl-deprecated-dropdown>
- <div class="filtered-search-input-container flex-fill">
+ </gl-dropdown>
+ <div class="filtered-search-input-container gl-flex-fill-1">
<gl-form-input
v-model="errorSearchQuery"
class="pl-2 filtered-search"
@@ -280,49 +280,44 @@ export default {
</div>
</div>
- <gl-deprecated-dropdown
+ <gl-dropdown
:text="$options.statusFilters[statusFilter]"
class="status-dropdown mx-md-1 mb-1 mb-md-0"
- menu-class="dropdown"
:disabled="loading"
+ right
>
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
v-for="(label, status) in $options.statusFilters"
:key="status"
@click="filterErrors(status, label)"
>
<span class="d-flex">
<gl-icon
- class="flex-shrink-0 append-right-4"
+ class="gl-new-dropdown-item-check-icon"
:class="{ invisible: !isCurrentStatusFilter(status) }"
name="mobile-issue-close"
/>
{{ label }}
</span>
- </gl-deprecated-dropdown-item>
- </gl-deprecated-dropdown>
+ </gl-dropdown-item>
+ </gl-dropdown>
- <gl-deprecated-dropdown
- :text="$options.sortFields[sortField]"
- left
- :disabled="loading"
- menu-class="dropdown"
- >
- <gl-deprecated-dropdown-item
+ <gl-dropdown :text="$options.sortFields[sortField]" right :disabled="loading">
+ <gl-dropdown-item
v-for="(label, field) in $options.sortFields"
:key="field"
@click="sortByField(field)"
>
<span class="d-flex">
<gl-icon
- class="flex-shrink-0 append-right-4"
+ class="gl-new-dropdown-item-check-icon"
:class="{ invisible: !isCurrentSortField(field) }"
name="mobile-issue-close"
/>
{{ label }}
</span>
- </gl-deprecated-dropdown-item>
- </gl-deprecated-dropdown>
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
<div v-if="loading" class="py-3">
diff --git a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue
index 561b2565880..2323370a3aa 100644
--- a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue
@@ -1,11 +1,11 @@
<script>
-import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { getDisplayName } from '../utils';
export default {
components: {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownItem,
},
props: {
dropdownLabel: {
@@ -52,22 +52,22 @@ export default {
<div :class="{ 'gl-show-field-errors': isProjectInvalid }">
<label class="label-bold" for="project-dropdown">{{ __('Project') }}</label>
<div class="row">
- <gl-deprecated-dropdown
+ <gl-dropdown
id="project-dropdown"
class="col-8 col-md-9 gl-pr-0"
:disabled="!hasProjects"
menu-class="w-100 mw-100"
- toggle-class="dropdown-menu-toggle w-100 gl-field-error-outline"
+ toggle-class="dropdown-menu-toggle gl-field-error-outline"
:text="dropdownLabel"
>
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
v-for="project in projects"
:key="`${project.organizationSlug}.${project.slug}`"
class="w-100"
@click="$emit('select-project', project)"
- >{{ getDisplayName(project) }}</gl-deprecated-dropdown-item
+ >{{ getDisplayName(project) }}</gl-dropdown-item
>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
</div>
<p v-if="isProjectInvalid" class="js-project-dropdown-error gl-field-error">
{{ invalidProjectLabel }}
diff --git a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
index 686399843dd..bf47d7cf7c0 100644
--- a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
+++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
@@ -2,6 +2,7 @@
import {
GlFormGroup,
GlFormInput,
+ GlFormInputGroup,
GlModal,
GlTooltipDirective,
GlLoadingIcon,
@@ -17,6 +18,7 @@ export default {
components: {
GlFormGroup,
GlFormInput,
+ GlFormInputGroup,
GlModal,
ModalCopyButton,
GlIcon,
@@ -167,63 +169,47 @@ export default {
</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">
+ <gl-form-group :label="$options.translations.apiUrlLabelText" label-for="api-url">
+ <gl-form-input-group id="api-url" :value="unleashApiUrl" readonly type="text" name="api-url">
+ <template #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
+ </template>
+ </gl-form-input-group>
+ </gl-form-group>
+ <gl-form-group :label="$options.translations.instanceIdLabelText" label-for="instance_id">
+ <gl-form-input-group>
+ <gl-form-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"
+ class="gl-absolute gl-align-self-center gl-right-5 gl-mr-7"
/>
- <div class="input-group-append">
+ <template #append>
<modal-copy-button
:text="instanceId"
:title="$options.translations.instanceIdCopyText"
:modal-id="modalId"
:disabled="isRotating"
- class="input-group-text"
/>
- </div>
- </div>
- </div>
+ </template>
+ </gl-form-input-group>
+ </gl-form-group>
<div
v-if="hasRotateError"
- class="text-danger d-flex align-items-center font-weight-normal mb-2"
+ class="gl-text-red-500 gl-display-flex gl-align-items-center gl-font-weight-normal gl-mb-3"
>
- <gl-icon name="warning" class="mr-1" />
+ <gl-icon name="warning" class="gl-mr-2" />
<span>{{ $options.translations.instanceIdRegenerateError }}</span>
</div>
<callout
diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
index 26b18f9bf5a..9ec65bb0b43 100644
--- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
+++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
@@ -103,7 +103,7 @@ export default {
>
{{ $options.translations.newFlagAlert }}
</gl-alert>
- <gl-loading-icon v-if="isLoading" />
+ <gl-loading-icon v-if="isLoading" size="xl" class="gl-mt-7" />
<template v-else-if="!isLoading && !hasError">
<gl-alert v-if="deprecatedAndEditable" variant="warning" :dismissible="false" class="gl-my-5">
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
index eb7046a3d9b..340cf68793f 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -278,7 +278,7 @@ export default {
/>
</feature-flags-tab>
<template #tabs-end>
- <div
+ <li
class="gl-display-none gl-display-md-flex gl-align-items-center gl-flex-fill-1 gl-justify-content-end"
>
<gl-button
@@ -313,7 +313,7 @@ export default {
>
{{ s__('FeatureFlags|New feature flag') }}
</gl-button>
- </div>
+ </li>
</template>
</gl-tabs>
</div>
diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue
index 3c1944d91bd..36ebf893486 100644
--- a/app/assets/javascripts/feature_flags/components/form.vue
+++ b/app/assets/javascripts/feature_flags/components/form.vue
@@ -3,7 +3,7 @@ import Vue from 'vue';
import { memoize, isString, cloneDeep, isNumber, uniqueId } from 'lodash';
import {
GlButton,
- GlDeprecatedBadge as GlBadge,
+ GlBadge,
GlTooltip,
GlTooltipDirective,
GlFormTextarea,
@@ -11,10 +11,8 @@ import {
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';
@@ -89,7 +87,6 @@ export default {
},
},
inject: {
- projectId: {},
featureFlagIssuesEndpoint: {
default: '',
},
@@ -124,7 +121,6 @@ export default {
formStrategies: cloneDeep(this.strategies),
newScope: '',
- userLists: [],
};
},
computed: {
@@ -155,17 +151,6 @@ export default {
);
},
},
- 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) {
@@ -346,7 +331,6 @@ export default {
:key="keyFor(strategy)"
:strategy="strategy"
:index="index"
- :user-lists="userLists"
@change="onFormStrategyChange($event, index)"
@delete="deleteStrategy(strategy)"
/>
@@ -488,7 +472,9 @@ export default {
:target="rolloutPercentageId(index)"
>
{{
- s__('FeatureFlags|Percent rollout must be a whole number between 0 and 100')
+ s__(
+ 'FeatureFlags|Percent rollout must be an integer number between 0 and 100',
+ )
}}
</gl-tooltip>
<span class="ml-1">%</span>
diff --git a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue
index 020a0d43096..4daf8b4e6bf 100644
--- a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue
+++ b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue
@@ -17,8 +17,8 @@ export default {
},
},
i18n: {
- percentageDescription: __('Enter a whole number between 0 and 100'),
- percentageInvalid: __('Percent rollout must be a whole number between 0 and 100'),
+ percentageDescription: __('Enter an integer number number between 0 and 100'),
+ percentageInvalid: __('Percent rollout must be an integer number between 0 and 100'),
percentageLabel: __('Percentage'),
stickinessDescription: __('Consistency guarantee method'),
stickinessLabel: __('Based on'),
diff --git a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue
index ec97e8b1350..6a57e9a8759 100644
--- a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue
+++ b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue
@@ -1,11 +1,20 @@
<script>
-import { GlFormSelect } from '@gitlab/ui';
+import { debounce } from 'lodash';
+import { createNamespacedHelpers } from 'vuex';
+import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { s__ } from '~/locale';
import ParameterFormGroup from './parameter_form_group.vue';
+const { mapActions, mapGetters, mapState } = createNamespacedHelpers('userLists');
+
+const { fetchUserLists, setFilter } = mapActions(['fetchUserLists', 'setFilter']);
+
export default {
components: {
- GlFormSelect,
+ GlDropdown,
+ GlDropdownItem,
+ GlLoadingIcon,
+ GlSearchBoxByType,
ParameterFormGroup,
},
props: {
@@ -13,34 +22,40 @@ export default {
required: true,
type: Object,
},
- userLists: {
- required: false,
- type: Array,
- default: () => [],
- },
},
translations: {
- rolloutUserListLabel: s__('FeatureFlag|List'),
+ rolloutUserListLabel: s__('FeatureFlag|User List'),
rolloutUserListDescription: s__('FeatureFlag|Select a user list'),
rolloutUserListNoListError: s__('FeatureFlag|There are no configured user lists'),
+ defaultDropdownText: s__('FeatureFlags|No user list selected'),
},
computed: {
- userListOptions() {
- return this.userLists.map(({ name, id }) => ({ value: id, text: name }));
- },
- hasUserLists() {
- return this.userListOptions.length > 0;
- },
+ ...mapGetters(['hasUserLists', 'isLoading', 'hasError', 'userListOptions']),
+ ...mapState(['filter', 'userLists']),
userListId() {
- return this.strategy?.userListId ?? '';
+ return this.strategy?.userList?.id ?? '';
},
+ dropdownText() {
+ return this.strategy?.userList?.name ?? this.$options.translations.defaultDropdownText;
+ },
+ },
+ mounted() {
+ fetchUserLists.apply(this);
},
methods: {
+ setFilter: debounce(setFilter, 250),
+ fetchUserLists: debounce(fetchUserLists, 250),
onUserListChange(list) {
this.$emit('change', {
- userListId: list,
+ userList: list,
});
},
+ isSelectedUserList({ id }) {
+ return id === this.userListId;
+ },
+ setFocus() {
+ this.$refs.searchBox.focusInput();
+ },
},
};
</script>
@@ -52,12 +67,26 @@ export default {
:description="hasUserLists ? $options.translations.rolloutUserListDescription : ''"
>
<template #default="{ inputId }">
- <gl-form-select
- :id="inputId"
- :value="userListId"
- :options="userListOptions"
- @change="onUserListChange"
- />
+ <gl-dropdown :id="inputId" :text="dropdownText" @shown="setFocus">
+ <gl-search-box-by-type
+ ref="searchBox"
+ class="gl-m-3"
+ :value="filter"
+ @input="setFilter"
+ @focus="fetchUserLists"
+ @keyup="fetchUserLists"
+ />
+ <gl-loading-icon v-if="isLoading" />
+ <gl-dropdown-item
+ v-for="list in userLists"
+ :key="list.id"
+ :is-checked="isSelectedUserList(list)"
+ is-check-item
+ @click="onUserListChange(list)"
+ >
+ {{ list.name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</template>
</parameter-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
index d262769c891..91e1b85d66e 100644
--- a/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue
+++ b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue
@@ -16,9 +16,9 @@ export default {
},
},
i18n: {
- rolloutPercentageDescription: __('Enter a whole number between 0 and 100'),
+ rolloutPercentageDescription: __('Enter an integer number between 0 and 100'),
rolloutPercentageInvalid: s__(
- 'FeatureFlags|Percent rollout must be a whole number between 0 and 100',
+ 'FeatureFlags|Percent rollout must be an integer number between 0 and 100',
),
rolloutPercentageLabel: s__('FeatureFlag|Percentage'),
},
diff --git a/app/assets/javascripts/feature_flags/edit.js b/app/assets/javascripts/feature_flags/edit.js
index b4d2111acf3..05a9bbce654 100644
--- a/app/assets/javascripts/feature_flags/edit.js
+++ b/app/assets/javascripts/feature_flags/edit.js
@@ -22,7 +22,7 @@ export default () => {
} = el.dataset;
return new Vue({
- store: createStore({ endpoint, path: featureFlagsPath }),
+ store: createStore({ endpoint, projectId, path: featureFlagsPath }),
el,
provide: {
environmentsScopeDocsPath,
diff --git a/app/assets/javascripts/feature_flags/new.js b/app/assets/javascripts/feature_flags/new.js
index a1efbd87ec4..8e18213cc03 100644
--- a/app/assets/javascripts/feature_flags/new.js
+++ b/app/assets/javascripts/feature_flags/new.js
@@ -22,7 +22,7 @@ export default () => {
return new Vue({
el,
- store: createStore({ endpoint, path: featureFlagsPath }),
+ store: createStore({ endpoint, projectId, path: featureFlagsPath }),
provide: {
environmentsScopeDocsPath,
strategyTypeDocsPagePath,
diff --git a/app/assets/javascripts/feature_flags/store/edit/index.js b/app/assets/javascripts/feature_flags/store/edit/index.js
index f737e0517fc..81edc791924 100644
--- a/app/assets/javascripts/feature_flags/store/edit/index.js
+++ b/app/assets/javascripts/feature_flags/store/edit/index.js
@@ -1,4 +1,5 @@
import Vuex from 'vuex';
+import userLists from '../gitlab_user_list';
import state from './state';
import * as actions from './actions';
import mutations from './mutations';
@@ -8,4 +9,7 @@ export default data =>
actions,
mutations,
state: state(data),
+ modules: {
+ userLists: userLists(data),
+ },
});
diff --git a/app/assets/javascripts/feature_flags/store/gitlab_user_list/actions.js b/app/assets/javascripts/feature_flags/store/gitlab_user_list/actions.js
new file mode 100644
index 00000000000..d4587713fed
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/gitlab_user_list/actions.js
@@ -0,0 +1,17 @@
+import Api from '~/api';
+import * as types from './mutation_types';
+
+const getErrorMessages = error => [].concat(error?.response?.data?.message ?? error.message);
+
+export const fetchUserLists = ({ commit, state: { filter, projectId } }) => {
+ commit(types.FETCH_USER_LISTS);
+
+ return Api.searchFeatureFlagUserLists(projectId, filter)
+ .then(({ data }) => commit(types.RECEIVE_USER_LISTS_SUCCESS, data))
+ .catch(error => commit(types.RECEIVE_USER_LISTS_ERROR, getErrorMessages(error)));
+};
+
+export const setFilter = ({ commit, dispatch }, filter) => {
+ commit(types.SET_FILTER, filter);
+ return dispatch('fetchUserLists');
+};
diff --git a/app/assets/javascripts/feature_flags/store/gitlab_user_list/getters.js b/app/assets/javascripts/feature_flags/store/gitlab_user_list/getters.js
new file mode 100644
index 00000000000..164b0980120
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/gitlab_user_list/getters.js
@@ -0,0 +1,11 @@
+import statuses from './status';
+
+export const userListOptions = ({ userLists }) =>
+ userLists.map(({ name, id }) => ({ value: id, text: name }));
+
+export const hasUserLists = ({ userLists, status }) =>
+ [statuses.START, statuses.LOADING].indexOf(status) > -1 || userLists.length > 0;
+
+export const isLoading = ({ status }) => status === statuses.LOADING;
+
+export const hasError = ({ status }) => status === statuses.ERROR;
diff --git a/app/assets/javascripts/feature_flags/store/gitlab_user_list/index.js b/app/assets/javascripts/feature_flags/store/gitlab_user_list/index.js
new file mode 100644
index 00000000000..d25b574981f
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/gitlab_user_list/index.js
@@ -0,0 +1,12 @@
+import state from './state';
+import mutations from './mutations';
+import * as actions from './actions';
+import * as getters from './getters';
+
+export default data => ({
+ state: state(data),
+ actions,
+ getters,
+ mutations,
+ namespaced: true,
+});
diff --git a/app/assets/javascripts/feature_flags/store/gitlab_user_list/mutation_types.js b/app/assets/javascripts/feature_flags/store/gitlab_user_list/mutation_types.js
new file mode 100644
index 00000000000..0fe12f06785
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/gitlab_user_list/mutation_types.js
@@ -0,0 +1,5 @@
+export const FETCH_USER_LISTS = 'FETCH_USER_LISTS';
+export const RECEIVE_USER_LISTS_SUCCESS = 'RECEIVE_USER_LISTS_SUCCESS';
+export const RECEIVE_USER_LISTS_ERROR = 'RECEIVE_USER_LISTS_ERROR';
+
+export const SET_FILTER = 'SET_FILTER';
diff --git a/app/assets/javascripts/feature_flags/store/gitlab_user_list/mutations.js b/app/assets/javascripts/feature_flags/store/gitlab_user_list/mutations.js
new file mode 100644
index 00000000000..bd7c6f68009
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/gitlab_user_list/mutations.js
@@ -0,0 +1,19 @@
+import statuses from './status';
+import * as types from './mutation_types';
+
+export default {
+ [types.FETCH_USER_LISTS](state) {
+ state.status = statuses.LOADING;
+ },
+ [types.RECEIVE_USER_LISTS_SUCCESS](state, lists) {
+ state.userLists = lists;
+ state.status = statuses.IDLE;
+ },
+ [types.RECEIVE_USER_LISTS_ERROR](state, error) {
+ state.error = error;
+ state.status = statuses.ERROR;
+ },
+ [types.SET_FILTER](state, filter) {
+ state.filter = filter;
+ },
+};
diff --git a/app/assets/javascripts/feature_flags/store/gitlab_user_list/state.js b/app/assets/javascripts/feature_flags/store/gitlab_user_list/state.js
new file mode 100644
index 00000000000..2664ec794fc
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/gitlab_user_list/state.js
@@ -0,0 +1,9 @@
+import statuses from './status';
+
+export default ({ projectId }) => ({
+ projectId,
+ userLists: [],
+ filter: '',
+ status: statuses.START,
+ error: '',
+});
diff --git a/app/assets/javascripts/feature_flags/store/gitlab_user_list/status.js b/app/assets/javascripts/feature_flags/store/gitlab_user_list/status.js
new file mode 100644
index 00000000000..67f153eb58e
--- /dev/null
+++ b/app/assets/javascripts/feature_flags/store/gitlab_user_list/status.js
@@ -0,0 +1,6 @@
+export default {
+ START: 'START',
+ LOADING: 'LOADING',
+ IDLE: 'IDLE',
+ ERROR: 'ERROR',
+};
diff --git a/app/assets/javascripts/feature_flags/store/helpers.js b/app/assets/javascripts/feature_flags/store/helpers.js
index db6da815abf..d42e5c504db 100644
--- a/app/assets/javascripts/feature_flags/store/helpers.js
+++ b/app/assets/javascripts/feature_flags/store/helpers.js
@@ -174,7 +174,7 @@ export const mapStrategiesToViewModel = strategiesFromRails =>
id: s.id,
name: s.name,
parameters: mapStrategiesParametersToViewModel(s.parameters),
- userListId: s.user_list?.id,
+ userList: s.user_list,
// eslint-disable-next-line no-underscore-dangle
shouldBeDestroyed: Boolean(s._destroy),
scopes: mapStrategyScopesToView(s.scopes),
@@ -197,7 +197,7 @@ const mapStrategyToRails = strategy => {
};
if (strategy.name === ROLLOUT_STRATEGY_GITLAB_USER_LIST) {
- mappedStrategy.user_list_id = strategy.userListId;
+ mappedStrategy.user_list_id = strategy.userList.id;
}
return mappedStrategy;
};
diff --git a/app/assets/javascripts/feature_flags/store/new/index.js b/app/assets/javascripts/feature_flags/store/new/index.js
index f737e0517fc..81edc791924 100644
--- a/app/assets/javascripts/feature_flags/store/new/index.js
+++ b/app/assets/javascripts/feature_flags/store/new/index.js
@@ -1,4 +1,5 @@
import Vuex from 'vuex';
+import userLists from '../gitlab_user_list';
import state from './state';
import * as actions from './actions';
import mutations from './mutations';
@@ -8,4 +9,7 @@ export default data =>
actions,
mutations,
state: state(data),
+ modules: {
+ userLists: userLists(data),
+ },
});
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 51077296e20..7d4df25816b 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
@@ -12,6 +12,7 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
tag: __('Yes or No'),
lowercaseValueOnSubmit: true,
capitalizeTokenValue: true,
+ hideNotEqual: true,
},
conditions: [
{
@@ -30,20 +31,6 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
value: __('No'),
operator: '=',
},
- {
- url: 'not[wip]=yes',
- replacementUrl: 'not[draft]=yes',
- tokenKey: 'draft',
- value: __('Yes'),
- operator: '!=',
- },
- {
- url: 'not[wip]=no',
- replacementUrl: 'not[draft]=no',
- tokenKey: 'draft',
- value: __('No'),
- operator: '!=',
- },
],
};
@@ -109,43 +96,41 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
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 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 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',
- };
+ const deployedAfterToken = {
+ formattedKey: __('Deployed-after'),
+ key: 'deployed-after',
+ type: 'string',
+ param: '',
+ symbol: '',
+ icon: 'clock',
+ tag: 'deployed_after',
+ };
- IssuableTokenKeys.tokenKeys.push(environmentToken, deployedBeforeToken, deployedAfterToken);
+ IssuableTokenKeys.tokenKeys.push(environmentToken, deployedBeforeToken, deployedAfterToken);
- IssuableTokenKeys.tokenKeysWithAlternative.push(
- environmentToken,
- deployedBeforeToken,
- deployedAfterToken,
- );
- }
+ IssuableTokenKeys.tokenKeysWithAlternative.push(
+ environmentToken,
+ deployedBeforeToken,
+ deployedAfterToken,
+ );
};
diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
index 4652dfe71c3..30f412e590f 100644
--- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
+++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
@@ -22,7 +22,7 @@ export default class DropdownAjaxFilter extends FilteredSearchDropdown {
ajaxFilterConfig() {
return {
- endpoint: `${gon.relative_url_root || ''}${this.endpoint}`,
+ endpoint: this.endpoint,
searchKey: 'search',
searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate,
@@ -33,9 +33,11 @@ export default class DropdownAjaxFilter extends FilteredSearchDropdown {
}
itemClicked(e) {
- super.itemClicked(e, selected =>
- selected.querySelector('.dropdown-light-content').innerText.trim(),
- );
+ super.itemClicked(e, selected => {
+ const title = selected.querySelector('.dropdown-light-content').innerText.trim();
+
+ return DropdownUtils.getEscapedText(title);
+ });
}
renderContent(forceShowList = false) {
diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js
index 1bbd33b6258..8fee3385de1 100644
--- a/app/assets/javascripts/filtered_search/dropdown_operator.js
+++ b/app/assets/javascripts/filtered_search/dropdown_operator.js
@@ -39,7 +39,7 @@ export default class DropdownOperator extends FilteredSearchDropdown {
this.dispatchInputEvent();
}
- renderContent(forceShowList = false) {
+ renderContent(forceShowList = false, dropdownName = '') {
const dropdownData = [
{
tag: 'equal',
@@ -48,8 +48,9 @@ export default class DropdownOperator extends FilteredSearchDropdown {
help: __('is'),
},
];
+ const dropdownToken = this.tokenKeys.searchByKey(dropdownName.toLowerCase());
- if (gon.features?.notIssuableQueries) {
+ if (gon.features?.notIssuableQueries && !dropdownToken?.hideNotEqual) {
dropdownData.push({
tag: 'not-equal',
type: 'string',
diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js
index 0fb1828fc98..9a23ff25eac 100644
--- a/app/assets/javascripts/filtered_search/dropdown_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js
@@ -5,7 +5,7 @@ export default class DropdownUser extends DropdownAjaxFilter {
constructor(options = {}) {
super({
...options,
- endpoint: '/-/autocomplete/users.json',
+ endpoint: `${gon.relative_url_root || ''}/-/autocomplete/users.json`,
symbol: '@',
});
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
index f7ce2ea01e0..8626e1a3d18 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
@@ -83,16 +83,16 @@ export default class FilteredSearchDropdown {
}
}
- render(forceRenderContent = false, forceShowList = false) {
+ render(forceRenderContent = false, forceShowList = false, hideNotEqual = false) {
this.setAsDropdown();
const currentHook = this.getCurrentHook();
const firstTimeInitialized = currentHook === null;
if (firstTimeInitialized || forceRenderContent) {
- this.renderContent(forceShowList);
+ this.renderContent(forceShowList, hideNotEqual);
} else if (currentHook.list.list.id !== this.dropdown.id) {
- this.renderContent(forceShowList);
+ this.renderContent(forceShowList, hideNotEqual);
}
}
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 762383f5a1d..d446e32394b 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -12,6 +12,7 @@ export default class FilteredSearchDropdownManager {
runnerTagsEndpoint = '',
labelsEndpoint = '',
milestonesEndpoint = '',
+ iterationsEndpoint = '',
releasesEndpoint = '',
environmentsEndpoint = '',
epicsEndpoint = '',
@@ -28,6 +29,7 @@ export default class FilteredSearchDropdownManager {
this.runnerTagsEndpoint = removeTrailingSlash(runnerTagsEndpoint);
this.labelsEndpoint = removeTrailingSlash(labelsEndpoint);
this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint);
+ this.iterationsEndpoint = removeTrailingSlash(iterationsEndpoint);
this.releasesEndpoint = removeTrailingSlash(releasesEndpoint);
this.epicsEndpoint = removeTrailingSlash(epicsEndpoint);
this.environmentsEndpoint = removeTrailingSlash(environmentsEndpoint);
@@ -107,7 +109,7 @@ export default class FilteredSearchDropdownManager {
this.mapping[key].reference.setOffset(offset);
}
- load(key, firstLoad = false) {
+ load(key, firstLoad = false, dropdownKey = '') {
const mappingKey = this.mapping[key];
const glClass = mappingKey.gl;
const { element } = mappingKey;
@@ -141,12 +143,12 @@ export default class FilteredSearchDropdownManager {
}
this.updateDropdownOffset(key);
- mappingKey.reference.render(firstLoad, forceShowList);
+ mappingKey.reference.render(firstLoad, forceShowList, dropdownKey);
this.currentDropdown = key;
}
- loadDropdown(dropdownName = '') {
+ loadDropdown(dropdownName = '', dropdownKey = '') {
let firstLoad = false;
if (!this.droplab) {
@@ -155,7 +157,7 @@ export default class FilteredSearchDropdownManager {
}
if (dropdownName === DROPDOWN_TYPE.operator) {
- this.load(dropdownName, firstLoad);
+ this.load(dropdownName, firstLoad, dropdownKey);
return;
}
@@ -167,7 +169,7 @@ export default class FilteredSearchDropdownManager {
if (shouldOpenFilterDropdown || shouldOpenHintDropdown) {
const key = match && match.key ? match.key : DROPDOWN_TYPE.hint;
- this.load(key, firstLoad);
+ this.load(key, firstLoad, dropdownKey);
}
}
@@ -200,11 +202,11 @@ export default class FilteredSearchDropdownManager {
dropdownToOpen = hasOperator && lastOperatorToken ? dropdownName : DROPDOWN_TYPE.operator;
}
- this.loadDropdown(dropdownToOpen);
+ this.loadDropdown(dropdownToOpen, dropdownName);
} else if (lastToken) {
const lastOperator = FilteredSearchVisualTokens.getLastTokenOperator();
// Token has been initialized into an object because it has a value
- this.loadDropdown(lastOperator ? lastToken.key : DROPDOWN_TYPE.operator);
+ this.loadDropdown(lastOperator ? lastToken.key : DROPDOWN_TYPE.operator, lastToken.key);
} else {
this.loadDropdown(DROPDOWN_TYPE.hint);
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 261532f8867..921d686bb28 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -52,16 +52,24 @@ export default class FilteredSearchManager {
this.placeholder = placeholder;
this.anchor = anchor;
- const { multipleAssignees } = this.filteredSearchInput.dataset;
+ const {
+ multipleAssignees,
+ epicsEndpoint,
+ iterationsEndpoint,
+ } = this.filteredSearchInput.dataset;
+
if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) {
this.filteredSearchTokenKeys.enableMultipleAssignees();
}
- const { epicsEndpoint } = this.filteredSearchInput.dataset;
if (!epicsEndpoint && this.filteredSearchTokenKeys.removeEpicToken) {
this.filteredSearchTokenKeys.removeEpicToken();
}
+ if (!iterationsEndpoint && this.filteredSearchTokenKeys.removeIterationToken) {
+ this.filteredSearchTokenKeys.removeIterationToken();
+ }
+
this.recentSearchesStore = new RecentSearchesStore({
isLocalStorageAvailable: RecentSearchesService.isAvailable(),
allowedKeys: this.filteredSearchTokenKeys.getKeys(),
@@ -112,6 +120,7 @@ export default class FilteredSearchManager {
releasesEndpoint = '',
environmentsEndpoint = '',
epicsEndpoint = '',
+ iterationsEndpoint = '',
} = this.filteredSearchInput.dataset;
this.dropdownManager = new FilteredSearchDropdownManager({
@@ -121,6 +130,7 @@ export default class FilteredSearchManager {
releasesEndpoint,
environmentsEndpoint,
epicsEndpoint,
+ iterationsEndpoint,
tokenizer: this.tokenizer,
page: this.page,
isGroup: this.isGroup,
diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js
index 5b03e1d19db..1998bf4358a 100644
--- a/app/assets/javascripts/frequent_items/index.js
+++ b/app/assets/javascripts/frequent_items/index.js
@@ -16,63 +16,53 @@ 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 (!navEl) {
+ if (!el || !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/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 62948f74aaa..202f04f98f6 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -1,9 +1,12 @@
import $ from 'jquery';
import '~/lib/utils/jquery_at_who';
import { escape, template } from 'lodash';
+import { s__ } from '~/locale';
import SidebarMediator from '~/sidebar/sidebar_mediator';
+import { isUserBusy } from '~/set_status_modal/utils';
import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache';
+import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from './lib/utils/common_utils';
import * as Emoji from '~/emoji';
@@ -39,6 +42,7 @@ export function membersBeforeSave(members) {
title: sanitize(title),
search: sanitize(`${member.username} ${member.name}`),
icon: avatarIcon,
+ availability: member.availability,
};
});
}
@@ -52,6 +56,7 @@ export const defaultAutocompleteConfig = {
milestones: true,
labels: true,
snippets: true,
+ vulnerabilities: true,
};
class GfmAutoComplete {
@@ -59,6 +64,7 @@ class GfmAutoComplete {
this.dataSources = dataSources;
this.cachedData = {};
this.isLoadingData = {};
+ this.previousQuery = '';
}
setup(input, enableMap = defaultAutocompleteConfig) {
@@ -253,13 +259,17 @@ class GfmAutoComplete {
alias: 'users',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
- const { avatarTag, username, title, icon } = value;
+ const { avatarTag, username, title, icon, availability } = value;
if (username != null) {
tmpl = GfmAutoComplete.Members.templateFunction({
avatarTag,
username,
title,
icon,
+ availabilityStatus:
+ availability && isUserBusy(availability)
+ ? `<span class="gl-text-gray-500"> ${s__('UserAvailability|(Busy)')}</span>`
+ : '',
});
}
return tmpl;
@@ -554,7 +564,7 @@ class GfmAutoComplete {
}
getDefaultCallbacks() {
- const fetchData = this.fetchData.bind(this);
+ const self = this;
return {
sorter(query, items, searchKey) {
@@ -567,7 +577,14 @@ class GfmAutoComplete {
},
filter(query, data, searchKey) {
if (GfmAutoComplete.isLoading(data)) {
- fetchData(this.$inputor, this.at);
+ self.fetchData(this.$inputor, this.at);
+ return data;
+ } else if (
+ GfmAutoComplete.isTypeWithBackendFiltering(this.at) &&
+ self.previousQuery !== query
+ ) {
+ self.fetchData(this.$inputor, this.at, query);
+ self.previousQuery = query;
return data;
}
return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
@@ -615,13 +632,22 @@ class GfmAutoComplete {
};
}
- fetchData($input, at) {
+ fetchData($input, at, search) {
if (this.isLoadingData[at]) return;
this.isLoadingData[at] = true;
const dataSource = this.dataSources[GfmAutoComplete.atTypeMap[at]];
- if (this.cachedData[at]) {
+ if (GfmAutoComplete.isTypeWithBackendFiltering(at)) {
+ axios
+ .get(dataSource, { params: { search } })
+ .then(({ data }) => {
+ this.loadData($input, at, data);
+ })
+ .catch(() => {
+ this.isLoadingData[at] = false;
+ });
+ } else if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]);
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
this.loadEmojiData($input, at).catch(() => {});
@@ -707,7 +733,9 @@ class GfmAutoComplete {
// https://github.com/ichord/At.js
const atSymbolsWithBar = Object.keys(controllers)
.join('|')
- .replace(/[$]/, '\\$&');
+ .replace(/[$]/, '\\$&')
+ .replace(/([[\]:])/g, '\\$1');
+
const atSymbolsWithoutBar = Object.keys(controllers).join('');
const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop();
const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
@@ -738,9 +766,14 @@ GfmAutoComplete.atTypeMap = {
'~': 'labels',
'%': 'milestones',
'/': 'commands',
+ '[vulnerability:': 'vulnerabilities',
$: 'snippets',
};
+GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
+GfmAutoComplete.isTypeWithBackendFiltering = type =>
+ GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[type]);
+
function findEmoji(name) {
return Emoji.searchEmoji(name, { match: 'contains', raw: true }).sort((a, b) => {
if (a.index !== b.index) {
@@ -775,8 +808,10 @@ GfmAutoComplete.Emoji = {
};
// Team Members
GfmAutoComplete.Members = {
- templateFunction({ avatarTag, username, title, icon }) {
- return `<li>${avatarTag} ${username} <small>${escape(title)}</small> ${icon}</li>`;
+ templateFunction({ avatarTag, username, title, icon, availabilityStatus }) {
+ return `<li>${avatarTag} ${username} <small>${escape(
+ title,
+ )}${availabilityStatus}</small> ${icon}</li>`;
},
};
GfmAutoComplete.Labels = {
diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js
index a262fbd9ac3..5487aeb9391 100644
--- a/app/assets/javascripts/graphql_shared/utils.js
+++ b/app/assets/javascripts/graphql_shared/utils.js
@@ -7,6 +7,10 @@
* @returns {Number}
*/
export const getIdFromGraphQLId = (gid = '') =>
- parseInt((gid || '').replace(/gid:\/\/gitlab\/.*\//g, ''), 10) || null;
+ parseInt(`${gid}`.replace(/gid:\/\/gitlab\/.*\//g, ''), 10) || null;
-export default {};
+export const MutationOperationMode = {
+ Append: 'APPEND',
+ Remove: 'REMOVE',
+ Replace: 'REPLACE',
+};
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index 871f5c9a845..e057012a246 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -3,9 +3,8 @@
import $ from 'jquery';
import 'vendor/jquery.scrollTo';
-import { GlLoadingIcon } from '@gitlab/ui';
-import { s__, sprintf } from '~/locale';
-import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+import { GlLoadingIcon, GlModal } from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
import { HIDDEN_CLASS } from '~/lib/utils/constants';
import { getParameterByName } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
@@ -16,8 +15,8 @@ import groupsComponent from './groups.vue';
export default {
components: {
- DeprecatedModal,
groupsComponent,
+ GlModal,
GlLoadingIcon,
},
props: {
@@ -49,13 +48,30 @@ export default {
isLoading: true,
isSearchEmpty: false,
searchEmptyMessage: '',
- showModal: false,
- groupLeaveConfirmationMessage: '',
targetGroup: null,
targetParentGroup: null,
};
},
computed: {
+ primaryProps() {
+ return {
+ text: __('Leave group'),
+ attributes: [{ variant: 'warning' }, { category: 'primary' }],
+ };
+ },
+ cancelProps() {
+ return {
+ text: __('Cancel'),
+ };
+ },
+ groupLeaveConfirmationMessage() {
+ if (!this.targetGroup) {
+ return '';
+ }
+ return sprintf(s__('GroupsTree|Are you sure you want to leave the "%{fullName}" group?'), {
+ fullName: this.targetGroup.fullName,
+ });
+ },
groups() {
return this.store.getGroups();
},
@@ -171,27 +187,17 @@ export default {
}
},
showLeaveGroupModal(group, parentGroup) {
- const { fullName } = group;
this.targetGroup = group;
this.targetParentGroup = parentGroup;
- this.showModal = true;
- this.groupLeaveConfirmationMessage = sprintf(
- s__('GroupsTree|Are you sure you want to leave the "%{fullName}" group?'),
- { fullName },
- );
- },
- hideLeaveGroupModal() {
- this.showModal = false;
},
leaveGroup() {
- this.showModal = false;
this.targetGroup.isBeingRemoved = true;
this.service
.leaveGroup(this.targetGroup.leavePath)
.then(res => {
$.scrollTo(0);
this.store.removeGroup(this.targetGroup, this.targetParentGroup);
- Flash(res.data.notice, 'notice');
+ this.$toast.show(res.data.notice);
})
.catch(err => {
let message = COMMON_STR.FAILURE;
@@ -245,21 +251,21 @@ export default {
class="loading-animation prepend-top-20"
/>
<groups-component
- v-if="!isLoading"
+ v-else
:groups="groups"
:search-empty="isSearchEmpty"
:search-empty-message="searchEmptyMessage"
:page-info="pageInfo"
:action="action"
/>
- <deprecated-modal
- v-show="showModal"
- :primary-button-label="__('Leave')"
+ <gl-modal
+ modal-id="leave-group-modal"
:title="__('Are you sure?')"
- :text="groupLeaveConfirmationMessage"
- kind="warning"
- @cancel="hideLeaveGroupModal"
- @submit="leaveGroup"
- />
+ :action-primary="primaryProps"
+ :action-cancel="cancelProps"
+ @primary="leaveGroup"
+ >
+ {{ groupLeaveConfirmationMessage }}
+ </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 44349b33386..6e99b6ad4fa 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -1,8 +1,7 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlLoadingIcon, GlBadge } from '@gitlab/ui';
+import { GlLoadingIcon, GlBadge, GlTooltipDirective } from '@gitlab/ui';
import { visitUrl } from '../../lib/utils/url_utility';
-import tooltip from '../../vue_shared/directives/tooltip';
import identicon from '../../vue_shared/components/identicon.vue';
import eventHub from '../event_hub';
import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '../constants';
@@ -17,7 +16,7 @@ import { showLearnGitLabGroupItemPopover } from '~/onboarding_issues';
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
GlBadge,
@@ -127,11 +126,10 @@ export default {
<div class="group-text flex-grow-1 flex-shrink-1">
<div class="d-flex align-items-center flex-wrap title namespace-title gl-mr-3">
<a
- v-tooltip
+ v-gl-tooltip.bottom
:href="group.relativePath"
:title="group.fullName"
class="no-expand gl-mt-3 gl-mr-3 gl-text-gray-900!"
- data-placement="bottom"
>{{
// ending bracket must be by closing tag to prevent
// link hover text-decoration from over-extending
diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue
index 2e92a608f76..ff52f5ef51c 100644
--- a/app/assets/javascripts/groups/components/item_actions.vue
+++ b/app/assets/javascripts/groups/components/item_actions.vue
@@ -1,15 +1,15 @@
<script>
-import { GlIcon } from '@gitlab/ui';
-import tooltip from '~/vue_shared/directives/tooltip';
+import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui';
import eventHub from '../event_hub';
import { COMMON_STR } from '../constants';
export default {
components: {
- GlIcon,
+ GlButton,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
+ GlModal: GlModalDirective,
},
props: {
parentGroup: {
@@ -45,32 +45,28 @@ export default {
<template>
<div class="controls d-flex justify-content-end">
- <a
+ <gl-button
v-if="group.canLeave"
- v-tooltip
- :href="group.leavePath"
+ v-gl-tooltip.top
+ v-gl-modal.leave-group-modal
:title="leaveBtnTitle"
: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"
- >
- <gl-icon name="leave" class="position-top-0" />
- </a>
- <a
+ size="small"
+ icon="leave"
+ class="leave-group gl-ml-3"
+ @click.stop="onLeaveGroup"
+ />
+ <gl-button
v-if="group.canEdit"
- v-tooltip
+ v-gl-tooltip.top
:href="group.editPath"
:title="editBtnTitle"
: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" />
- </a>
+ size="small"
+ icon="pencil"
+ class="edit-group gl-ml-3"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index c538934a37d..e2722d780dc 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -31,14 +31,16 @@ export const GROUP_VISIBILITY_TYPE = {
'Public - The group and any public projects can be viewed without any authentication.',
),
internal: __(
- 'Internal - The group and any internal projects can be viewed by any logged in user.',
+ 'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
),
private: __('Private - The group and its projects can only be viewed by members.'),
};
export const PROJECT_VISIBILITY_TYPE = {
public: __('Public - The project can be accessed without any authentication.'),
- internal: __('Internal - The project can be accessed by any logged in user.'),
+ internal: __(
+ 'Internal - The project can be accessed by any logged in user except external users.',
+ ),
private: __(
'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
),
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index 928f1fe409f..522f1d16df2 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -1,4 +1,6 @@
import Vue from 'vue';
+import { GlToast } from '@gitlab/ui';
+import UserCallout from '~/user_callout';
import { parseBoolean } from '~/lib/utils/common_utils';
import Translate from '../vue_shared/translate';
import GroupFilterableList from './groups_filterable_list';
@@ -16,6 +18,9 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
const containerEl = document.getElementById(containerId);
let dataEl;
+ // eslint-disable-next-line no-new
+ new UserCallout();
+
// Don't do anything if element doesn't exist (No groups)
// This is for when the user enters directly to the page via URL
if (!containerEl) {
@@ -31,6 +36,8 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
Vue.component('group-folder', groupFolderComponent);
Vue.component('group-item', groupItemComponent);
+ Vue.use(GlToast);
+
// eslint-disable-next-line no-new
new Vue({
el,
diff --git a/app/assets/javascripts/groups/members/index.js b/app/assets/javascripts/groups/members/index.js
index 3bbef14d199..cb28fb057c9 100644
--- a/app/assets/javascripts/groups/members/index.js
+++ b/app/assets/javascripts/groups/members/index.js
@@ -5,7 +5,7 @@ import { parseDataAttributes } from 'ee_else_ce/groups/members/utils';
import App from './components/app.vue';
import membersModule from '~/vuex_shared/modules/members';
-export const initGroupMembersApp = (el, tableFields, requestFormatter) => {
+export const initGroupMembersApp = (el, tableFields, tableAttrs, requestFormatter) => {
if (!el) {
return () => {};
}
@@ -18,6 +18,7 @@ export const initGroupMembersApp = (el, tableFields, requestFormatter) => {
...parseDataAttributes(el),
currentUserId: gon.current_user_id || null,
tableFields,
+ tableAttrs,
requestFormatter,
}),
});
diff --git a/app/assets/javascripts/groups/new_group_child.js b/app/assets/javascripts/groups/new_group_child.js
deleted file mode 100644
index bb2aea3ea76..00000000000
--- a/app/assets/javascripts/groups/new_group_child.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import { visitUrl } from '../lib/utils/url_utility';
-import DropLab from '../droplab/drop_lab';
-import ISetter from '../droplab/plugins/input_setter';
-
-const InputSetter = { ...ISetter };
-
-const NEW_PROJECT = 'new-project';
-const NEW_SUBGROUP = 'new-subgroup';
-
-export default class NewGroupChild {
- constructor(buttonWrapper) {
- this.buttonWrapper = buttonWrapper;
- this.newGroupChildButton = this.buttonWrapper.querySelector('.js-new-group-child');
- this.dropdownToggle = this.buttonWrapper.querySelector('.js-dropdown-toggle');
- this.dropdownList = this.buttonWrapper.querySelector('.dropdown-menu');
-
- this.newGroupPath = this.buttonWrapper.dataset.projectPath;
- this.subgroupPath = this.buttonWrapper.dataset.subgroupPath;
-
- this.init();
- }
-
- init() {
- this.initDroplab();
- this.bindEvents();
- }
-
- initDroplab() {
- this.droplab = new DropLab();
- this.droplab.init(
- this.dropdownToggle,
- this.dropdownList,
- [InputSetter],
- this.getDroplabConfig(),
- );
- }
-
- getDroplabConfig() {
- return {
- InputSetter: [
- {
- input: this.newGroupChildButton,
- valueAttribute: 'data-value',
- inputAttribute: 'data-action',
- },
- {
- input: this.newGroupChildButton,
- valueAttribute: 'data-text',
- },
- ],
- };
- }
-
- bindEvents() {
- this.newGroupChildButton.addEventListener('click', this.onClickNewGroupChildButton.bind(this));
- }
-
- onClickNewGroupChildButton(e) {
- if (e.target.dataset.action === NEW_PROJECT) {
- visitUrl(this.newGroupPath);
- } else if (e.target.dataset.action === NEW_SUBGROUP) {
- visitUrl(this.subgroupPath);
- }
- }
-}
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index b833cca1db6..1cedb557d46 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import Vue from 'vue';
+import { GlToast } from '@gitlab/ui';
import Translate from '~/vue_shared/translate';
import { highCountTrim } from '~/lib/utils/text_utility';
import Tracking from '~/tracking';
@@ -34,26 +35,45 @@ function initStatusTriggers() {
const statusModalElement = document.createElement('div');
setStatusModalWrapperEl.appendChild(statusModalElement);
+ Vue.use(GlToast);
Vue.use(Translate);
// eslint-disable-next-line no-new
new Vue({
el: statusModalElement,
data() {
- const { currentEmoji, currentMessage } = setStatusModalWrapperEl.dataset;
+ const {
+ currentEmoji,
+ defaultEmoji,
+ currentMessage,
+ currentAvailability,
+ canSetUserAvailability,
+ } = setStatusModalWrapperEl.dataset;
return {
currentEmoji,
+ defaultEmoji,
currentMessage,
+ currentAvailability,
+ canSetUserAvailability,
};
},
render(createElement) {
- const { currentEmoji, currentMessage } = this;
+ const {
+ currentEmoji,
+ defaultEmoji,
+ currentMessage,
+ currentAvailability,
+ canSetUserAvailability,
+ } = this;
return createElement(SetStatusModalWrapper, {
props: {
currentEmoji,
+ defaultEmoji,
currentMessage,
+ currentAvailability,
+ canSetUserAvailability,
},
});
},
diff --git a/app/assets/javascripts/helpers/startup_css_helper.js b/app/assets/javascripts/helpers/startup_css_helper.js
index 8e25e1421c0..d41a6209898 100644
--- a/app/assets/javascripts/helpers/startup_css_helper.js
+++ b/app/assets/javascripts/helpers/startup_css_helper.js
@@ -20,19 +20,9 @@ const handleStartupEvents = () => {
}
};
-/* Wait for.... The methods can be used:
- - with a callback (preferred),
- waitFor(action)
-
- - with then (discouraged),
- await waitFor().then(action);
-
- - with await,
- await waitFor;
- action();
--*/
+/* For `waitForCSSLoaded` methods, see docs.gitlab.com/ee/development/fe_guide/performance.html#important-considerations */
export const waitForCSSLoaded = (action = () => {}) => {
- if (!gon.features.startupCss || allLinksLoaded()) {
+ if (!gon?.features?.startupCss || allLinksLoaded()) {
return new Promise(resolve => {
action();
resolve();
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index f36fe87ccfa..9d2deb1d4d0 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -1,8 +1,7 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
-import { GlModal, GlSafeHtmlDirective } from '@gitlab/ui';
+import { GlModal, GlSafeHtmlDirective, GlButton } from '@gitlab/ui';
import { n__, __ } from '~/locale';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
import CommitMessageField from './message_field.vue';
import Actions from './actions.vue';
import SuccessMessage from './success_message.vue';
@@ -12,10 +11,10 @@ import { createUnexpectedCommitError } from '../../lib/errors';
export default {
components: {
Actions,
- LoadingButton,
CommitMessageField,
SuccessMessage,
GlModal,
+ GlButton,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
@@ -156,12 +155,16 @@ export default {
/>
<div class="clearfix gl-mt-5">
<actions />
- <loading-button
+ <gl-button
:loading="submitCommitLoading"
- :label="commitButtonText"
- container-class="btn btn-success btn-sm float-left qa-commit-button"
+ class="float-left qa-commit-button"
+ size="small"
+ category="primary"
+ variant="success"
@click="commit"
- />
+ >
+ {{ __('Commit') }}
+ </gl-button>
<button
v-if="!discardDraftButtonDisabled"
type="button"
@@ -170,14 +173,17 @@ export default {
>
{{ __('Discard draft') }}
</button>
- <button
+ <gl-button
v-else
type="button"
- class="btn btn-default btn-sm float-right"
+ class="float-right"
+ category="secondary"
+ variant="default"
+ size="small"
@click="toggleIsCompact"
>
{{ __('Collapse') }}
- </button>
+ </gl-button>
</div>
<gl-modal
ref="commitErrorModal"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
index 609ce287d3f..729ff7c74ec 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -1,8 +1,7 @@
<script>
import { mapActions } from 'vuex';
-import { GlModal, GlIcon } from '@gitlab/ui';
+import { GlModal, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
import ListItem from './list_item.vue';
export default {
@@ -12,17 +11,13 @@ export default {
GlModal,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
fileList: {
type: Array,
required: true,
},
- iconName: {
- type: String,
- required: true,
- },
stagedList: {
type: Boolean,
required: false,
@@ -73,12 +68,11 @@ export default {
<div class="ide-commit-list-container">
<header class="multi-file-commit-panel-header d-flex mb-0">
<div class="d-flex align-items-center flex-fill">
- <gl-icon v-once :name="iconName" :size="18" class="gl-mr-3" />
<strong> {{ titleText }} </strong>
<div class="d-flex ml-auto">
<button
v-if="!stagedList"
- v-tooltip
+ v-gl-tooltip
:title="__('Discard all changes')"
:aria-label="__('Discard all changes')"
:disabled="!filesLength"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
deleted file mode 100644
index 4821b8389ff..00000000000
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
+++ /dev/null
@@ -1,103 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-import tooltip from '~/vue_shared/directives/tooltip';
-import { sprintf, n__, __ } from '~/locale';
-
-export default {
- components: {
- GlIcon,
- },
- directives: {
- tooltip,
- },
- props: {
- files: {
- type: Array,
- required: true,
- },
- iconName: {
- type: String,
- required: true,
- },
- title: {
- type: String,
- required: true,
- },
- },
- computed: {
- addedFilesLength() {
- return this.files.filter(f => f.tempFile).length;
- },
- modifiedFilesLength() {
- return this.files.filter(f => !f.tempFile).length;
- },
- addedFilesIconClass() {
- return this.addedFilesLength ? 'multi-file-addition' : '';
- },
- modifiedFilesClass() {
- return this.modifiedFilesLength ? 'multi-file-modified' : '';
- },
- additionsTooltip() {
- return sprintf(
- n__('1 %{type} addition', '%{count} %{type} additions', this.addedFilesLength),
- {
- type: this.title.toLowerCase(),
- count: this.addedFilesLength,
- },
- );
- },
- modifiedTooltip() {
- return sprintf(
- n__('1 %{type} modification', '%{count} %{type} modifications', this.modifiedFilesLength),
- {
- type: this.title.toLowerCase(),
- count: this.modifiedFilesLength,
- },
- );
- },
- titleTooltip() {
- return sprintf(__('%{title} changes'), { title: this.title });
- },
- additionIconName() {
- return this.title.toLowerCase() === 'staged' ? 'file-addition-solid' : 'file-addition';
- },
- modifiedIconName() {
- return this.title.toLowerCase() === 'staged' ? 'file-modified-solid' : 'file-modified';
- },
- },
-};
-</script>
-
-<template>
- <div class="multi-file-commit-list-collapsed text-center">
- <div
- v-tooltip
- :title="titleTooltip"
- data-container="body"
- data-placement="left"
- class="gl-mb-5"
- >
- <gl-icon v-once :name="iconName" :size="18" />
- </div>
- <div
- v-tooltip
- :title="additionsTooltip"
- data-container="body"
- data-placement="left"
- class="gl-mb-3"
- >
- <gl-icon :name="additionIconName" :size="18" :class="addedFilesIconClass" />
- </div>
- {{ addedFilesLength }}
- <div
- v-tooltip
- :title="modifiedTooltip"
- data-container="body"
- data-placement="left"
- class="gl-mt-3 gl-mb-3"
- >
- <gl-icon :name="modifiedIconName" :size="18" :class="modifiedFilesClass" />
- </div>
- {{ modifiedFilesLength }}
- </div>
-</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
index a0d6cf3c42d..123e0aba959 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -1,7 +1,6 @@
<script>
import { mapActions } from 'vuex';
-import { GlIcon } from '@gitlab/ui';
-import tooltip from '~/vue_shared/directives/tooltip';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import { viewerTypes } from '../../constants';
import getCommitIconMap from '../../commit_icon';
@@ -12,7 +11,7 @@ export default {
FileIcon,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
file: {
@@ -77,7 +76,7 @@ export default {
<template>
<div class="multi-file-commit-list-item position-relative">
<div
- v-tooltip
+ v-gl-tooltip
:title="tooltipTitle"
:class="{
'is-active': isActive,
diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue
index 48ab58e1cb7..fb0d00dc6a1 100644
--- a/app/assets/javascripts/ide/components/file_row_extra.vue
+++ b/app/assets/javascripts/ide/components/file_row_extra.vue
@@ -1,8 +1,7 @@
<script>
import { mapGetters } from 'vuex';
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { n__ } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
import NewDropdown from './new_dropdown/index.vue';
import MrFileIcon from './mr_file_icon.vue';
@@ -10,7 +9,7 @@ import MrFileIcon from './mr_file_icon.vue';
export default {
name: 'FileRowExtra',
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
@@ -70,7 +69,7 @@ export default {
<span v-if="showTreeChangesCount" class="ide-tree-changes">
{{ changesCount }}
<gl-icon
- v-tooltip
+ v-gl-tooltip.left.viewport
:title="folderChangesTooltip"
:size="12"
data-container="body"
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 8f23856fd6c..e1d2895831a 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,6 +1,5 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
-import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import {
WEBIDE_MARK_APP_START,
@@ -10,19 +9,12 @@ import {
WEBIDE_MEASURE_TREE_FROM_REQUEST,
WEBIDE_MEASURE_FILE_FROM_REQUEST,
WEBIDE_MEASURE_FILE_AFTER_INTERACTION,
-} from '~/performance_constants';
-import { performanceMarkAndMeasure } from '~/performance_utils';
+} 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';
-import RepoTabs from './repo_tabs.vue';
-import IdeStatusBar from './ide_status_bar.vue';
import RepoEditor from './repo_editor.vue';
-import RightPane from './panes/right.vue';
-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';
@@ -43,19 +35,24 @@ eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, () =>
export default {
components: {
- NewModal,
IdeSidebar,
- RepoTabs,
- IdeStatusBar,
RepoEditor,
- FindFile,
- ErrorMessage,
- CommitEditorHeader,
- GlButton,
- GlLoadingIcon,
- RightPane,
+ 'error-message': () => import('./error_message.vue'),
+ 'gl-button': () => import('@gitlab/ui/src/components/base/button/button.vue'),
+ 'gl-loading-icon': () => import('@gitlab/ui/src/components/base/loading_icon/loading_icon.vue'),
+ 'commit-editor-header': () => import('./commit_sidebar/editor_header.vue'),
+ 'repo-tabs': () => import('./repo_tabs.vue'),
+ 'ide-status-bar': () => import('./ide_status_bar.vue'),
+ 'find-file': () => import('~/vue_shared/components/file_finder/index.vue'),
+ 'right-pane': () => import('./panes/right.vue'),
+ 'new-modal': () => import('./new_dropdown/modal.vue'),
},
mixins: [glFeatureFlagsMixin()],
+ data() {
+ return {
+ loadDeferred: false,
+ };
+ },
computed: {
...mapState([
'openFiles',
@@ -107,6 +104,9 @@ export default {
createNewFile() {
this.$refs.newModal.open(modalTypes.blob);
},
+ loadDeferredComponents() {
+ this.loadDeferred = true;
+ },
},
};
</script>
@@ -118,19 +118,23 @@ export default {
>
<error-message v-if="errorMessage" :message="errorMessage" />
<div class="ide-view flex-grow d-flex">
- <find-file
- v-show="fileFindVisible"
- :files="allBlobs"
- :visible="fileFindVisible"
- :loading="loading"
- @toggle="toggleFileFinder"
- @click="openFile"
- />
- <ide-sidebar />
+ <template v-if="loadDeferred">
+ <find-file
+ v-show="fileFindVisible"
+ :files="allBlobs"
+ :visible="fileFindVisible"
+ :loading="loading"
+ @toggle="toggleFileFinder"
+ @click="openFile"
+ />
+ </template>
+ <ide-sidebar @tree-ready="loadDeferredComponents" />
<div class="multi-file-edit-pane">
<template v-if="activeFile">
- <commit-editor-header v-if="isCommitModeActive" :active-file="activeFile" />
- <repo-tabs v-else :active-file="activeFile" :files="openFiles" :viewer="viewer" />
+ <template v-if="loadDeferred">
+ <commit-editor-header v-if="isCommitModeActive" :active-file="activeFile" />
+ <repo-tabs v-else :active-file="activeFile" :files="openFiles" :viewer="viewer" />
+ </template>
<repo-editor :file="activeFile" class="multi-file-edit-pane-content" />
</template>
<template v-else>
@@ -177,9 +181,13 @@ export default {
</div>
</template>
</div>
- <right-pane v-if="currentProjectId" />
+ <template v-if="loadDeferred">
+ <right-pane v-if="currentProjectId" />
+ </template>
</div>
- <ide-status-bar />
- <new-modal ref="newModal" />
+ <template v-if="loadDeferred">
+ <ide-status-bar />
+ <new-modal ref="newModal" />
+ </template>
</article>
</template>
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
index 53dfc133fc8..99215d6c3f1 100644
--- a/app/assets/javascripts/ide/components/ide_side_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -4,21 +4,19 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import IdeTree from './ide_tree.vue';
import ResizablePanel from './resizable_panel.vue';
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 IdeProjectHeader from './ide_project_header.vue';
-import { SIDEBAR_INIT_WIDTH } from '../constants';
+import { SIDEBAR_INIT_WIDTH, leftSidebarViews } from '../constants';
export default {
components: {
GlSkeletonLoading,
ResizablePanel,
ActivityBar,
- RepoCommitSection,
IdeTree,
+ [leftSidebarViews.review.name]: () => import('./ide_review.vue'),
+ [leftSidebarViews.commit.name]: () => import('./repo_commit_section.vue'),
CommitForm,
- IdeReview,
IdeProjectHeader,
},
computed: {
@@ -49,7 +47,7 @@ export default {
<div class="multi-file-commit-panel-inner" data-testid="ide-side-bar-inner">
<div class="multi-file-commit-panel-inner-content">
<keep-alive>
- <component :is="currentActivityView" />
+ <component :is="currentActivityView" @tree-ready="$emit('tree-ready')" />
</keep-alive>
</div>
<commit-form />
diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue
index caa122f6ed2..aa61c0d9b5e 100644
--- a/app/assets/javascripts/ide/components/ide_status_list.vue
+++ b/app/assets/javascripts/ide/components/ide_status_list.vue
@@ -14,6 +14,7 @@ export default {
},
computed: {
...mapGetters(['activeFile']),
+ ...mapGetters('editor', ['activeFileEditor']),
activeFileEOL() {
return getFileEOL(this.activeFile.content);
},
@@ -33,8 +34,10 @@ export default {
</gl-link>
</div>
<div>{{ activeFileEOL }}</div>
- <div v-if="activeFileIsText">{{ activeFile.editorRow }}:{{ activeFile.editorColumn }}</div>
- <div>{{ activeFile.fileLanguage }}</div>
+ <div v-if="activeFileIsText">
+ {{ activeFileEditor.editorRow }}:{{ activeFileEditor.editorColumn }}
+ </div>
+ <div>{{ activeFileEditor.fileLanguage }}</div>
</template>
<terminal-sync-status-safe />
</div>
diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue
index 56fcb6c2600..e563de6659a 100644
--- a/app/assets/javascripts/ide/components/ide_tree.vue
+++ b/app/assets/javascripts/ide/components/ide_tree.vue
@@ -51,7 +51,7 @@ export default {
</script>
<template>
- <ide-tree-list>
+ <ide-tree-list @tree-ready="$emit('tree-ready')">
<template #header>
{{ __('Edit') }}
<div class="ide-tree-actions ml-auto d-flex" data-testid="ide-root-actions">
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index dd226f07fb0..e7e94f5b5da 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -6,8 +6,8 @@ import {
WEBIDE_MARK_TREE_START,
WEBIDE_MEASURE_TREE_FROM_REQUEST,
WEBIDE_MARK_FILE_CLICKED,
-} from '~/performance_constants';
-import { performanceMarkAndMeasure } from '~/performance_utils';
+} 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';
@@ -32,6 +32,13 @@ export default {
return !this.currentTree || this.currentTree.loading;
},
},
+ watch: {
+ showLoading(newVal) {
+ if (!newVal) {
+ this.$emit('tree-ready');
+ }
+ },
+ },
beforeCreate() {
performanceMarkAndMeasure({ mark: WEBIDE_MARK_TREE_START });
},
diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue
index a5ae8bbfe9a..d65304034c2 100644
--- a/app/assets/javascripts/ide/components/jobs/detail.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail.vue
@@ -2,7 +2,7 @@
/* eslint-disable vue/no-v-html */
import { mapActions, mapState } from 'vuex';
import { throttle } from 'lodash';
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlTooltipDirective, GlButton, GlIcon } from '@gitlab/ui';
import { __ } from '../../../locale';
import ScrollButton from './detail/scroll_button.vue';
import JobDescription from './detail/description.vue';
@@ -17,6 +17,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: {
+ GlButton,
GlIcon,
ScrollButton,
JobDescription,
@@ -75,9 +76,9 @@ export default {
<template>
<div class="ide-pipeline build-page d-flex flex-column flex-fill">
<header class="ide-job-header d-flex align-items-center">
- <button class="btn btn-default btn-sm d-flex" @click="setDetailJob(null)">
- <gl-icon name="chevron-left" /> {{ __('View jobs') }}
- </button>
+ <gl-button category="secondary" icon="chevron-left" size="small" @click="setDetailJob(null)">
+ {{ __('View jobs') }}
+ </gl-button>
</header>
<div class="top-bar d-flex border-left-0 mr-3">
<job-description :job="detailJob" />
diff --git a/app/assets/javascripts/ide/components/jobs/item.vue b/app/assets/javascripts/ide/components/jobs/item.vue
index db3630bc1d1..f84315b63d2 100644
--- a/app/assets/javascripts/ide/components/jobs/item.vue
+++ b/app/assets/javascripts/ide/components/jobs/item.vue
@@ -1,9 +1,11 @@
<script>
+import { GlButton } from '@gitlab/ui';
import JobDescription from './detail/description.vue';
export default {
components: {
JobDescription,
+ GlButton,
},
props: {
job: {
@@ -28,9 +30,9 @@ export default {
<div class="ide-job-item">
<job-description :job="job" class="gl-mr-3" />
<div class="ml-auto align-self-center">
- <button v-if="job.started" type="button" class="btn btn-default btn-sm" @click="clickViewLog">
+ <gl-button v-if="job.started" category="secondary" size="small" @click="clickViewLog">
{{ __('View log') }}
- </button>
+ </gl-button>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/mr_file_icon.vue b/app/assets/javascripts/ide/components/mr_file_icon.vue
index c8629a869e0..af297753c28 100644
--- a/app/assets/javascripts/ide/components/mr_file_icon.vue
+++ b/app/assets/javascripts/ide/components/mr_file_icon.vue
@@ -1,20 +1,19 @@
<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,
},
};
</script>
<template>
<gl-icon
- v-tooltip
+ v-gl-tooltip
:title="__('Part of merge request changes')"
:size="12"
name="git-merge"
diff --git a/app/assets/javascripts/ide/components/new_dropdown/button.vue b/app/assets/javascripts/ide/components/new_dropdown/button.vue
index 8ae8f97f237..ce80fbee2e0 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/button.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/button.vue
@@ -1,10 +1,9 @@
<script>
-import { GlIcon } from '@gitlab/ui';
-import tooltip from '~/vue_shared/directives/tooltip';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
@@ -45,7 +44,7 @@ export default {
<template>
<button
- v-tooltip
+ v-gl-tooltip
:aria-label="label"
:title="tooltipTitle"
type="button"
diff --git a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
index f1b882d8f29..87019c3b2a5 100644
--- a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
+++ b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
@@ -1,13 +1,9 @@
<script>
import { mapActions, mapState } from 'vuex';
-import tooltip from '~/vue_shared/directives/tooltip';
import IdeSidebarNav from '../ide_sidebar_nav.vue';
export default {
name: 'CollapsibleSidebar',
- directives: {
- tooltip,
- },
components: {
IdeSidebarNav,
},
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
index 91bd64a2c9c..6f15773c9ab 100644
--- a/app/assets/javascripts/ide/components/pipelines/list.vue
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -1,11 +1,16 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { escape } from 'lodash';
-import { GlLoadingIcon, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import {
+ GlLoadingIcon,
+ GlIcon,
+ GlSafeHtmlDirective as SafeHtml,
+ GlTabs,
+ GlTab,
+ GlBadge,
+} from '@gitlab/ui';
import { sprintf, __ } from '../../../locale';
import CiIcon from '../../../vue_shared/components/ci_icon.vue';
-import Tabs from '../../../vue_shared/components/tabs/tabs';
-import Tab from '../../../vue_shared/components/tabs/tab.vue';
import EmptyState from '../../../pipelines/components/pipelines_list/empty_state.vue';
import JobsList from '../jobs/list.vue';
@@ -15,11 +20,12 @@ export default {
components: {
GlIcon,
CiIcon,
- Tabs,
- Tab,
JobsList,
EmptyState,
GlLoadingIcon,
+ GlTabs,
+ GlTab,
+ GlBadge,
},
directives: {
SafeHtml,
@@ -88,22 +94,26 @@ export default {
<p class="gl-mb-0 break-word">{{ latestPipeline.yamlError }}</p>
<p v-safe-html="ciLintText" class="gl-mb-0"></p>
</div>
- <tabs v-else class="ide-pipeline-list">
- <tab :active="!pipelineFailed">
+ <gl-tabs v-else>
+ <gl-tab :active="!pipelineFailed">
<template #title>
{{ __('Jobs') }}
- <span v-if="jobsCount" class="badge badge-pill"> {{ jobsCount }} </span>
+ <gl-badge v-if="jobsCount" size="sm" class="gl-tab-counter-badge">{{
+ jobsCount
+ }}</gl-badge>
</template>
<jobs-list :loading="isLoadingJobs" :stages="stages" />
- </tab>
- <tab :active="pipelineFailed">
+ </gl-tab>
+ <gl-tab :active="pipelineFailed">
<template #title>
{{ __('Failed Jobs') }}
- <span v-if="failedJobsCount" class="badge badge-pill"> {{ failedJobsCount }} </span>
+ <gl-badge v-if="failedJobsCount" size="sm" class="gl-tab-counter-badge">{{
+ failedJobsCount
+ }}</gl-badge>
</template>
<jobs-list :loading="isLoadingJobs" :stages="failedStages" />
- </tab>
- </tabs>
+ </gl-tab>
+ </gl-tabs>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
index 92b99b5c731..dfd25feed08 100644
--- a/app/assets/javascripts/ide/components/repo_commit_section.vue
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -1,6 +1,5 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
-import tooltip from '~/vue_shared/directives/tooltip';
import CommitFilesList from './commit_sidebar/list.vue';
import EmptyState from './commit_sidebar/empty_state.vue';
import { stageKeys } from '../constants';
@@ -10,9 +9,6 @@ export default {
CommitFilesList,
EmptyState,
},
- directives: {
- tooltip,
- },
computed: {
...mapState(['changedFiles', 'stagedFiles', 'lastCommitMsg']),
...mapState('commit', ['commitMessage', 'submitCommitLoading']),
@@ -68,7 +64,6 @@ export default {
:active-file-key="activeFileKey"
:empty-state-text="__('There are no changes')"
class="is-first"
- icon-name="unstaged"
/>
</template>
<empty-state v-else />
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 56bbb6349cd..c8a825065f1 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -9,8 +9,8 @@ import {
WEBIDE_MARK_FILE_START,
WEBIDE_MEASURE_FILE_AFTER_INTERACTION,
WEBIDE_MEASURE_FILE_FROM_REQUEST,
-} from '~/performance_constants';
-import { performanceMarkAndMeasure } from '~/performance_utils';
+} from '~/performance/constants';
+import { performanceMarkAndMeasure } from '~/performance/utils';
import eventHub from '../eventhub';
import {
leftSidebarViews,
@@ -22,6 +22,7 @@ import Editor from '../lib/editor';
import FileTemplatesBar from './file_templates/bar.vue';
import { __ } from '~/locale';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
+import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '../utils';
import { getRulesWithTraversal } from '../lib/editorconfig/parser';
import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
@@ -49,6 +50,7 @@ export default {
...mapState('rightPane', {
rightPaneIsOpen: 'isOpen',
}),
+ ...mapState('editor', ['fileEditors']),
...mapState([
'viewer',
'panelResizing',
@@ -67,6 +69,9 @@ export default {
'getJsonSchemaForPath',
]),
...mapGetters('fileTemplates', ['showFileTemplatesBar']),
+ fileEditor() {
+ return getFileEditorOrDefault(this.fileEditors, this.file.path);
+ },
shouldHideEditor() {
return this.file && !this.file.loading && !isTextFile(this.file);
},
@@ -80,10 +85,10 @@ export default {
return this.shouldHideEditor && this.file.mrChange && this.viewer === viewerTypes.mr;
},
isEditorViewMode() {
- return this.file.viewMode === FILE_VIEW_MODE_EDITOR;
+ return this.fileEditor.viewMode === FILE_VIEW_MODE_EDITOR;
},
isPreviewViewMode() {
- return this.file.viewMode === FILE_VIEW_MODE_PREVIEW;
+ return this.fileEditor.viewMode === FILE_VIEW_MODE_PREVIEW;
},
editTabCSS() {
return {
@@ -125,8 +130,7 @@ export default {
this.initEditor();
if (this.currentActivityView !== leftSidebarViews.edit.name) {
- this.setFileViewMode({
- file: this.file,
+ this.updateEditor({
viewMode: FILE_VIEW_MODE_EDITOR,
});
}
@@ -134,8 +138,7 @@ export default {
},
currentActivityView() {
if (this.currentActivityView !== leftSidebarViews.edit.name) {
- this.setFileViewMode({
- file: this.file,
+ this.updateEditor({
viewMode: FILE_VIEW_MODE_EDITOR,
});
}
@@ -195,13 +198,11 @@ export default {
'getFileData',
'getRawFileData',
'changeFileContent',
- 'setFileLanguage',
- 'setEditorPosition',
- 'setFileViewMode',
'removePendingTab',
'triggerFilesChange',
'addTempImage',
]),
+ ...mapActions('editor', ['updateFileEditor']),
initEditor() {
if (this.shouldHideEditor && (this.file.content || this.file.raw)) {
return;
@@ -284,19 +285,19 @@ export default {
// Handle Cursor Position
this.editor.onPositionChange((instance, e) => {
- this.setEditorPosition({
+ this.updateEditor({
editorRow: e.position.lineNumber,
editorColumn: e.position.column,
});
});
this.editor.setPosition({
- lineNumber: this.file.editorRow,
- column: this.file.editorColumn,
+ lineNumber: this.fileEditor.editorRow,
+ column: this.fileEditor.editorColumn,
});
// Handle File Language
- this.setFileLanguage({
+ this.updateEditor({
fileLanguage: this.model.language,
});
@@ -354,6 +355,16 @@ export default {
const schema = this.getJsonSchemaForPath(this.file.path);
registerSchema(schema);
},
+ updateEditor(data) {
+ // Looks like our model wrapper `.dispose` causes the monaco editor to emit some position changes after
+ // when disposing. We want to ignore these by only capturing editor changes that happen to the currently active
+ // file.
+ if (!this.file.active) {
+ return;
+ }
+
+ this.updateFileEditor({ path: this.file.path, data });
+ },
},
viewerTypes,
FILE_VIEW_MODE_EDITOR,
@@ -369,7 +380,7 @@ export default {
<a
href="javascript:void(0);"
role="button"
- @click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_EDITOR })"
+ @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
>
{{ __('Edit') }}
</a>
@@ -378,7 +389,7 @@ export default {
<a
href="javascript:void(0);"
role="button"
- @click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
+ @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
>{{ previewMode.previewTitle }}</a
>
</li>
diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
index 1402f7aaf39..72c56daf69c 100644
--- a/app/assets/javascripts/ide/components/repo_file_status_icon.vue
+++ b/app/assets/javascripts/ide/components/repo_file_status_icon.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';
import '~/lib/utils/datetime_utility';
export default {
@@ -9,7 +8,7 @@ export default {
GlIcon,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
file: {
@@ -28,7 +27,7 @@ export default {
</script>
<template>
- <span v-if="file.file_lock" v-tooltip :title="lockTooltip" data-container="body">
+ <span v-if="file.file_lock" v-gl-tooltip :title="lockTooltip" data-container="body">
<gl-icon name="lock" class="file-status-icon" />
</span>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
index 60a80a31a8b..e3c41eee15e 100644
--- a/app/assets/javascripts/ide/components/repo_tab.vue
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -29,9 +29,9 @@ export default {
...mapGetters(['getUrlForPath']),
closeLabel() {
if (this.fileHasChanged) {
- return sprintf(__(`%{tabname} changed`), { tabname: this.tab.name });
+ return sprintf(__('%{tabname} changed'), { tabname: this.tab.name });
}
- return sprintf(__(`Close %{tabname}`, { tabname: this.tab.name }));
+ return sprintf(__('Close %{tabname}'), { tabname: this.tab.name });
},
showChangedIcon() {
if (this.tab.pending) return true;
diff --git a/app/assets/javascripts/ide/components/terminal/empty_state.vue b/app/assets/javascripts/ide/components/terminal/empty_state.vue
index 3668dd24e81..f4dd83b16c7 100644
--- a/app/assets/javascripts/ide/components/terminal/empty_state.vue
+++ b/app/assets/javascripts/ide/components/terminal/empty_state.vue
@@ -1,10 +1,14 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton, GlAlert, GlSafeHtmlDirective } from '@gitlab/ui';
export default {
components: {
GlLoadingIcon,
+ GlButton,
+ GlAlert,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
},
props: {
isLoading: {
@@ -41,24 +45,26 @@ export default {
};
</script>
<template>
- <div class="text-center p-3">
+ <div class="gl-text-center gl-p-5">
<div v-if="illustrationPath" class="svg-content svg-130"><img :src="illustrationPath" /></div>
<h4>{{ __('Web Terminal') }}</h4>
<gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" />
<template v-else>
<p>{{ __('Run tests against your code live using the Web Terminal') }}</p>
<p>
- <button
+ <gl-button
:disabled="!isValid"
- class="btn btn-info"
- type="button"
+ category="primary"
+ variant="info"
data-qa-selector="start_web_terminal_button"
@click="onStart"
>
{{ __('Start Web Terminal') }}
- </button>
+ </gl-button>
</p>
- <div v-if="!isValid && message" class="bs-callout text-left" v-html="message"></div>
+ <gl-alert v-if="!isValid && message" variant="tip" :dismissible="false">
+ <span v-safe-html="message"></span>
+ </gl-alert>
<p v-else>
<a
v-if="helpPath"
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index b8d59f8bd36..1496170447d 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -5,7 +5,7 @@ import { visitUrl } from '~/lib/utils/url_utility';
import { deprecatedCreateFlash as flash } from '~/flash';
import * as types from './mutation_types';
import { decorateFiles } from '../lib/files';
-import { stageKeys } from '../constants';
+import { stageKeys, commitActionTypes } from '../constants';
import service from '../services';
import eventHub from '../eventhub';
@@ -242,7 +242,7 @@ export const renameEntry = ({ dispatch, commit, state, getters }, { path, name,
}
}
- dispatch('triggerFilesChange');
+ dispatch('triggerFilesChange', { type: commitActionTypes.move, path, newPath });
};
export const getBranchData = ({ commit, state }, { projectId, branchId, force = false } = {}) =>
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index a0df85540f9..4b9b958ddd6 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -164,26 +164,6 @@ export const changeFileContent = ({ commit, state, getters }, { path, content })
}
};
-export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => {
- if (getters.activeFile) {
- commit(types.SET_FILE_LANGUAGE, { file: getters.activeFile, fileLanguage });
- }
-};
-
-export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn }) => {
- if (getters.activeFile) {
- commit(types.SET_FILE_POSITION, {
- file: getters.activeFile,
- editorRow,
- editorColumn,
- });
- }
-};
-
-export const setFileViewMode = ({ commit }, { file, viewMode }) => {
- commit(types.SET_FILE_VIEWMODE, { file, viewMode });
-};
-
export const restoreOriginalFile = ({ dispatch, state, commit }, path) => {
const file = state.entries[path];
const isDestructiveDiscard = file.tempFile || file.prevPath;
@@ -289,7 +269,7 @@ export const removePendingTab = ({ commit }, file) => {
eventHub.$emit(`editor.update.model.dispose.${file.key}`);
};
-export const triggerFilesChange = () => {
+export const triggerFilesChange = (ctx, payload = {}) => {
// Used in EE for file mirroring
- eventHub.$emit('ide.files.change');
+ eventHub.$emit('ide.files.change', payload);
};
diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js
index 324c5b0c6e4..d543209716a 100644
--- a/app/assets/javascripts/ide/stores/index.js
+++ b/app/assets/javascripts/ide/stores/index.js
@@ -12,6 +12,8 @@ import fileTemplates from './modules/file_templates';
import paneModule from './modules/pane';
import clientsideModule from './modules/clientside';
import routerModule from './modules/router';
+import editorModule from './modules/editor';
+import { setupFileEditorsSync } from './modules/editor/setup';
Vue.use(Vuex);
@@ -29,7 +31,14 @@ export const createStoreOptions = () => ({
rightPane: paneModule(),
clientside: clientsideModule(),
router: routerModule,
+ editor: editorModule,
},
});
-export const createStore = () => new Vuex.Store(createStoreOptions());
+export const createStore = () => {
+ const store = new Vuex.Store(createStoreOptions());
+
+ setupFileEditorsSync(store);
+
+ return store;
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js
index 37f887bcf0a..416ca88d6c9 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js
@@ -14,6 +14,8 @@ const createTranslatedTextForFiles = (files, text) => {
export const discardDraftButtonDisabled = state =>
state.commitMessage === '' || state.submitCommitLoading;
+// Note: If changing the structure of the placeholder branch name, please also
+// update #patch_branch_name in app/helpers/tree_helper.rb
export const placeholderBranchName = (state, _, rootState) =>
`${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(
-BRANCH_SUFFIX_COUNT,
diff --git a/app/assets/javascripts/ide/stores/modules/editor/actions.js b/app/assets/javascripts/ide/stores/modules/editor/actions.js
new file mode 100644
index 00000000000..cc23a655235
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/editor/actions.js
@@ -0,0 +1,19 @@
+import * as types from './mutation_types';
+
+/**
+ * Action to update the current file editor info at the given `path` with the given `data`
+ *
+ * @param {} vuex
+ * @param {{ path: String, data: any }} payload
+ */
+export const updateFileEditor = ({ commit }, payload) => {
+ commit(types.UPDATE_FILE_EDITOR, payload);
+};
+
+export const removeFileEditor = ({ commit }, path) => {
+ commit(types.REMOVE_FILE_EDITOR, path);
+};
+
+export const renameFileEditor = ({ commit }, payload) => {
+ commit(types.RENAME_FILE_EDITOR, payload);
+};
diff --git a/app/assets/javascripts/ide/stores/modules/editor/getters.js b/app/assets/javascripts/ide/stores/modules/editor/getters.js
new file mode 100644
index 00000000000..dabaafa453a
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/editor/getters.js
@@ -0,0 +1,13 @@
+import { getFileEditorOrDefault } from './utils';
+
+export const activeFileEditor = (state, getters, rootState, rootGetters) => {
+ const { activeFile } = rootGetters;
+
+ if (!activeFile) {
+ return null;
+ }
+
+ const { path } = rootGetters.activeFile;
+
+ return getFileEditorOrDefault(state.fileEditors, path);
+};
diff --git a/app/assets/javascripts/ide/stores/modules/editor/index.js b/app/assets/javascripts/ide/stores/modules/editor/index.js
new file mode 100644
index 00000000000..8a7437b427d
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/editor/index.js
@@ -0,0 +1,12 @@
+import * as actions from './actions';
+import * as getters from './getters';
+import state from './state';
+import mutations from './mutations';
+
+export default {
+ namespaced: true,
+ actions,
+ state,
+ mutations,
+ getters,
+};
diff --git a/app/assets/javascripts/ide/stores/modules/editor/mutation_types.js b/app/assets/javascripts/ide/stores/modules/editor/mutation_types.js
new file mode 100644
index 00000000000..89b7e9cbc76
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/editor/mutation_types.js
@@ -0,0 +1,3 @@
+export const UPDATE_FILE_EDITOR = 'UPDATE_FILE_EDITOR';
+export const REMOVE_FILE_EDITOR = 'REMOVE_FILE_EDITOR';
+export const RENAME_FILE_EDITOR = 'RENAME_FILE_EDITOR';
diff --git a/app/assets/javascripts/ide/stores/modules/editor/mutations.js b/app/assets/javascripts/ide/stores/modules/editor/mutations.js
new file mode 100644
index 00000000000..f332fe9dce9
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/editor/mutations.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import * as types from './mutation_types';
+import { getFileEditorOrDefault } from './utils';
+
+export default {
+ [types.UPDATE_FILE_EDITOR](state, { path, data }) {
+ const editor = getFileEditorOrDefault(state.fileEditors, path);
+
+ Vue.set(state.fileEditors, path, Object.assign(editor, data));
+ },
+ [types.REMOVE_FILE_EDITOR](state, path) {
+ Vue.delete(state.fileEditors, path);
+ },
+ [types.RENAME_FILE_EDITOR](state, { path, newPath }) {
+ const existing = state.fileEditors[path];
+
+ // Gracefully do nothing if fileEditor isn't found.
+ if (!existing) {
+ return;
+ }
+
+ Vue.delete(state.fileEditors, path);
+ Vue.set(state.fileEditors, newPath, existing);
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/modules/editor/setup.js b/app/assets/javascripts/ide/stores/modules/editor/setup.js
new file mode 100644
index 00000000000..c5a613c6baa
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/editor/setup.js
@@ -0,0 +1,19 @@
+import eventHub from '~/ide/eventhub';
+import { commitActionTypes } from '~/ide/constants';
+
+const removeUnusedFileEditors = store => {
+ Object.keys(store.state.editor.fileEditors)
+ .filter(path => !store.state.entries[path])
+ .forEach(path => store.dispatch('editor/removeFileEditor', path));
+};
+
+export const setupFileEditorsSync = store => {
+ eventHub.$on('ide.files.change', ({ type, ...payload } = {}) => {
+ if (type === commitActionTypes.move) {
+ store.dispatch('editor/renameFileEditor', payload);
+ } else {
+ // The files have changed, but the specific change is not known.
+ removeUnusedFileEditors(store);
+ }
+ });
+};
diff --git a/app/assets/javascripts/ide/stores/modules/editor/state.js b/app/assets/javascripts/ide/stores/modules/editor/state.js
new file mode 100644
index 00000000000..484aeec5cc3
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/editor/state.js
@@ -0,0 +1,8 @@
+export default () => ({
+ // Object which represents a dictionary of filePath to editor specific properties, including:
+ // - fileLanguage
+ // - editorRow
+ // - editorCol
+ // - viewMode
+ fileEditors: {},
+});
diff --git a/app/assets/javascripts/ide/stores/modules/editor/utils.js b/app/assets/javascripts/ide/stores/modules/editor/utils.js
new file mode 100644
index 00000000000..bef21d04b2b
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/editor/utils.js
@@ -0,0 +1,11 @@
+import { FILE_VIEW_MODE_EDITOR } from '../../../constants';
+
+export const createDefaultFileEditor = () => ({
+ editorRow: 1,
+ editorColumn: 1,
+ fileLanguage: '',
+ viewMode: FILE_VIEW_MODE_EDITOR,
+});
+
+export const getFileEditorOrDefault = (fileEditors, path) =>
+ fileEditors[path] || createDefaultFileEditor();
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index ae119c2b1fd..22ff29e8866 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -36,9 +36,6 @@ export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE';
export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA';
export const SET_FILE_BASE_RAW_DATA = 'SET_FILE_BASE_RAW_DATA';
export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT';
-export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE';
-export const SET_FILE_POSITION = 'SET_FILE_POSITION';
-export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE';
export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES';
export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED';
export const REMOVE_FILE_FROM_CHANGED = 'REMOVE_FILE_FROM_CHANGED';
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
index a981f86fa40..61a55d45128 100644
--- a/app/assets/javascripts/ide/stores/mutations/file.js
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -95,17 +95,6 @@ export default {
changed,
});
},
- [types.SET_FILE_LANGUAGE](state, { file, fileLanguage }) {
- Object.assign(state.entries[file.path], {
- fileLanguage,
- });
- },
- [types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) {
- Object.assign(state.entries[file.path], {
- editorRow,
- editorColumn,
- });
- },
[types.SET_FILE_MERGE_REQUEST_CHANGE](state, { file, mrChange }) {
let diffMode = diffModes.replaced;
if (mrChange.new_file) {
@@ -122,11 +111,6 @@ export default {
},
});
},
- [types.SET_FILE_VIEWMODE](state, { file, viewMode }) {
- Object.assign(state.entries[file.path], {
- viewMode,
- });
- },
[types.DISCARD_FILE_CHANGES](state, path) {
const stagedFile = state.stagedFiles.find(f => f.path === path);
const entry = state.entries[path];
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index b7ced3a271a..96f3caf1e98 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -1,4 +1,4 @@
-import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants';
+import { commitActionTypes } from '../constants';
import {
relativePathToAbsolute,
isAbsolute,
@@ -25,10 +25,6 @@ export const dataStructure = () => ({
rawPath: '',
raw: '',
content: '',
- editorRow: 1,
- editorColumn: 1,
- fileLanguage: '',
- viewMode: FILE_VIEW_MODE_EDITOR,
size: 0,
parentPath: null,
lastOpenedAt: 0,
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index 4cf4f5e1d81..1ca1b971de1 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -1,7 +1,7 @@
import { languages } from 'monaco-editor';
import { flatten, isString } from 'lodash';
import { SIDE_LEFT, SIDE_RIGHT } from './constants';
-import { performanceMarkAndMeasure } from '~/performance_utils';
+import { performanceMarkAndMeasure } from '~/performance/utils';
const toLowerCase = x => x.toLowerCase();
diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js
index 7b70d290278..7b7afd13c55 100644
--- a/app/assets/javascripts/import_projects/store/actions.js
+++ b/app/assets/javascripts/import_projects/store/actions.js
@@ -7,11 +7,14 @@ import { visitUrl, objectToQuery } from '~/lib/utils/url_utility';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
let eTagPoll;
const hasRedirectInError = e => e?.response?.data?.error?.redirect;
const redirectToUrlInError = e => visitUrl(e.response.data.error.redirect);
+const tooManyRequests = e => e.response.status === httpStatusCodes.TOO_MANY_REQUESTS;
const pathWithParams = ({ path, ...params }) => {
const filteredParams = Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== ''),
@@ -37,8 +40,6 @@ const restartJobsPolling = () => {
if (eTagPoll) eTagPoll.restart();
};
-const setFilter = ({ commit }, filter) => commit(types.SET_FILTER, filter);
-
const setImportTarget = ({ commit }, { repoId, importTarget }) =>
commit(types.SET_IMPORT_TARGET, { repoId, importTarget });
@@ -73,6 +74,14 @@ const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit })
if (hasRedirectInError(e)) {
redirectToUrlInError(e);
+ } else if (tooManyRequests(e)) {
+ createFlash(
+ sprintf(s__('ImportProjects|%{provider} rate limit exceeded. Try again later'), {
+ provider: capitalizeFirstCharacter(provider),
+ }),
+ );
+
+ commit(types.RECEIVE_REPOS_ERROR);
} else {
createFlash(
sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), {
@@ -172,12 +181,9 @@ const fetchNamespacesFactory = (namespacesPath = isRequired()) => ({ commit }) =
});
};
-const setPage = ({ state, commit, dispatch }, page) => {
- if (page === state.pageInfo.page) {
- return null;
- }
+const setFilter = ({ commit, dispatch }, filter) => {
+ commit(types.SET_FILTER, filter);
- commit(types.SET_PAGE, page);
return dispatch('fetchRepos');
};
@@ -188,7 +194,6 @@ export default ({ endpoints = isRequired() }) => ({
setFilter,
setImportTarget,
importAll,
- setPage,
fetchRepos: fetchReposFactory({ reposPath: endpoints.reposPath }),
fetchImport: fetchImportFactory(endpoints.importPath),
fetchJobs: fetchJobsFactory(endpoints.jobsPath),
diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js
index 349ca14b4e8..078c50ee9c6 100644
--- a/app/assets/javascripts/importer_status.js
+++ b/app/assets/javascripts/importer_status.js
@@ -3,7 +3,7 @@ import { escape } from 'lodash';
import { __, sprintf } from './locale';
import axios from './lib/utils/axios_utils';
import { deprecatedCreateFlash as flash } from './flash';
-import { parseBoolean } from './lib/utils/common_utils';
+import { parseBoolean, spriteIcon } from './lib/utils/common_utils';
class ImporterStatus {
constructor({ jobsUrl, importUrl, ciCdOnly }) {
@@ -108,7 +108,7 @@ class ImporterStatus {
switch (job.import_status) {
case 'finished':
jobItem.removeClass('table-active').addClass('table-success');
- statusField.html(`<span><i class="fa fa-check"></i> ${__('Done')}</span>`);
+ statusField.html(`<span>${spriteIcon('check', 's16')} ${__('Done')}</span>`);
break;
case 'scheduled':
statusField.html(`${spinner} ${__('Scheduled')}`);
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index e1f9d858f2b..0e3839deaf5 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -69,9 +69,12 @@ export default {
{
key: 'incidentSla',
label: s__('IncidentManagement|Time to SLA'),
- thClass: `gl-pointer-events-none gl-text-right gl-w-eighth`,
+ thClass: `gl-text-right gl-w-eighth`,
tdClass: `${tdClass} gl-text-right`,
thAttr: TH_INCIDENT_SLA_TEST_ID,
+ sortKey: 'SLA_DUE_AT',
+ sortable: true,
+ sortDirection: 'asc',
},
{
key: 'assignees',
@@ -253,13 +256,22 @@ export default {
this.redirecting = true;
},
fetchSortedData({ sortBy, sortDesc }) {
+ let sortKey;
+ // In bootstrap-vue v2.17.0, sortKey becomes natively supported and we can eliminate this function
+ const field = this.availableFields.find(({ key }) => key === sortBy);
const sortingDirection = sortDesc ? 'DESC' : 'ASC';
- const sortingColumn = convertToSnakeCase(sortBy)
- .replace(/_.*/, '')
- .toUpperCase();
+
+ // Use `sortKey` if provided, otherwise fall back to existing algorithm
+ if (field?.sortKey) {
+ sortKey = field.sortKey;
+ } else {
+ sortKey = convertToSnakeCase(sortBy)
+ .replace(/_.*/, '')
+ .toUpperCase();
+ }
this.pagination = initialPaginationState;
- this.sort = `${sortingColumn}_${sortingDirection}`;
+ this.sort = `${sortKey}_${sortingDirection}`;
},
getSeverity(severity) {
return INCIDENT_SEVERITY[severity];
@@ -407,7 +419,7 @@ export default {
</template>
</gl-table>
</template>
- <template #emtpy-state>
+ <template #empty-state>
<gl-empty-state
:title="emptyStateData.title"
:svg-path="emptyListSvgPath"
diff --git a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
index 890381a8f29..93ea1f4f636 100644
--- a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
+++ b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
@@ -8,14 +8,14 @@ export default {
GlModal,
},
computed: {
- ...mapGetters(['isSavingOrTesting']),
+ ...mapGetters(['isDisabled']),
primaryProps() {
return {
text: __('Save'),
attributes: [
{ variant: 'success' },
{ category: 'primary' },
- { disabled: this.isSavingOrTesting },
+ { disabled: this.isDisabled },
],
};
},
@@ -52,7 +52,7 @@ export default {
<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.',
+ 'Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use parent level defaults.',
)
}}
</p>
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 0fd39c5635d..bbfa865905a 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -12,6 +12,7 @@ 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';
+import ResetConfirmationModal from './reset_confirmation_modal.vue';
export default {
name: 'IntegrationForm',
@@ -23,6 +24,7 @@ export default {
TriggerFields,
DynamicField,
ConfirmationModal,
+ ResetConfirmationModal,
GlButton,
},
directives: {
@@ -30,23 +32,29 @@ export default {
},
mixins: [glFeatureFlagsMixin()],
computed: {
- ...mapGetters(['currentKey', 'propsSource', 'isSavingOrTesting']),
- ...mapState(['defaultState', 'override', 'isSaving', 'isTesting']),
+ ...mapGetters(['currentKey', 'propsSource', 'isDisabled']),
+ ...mapState(['defaultState', 'override', 'isSaving', 'isTesting', 'isResetting']),
isEditable() {
return this.propsSource.editable;
},
isJira() {
return this.propsSource.type === 'jira';
},
- isInstanceLevel() {
- return this.propsSource.integrationLevel === integrationLevels.INSTANCE;
+ isInstanceOrGroupLevel() {
+ return (
+ this.propsSource.integrationLevel === integrationLevels.INSTANCE ||
+ this.propsSource.integrationLevel === integrationLevels.GROUP
+ );
},
showJiraIssuesFields() {
return this.isJira && this.glFeatures.jiraIssuesIntegration;
},
+ showReset() {
+ return this.isInstanceOrGroupLevel && this.propsSource.resetPath;
+ },
},
methods: {
- ...mapActions(['setOverride', 'setIsSaving', 'setIsTesting']),
+ ...mapActions(['setOverride', 'setIsSaving', 'setIsTesting', 'setIsResetting']),
onSaveClick() {
this.setIsSaving(true);
eventHub.$emit('saveIntegration');
@@ -55,6 +63,7 @@ export default {
this.setIsTesting(true);
eventHub.$emit('testIntegration');
},
+ onResetClick() {},
},
};
</script>
@@ -91,13 +100,13 @@ export default {
v-bind="propsSource.jiraIssuesProps"
/>
<div v-if="isEditable" class="footer-block row-content-block">
- <template v-if="isInstanceLevel">
+ <template v-if="isInstanceOrGroupLevel">
<gl-button
v-gl-modal.confirmSaveIntegration
category="primary"
variant="success"
:loading="isSaving"
- :disabled="isSavingOrTesting"
+ :disabled="isDisabled"
data-qa-selector="save_changes_button"
>
{{ __('Save changes') }}
@@ -110,7 +119,7 @@ export default {
variant="success"
type="submit"
:loading="isSaving"
- :disabled="isSavingOrTesting"
+ :disabled="isDisabled"
data-qa-selector="save_changes_button"
@click.prevent="onSaveClick"
>
@@ -120,13 +129,27 @@ export default {
<gl-button
v-if="propsSource.canTest"
:loading="isTesting"
- :disabled="isSavingOrTesting"
+ :disabled="isDisabled"
:href="propsSource.testPath"
@click.prevent="onTestClick"
>
{{ __('Test settings') }}
</gl-button>
+ <template v-if="showReset">
+ <gl-button
+ v-gl-modal.confirmResetIntegration
+ category="secondary"
+ variant="default"
+ :loading="isResetting"
+ :disabled="isDisabled"
+ data-testid="reset-button"
+ >
+ {{ __('Reset') }}
+ </gl-button>
+ <reset-confirmation-modal @reset="onResetClick" />
+ </template>
+
<gl-button class="btn-cancel" :href="propsSource.cancelPath">{{ __('Cancel') }}</gl-button>
</div>
</div>
diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
index 08f24ce8ab6..123d794912a 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -108,12 +108,7 @@ export default {
:label="s__('Integrations|Comment detail:')"
data-testid="comment-detail"
>
- <input
- v-if="isInheriting"
- name="service[comment_detail]"
- type="hidden"
- :value="commentDetail"
- />
+ <input name="service[comment_detail]" type="hidden" :value="commentDetail" />
<gl-form-radio
v-for="commentDetailOption in commentDetailOptions"
:key="commentDetailOption.value"
diff --git a/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue
new file mode 100644
index 00000000000..d8503910566
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue
@@ -0,0 +1,61 @@
+<script>
+import { mapGetters } from 'vuex';
+import { GlModal } from '@gitlab/ui';
+
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlModal,
+ },
+ computed: {
+ ...mapGetters(['isDisabled']),
+ primaryProps() {
+ return {
+ text: __('Reset'),
+ attributes: [
+ { variant: 'warning' },
+ { category: 'primary' },
+ { disabled: this.isDisabled },
+ ],
+ };
+ },
+ cancelProps() {
+ return {
+ text: __('Cancel'),
+ };
+ },
+ },
+ methods: {
+ onReset() {
+ this.$emit('reset');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ modal-id="confirmResetIntegration"
+ size="sm"
+ :title="s__('Integrations|Reset integration?')"
+ :action-primary="primaryProps"
+ :action-cancel="cancelProps"
+ @primary="onReset"
+ >
+ <p>
+ {{
+ s__(
+ 'Integrations|Resetting this integration will clear the settings and deactivate this integration.',
+ )
+ }}
+ </p>
+ <p>
+ {{ s__('Integrations|All projects inheriting these settings will also be reset.') }}
+ </p>
+
+ <p class="gl-mb-0">
+ {{ s__('Integrations|Projects using custom settings will not be affected.') }}
+ </p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js
index 248ee62d43a..95a53f1beab 100644
--- a/app/assets/javascripts/integrations/edit/index.js
+++ b/app/assets/javascripts/integrations/edit/index.js
@@ -26,6 +26,7 @@ function parseDatasetToProps(data) {
integrationLevel,
cancelPath,
testPath,
+ resetPath,
...booleanAttributes
} = data;
const {
@@ -49,6 +50,7 @@ function parseDatasetToProps(data) {
editable,
canTest,
testPath,
+ resetPath,
triggerFieldsProps: {
initialTriggerCommit: commitEvents,
initialTriggerMergeRequest: mergeRequestEvents,
diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js
index 199c9074ead..097304be242 100644
--- a/app/assets/javascripts/integrations/edit/store/actions.js
+++ b/app/assets/javascripts/integrations/edit/store/actions.js
@@ -3,3 +3,5 @@ import * as types from './mutation_types';
export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override);
export const setIsSaving = ({ commit }, isSaving) => commit(types.SET_IS_SAVING, isSaving);
export const setIsTesting = ({ commit }, isTesting) => commit(types.SET_IS_TESTING, isTesting);
+export const setIsResetting = ({ commit }, isResetting) =>
+ commit(types.SET_IS_RESETTING, isResetting);
diff --git a/app/assets/javascripts/integrations/edit/store/getters.js b/app/assets/javascripts/integrations/edit/store/getters.js
index 4ee5f11855c..310d970c73e 100644
--- a/app/assets/javascripts/integrations/edit/store/getters.js
+++ b/app/assets/javascripts/integrations/edit/store/getters.js
@@ -1,6 +1,6 @@
export const isInheriting = state => (state.defaultState === null ? false : !state.override);
-export const isSavingOrTesting = state => state.isSaving || state.isTesting;
+export const isDisabled = state => state.isSaving || state.isTesting || state.isResetting;
export const propsSource = (state, getters) =>
getters.isInheriting ? state.defaultState : state.customState;
diff --git a/app/assets/javascripts/integrations/edit/store/mutation_types.js b/app/assets/javascripts/integrations/edit/store/mutation_types.js
index 0dae8ea079e..2a84408f658 100644
--- a/app/assets/javascripts/integrations/edit/store/mutation_types.js
+++ b/app/assets/javascripts/integrations/edit/store/mutation_types.js
@@ -1,3 +1,4 @@
export const SET_OVERRIDE = 'SET_OVERRIDE';
export const SET_IS_SAVING = 'SET_IS_SAVING';
export const SET_IS_TESTING = 'SET_IS_TESTING';
+export const SET_IS_RESETTING = 'SET_IS_RESETTING';
diff --git a/app/assets/javascripts/integrations/edit/store/mutations.js b/app/assets/javascripts/integrations/edit/store/mutations.js
index 8ac3c476f9e..07e3e25ccf0 100644
--- a/app/assets/javascripts/integrations/edit/store/mutations.js
+++ b/app/assets/javascripts/integrations/edit/store/mutations.js
@@ -10,4 +10,7 @@ export default {
[types.SET_IS_TESTING](state, isTesting) {
state.isTesting = isTesting;
},
+ [types.SET_IS_RESETTING](state, isResetting) {
+ state.isResetting = isResetting;
+ },
};
diff --git a/app/assets/javascripts/integrations/edit/store/state.js b/app/assets/javascripts/integrations/edit/store/state.js
index a9ecee6c539..aae3db1583f 100644
--- a/app/assets/javascripts/integrations/edit/store/state.js
+++ b/app/assets/javascripts/integrations/edit/store/state.js
@@ -7,5 +7,6 @@ export default ({ defaultState = null, customState = {} } = {}) => {
customState,
isSaving: false,
isTesting: false,
+ isResetting: false,
};
};
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index d2ea14a658b..b55ef77ae5d 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -6,13 +6,13 @@ import {
GlDatepicker,
GlLink,
GlSprintf,
- GlSearchBoxByType,
GlButton,
GlFormInput,
} from '@gitlab/ui';
import eventHub from '../event_hub';
-import { s__, sprintf } from '~/locale';
+import { s__, __, sprintf } from '~/locale';
import Api from '~/api';
+import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
export default {
name: 'InviteMembersModal',
@@ -23,16 +23,20 @@ export default {
GlDropdown,
GlDropdownItem,
GlSprintf,
- GlSearchBoxByType,
GlButton,
GlFormInput,
+ MembersTokenSelect,
},
props: {
- groupId: {
+ id: {
type: String,
required: true,
},
- groupName: {
+ isProject: {
+ type: Boolean,
+ required: true,
+ },
+ name: {
type: String,
required: true,
},
@@ -59,9 +63,16 @@ export default {
};
},
computed: {
+ inviteToName() {
+ return this.name.toUpperCase();
+ },
+ inviteToType() {
+ return this.isProject ? __('project') : __('group');
+ },
introText() {
- return sprintf(s__("InviteMembersModal|You're inviting members to the %{group_name} group"), {
- group_name: this.groupName,
+ return sprintf(s__("InviteMembersModal|You're inviting members to the %{name} %{type}"), {
+ name: this.inviteToName,
+ type: this.inviteToType,
});
},
toastOptions() {
@@ -110,13 +121,14 @@ export default {
this.selectedAccessLevel = item;
},
submitForm(formData) {
- return Api.inviteGroupMember(this.groupId, formData)
- .then(() => {
- this.showToastMessageSuccess();
- })
- .catch(error => {
- this.showToastMessageError(error);
- });
+ if (this.isProject) {
+ return Api.inviteProjectMembers(this.id, formData)
+ .then(this.showToastMessageSuccess)
+ .catch(this.showToastMessageError);
+ }
+ return Api.inviteGroupMember(this.id, formData)
+ .then(this.showToastMessageSuccess)
+ .catch(this.showToastMessageError);
},
showToastMessageSuccess() {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
@@ -129,44 +141,45 @@ export default {
},
labels: {
modalTitle: s__('InviteMembersModal|Invite team members'),
- userToInvite: s__('InviteMembersModal|GitLab member or Email address'),
+ newUsersToInvite: 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!'),
+ toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'),
+ toastMessageUnsuccessful: s__('InviteMembersModal|Some of the members could not be added'),
readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`),
inviteButtonText: s__('InviteMembersModal|Invite'),
cancelButtonText: s__('InviteMembersModal|Cancel'),
+ headerCloseLabel: s__('InviteMembersModal|Close invite team members'),
},
+ membersTokenSelectLabelId: 'invite-members-input',
};
</script>
<template>
- <gl-modal :modal-id="modalId" size="sm" :title="$options.labels.modalTitle">
+ <gl-modal
+ :modal-id="modalId"
+ size="sm"
+ :title="$options.labels.modalTitle"
+ :header-close-label="$options.labels.headerCloseLabel"
+ >
<div class="gl-ml-5 gl-mr-5">
<div>{{ introText }}</div>
- <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.userToInvite }}</label>
+ <label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{
+ $options.labels.newUsersToInvite
+ }}</label>
<div class="gl-mt-2">
- <gl-search-box-by-type
+ <members-token-select
v-model="newUsersToInvite"
+ :label="$options.labels.newUsersToInvite"
+ :aria-labelledby="$options.membersTokenSelectLabelId"
: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"
- >
+ <gl-dropdown class="gl-shadow-none gl-w-full" v-bind="$attrs" :text="selectedRoleName">
<template v-for="(key, item) in accessLevels">
<gl-dropdown-item
:key="key"
@@ -215,9 +228,13 @@ export default {
{{ $options.labels.cancelButtonText }}
</gl-button>
<div class="gl-mr-3"></div>
- <gl-button ref="inviteButton" variant="success" @click="sendInvite">{{
- $options.labels.inviteButtonText
- }}</gl-button>
+ <gl-button
+ ref="inviteButton"
+ :disabled="!newUsersToInvite"
+ variant="success"
+ @click="sendInvite"
+ >{{ $options.labels.inviteButtonText }}</gl-button
+ >
</div>
</template>
</gl-modal>
diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue
new file mode 100644
index 00000000000..aed2e5e3236
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/members_token_select.vue
@@ -0,0 +1,120 @@
+<script>
+import { debounce } from 'lodash';
+import { GlTokenSelector, GlAvatar, GlAvatarLabeled } from '@gitlab/ui';
+import { USER_SEARCH_DELAY } from '../constants';
+import Api from '~/api';
+
+export default {
+ components: {
+ GlTokenSelector,
+ GlAvatar,
+ GlAvatarLabeled,
+ },
+ props: {
+ placeholder: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ ariaLabelledby: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ query: '',
+ users: [],
+ selectedTokens: [],
+ hasBeenFocused: false,
+ hideDropdownWithNoItems: true,
+ };
+ },
+ computed: {
+ newUsersToInvite() {
+ return this.selectedTokens
+ .map(obj => {
+ return obj.id;
+ })
+ .join(',');
+ },
+ placeholderText() {
+ if (this.selectedTokens.length === 0) {
+ return this.placeholder;
+ }
+ return '';
+ },
+ },
+ methods: {
+ handleTextInput(query) {
+ this.hideDropdownWithNoItems = false;
+ this.query = query;
+ this.loading = true;
+ this.retrieveUsers(query);
+ },
+ retrieveUsers: debounce(function debouncedRetrieveUsers() {
+ return Api.users(this.query, this.$options.queryOptions)
+ .then(response => {
+ this.users = response.data.map(token => ({
+ id: token.id,
+ name: token.name,
+ username: token.username,
+ avatar_url: token.avatar_url,
+ }));
+ this.loading = false;
+ })
+ .catch(() => {
+ this.loading = false;
+ });
+ }, USER_SEARCH_DELAY),
+ handleInput() {
+ this.$emit('input', this.newUsersToInvite);
+ },
+ handleBlur() {
+ this.hideDropdownWithNoItems = false;
+ },
+ handleFocus() {
+ // The modal auto-focuses on the input when opened.
+ // This prevents the dropdown from opening when the modal opens.
+ if (this.hasBeenFocused) {
+ this.loading = true;
+ this.retrieveUsers();
+ }
+
+ this.hasBeenFocused = true;
+ },
+ },
+ queryOptions: { exclude_internal: true, active: true },
+};
+</script>
+
+<template>
+ <gl-token-selector
+ v-model="selectedTokens"
+ :dropdown-items="users"
+ :loading="loading"
+ :allow-user-defined-tokens="false"
+ :hide-dropdown-with-no-items="hideDropdownWithNoItems"
+ :placeholder="placeholderText"
+ :aria-labelledby="ariaLabelledby"
+ @blur="handleBlur"
+ @text-input="handleTextInput"
+ @input="handleInput"
+ @focus="handleFocus"
+ >
+ <template #token-content="{ token }">
+ <gl-avatar v-if="token.avatar_url" :src="token.avatar_url" :size="16" />
+ {{ token.name }}
+ </template>
+
+ <template #dropdown-item-content="{ dropdownItem }">
+ <gl-avatar-labeled
+ :src="dropdownItem.avatar_url"
+ :size="32"
+ :label="dropdownItem.name"
+ :sub-label="dropdownItem.username"
+ />
+ </template>
+ </gl-token-selector>
+</template>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
new file mode 100644
index 00000000000..1ff2125c292
--- /dev/null
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -0,0 +1 @@
+export const USER_SEARCH_DELAY = 200;
diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js
index 92aa3187fc3..db957ecacfd 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_modal.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -18,7 +18,6 @@ export default function initInviteMembersModal() {
props: {
...el.dataset,
accessLevels: JSON.parse(el.dataset.accessLevels),
- groupName: el.dataset.groupName.toUpperCase(),
},
}),
});
diff --git a/app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue b/app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue
new file mode 100644
index 00000000000..5ca9e50d854
--- /dev/null
+++ b/app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue
@@ -0,0 +1,35 @@
+<script>
+export default {
+ props: {
+ expanded: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ watch: {
+ expanded(value) {
+ const layoutPageEl = document.querySelector('.layout-page');
+
+ if (layoutPageEl) {
+ layoutPageEl.classList.toggle('right-sidebar-expanded', value);
+ layoutPageEl.classList.toggle('right-sidebar-collapsed', !value);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <aside
+ :class="{ 'right-sidebar-expanded': expanded, 'right-sidebar-collapsed': !expanded }"
+ class="issues-bulk-update right-sidebar"
+ aria-live="polite"
+ >
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-p-4 gl-border-b-1 gl-border-b-solid gl-border-gray-100"
+ >
+ <slot name="bulk-edit-actions"></slot>
+ </div>
+ <slot name="sidebar-items"></slot>
+ </aside>
+</template>
diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue
index d8cb1ab07cd..1ee794ab208 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_item.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue
@@ -1,15 +1,21 @@
<script>
-import { GlLink, GlLabel, GlTooltipDirective } from '@gitlab/ui';
+import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { isScopedLabel } from '~/lib/utils/common_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
+import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
+
export default {
components: {
GlLink,
+ GlIcon,
GlLabel,
+ GlFormCheckbox,
+ IssuableAssignees,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -24,25 +30,42 @@ export default {
type: Object,
required: true,
},
+ enableLabelPermalinks: {
+ type: Boolean,
+ required: true,
+ },
+ showCheckbox: {
+ type: Boolean,
+ required: true,
+ },
+ checked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
author() {
return this.issuable.author;
},
authorId() {
- const id = parseInt(this.author.id, 10);
-
- if (Number.isNaN(id)) {
- return this.author.id.includes('gid')
- ? this.author.id.split('gid://gitlab/User/').pop()
- : '';
+ return getIdFromGraphQLId(`${this.author.id}`);
+ },
+ isIssuableUrlExternal() {
+ // Check if URL is relative, which means it is internal.
+ if (!/^https?:\/\//g.test(this.issuable.webUrl)) {
+ return false;
}
-
- return id;
+ // In case URL is absolute, it may or may not be internal,
+ // hence use `gon.gitlab_url` which is current instance domain.
+ return !this.issuable.webUrl.includes(gon.gitlab_url);
},
labels() {
return this.issuable.labels?.nodes || this.issuable.labels || [];
},
+ assignees() {
+ return this.issuable.assignees || [];
+ },
createdAt() {
return sprintf(__('created %{timeAgo}'), {
timeAgo: getTimeago().format(this.issuable.createdAt),
@@ -53,11 +76,41 @@ export default {
timeAgo: getTimeago().format(this.issuable.updatedAt),
});
},
+ issuableTitleProps() {
+ if (this.isIssuableUrlExternal) {
+ return {
+ target: '_blank',
+ };
+ }
+ return {};
+ },
+ showDiscussions() {
+ return typeof this.issuable.userDiscussionsCount === 'number';
+ },
+ showIssuableMeta() {
+ return Boolean(
+ this.hasSlotContents('status') || this.showDiscussions || this.issuable.assignees,
+ );
+ },
},
methods: {
+ hasSlotContents(slotName) {
+ return Boolean(this.$slots[slotName]);
+ },
scopedLabel(label) {
return isScopedLabel(label);
},
+ labelTitle(label) {
+ return label.title || label.name;
+ },
+ labelTarget(label) {
+ if (this.enableLabelPermalinks) {
+ const key = encodeURIComponent('label_name[]');
+ const value = encodeURIComponent(this.labelTitle(label));
+ return `?${key}=${value}`;
+ }
+ return '#';
+ },
/**
* This is needed as an independent method since
* when user changes current page, `$refs.authorLink`
@@ -74,17 +127,28 @@ export default {
</script>
<template>
- <li class="issue">
+ <li class="issue gl-px-5!">
<div class="issue-box">
+ <div v-if="showCheckbox" class="issue-check">
+ <gl-form-checkbox
+ class="gl-mr-0"
+ :checked="checked"
+ @input="$emit('checked-input', $event)"
+ />
+ </div>
<div class="issuable-info-container">
<div class="issuable-main-info">
<div data-testid="issuable-title" class="issue-title title">
<span class="issue-title-text" dir="auto">
- <gl-link :href="issuable.webUrl">{{ issuable.title }}</gl-link>
+ <gl-link :href="issuable.webUrl" v-bind="issuableTitleProps"
+ >{{ issuable.title
+ }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2"
+ /></gl-link>
</span>
</div>
<div class="issuable-info">
- <span data-testid="issuable-reference" class="issuable-reference"
+ <slot v-if="hasSlotContents('reference')" name="reference"></slot>
+ <span v-else data-testid="issuable-reference" class="issuable-reference"
>{{ issuableSymbol }}{{ issuable.iid }}</span
>
<span class="issuable-authored d-none d-sm-inline-block">
@@ -96,7 +160,9 @@ export default {
>{{ createdAt }}</span
>
{{ __('by') }}
+ <slot v-if="hasSlotContents('author')" name="author"></slot>
<gl-link
+ v-else
:data-user-id="authorId"
:data-username="author.username"
:data-name="author.name"
@@ -108,20 +174,52 @@ export default {
<span class="author">{{ author.name }}</span>
</gl-link>
</span>
+ <slot name="timeframe"></slot>
&nbsp;
<gl-label
v-for="(label, index) in labels"
:key="index"
:background-color="label.color"
- :title="label.title"
+ :title="labelTitle(label)"
:description="label.description"
:scoped="scopedLabel(label)"
+ :target="labelTarget(label)"
:class="{ 'gl-ml-2': index }"
size="sm"
/>
</div>
</div>
<div class="issuable-meta">
+ <ul v-if="showIssuableMeta" class="controls">
+ <li v-if="hasSlotContents('status')" class="issuable-status">
+ <slot name="status"></slot>
+ </li>
+ <li
+ v-if="showDiscussions"
+ data-testid="issuable-discussions"
+ class="issuable-comments gl-display-none gl-display-sm-block"
+ >
+ <gl-link
+ v-gl-tooltip:tooltipcontainer.top
+ :title="__('Comments')"
+ :href="`${issuable.webUrl}#notes`"
+ :class="{ 'no-comments': !issuable.userDiscussionsCount }"
+ class="gl-reset-color!"
+ >
+ <gl-icon name="comments" />
+ {{ issuable.userDiscussionsCount }}
+ </gl-link>
+ </li>
+ <li v-if="assignees.length" class="gl-display-flex">
+ <issuable-assignees
+ :assignees="issuable.assignees"
+ :icon-size="16"
+ :max-visible="4"
+ img-css-classes="gl-mr-2!"
+ class="gl-align-items-center gl-display-flex gl-ml-3"
+ />
+ </li>
+ </ul>
<div
data-testid="issuable-updated-at"
class="float-right issuable-updated-at d-none d-sm-inline-block"
diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
index 7535203dea1..b2312c55f01 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
@@ -1,17 +1,23 @@
<script>
-import { GlLoadingIcon, GlPagination } from '@gitlab/ui';
+import { GlSkeletonLoading, GlPagination } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import IssuableTabs from './issuable_tabs.vue';
import IssuableItem from './issuable_item.vue';
+import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue';
+
+import { DEFAULT_SKELETON_COUNT } from '../constants';
export default {
components: {
- GlLoadingIcon,
+ GlSkeletonLoading,
IssuableTabs,
FilteredSearchBar,
IssuableItem,
+ IssuableBulkEditSidebar,
GlPagination,
},
props: {
@@ -35,6 +41,11 @@ export default {
type: Array,
required: true,
},
+ urlParams: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
initialFilterValue: {
type: Array,
required: false,
@@ -55,7 +66,8 @@ export default {
},
tabCounts: {
type: Object,
- required: true,
+ required: false,
+ default: null,
},
currentTab: {
type: String,
@@ -76,11 +88,21 @@ export default {
required: false,
default: false,
},
+ showBulkEditSidebar: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
defaultPageSize: {
type: Number,
required: false,
default: 20,
},
+ totalItems: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
currentPage: {
type: Number,
required: false,
@@ -96,6 +118,92 @@ export default {
required: false,
default: 2,
},
+ enableLabelPermalinks: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ checkedIssuables: {},
+ };
+ },
+ computed: {
+ skeletonItemCount() {
+ const { totalItems, defaultPageSize, currentPage } = this;
+ const totalPages = Math.ceil(totalItems / defaultPageSize);
+
+ if (totalPages) {
+ return currentPage < totalPages
+ ? defaultPageSize
+ : totalItems % defaultPageSize || defaultPageSize;
+ }
+ return DEFAULT_SKELETON_COUNT;
+ },
+ allIssuablesChecked() {
+ return this.bulkEditIssuables.length === this.issuables.length;
+ },
+ /**
+ * Returns all the checked issuables from `checkedIssuables` map.
+ */
+ bulkEditIssuables() {
+ return Object.keys(this.checkedIssuables).reduce((acc, issuableId) => {
+ if (this.checkedIssuables[issuableId].checked) {
+ acc.push(this.checkedIssuables[issuableId].issuable);
+ }
+ return acc;
+ }, []);
+ },
+ },
+ watch: {
+ issuables(list) {
+ this.checkedIssuables = list.reduce((acc, issuable) => {
+ const id = this.issuableId(issuable);
+ acc[id] = {
+ // By default, an issuable is not checked,
+ // But if `checkedIssuables` is already
+ // populated, use existing value.
+ checked:
+ typeof this.checkedIssuables[id] !== 'boolean'
+ ? false
+ : this.checkedIssuables[id].checked,
+ // We're caching issuable reference here
+ // for ease of populating in `bulkEditIssuables`.
+ issuable,
+ };
+ return acc;
+ }, {});
+ },
+ urlParams: {
+ deep: true,
+ immediate: true,
+ handler(params) {
+ if (Object.keys(params).length) {
+ updateHistory({
+ url: setUrlParams(params, window.location.href, true),
+ title: document.title,
+ replace: true,
+ });
+ }
+ },
+ },
+ },
+ methods: {
+ issuableId(issuable) {
+ return issuable.id || issuable.iid || uniqueId();
+ },
+ issuableChecked(issuable) {
+ return this.checkedIssuables[this.issuableId(issuable)]?.checked;
+ },
+ handleIssuableCheckedInput(issuable, value) {
+ this.checkedIssuables[this.issuableId(issuable)].checked = value;
+ },
+ handleAllIssuablesCheckedInput(value) {
+ Object.keys(this.checkedIssuables).forEach(issuableId => {
+ this.checkedIssuables[issuableId].checked = value;
+ });
+ },
},
};
</script>
@@ -120,27 +228,60 @@ export default {
:sort-options="sortOptions"
:initial-filter-value="initialFilterValue"
:initial-sort-by="initialSortBy"
+ :show-checkbox="showBulkEditSidebar"
+ :checkbox-checked="allIssuablesChecked"
class="gl-flex-grow-1 row-content-block"
+ @checked-input="handleAllIssuablesCheckedInput"
@onFilter="$emit('filter', $event)"
@onSort="$emit('sort', $event)"
/>
+ <issuable-bulk-edit-sidebar :expanded="showBulkEditSidebar">
+ <template #bulk-edit-actions>
+ <slot name="bulk-edit-actions" :checked-issuables="bulkEditIssuables"></slot>
+ </template>
+ <template #sidebar-items>
+ <slot name="sidebar-items" :checked-issuables="bulkEditIssuables"></slot>
+ </template>
+ </issuable-bulk-edit-sidebar>
<div class="issuables-holder">
- <gl-loading-icon v-if="issuablesLoading" size="md" class="gl-mt-5" />
+ <ul v-if="issuablesLoading" class="content-list">
+ <li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!">
+ <gl-skeleton-loading />
+ </li>
+ </ul>
<ul
v-if="!issuablesLoading && issuables.length"
class="content-list issuable-list issues-list"
>
<issuable-item
v-for="issuable in issuables"
- :key="issuable.id"
+ :key="issuableId(issuable)"
:issuable-symbol="issuableSymbol"
:issuable="issuable"
- />
+ :enable-label-permalinks="enableLabelPermalinks"
+ :show-checkbox="showBulkEditSidebar"
+ :checked="issuableChecked(issuable)"
+ @checked-input="handleIssuableCheckedInput(issuable, $event)"
+ >
+ <template #reference>
+ <slot name="reference" :issuable="issuable"></slot>
+ </template>
+ <template #author>
+ <slot name="author" :author="issuable.author"></slot>
+ </template>
+ <template #timeframe>
+ <slot name="timeframe" :issuable="issuable"></slot>
+ </template>
+ <template #status>
+ <slot name="status" :issuable="issuable"></slot>
+ </template>
+ </issuable-item>
</ul>
<slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot>
<gl-pagination
v-if="showPaginationControls"
:per-page="defaultPageSize"
+ :total-items="totalItems"
:value="currentPage"
:prev-page="previousPage"
:next-page="nextPage"
diff --git a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue
index df544ce69e7..d9aab004077 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue
@@ -14,7 +14,8 @@ export default {
},
tabCounts: {
type: Object,
- required: true,
+ required: false,
+ default: null,
},
currentTab: {
type: String,
@@ -40,7 +41,7 @@ export default {
>
<template #title>
<span :title="tab.titleTooltip">{{ tab.title }}</span>
- <gl-badge variant="neutral" size="sm" class="gl-px-2 gl-py-1!">{{
+ <gl-badge v-if="tabCounts" variant="neutral" size="sm" class="gl-px-2 gl-py-1!">{{
tabCounts[tab.name]
}}</gl-badge>
</template>
diff --git a/app/assets/javascripts/issuable_list/constants.js b/app/assets/javascripts/issuable_list/constants.js
new file mode 100644
index 00000000000..773ad0f8e93
--- /dev/null
+++ b/app/assets/javascripts/issuable_list/constants.js
@@ -0,0 +1,51 @@
+import { __ } from '~/locale';
+
+export const IssuableStates = {
+ Opened: 'opened',
+ Closed: 'closed',
+ All: 'all',
+};
+
+export const IssuableListTabs = [
+ {
+ id: 'state-opened',
+ name: IssuableStates.Opened,
+ title: __('Open'),
+ titleTooltip: __('Filter by issues that are currently opened.'),
+ },
+ {
+ id: 'state-closed',
+ name: IssuableStates.Closed,
+ title: __('Closed'),
+ titleTooltip: __('Filter by issues that are currently closed.'),
+ },
+ {
+ id: 'state-all',
+ name: IssuableStates.All,
+ title: __('All'),
+ titleTooltip: __('Show all issues.'),
+ },
+];
+
+export const AvailableSortOptions = [
+ {
+ id: 1,
+ title: __('Created date'),
+ sortDirection: {
+ descending: 'created_desc',
+ ascending: 'created_asc',
+ },
+ },
+ {
+ id: 2,
+ title: __('Last updated'),
+ sortDirection: {
+ descending: 'updated_desc',
+ ascending: 'updated_asc',
+ },
+ },
+];
+
+export const DEFAULT_PAGE_SIZE = 20;
+
+export const DEFAULT_SKELETON_COUNT = 5;
diff --git a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue
index 7d1339f833d..7cacba1cb65 100644
--- a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue
+++ b/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue
@@ -29,6 +29,7 @@ export default {
},
mounted() {
window.addEventListener('resize', this.handleWindowResize);
+ this.updatePageContainerClass();
},
beforeDestroy() {
window.removeEventListener('resize', this.handleWindowResize);
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 0a0cfe918af..f65d9259e7b 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -84,13 +84,7 @@ export default class Issue {
projectIssuesCounter.text(addDelimiter(numProjectIssues));
if (this.createMergeRequestDropdown) {
- if (isClosed) {
- this.createMergeRequestDropdown.unavailable();
- this.createMergeRequestDropdown.disable();
- } else {
- // We should check in case a branch was created in another tab
- this.createMergeRequestDropdown.checkAbilityToCreateBranch();
- }
+ this.createMergeRequestDropdown.checkAbilityToCreateBranch();
}
} else {
flash(issueFailMessage);
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 22db0f1cfc1..61e5db0970a 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -136,6 +136,16 @@ export default {
type: String,
required: true,
},
+ isConfidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isLocked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
issuableType: {
type: String,
required: false,
@@ -217,8 +227,8 @@ export default {
defaultErrorMessage() {
return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType });
},
- isOpenStatus() {
- return this.issuableStatus === IssuableStatus.Open;
+ isClosed() {
+ return this.issuableStatus === IssuableStatus.Closed;
},
pinnedLinkClasses() {
return this.showTitleBorder
@@ -226,13 +236,13 @@ export default {
: '';
},
statusIcon() {
- return this.isOpenStatus ? 'issue-open-m' : 'mobile-issue-close';
+ return this.isClosed ? 'mobile-issue-close' : 'issue-open-m';
},
statusText() {
return IssuableStatusText[this.issuableStatus];
},
shouldShowStickyHeader() {
- return this.isStickyHeaderShowing && this.issuableType === IssuableType.Issue;
+ return this.issuableType === IssuableType.Issue;
},
},
created() {
@@ -432,10 +442,14 @@ export default {
:show-inline-edit-button="showInlineEditButton"
/>
- <gl-intersection-observer @appear="hideStickyHeader" @disappear="showStickyHeader">
+ <gl-intersection-observer
+ v-if="shouldShowStickyHeader"
+ @appear="hideStickyHeader"
+ @disappear="showStickyHeader"
+ >
<transition name="issuable-header-slide">
<div
- v-if="shouldShowStickyHeader"
+ v-if="isStickyHeaderShowing"
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="issue-sticky-header"
>
@@ -444,11 +458,17 @@ export default {
>
<p
class="issuable-status-box status-box gl-my-0"
- :class="[isOpenStatus ? 'status-box-open' : 'status-box-issue-closed']"
+ :class="[isClosed ? 'status-box-issue-closed' : 'status-box-open']"
>
<gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" />
<span class="gl-display-none d-sm-block">{{ statusText }}</span>
</p>
+ <span v-if="isLocked" data-testid="locked" class="issuable-warning-icon">
+ <gl-icon name="lock" :aria-label="__('Locked')" />
+ </span>
+ <span v-if="isConfidential" data-testid="confidential" class="issuable-warning-icon">
+ <gl-icon name="eye-slash" :aria-label="__('Confidential')" />
+ </span>
<p
class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0"
:title="state.titleText"
diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue
new file mode 100644
index 00000000000..4c8c86390f4
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/header_actions.vue
@@ -0,0 +1,281 @@
+<script>
+import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui';
+import { mapGetters } from 'vuex';
+import createFlash, { FLASH_TYPES } from '~/flash';
+import { IssuableType } from '~/issuable_show/constants';
+import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { __, sprintf } from '~/locale';
+import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql';
+import updateIssueMutation from '../queries/update_issue.mutation.graphql';
+
+export default {
+ components: {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlLink,
+ GlModal,
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ actionPrimary: {
+ text: __('Yes, close issue'),
+ attributes: [{ variant: 'warning' }],
+ },
+ i18n: {
+ promoteErrorMessage: __(
+ 'Something went wrong while promoting the issue to an epic. Please try again.',
+ ),
+ promoteSuccessMessage: __(
+ 'The issue was successfully promoted to an epic. Redirecting to epic...',
+ ),
+ },
+ inject: {
+ canCreateIssue: {
+ default: false,
+ },
+ canPromoteToEpic: {
+ default: false,
+ },
+ canReopenIssue: {
+ default: false,
+ },
+ canReportSpam: {
+ default: false,
+ },
+ canUpdateIssue: {
+ default: false,
+ },
+ iid: {
+ default: '',
+ },
+ isIssueAuthor: {
+ default: false,
+ },
+ issueType: {
+ default: IssuableType.Issue,
+ },
+ newIssuePath: {
+ default: '',
+ },
+ projectPath: {
+ default: '',
+ },
+ reportAbusePath: {
+ default: '',
+ },
+ submitAsSpamPath: {
+ default: '',
+ },
+ },
+ data() {
+ return {
+ isUpdatingState: false,
+ };
+ },
+ computed: {
+ ...mapGetters(['getNoteableData']),
+ isClosed() {
+ return this.getNoteableData.state === IssuableStatus.Closed;
+ },
+ buttonText() {
+ return this.isClosed
+ ? sprintf(__('Reopen %{issueType}'), { issueType: this.issueType })
+ : sprintf(__('Close %{issueType}'), { issueType: this.issueType });
+ },
+ qaSelector() {
+ return this.isClosed ? 'reopen_issue_button' : 'close_issue_button';
+ },
+ buttonVariant() {
+ return this.isClosed ? 'default' : 'warning';
+ },
+ dropdownText() {
+ return sprintf(__('%{issueType} actions'), {
+ issueType: capitalizeFirstCharacter(this.issueType),
+ });
+ },
+ newIssueTypeText() {
+ return sprintf(__('New %{issueType}'), { issueType: this.issueType });
+ },
+ showToggleIssueStateButton() {
+ const canClose = !this.isClosed && this.canUpdateIssue;
+ const canReopen = this.isClosed && this.canReopenIssue;
+ return canClose || canReopen;
+ },
+ },
+ methods: {
+ toggleIssueState() {
+ if (!this.isClosed && this.getNoteableData?.blocked_by_issues?.length) {
+ this.$refs.blockedByIssuesModal.show();
+ return;
+ }
+
+ this.invokeUpdateIssueMutation();
+ },
+ invokeUpdateIssueMutation() {
+ this.isUpdatingState = true;
+
+ this.$apollo
+ .mutate({
+ mutation: updateIssueMutation,
+ variables: {
+ input: {
+ iid: this.iid.toString(),
+ projectPath: this.projectPath,
+ stateEvent: this.isClosed ? IssueStateEvent.Reopen : IssueStateEvent.Close,
+ },
+ },
+ })
+ .then(({ data }) => {
+ if (data.updateIssue.errors.length) {
+ createFlash({ message: data.updateIssue.errors.join('. ') });
+ return;
+ }
+
+ const payload = {
+ detail: {
+ data: { id: this.iid },
+ isClosed: !this.isClosed,
+ },
+ };
+
+ // Dispatch event which updates open/close state, shared among the issue show page
+ document.dispatchEvent(new CustomEvent('issuable_vue_app:change', payload));
+ })
+ .catch(() => createFlash({ message: __('Update failed. Please try again.') }))
+ .finally(() => {
+ this.isUpdatingState = false;
+ });
+ },
+ promoteToEpic() {
+ this.isUpdatingState = true;
+
+ this.$apollo
+ .mutate({
+ mutation: promoteToEpicMutation,
+ variables: {
+ input: {
+ iid: this.iid,
+ projectPath: this.projectPath,
+ },
+ },
+ })
+ .then(({ data }) => {
+ if (data.promoteToEpic.errors.length) {
+ createFlash({ message: data.promoteToEpic.errors.join('; ') });
+ return;
+ }
+
+ createFlash({
+ message: this.$options.i18n.promoteSuccessMessage,
+ type: FLASH_TYPES.SUCCESS,
+ });
+
+ visitUrl(data.promoteToEpic.epic.webPath);
+ })
+ .catch(() => createFlash({ message: this.$options.i18n.promoteErrorMessage }))
+ .finally(() => {
+ this.isUpdatingState = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="detail-page-header-actions">
+ <gl-dropdown class="gl-display-block gl-display-sm-none!" block :text="dropdownText">
+ <gl-dropdown-item
+ v-if="showToggleIssueStateButton"
+ :disabled="isUpdatingState"
+ @click="toggleIssueState"
+ >
+ {{ buttonText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
+ {{ newIssueTypeText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="canPromoteToEpic" :disabled="isUpdatingState" @click="promoteToEpic">
+ {{ __('Promote to epic') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
+ {{ __('Report abuse') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="canReportSpam"
+ :href="submitAsSpamPath"
+ data-method="post"
+ rel="nofollow"
+ >
+ {{ __('Submit as spam') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+
+ <gl-button
+ v-if="showToggleIssueStateButton"
+ class="gl-display-none gl-display-sm-inline-flex!"
+ category="secondary"
+ :data-qa-selector="qaSelector"
+ :loading="isUpdatingState"
+ :variant="buttonVariant"
+ @click="toggleIssueState"
+ >
+ {{ buttonText }}
+ </gl-button>
+
+ <gl-dropdown
+ class="gl-display-none gl-display-sm-inline-flex!"
+ toggle-class="gl-border-0! gl-shadow-none!"
+ no-caret
+ right
+ >
+ <template #button-content>
+ <gl-icon name="ellipsis_v" aria-hidden="true" />
+ <span class="gl-sr-only">{{ dropdownText }}</span>
+ </template>
+
+ <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
+ {{ newIssueTypeText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="canPromoteToEpic"
+ :disabled="isUpdatingState"
+ data-testid="promote-button"
+ @click="promoteToEpic"
+ >
+ {{ __('Promote to epic') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
+ {{ __('Report abuse') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="canReportSpam"
+ :href="submitAsSpamPath"
+ data-method="post"
+ rel="nofollow"
+ >
+ {{ __('Submit as spam') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+
+ <gl-modal
+ ref="blockedByIssuesModal"
+ modal-id="blocked-by-issues-modal"
+ :action-cancel="$options.actionCancel"
+ :action-primary="$options.actionPrimary"
+ :title="__('Are you sure you want to close this blocked issue?')"
+ @primary="invokeUpdateIssueMutation"
+ >
+ <p>{{ __('This issue is currently blocked by the following issues:') }}</p>
+ <ul>
+ <li v-for="issue in getNoteableData.blocked_by_issues" :key="issue.iid">
+ <gl-link :href="issue.web_url">#{{ issue.iid }}</gl-link>
+ </li>
+ </ul>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issue_show/constants.js
index 6bc6ed2b372..a5ca91dffd4 100644
--- a/app/assets/javascripts/issue_show/constants.js
+++ b/app/assets/javascripts/issue_show/constants.js
@@ -1,13 +1,15 @@
import { __ } from '~/locale';
export const IssuableStatus = {
- Open: 'opened',
Closed: 'closed',
+ Open: 'opened',
+ Reopened: 'reopened',
};
export const IssuableStatusText = {
- [IssuableStatus.Open]: __('Open'),
[IssuableStatus.Closed]: __('Closed'),
+ [IssuableStatus.Open]: __('Open'),
+ [IssuableStatus.Reopened]: __('Open'),
};
export const IssuableType = {
@@ -16,5 +18,10 @@ export const IssuableType = {
MergeRequest: 'merge_request',
};
+export const IssueStateEvent = {
+ Close: 'CLOSE',
+ Reopen: 'REOPEN',
+};
+
export const STATUS_PAGE_PUBLISHED = __('Published on status page');
export const JOIN_ZOOM_MEETING = __('Join Zoom meeting');
diff --git a/app/assets/javascripts/issue_show/issue.js b/app/assets/javascripts/issue_show/issue.js
index f9f61d5aa64..8260460828b 100644
--- a/app/assets/javascripts/issue_show/issue.js
+++ b/app/assets/javascripts/issue_show/issue.js
@@ -1,16 +1,62 @@
import Vue from 'vue';
-import issuableApp from './components/app.vue';
+import VueApollo from 'vue-apollo';
+import { mapGetters } from 'vuex';
+import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import IssuableApp from './components/app.vue';
+import HeaderActions from './components/header_actions.vue';
-export default function initIssuableApp(issuableData) {
+export function initIssuableApp(issuableData, store) {
return new Vue({
el: document.getElementById('js-issuable-app'),
- components: {
- issuableApp,
+ store,
+ computed: {
+ ...mapGetters(['getNoteableData']),
},
render(createElement) {
- return createElement('issuable-app', {
- props: issuableData,
+ return createElement(IssuableApp, {
+ props: {
+ ...issuableData,
+ isConfidential: this.getNoteableData?.confidential,
+ isLocked: this.getNoteableData?.discussion_locked,
+ issuableStatus: this.getNoteableData?.state,
+ },
});
},
});
}
+
+export function initIssueHeaderActions(store) {
+ const el = document.querySelector('.js-issue-header-actions');
+
+ if (!el) {
+ return undefined;
+ }
+
+ Vue.use(VueApollo);
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ store,
+ provide: {
+ canCreateIssue: parseBoolean(el.dataset.canCreateIssue),
+ canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic),
+ canReopenIssue: parseBoolean(el.dataset.canReopenIssue),
+ canReportSpam: parseBoolean(el.dataset.canReportSpam),
+ canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue),
+ iid: el.dataset.iid,
+ isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor),
+ issueType: el.dataset.issueType,
+ newIssuePath: el.dataset.newIssuePath,
+ projectPath: el.dataset.projectPath,
+ reportAbusePath: el.dataset.reportAbusePath,
+ submitAsSpamPath: el.dataset.submitAsSpamPath,
+ },
+ render: createElement => createElement(HeaderActions),
+ });
+}
diff --git a/app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql b/app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql
new file mode 100644
index 00000000000..12d05af0f5e
--- /dev/null
+++ b/app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql
@@ -0,0 +1,8 @@
+mutation promoteToEpic($input: PromoteToEpicInput!) {
+ promoteToEpic(input: $input) {
+ epic {
+ webPath
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql b/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql
new file mode 100644
index 00000000000..9c28fdded21
--- /dev/null
+++ b/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql
@@ -0,0 +1,5 @@
+mutation updateIssue($input: UpdateIssueInput!) {
+ updateIssue(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue
index dc63d613b5b..b12b20d0135 100644
--- a/app/assets/javascripts/issues_list/components/issuable.vue
+++ b/app/assets/javascripts/issues_list/components/issuable.vue
@@ -28,7 +28,6 @@ import initUserPopovers from '~/user_popovers';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import { isScopedLabel } from '~/lib/utils/common_utils';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { convertToCamelCase } from '~/lib/utils/text_utility';
@@ -37,6 +36,7 @@ export default {
openedAgo: __('opened %{timeAgoString} by %{user}'),
openedAgoJira: __('opened %{timeAgoString} by %{user} in Jira'),
},
+ inject: ['scopedLabelsAvailable'],
components: {
IssueAssignees,
GlLink,
@@ -50,7 +50,6 @@ export default {
GlTooltip,
SafeHtml,
},
- mixins: [glFeatureFlagsMixin()],
props: {
issuable: {
type: Object,
@@ -85,9 +84,6 @@ export default {
return this.issuableLink({ milestone_title: title });
},
- scopedLabelsAvailable() {
- return this.glFeatures.scopedLabels;
- },
hasWeight() {
return isNumber(this.issuable.weight);
},
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index 1ff41c20d08..5ef86536865 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -41,10 +41,13 @@ function mountIssuablesListApp() {
}
document.querySelectorAll('.js-issuables-list').forEach(el => {
- const { canBulkEdit, emptyStateMeta = {}, ...data } = el.dataset;
+ const { canBulkEdit, emptyStateMeta = {}, scopedLabelsAvailable, ...data } = el.dataset;
return new Vue({
el,
+ provide: {
+ scopedLabelsAvailable: parseBoolean(scopedLabelsAvailable),
+ },
render(createElement) {
return createElement(IssuablesListApp, {
props: {
diff --git a/app/assets/javascripts/jira_connect/.eslintrc.yml b/app/assets/javascripts/jira_connect/.eslintrc.yml
new file mode 100644
index 00000000000..053f8c6b285
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/.eslintrc.yml
@@ -0,0 +1,5 @@
+globals:
+ AP: readonly
+rules:
+ '@gitlab/require-i18n-strings': off
+ '@gitlab/vue-require-i18n-strings': off
diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue
new file mode 100644
index 00000000000..6d32ba41eae
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/components/app.vue
@@ -0,0 +1,7 @@
+<script>
+export default {};
+</script>
+
+<template>
+ <div></div>
+</template>
diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js
new file mode 100644
index 00000000000..37f00d56a05
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/index.js
@@ -0,0 +1,15 @@
+import Vue from 'vue';
+import App from './components/app.vue';
+
+function initJiraConnect() {
+ const el = document.querySelector('.js-jira-connect-app');
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(App, {});
+ },
+ });
+}
+
+document.addEventListener('DOMContentLoaded', initJiraConnect);
diff --git a/app/assets/javascripts/jobs/components/job_retry_forward_deployment_modal.vue b/app/assets/javascripts/jobs/components/job_retry_forward_deployment_modal.vue
new file mode 100644
index 00000000000..5ce9d08035d
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job_retry_forward_deployment_modal.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlLink, GlModal } from '@gitlab/ui';
+import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '../constants';
+
+export default {
+ name: 'JobRetryForwardDeploymentModal',
+ components: {
+ GlLink,
+ GlModal,
+ },
+ i18n: {
+ ...JOB_RETRY_FORWARD_DEPLOYMENT_MODAL,
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ href: {
+ type: String,
+ required: true,
+ },
+ },
+ inject: {
+ retryOutdatedJobDocsUrl: {
+ default: '',
+ },
+ },
+ data() {
+ return {
+ primaryProps: {
+ text: this.$options.i18n.primaryText,
+ attributes: [
+ {
+ 'data-method': 'post',
+ 'data-testid': 'retry-button-modal',
+ href: this.href,
+ variant: 'danger',
+ },
+ ],
+ },
+ cancelProps: {
+ text: this.$options.i18n.cancel,
+ attributes: [{ category: 'secondary', variant: 'default' }],
+ },
+ };
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ :action-cancel="cancelProps"
+ :action-primary="primaryProps"
+ :modal-id="modalId"
+ :title="$options.i18n.title"
+ >
+ <p>
+ {{ $options.i18n.info }}
+ <gl-link v-if="retryOutdatedJobDocsUrl" :href="retryOutdatedJobDocsUrl" target="_blank">
+ {{ $options.i18n.moreInfo }}
+ </gl-link>
+ </p>
+ <p>{{ $options.i18n.areYouSure }}</p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue
new file mode 100644
index 00000000000..258b8cadd63
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlButton, GlLink, GlModalDirective } from '@gitlab/ui';
+import { mapGetters } from 'vuex';
+import { JOB_SIDEBAR } from '../constants';
+
+export default {
+ name: 'JobSidebarRetryButton',
+ i18n: {
+ retryLabel: JOB_SIDEBAR.retry,
+ },
+ components: {
+ GlButton,
+ GlLink,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ href: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters(['hasForwardDeploymentFailure']),
+ },
+};
+</script>
+<template>
+ <gl-button
+ v-if="hasForwardDeploymentFailure"
+ v-gl-modal="modalId"
+ :aria-label="$options.i18n.retryLabel"
+ category="primary"
+ variant="info"
+ >{{ $options.i18n.retryLabel }}</gl-button
+ >
+ <gl-link v-else :href="href" data-method="post" rel="nofollow"
+ >{{ $options.i18n.retryLabel }}
+ </gl-link>
+</template>
diff --git a/app/assets/javascripts/jobs/components/jobs_container.vue b/app/assets/javascripts/jobs/components/jobs_container.vue
index 951bcb36600..df64b6422c7 100644
--- a/app/assets/javascripts/jobs/components/jobs_container.vue
+++ b/app/assets/javascripts/jobs/components/jobs_container.vue
@@ -24,7 +24,7 @@ export default {
};
</script>
<template>
- <div class="js-jobs-container builds-container">
+ <div class="builds-container">
<job-container-item
v-for="job in jobs"
:key="job.id"
diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue
index e68d5b8eda4..affaddcdee2 100644
--- a/app/assets/javascripts/jobs/components/log/line.vue
+++ b/app/assets/javascripts/jobs/components/log/line.vue
@@ -1,4 +1,6 @@
<script>
+import { linkRegex } from '../../utils';
+
import LineNumber from './line_number.vue';
export default {
@@ -16,15 +18,46 @@ export default {
render(h, { props }) {
const { line, path } = props;
- const chars = line.content.map(content => {
- return h(
- 'span',
- {
- class: ['gl-white-space-pre-wrap', content.style],
- },
- content.text,
- );
- });
+ let chars;
+ if (gon?.features?.ciJobLineLinks) {
+ chars = line.content.map(content => {
+ return h(
+ 'span',
+ {
+ class: ['gl-white-space-pre-wrap', content.style],
+ },
+ // Simple "tokenization": Split text in chunks of text
+ // which alternate between text and urls.
+ content.text.split(linkRegex).map(chunk => {
+ // Return normal string for non-links
+ if (!chunk.match(linkRegex)) {
+ return chunk;
+ }
+ return h(
+ 'a',
+ {
+ attrs: {
+ href: chunk,
+ class: 'gl-reset-color! gl-text-decoration-underline',
+ rel: 'nofollow noopener noreferrer', // eslint-disable-line @gitlab/require-i18n-strings
+ },
+ },
+ chunk,
+ );
+ }),
+ );
+ });
+ } else {
+ chars = line.content.map(content => {
+ return h(
+ 'span',
+ {
+ class: ['gl-white-space-pre-wrap', content.style],
+ },
+ content.text,
+ );
+ });
+ }
return h('div', { class: 'js-line log-line' }, [
h(LineNumber, {
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index 8701e05a01f..0789bb54f0f 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -1,33 +1,40 @@
<script>
import { isEmpty } from 'lodash';
-import { mapActions, mapState } from 'vuex';
-import { GlLink, GlButton, GlIcon } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { GlButton, GlIcon, GlLink } from '@gitlab/ui';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
-import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
-import DetailRow from './sidebar_detail_row.vue';
import ArtifactsBlock from './artifacts_block.vue';
+import JobSidebarRetryButton from './job_sidebar_retry_button.vue';
+import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue';
import TriggerBlock from './trigger_block.vue';
import CommitBlock from './commit_block.vue';
import StagesDropdown from './stages_dropdown.vue';
import JobsContainer from './jobs_container.vue';
+import JobSidebarDetailsContainer from './sidebar_job_details_container.vue';
+import { JOB_SIDEBAR } from '../constants';
+
+export const forwardDeploymentFailureModalId = 'forward-deployment-failure';
export default {
name: 'JobSidebar',
+ i18n: {
+ ...JOB_SIDEBAR,
+ },
+ forwardDeploymentFailureModalId,
components: {
ArtifactsBlock,
CommitBlock,
- DetailRow,
+ GlButton,
+ GlLink,
GlIcon,
- TriggerBlock,
- StagesDropdown,
JobsContainer,
- GlLink,
- GlButton,
+ JobSidebarRetryButton,
+ JobRetryForwardDeploymentModal,
+ JobSidebarDetailsContainer,
+ StagesDropdown,
TooltipOnTruncate,
+ TriggerBlock,
},
- mixins: [timeagoMixin],
props: {
artifactHelpUrl: {
type: String,
@@ -41,54 +48,14 @@ export default {
},
},
computed: {
+ ...mapGetters(['hasForwardDeploymentFailure']),
...mapState(['job', 'stages', 'jobs', 'selectedStage']),
- coverage() {
- return `${this.job.coverage}%`;
- },
- duration() {
- return timeIntervalInWords(this.job.duration);
- },
- queued() {
- return timeIntervalInWords(this.job.queued);
- },
- runnerId() {
- return `${this.job.runner.description} (#${this.job.runner.id})`;
- },
retryButtonClass() {
- let className = 'js-retry-button btn btn-retry';
+ let className = 'btn btn-retry';
className +=
this.job.status && this.job.recoverable ? ' btn-primary' : ' btn-inverted-secondary';
return className;
},
- hasTimeout() {
- return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null;
- },
- timeout() {
- if (this.job.metadata == null) {
- return '';
- }
-
- let t = this.job.metadata.timeout_human_readable;
- if (this.job.metadata.timeout_source !== '') {
- t += sprintf(__(` (from %{timeoutSource})`), {
- timeoutSource: this.job.metadata.timeout_source,
- });
- }
-
- return t;
- },
- renderBlock() {
- return (
- this.job.duration ||
- this.job.finished_at ||
- this.job.erased_at ||
- this.job.queued ||
- this.hasTimeout ||
- this.job.runner ||
- this.job.coverage ||
- this.job.tags.length
- );
- },
hasArtifact() {
return !isEmpty(this.job.artifact);
},
@@ -96,16 +63,13 @@ export default {
return !isEmpty(this.job.trigger);
},
hasStages() {
- return (
- (this.job &&
- this.job.pipeline &&
- this.job.pipeline.stages &&
- this.job.pipeline.stages.length > 0) ||
- false
- );
+ return this.job?.pipeline?.stages?.length > 0;
},
commit() {
- return this.job.pipeline && this.job.pipeline.commit ? this.job.pipeline.commit : {};
+ return this.job?.pipeline?.commit || {};
+ },
+ shouldShowJobRetryForwardDeploymentModal() {
+ return this.job.retry_path && this.hasForwardDeploymentFailure;
},
},
methods: {
@@ -124,29 +88,29 @@ export default {
</h4>
</tooltip-on-truncate>
<div class="flex-grow-1 flex-shrink-0 text-right">
- <gl-link
+ <job-sidebar-retry-button
v-if="job.retry_path"
:class="retryButtonClass"
:href="job.retry_path"
- data-method="post"
+ :modal-id="$options.forwardDeploymentFailureModalId"
data-qa-selector="retry_button"
- rel="nofollow"
- >{{ __('Retry') }}</gl-link
- >
+ data-testid="retry-button"
+ />
<gl-link
v-if="job.cancel_path"
:href="job.cancel_path"
- class="js-cancel-job btn btn-default"
+ class="btn btn-default"
data-method="post"
+ data-testid="cancel-button"
rel="nofollow"
- >{{ __('Cancel') }}</gl-link
- >
+ >{{ $options.i18n.cancel }}
+ </gl-link>
</div>
<gl-button
- :aria-label="__('Toggle Sidebar')"
- class="d-md-none gl-ml-2 js-sidebar-build-toggle"
+ :aria-label="$options.i18n.toggleSidebar"
category="tertiary"
+ class="gl-display-md-none gl-ml-2 js-sidebar-build-toggle"
icon="chevron-double-lg-right"
@click="toggleSidebar"
/>
@@ -158,77 +122,43 @@ export default {
:href="job.new_issue_path"
class="btn btn-success btn-inverted float-left mr-2"
data-testid="job-new-issue"
- >{{ __('New issue') }}</gl-link
- >
+ >{{ $options.i18n.newIssue }}
+ </gl-link>
<gl-link
v-if="job.terminal_path"
:href="job.terminal_path"
- class="js-terminal-link btn btn-primary btn-inverted visible-md-block visible-lg-block float-left"
+ class="btn btn-primary btn-inverted visible-md-block visible-lg-block float-left"
target="_blank"
+ data-testid="terminal-link"
>
- {{ __('Debug') }} <gl-icon name="external-link" :size="14" />
+ {{ $options.i18n.debug }}
+ <gl-icon :size="14" name="external-link" />
</gl-link>
</div>
-
- <div v-if="renderBlock" class="block">
- <detail-row
- v-if="job.duration"
- :value="duration"
- class="js-job-duration"
- title="Duration"
- />
- <detail-row
- v-if="job.finished_at"
- :value="timeFormatted(job.finished_at)"
- class="js-job-finished"
- title="Finished"
- />
- <detail-row
- v-if="job.erased_at"
- :value="timeFormatted(job.erased_at)"
- class="js-job-erased"
- title="Erased"
- />
- <detail-row v-if="job.queued" :value="queued" class="js-job-queued" title="Queued" />
- <detail-row
- v-if="hasTimeout"
- :help-url="runnerHelpUrl"
- :value="timeout"
- class="js-job-timeout"
- title="Timeout"
- />
- <detail-row v-if="job.runner" :value="runnerId" class="js-job-runner" title="Runner" />
- <detail-row
- v-if="job.coverage"
- :value="coverage"
- class="js-job-coverage"
- title="Coverage"
- />
- <p v-if="job.tags.length" class="build-detail-row js-job-tags">
- <span class="font-weight-bold">{{ __('Tags:') }}</span>
- <span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary mr-1">{{
- tag
- }}</span>
- </p>
- </div>
-
+ <job-sidebar-details-container :runner-help-url="runnerHelpUrl" />
<artifacts-block v-if="hasArtifact" :artifact="job.artifact" :help-url="artifactHelpUrl" />
<trigger-block v-if="hasTriggers" :trigger="job.trigger" />
<commit-block
- :is-last-block="hasStages"
:commit="commit"
+ :is-last-block="hasStages"
:merge-request="job.merge_request"
/>
<stages-dropdown
- :stages="stages"
+ v-if="job.pipeline"
:pipeline="job.pipeline"
:selected-stage="selectedStage"
+ :stages="stages"
@requestSidebarStageDropdown="fetchJobsForStage"
/>
</div>
- <jobs-container v-if="jobs.length" :jobs="jobs" :job-id="job.id" />
+ <jobs-container v-if="jobs.length" :job-id="job.id" :jobs="jobs" />
</div>
+ <job-retry-forward-deployment-modal
+ v-if="shouldShowJobRetryForwardDeploymentModal"
+ :modal-id="$options.forwardDeploymentFailureModalId"
+ :href="job.retry_path"
+ />
</aside>
</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
new file mode 100644
index 00000000000..8ad1008278e
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
@@ -0,0 +1,102 @@
+<script>
+import { mapState } from 'vuex';
+import DetailRow from './sidebar_detail_row.vue';
+import { __, sprintf } from '~/locale';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
+
+export default {
+ name: 'JobSidebarDetailsContainer',
+ components: {
+ DetailRow,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ runnerHelpUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ ...mapState(['job']),
+ coverage() {
+ return `${this.job.coverage}%`;
+ },
+ duration() {
+ return timeIntervalInWords(this.job.duration);
+ },
+ erasedAt() {
+ return this.timeFormatted(this.job.erased_at);
+ },
+ finishedAt() {
+ return this.timeFormatted(this.job.finished_at);
+ },
+ hasTags() {
+ return this.job?.tags?.length;
+ },
+ hasTimeout() {
+ return this.job?.metadata?.timeout_human_readable ?? false;
+ },
+ hasAnyDetail() {
+ return Boolean(
+ this.job.duration ||
+ this.job.finished_at ||
+ this.job.erased_at ||
+ this.job.queued ||
+ this.job.runner ||
+ this.job.coverage,
+ );
+ },
+ queued() {
+ return timeIntervalInWords(this.job.queued);
+ },
+ runnerId() {
+ return `${this.job.runner.description} (#${this.job.runner.id})`;
+ },
+ shouldRenderBlock() {
+ return Boolean(this.hasAnyDetail || this.hasTimeout || this.hasTags);
+ },
+ timeout() {
+ return `${this.job?.metadata?.timeout_human_readable}${this.timeoutSource}`;
+ },
+ timeoutSource() {
+ if (!this.job?.metadata?.timeout_source) {
+ return '';
+ }
+
+ return sprintf(__(` (from %{timeoutSource})`), {
+ timeoutSource: this.job.metadata.timeout_source,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="shouldRenderBlock" class="block">
+ <detail-row v-if="job.duration" :value="duration" title="Duration" />
+ <detail-row
+ v-if="job.finished_at"
+ :value="finishedAt"
+ data-testid="job-finished"
+ title="Finished"
+ />
+ <detail-row v-if="job.erased_at" :value="erasedAt" title="Erased" />
+ <detail-row v-if="job.queued" :value="queued" title="Queued" />
+ <detail-row
+ v-if="hasTimeout"
+ :help-url="runnerHelpUrl"
+ :value="timeout"
+ data-testid="job-timeout"
+ title="Timeout"
+ />
+ <detail-row v-if="job.runner" :value="runnerId" title="Runner" />
+ <detail-row v-if="job.coverage" :value="coverage" title="Coverage" />
+
+ <p v-if="hasTags" class="build-detail-row" data-testid="job-tags">
+ <span class="font-weight-bold">{{ __('Tags:') }}</span>
+ <span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary mr-1">{{ tag }}</span>
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue
index 116331d9549..aeae9f26ed3 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue
@@ -1,11 +1,13 @@
<script>
import { isEmpty } from 'lodash';
-import { GlLink } from '@gitlab/ui';
+import { GlLink, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
export default {
components: {
CiIcon,
+ GlDropdown,
+ GlDropdownItem,
GlLink,
},
props: {
@@ -78,20 +80,15 @@ export default {
</template>
</div>
- <button
- type="button"
- data-toggle="dropdown"
- class="js-selected-stage dropdown-menu-toggle gl-mt-3"
- >
- {{ selectedStage }} <i class="fa fa-chevron-down"></i>
- </button>
-
- <ul class="dropdown-menu">
- <li v-for="stage in stages" :key="stage.name">
- <button type="button" class="js-stage-item stage-item" @click="onStageClick(stage)">
- {{ stage.name }}
- </button>
- </li>
- </ul>
+ <gl-dropdown :text="selectedStage" class="js-selected-stage gl-w-full gl-mt-3">
+ <gl-dropdown-item
+ v-for="stage in stages"
+ :key="stage.name"
+ class="js-stage-item stage-item"
+ @click="onStageClick(stage)"
+ >
+ {{ stage.name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js
new file mode 100644
index 00000000000..d0d625d794d
--- /dev/null
+++ b/app/assets/javascripts/jobs/constants.js
@@ -0,0 +1,24 @@
+import { __, s__ } from '~/locale';
+
+const cancel = __('Cancel');
+const moreInfo = __('More information');
+
+export const JOB_SIDEBAR = {
+ cancel,
+ debug: __('Debug'),
+ newIssue: __('New issue'),
+ retry: __('Retry'),
+ toggleSidebar: __('Toggle Sidebar'),
+};
+
+export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = {
+ cancel,
+ info: s__(
+ `Jobs|You're about to retry a job that failed because it attempted to deploy code that is older than the latest deployment.
+ Retrying this job could result in overwriting the environment with the older source code.`,
+ ),
+ areYouSure: s__('Jobs|Are you sure you want to proceed?'),
+ moreInfo,
+ primaryText: __('Retry job'),
+ title: s__('Jobs|Are you sure you want to retry this job?'),
+};
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index 6e15360b66c..1ad6292a030 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -10,27 +10,31 @@ export default () => {
// Let's start initializing the store (i.e. fetching data) right away
store.dispatch('init', element.dataset);
+ const {
+ artifactHelpUrl,
+ deploymentHelpUrl,
+ runnerHelpUrl,
+ runnerSettingsUrl,
+ variablesSettingsUrl,
+ subscriptionsMoreMinutesUrl,
+ endpoint,
+ pagePath,
+ logState,
+ buildStatus,
+ projectPath,
+ retryOutdatedJobDocsUrl,
+ } = element.dataset;
+
return new Vue({
el: element,
store,
components: {
JobApp,
},
+ provide: {
+ retryOutdatedJobDocsUrl,
+ },
render(createElement) {
- const {
- artifactHelpUrl,
- deploymentHelpUrl,
- runnerHelpUrl,
- runnerSettingsUrl,
- variablesSettingsUrl,
- subscriptionsMoreMinutesUrl,
- endpoint,
- pagePath,
- logState,
- buildStatus,
- projectPath,
- } = element.dataset;
-
return createElement('job-app', {
props: {
artifactHelpUrl,
diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js
index dc4a3578a86..8c2d1dd8ab2 100644
--- a/app/assets/javascripts/jobs/store/getters.js
+++ b/app/assets/javascripts/jobs/store/getters.js
@@ -3,8 +3,11 @@ import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at);
+export const hasForwardDeploymentFailure = state =>
+ state?.job?.failure_reason === 'forward_deployment_failure';
+
export const hasUnmetPrerequisitesFailure = state =>
- state.job && state.job.failure_reason && state.job.failure_reason === 'unmet_prerequisites';
+ state?.job?.failure_reason === 'unmet_prerequisites';
export const shouldRenderCalloutMessage = state =>
!isEmpty(state.job.status) && !isEmpty(state.job.callout_message);
diff --git a/app/assets/javascripts/jobs/utils.js b/app/assets/javascripts/jobs/utils.js
new file mode 100644
index 00000000000..28a125b2b8f
--- /dev/null
+++ b/app/assets/javascripts/jobs/utils.js
@@ -0,0 +1,4 @@
+// capture anything starting with http:// or https://
+// up until a disallowed character or whitespace
+export const linkRegex = /(https?:\/\/[^"<>\\^`{|}\s]+)/g;
+export default { linkRegex };
diff --git a/app/assets/javascripts/lib/ace.js b/app/assets/javascripts/lib/ace.js
deleted file mode 100644
index e90b3d2eec7..00000000000
--- a/app/assets/javascripts/lib/ace.js
+++ /dev/null
@@ -1,4 +0,0 @@
-/*= require ace/ace */
-/*= require ace/ext-modelist */
-/*= require ace/ext-searchbox */
-/*= require ./ace/ace_config_paths */
diff --git a/app/assets/javascripts/lib/ace/ace_config_paths.js.erb b/app/assets/javascripts/lib/ace/ace_config_paths.js.erb
deleted file mode 100644
index 976769ba84a..00000000000
--- a/app/assets/javascripts/lib/ace/ace_config_paths.js.erb
+++ /dev/null
@@ -1,34 +0,0 @@
-<%
-ace_gem_path = Bundler.rubygems.find_name('ace-rails-ap').first.full_gem_path
-ace_workers = Dir[ace_gem_path + '/vendor/assets/javascripts/ace/worker-*.js'].sort.map do |file|
- File.basename(file, '.js').sub(/^worker-/, '')
-end
-ace_modes = Dir[ace_gem_path + '/vendor/assets/javascripts/ace/mode-*.js'].sort.map do |file|
- File.basename(file, '.js').sub(/^mode-/, '')
-end
-%>
-// Lazy-load configuration when ace.edit is called
-(function() {
- var basePath;
- var ace = window.ace;
- var edit = ace.edit;
- ace.edit = function() {
- window.gon = window.gon || {};
- basePath = (window.gon.relative_url_root || '').replace(/\/$/, '') + '/assets/ace';
- ace.config.set('basePath', basePath);
-
- // configure paths for all worker modules
-<% ace_workers.each do |worker| %>
- ace.config.setModuleUrl('ace/mode/<%= worker %>_worker', basePath + '/<%= File.basename(asset_path("ace/worker-#{worker}.js")) %>');
-<% end %>
-
- // configure paths for all mode modules
-<% ace_modes.each do |mode| %>
- ace.config.setModuleUrl('ace/mode/<%= mode %>', basePath + '/<%= File.basename(asset_path("ace/mode-#{mode}.js")) %>');
-<% end %>
-
- // restore original method
- ace.edit = edit;
- return ace.edit.apply(ace, arguments);
- };
-})();
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index 0e07f7d8e44..e0d9a903e0a 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -5,6 +5,7 @@ import { ApolloLink } from 'apollo-link';
import { BatchHttpLink } from 'apollo-link-batch-http';
import csrf from '~/lib/utils/csrf';
import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
+import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
export const fetchPolicies = {
CACHE_FIRST: 'cache-first',
@@ -62,7 +63,7 @@ export default (resolvers = {}, config = {}) => {
return new ApolloClient({
typeDefs: config.typeDefs,
- link: ApolloLink.from([performanceBarLink, uploadsLink]),
+ link: ApolloLink.from([performanceBarLink, new StartupJSLink(), uploadsLink]),
cache: new InMemoryCache({
...config.cacheConfig,
freezeResults: config.assumeImmutableResults,
diff --git a/app/assets/javascripts/lib/utils/ace_utils.js b/app/assets/javascripts/lib/utils/ace_utils.js
deleted file mode 100644
index ee71ae0e61a..00000000000
--- a/app/assets/javascripts/lib/utils/ace_utils.js
+++ /dev/null
@@ -1,6 +0,0 @@
-/* global ace */
-
-export default function getModeByFileExtension(path) {
- const modelist = ace.require('ace/ext/modelist');
- return modelist.getModeForPath(path).mode;
-}
diff --git a/app/assets/javascripts/lib/utils/apollo_startup_js_link.js b/app/assets/javascripts/lib/utils/apollo_startup_js_link.js
new file mode 100644
index 00000000000..5c120dd532f
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/apollo_startup_js_link.js
@@ -0,0 +1,106 @@
+import { ApolloLink, Observable } from 'apollo-link';
+import { parse } from 'graphql';
+import { isEqual, pickBy } from 'lodash';
+
+/**
+ * Remove undefined values from object
+ * @param obj
+ * @returns {Dictionary<unknown>}
+ */
+const pickDefinedValues = obj => pickBy(obj, x => x !== undefined);
+
+/**
+ * Compares two set of variables, order independent
+ *
+ * Ignores undefined values (in the top level) and supports arrays etc.
+ */
+const variablesMatch = (var1 = {}, var2 = {}) => {
+ return isEqual(pickDefinedValues(var1), pickDefinedValues(var2));
+};
+
+export class StartupJSLink extends ApolloLink {
+ constructor() {
+ super();
+ this.startupCalls = new Map();
+ this.parseStartupCalls(window.gl?.startup_graphql_calls || []);
+ }
+
+ // Extract operationNames from the queries and ensure that we can
+ // match operationName => element from result array
+ parseStartupCalls(calls) {
+ calls.forEach(call => {
+ const { query, variables, fetchCall } = call;
+ const operationName = parse(query)?.definitions?.find(x => x.kind === 'OperationDefinition')
+ ?.name?.value;
+
+ if (operationName) {
+ this.startupCalls.set(operationName, {
+ variables,
+ fetchCall,
+ });
+ }
+ });
+ }
+
+ static noopRequest = (operation, forward) => forward(operation);
+
+ disable() {
+ this.request = StartupJSLink.noopRequest;
+ this.startupCalls = null;
+ }
+
+ request(operation, forward) {
+ // Disable StartupJSLink in case all calls are done or none are set up
+ if (this.startupCalls && this.startupCalls.size === 0) {
+ this.disable();
+ return forward(operation);
+ }
+
+ const { operationName } = operation;
+
+ // Skip startup call if the operationName doesn't match
+ if (!this.startupCalls.has(operationName)) {
+ return forward(operation);
+ }
+
+ const { variables: startupVariables, fetchCall } = this.startupCalls.get(operationName);
+ this.startupCalls.delete(operationName);
+
+ // Skip startup call if the variables values do not match
+ if (!variablesMatch(startupVariables, operation.variables)) {
+ return forward(operation);
+ }
+
+ return new Observable(observer => {
+ fetchCall
+ .then(response => {
+ // Handle HTTP errors
+ if (!response.ok) {
+ throw new Error('fetchCall failed');
+ }
+ operation.setContext({ response });
+ return response.json();
+ })
+ .then(result => {
+ if (result && (result.errors || !result.data)) {
+ throw new Error('Received GraphQL error');
+ }
+
+ // we have data and can send it to back up the link chain
+ observer.next(result);
+ observer.complete();
+ })
+ .catch(() => {
+ forward(operation).subscribe({
+ next: result => {
+ observer.next(result);
+ },
+ error: error => {
+ observer.error(error);
+ },
+ complete: observer.complete.bind(observer),
+ });
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index fe1ac00fd1d..42a5de68cfa 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -61,9 +61,6 @@ export const rstrip = val => {
return val;
};
-export const updateTooltipTitle = ($tooltipEl, newTitle) =>
- $tooltipEl.attr('title', newTitle).tooltip('_fixTitle');
-
export const disableButtonIfEmptyField = (fieldSelector, buttonSelector, eventName = 'input') => {
const field = $(fieldSelector);
const closestSubmit = field.closest('form').find(buttonSelector);
@@ -744,6 +741,24 @@ export const roundOffFloat = (number, precision = 0) => {
};
/**
+ * Method to round down values with decimal places
+ * with provided precision.
+ *
+ * Eg; roundDownFloat(3.141592, 3) = 3.141
+ *
+ * Refer to spec/javascripts/lib/utils/common_utils_spec.js for
+ * more supported examples.
+ *
+ * @param {Float} number
+ * @param {Number} precision
+ */
+export const roundDownFloat = (number, precision = 0) => {
+ // eslint-disable-next-line no-restricted-properties
+ const multiplier = Math.pow(10, precision);
+ return Math.floor(number * multiplier) / multiplier;
+};
+
+/**
* Represents navigation type constants of the Performance Navigation API.
* Detailed explanation see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigation.
*/
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index 1a4ecc12f01..993d51370ec 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -1,5 +1,4 @@
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/css_utils.js b/app/assets/javascripts/lib/utils/css_utils.js
index 90213221443..02f092e73e1 100644
--- a/app/assets/javascripts/lib/utils/css_utils.js
+++ b/app/assets/javascripts/lib/utils/css_utils.js
@@ -1,5 +1,7 @@
export function loadCSSFile(path) {
return new Promise(resolve => {
+ if (!path) resolve();
+
if (document.querySelector(`link[href="${path}"]`)) {
resolve();
} else {
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 753245147d2..46b0f0cbc70 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -206,10 +206,6 @@ export const localTimeAgo = ($timeagoEls, setTimeago = true) => {
$timeagoEls.each((i, el) => {
// Recreate with custom template
el.setAttribute('title', formatDate(el.dateTime));
- $(el).tooltip({
- template:
- '<div class="tooltip local-timeago" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>',
- });
});
}
diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
index d9b0e8c4476..7bba7ba2f45 100644
--- a/app/assets/javascripts/lib/utils/dom_utils.js
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -47,3 +47,25 @@ export const parseBooleanDataAttributes = ({ dataset }, names) =>
return acc;
}, {});
+
+/**
+ * Returns whether or not the provided element is currently visible.
+ * This function operates identically to jQuery's `:visible` pseudo-selector.
+ * Documentation for this selector: https://api.jquery.com/visible-selector/
+ * Implementation of this selector: https://github.com/jquery/jquery/blob/d0ce00cdfa680f1f0c38460bc51ea14079ae8b07/src/css/hiddenVisibleSelectors.js#L8
+ * @param {HTMLElement} element The element to test
+ * @returns {Boolean} `true` if the element is currently visible, otherwise false
+ */
+export const isElementVisible = element =>
+ Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
+
+/**
+ * The opposite of `isElementVisible`.
+ * Returns whether or not the provided element is currently hidden.
+ * This function operates identically to jQuery's `:hidden` pseudo-selector.
+ * Documentation for this selector: https://api.jquery.com/hidden-selector/
+ * Implementation of this selector: https://github.com/jquery/jquery/blob/d0ce00cdfa680f1f0c38460bc51ea14079ae8b07/src/css/hiddenVisibleSelectors.js#L6
+ * @param {HTMLElement} element The element to test
+ * @returns {Boolean} `true` if the element is currently hidden, otherwise false
+ */
+export const isElementHidden = element => !isElementVisible(element);
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index 7132986a7e6..06529f06a66 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -22,6 +22,7 @@ const httpStatusCodes = {
CONFLICT: 409,
GONE: 410,
UNPROCESSABLE_ENTITY: 422,
+ TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
};
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index 2424d6cbf3b..bc87232f40b 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, BYTES_IN_KB } from './constants';
+import { BYTES_IN_KIB } from './constants';
import { sprintf, __ } from '~/locale';
/**
@@ -35,18 +35,6 @@ 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/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 8ac6a44cba9..a81ca3f211f 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -399,3 +399,15 @@ export const truncateNamespace = (string = '') => {
* @returns {Boolean}
*/
export const hasContent = obj => isString(obj) && obj.trim() !== '';
+
+/**
+ * A utility function that validates if a
+ * string is valid SHA1 hash format.
+ *
+ * @param {String} hash to validate
+ *
+ * @return {Boolean} true if valid
+ */
+export const isValidSha1Hash = str => {
+ return /^[0-9a-f]{5,40}$/.test(str);
+};
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index d60f949c49d..b404f390a2d 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -40,6 +40,7 @@ import { initUserTracking, initDefaultTrackers } from './tracking';
import { __ } from './locale';
import * as tooltips from '~/tooltips';
+import * as popovers from '~/popovers';
import 'ee_else_ce/main_ee';
@@ -81,7 +82,7 @@ document.addEventListener('beforeunload', () => {
// Close any open tooltips
tooltips.dispose(document.querySelectorAll('.has-tooltip, [data-toggle="tooltip"]'));
// Close any open popover
- $('[data-toggle="popover"]').popover('dispose');
+ popovers.dispose();
});
window.addEventListener('hashchange', handleLocationHash);
@@ -166,13 +167,7 @@ function deferredInitialisation() {
});
// Initialize popovers
- $body.popover({
- selector: '[data-toggle="popover"]',
- trigger: 'focus',
- // set the viewport to the main content, excluding the navigation bar, so
- // the navigation can't overlap the popover
- viewport: '.layout-page',
- });
+ popovers.initPopovers();
// Adding a helper class to activate animations only after all is rendered
setTimeout(() => $body.addClass('page-initialised'), 1000);
diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js
index 3a67d0ad64a..356d8619fed 100644
--- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js
+++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js
@@ -1,11 +1,10 @@
/* eslint-disable no-param-reassign */
-/* global ace */
import Vue from 'vue';
+import { debounce } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as flash } from '~/flash';
import { __ } from '~/locale';
-import getModeByFileExtension from '~/lib/utils/ace_utils';
(global => {
global.mergeConflicts = global.mergeConflicts || {};
@@ -28,7 +27,6 @@ import getModeByFileExtension from '~/lib/utils/ace_utils';
data() {
return {
saved: false,
- loading: false,
fileLoaded: false,
originalContent: '',
};
@@ -37,7 +35,6 @@ import getModeByFileExtension from '~/lib/utils/ace_utils';
classObject() {
return {
saved: this.saved,
- 'is-loading': this.loading,
};
},
},
@@ -45,7 +42,7 @@ import getModeByFileExtension from '~/lib/utils/ace_utils';
'file.showEditor': function showEditorWatcher(val) {
this.resetEditorContent();
- if (!val || this.fileLoaded || this.loading) {
+ if (!val || this.fileLoaded) {
return;
}
@@ -59,30 +56,25 @@ import getModeByFileExtension from '~/lib/utils/ace_utils';
},
methods: {
loadEditor() {
- this.loading = true;
+ const EditorPromise = import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite');
+ const DataPromise = axios.get(this.file.content_path);
- axios
- .get(this.file.content_path)
- .then(({ data }) => {
- const content = this.$el.querySelector('pre');
- const fileContent = document.createTextNode(data.content);
+ Promise.all([EditorPromise, DataPromise])
+ .then(([{ default: EditorLite }, { data: { content, new_path: path } }]) => {
+ const contentEl = this.$el.querySelector('.editor');
- content.textContent = fileContent.textContent;
-
- this.originalContent = data.content;
+ this.originalContent = content;
this.fileLoaded = true;
- this.editor = ace.edit(content);
- this.editor.$blockScrolling = Infinity; // Turn off annoying warning
- this.editor.getSession().setMode(getModeByFileExtension(data.new_path));
- this.editor.on('change', () => {
- this.saveDiffResolution();
+
+ this.editor = new EditorLite().createInstance({
+ el: contentEl,
+ blobPath: path,
+ blobContent: content,
});
- this.saveDiffResolution();
- this.loading = false;
+ this.editor.onDidChangeModelContent(debounce(this.saveDiffResolution.bind(this), 250));
})
.catch(() => {
flash(__('An error occurred while loading the file'));
- this.loading = false;
});
},
saveDiffResolution() {
@@ -95,7 +87,7 @@ import getModeByFileExtension from '~/lib/utils/ace_utils';
},
resetEditorContent() {
if (this.fileLoaded) {
- this.editor.setValue(this.originalContent, -1);
+ this.editor.setValue(this.originalContent);
}
},
cancelDiscardConfirmation(file) {
diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue
new file mode 100644
index 00000000000..08fd5a5994f
--- /dev/null
+++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue
@@ -0,0 +1,250 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ GlIcon,
+} from '@gitlab/ui';
+import { debounce, isEqual } from 'lodash';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { s__, __, sprintf } from '~/locale';
+import createStore from '../stores';
+import MilestoneResultsSection from './milestone_results_section.vue';
+
+const SEARCH_DEBOUNCE_MS = 250;
+
+export default {
+ name: 'MilestoneCombobox',
+ store: createStore(),
+ components: {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ GlIcon,
+ MilestoneResultsSection,
+ },
+ props: {
+ value: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ groupId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ groupMilestonesAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ extraLinks: {
+ type: Array,
+ default: () => [],
+ required: false,
+ },
+ },
+ data() {
+ return {
+ searchQuery: '',
+ };
+ },
+ translations: {
+ milestone: s__('MilestoneCombobox|Milestone'),
+ selectMilestone: s__('MilestoneCombobox|Select milestone'),
+ noMilestone: s__('MilestoneCombobox|No milestone'),
+ noResultsLabel: s__('MilestoneCombobox|No matching results'),
+ searchMilestones: s__('MilestoneCombobox|Search Milestones'),
+ searchErrorMessage: s__('MilestoneCombobox|An error occurred while searching for milestones'),
+ projectMilestones: s__('MilestoneCombobox|Project milestones'),
+ groupMilestones: s__('MilestoneCombobox|Group milestones'),
+ },
+ computed: {
+ ...mapState(['matches', 'selectedMilestones']),
+ ...mapGetters(['isLoading', 'groupMilestonesEnabled']),
+ selectedMilestonesLabel() {
+ const { selectedMilestones } = this;
+ const firstMilestoneName = selectedMilestones[0];
+
+ if (selectedMilestones.length === 0) {
+ return this.$options.translations.noMilestone;
+ }
+
+ if (selectedMilestones.length === 1) {
+ return firstMilestoneName;
+ }
+
+ const numberOfOtherMilestones = selectedMilestones.length - 1;
+ return sprintf(__('%{firstMilestoneName} + %{numberOfOtherMilestones} more'), {
+ firstMilestoneName,
+ numberOfOtherMilestones,
+ });
+ },
+ showProjectMilestoneSection() {
+ return Boolean(
+ this.matches.projectMilestones.totalCount > 0 || this.matches.projectMilestones.error,
+ );
+ },
+ showGroupMilestoneSection() {
+ return (
+ this.groupMilestonesEnabled &&
+ Boolean(this.matches.groupMilestones.totalCount > 0 || this.matches.groupMilestones.error)
+ );
+ },
+ showNoResults() {
+ return !this.showProjectMilestoneSection && !this.showGroupMilestoneSection;
+ },
+ },
+ watch: {
+ // Keep the Vuex store synchronized if the parent
+ // component updates the selected milestones through v-model
+ value: {
+ immediate: true,
+ handler() {
+ const milestoneTitles = this.value.map(milestone =>
+ milestone.title ? milestone.title : milestone,
+ );
+ if (!isEqual(milestoneTitles, this.selectedMilestones)) {
+ this.setSelectedMilestones(milestoneTitles);
+ }
+ },
+ },
+ },
+ created() {
+ // This method is defined here instead of in `methods`
+ // because we need to access the .cancel() method
+ // lodash attaches to the function, which is
+ // made inaccessible by Vue. More info:
+ // https://stackoverflow.com/a/52988020/1063392
+ this.debouncedSearch = debounce(function search() {
+ this.search(this.searchQuery);
+ }, SEARCH_DEBOUNCE_MS);
+
+ this.setProjectId(this.projectId);
+ this.setGroupId(this.groupId);
+ this.setGroupMilestonesAvailable(this.groupMilestonesAvailable);
+ this.fetchMilestones();
+ },
+ methods: {
+ ...mapActions([
+ 'setProjectId',
+ 'setGroupId',
+ 'setGroupMilestonesAvailable',
+ 'setSelectedMilestones',
+ 'clearSelectedMilestones',
+ 'toggleMilestones',
+ 'search',
+ 'fetchMilestones',
+ ]),
+ focusSearchBox() {
+ this.$refs.searchBox.$el.querySelector('input').focus();
+ },
+ onSearchBoxEnter() {
+ this.debouncedSearch.cancel();
+ this.search(this.searchQuery);
+ },
+ onSearchBoxInput() {
+ this.debouncedSearch();
+ },
+ selectMilestone(milestone) {
+ this.toggleMilestones(milestone);
+ this.$emit('input', this.selectedMilestones);
+ },
+ selectNoMilestone() {
+ this.clearSelectedMilestones();
+ this.$emit('input', this.selectedMilestones);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown v-bind="$attrs" class="milestone-combobox" @shown="focusSearchBox">
+ <template slot="button-content">
+ <span data-testid="milestone-combobox-button-content" class="gl-flex-grow-1 text-muted">{{
+ selectedMilestonesLabel
+ }}</span>
+ <gl-icon name="chevron-down" />
+ </template>
+
+ <gl-dropdown-section-header>
+ <span class="text-center d-block">{{ $options.translations.selectMilestone }}</span>
+ </gl-dropdown-section-header>
+
+ <gl-dropdown-divider />
+
+ <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"
+ />
+
+ <gl-dropdown-item @click="selectNoMilestone()">
+ <span :class="{ 'gl-pl-6': true, 'selected-item': selectedMilestones.length === 0 }">
+ {{ $options.translations.noMilestone }}
+ </span>
+ </gl-dropdown-item>
+
+ <gl-dropdown-divider />
+
+ <template v-if="isLoading">
+ <gl-loading-icon />
+ <gl-dropdown-divider />
+ </template>
+ <template v-else-if="showNoResults">
+ <div class="dropdown-item-space">
+ <span data-testid="milestone-combobox-no-results" class="gl-pl-6">{{
+ $options.translations.noResultsLabel
+ }}</span>
+ </div>
+ <gl-dropdown-divider />
+ </template>
+ <template v-else>
+ <milestone-results-section
+ v-if="showProjectMilestoneSection"
+ :section-title="$options.translations.projectMilestones"
+ :total-count="matches.projectMilestones.totalCount"
+ :items="matches.projectMilestones.list"
+ :selected-milestones="selectedMilestones"
+ :error="matches.projectMilestones.error"
+ :error-message="$options.translations.searchErrorMessage"
+ data-testid="project-milestones-section"
+ @selected="selectMilestone($event)"
+ />
+
+ <milestone-results-section
+ v-if="showGroupMilestoneSection"
+ :section-title="$options.translations.groupMilestones"
+ :total-count="matches.groupMilestones.totalCount"
+ :items="matches.groupMilestones.list"
+ :selected-milestones="selectedMilestones"
+ :error="matches.groupMilestones.error"
+ :error-message="$options.translations.searchErrorMessage"
+ data-testid="group-milestones-section"
+ @selected="selectMilestone($event)"
+ />
+ </template>
+ <gl-dropdown-item
+ v-for="(item, idx) in extraLinks"
+ :key="idx"
+ :href="item.url"
+ data-testid="milestone-combobox-extra-links"
+ >
+ <span class="gl-pl-6">{{ item.text }}</span>
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/milestones/components/milestone_results_section.vue b/app/assets/javascripts/milestones/components/milestone_results_section.vue
new file mode 100644
index 00000000000..d53a59e58d4
--- /dev/null
+++ b/app/assets/javascripts/milestones/components/milestone_results_section.vue
@@ -0,0 +1,93 @@
+<script>
+import {
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlBadge,
+ GlIcon,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'MilestoneResultsSection',
+ components: {
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlBadge,
+ GlIcon,
+ },
+ props: {
+ sectionTitle: {
+ type: String,
+ required: true,
+ },
+ totalCount: {
+ type: Number,
+ required: true,
+ },
+ items: {
+ type: Array,
+ required: true,
+ },
+ selectedMilestones: {
+ type: Array,
+ required: true,
+ default: () => [],
+ },
+ error: {
+ type: Error,
+ required: false,
+ default: null,
+ },
+ errorMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ totalCountText() {
+ return this.totalCount > 999 ? s__('TotalMilestonesIndicator|1000+') : `${this.totalCount}`;
+ },
+ },
+ methods: {
+ isSelectedMilestone(item) {
+ return this.selectedMilestones.includes(item);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-dropdown-section-header>
+ <div
+ class="gl-display-flex gl-align-items-center gl-pl-6"
+ data-testid="milestone-results-section-header"
+ >
+ <span class="gl-mr-2 gl-mb-1">{{ sectionTitle }}</span>
+ <gl-badge variant="neutral">{{ totalCountText }}</gl-badge>
+ </div>
+ </gl-dropdown-section-header>
+ <template v-if="error">
+ <div class="gl-display-flex align-items-start gl-text-red-500 gl-ml-4 gl-mr-4 gl-mb-3">
+ <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" />
+ <span>{{ errorMessage }}</span>
+ </div>
+ </template>
+ <template v-else>
+ <gl-dropdown-item
+ v-for="{ title } in items"
+ :key="title"
+ role="milestone option"
+ @click="$emit('selected', title)"
+ >
+ <span class="gl-pl-6" :class="{ 'selected-item': isSelectedMilestone(title) }">
+ {{ title }}
+ </span>
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/milestones/project_milestone_combobox.vue b/app/assets/javascripts/milestones/project_milestone_combobox.vue
deleted file mode 100644
index 0fa5585e858..00000000000
--- a/app/assets/javascripts/milestones/project_milestone_combobox.vue
+++ /dev/null
@@ -1,249 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownDivider,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlLoadingIcon,
- GlSearchBoxByType,
- GlIcon,
-} from '@gitlab/ui';
-import { intersection, debounce } from 'lodash';
-import { __, sprintf } from '~/locale';
-import Api from '~/api';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
-
-const SEARCH_DEBOUNCE_MS = 250;
-
-export default {
- components: {
- GlDropdown,
- GlDropdownDivider,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlLoadingIcon,
- GlSearchBoxByType,
- GlIcon,
- },
- model: {
- prop: 'preselectedMilestones',
- event: 'change',
- },
- props: {
- projectId: {
- type: String,
- required: true,
- },
- preselectedMilestones: {
- type: Array,
- default: () => [],
- required: false,
- },
- extraLinks: {
- type: Array,
- default: () => [],
- required: false,
- },
- },
- data() {
- return {
- searchQuery: '',
- projectMilestones: [],
- searchResults: [],
- selectedMilestones: [],
- requestCount: 0,
- };
- },
- translations: {
- milestone: __('Milestone'),
- selectMilestone: __('Select milestone'),
- noMilestone: __('No milestone'),
- noResultsLabel: __('No matching results'),
- searchMilestones: __('Search Milestones'),
- },
- computed: {
- selectedMilestonesLabel() {
- if (this.milestoneTitles.length === 1) {
- return this.milestoneTitles[0];
- }
-
- if (this.milestoneTitles.length > 1) {
- const firstMilestoneName = this.milestoneTitles[0];
- const numberOfOtherMilestones = this.milestoneTitles.length - 1;
- return sprintf(__('%{firstMilestoneName} + %{numberOfOtherMilestones} more'), {
- firstMilestoneName,
- numberOfOtherMilestones,
- });
- }
-
- return this.$options.translations.noMilestone;
- },
- milestoneTitles() {
- return this.preselectedMilestones.map(milestone => milestone.title);
- },
- dropdownItems() {
- return this.searchResults.length ? this.searchResults : this.projectMilestones;
- },
- noResults() {
- return this.searchQuery.length > 2 && this.searchResults.length === 0;
- },
- isLoading() {
- return this.requestCount !== 0;
- },
- },
- created() {
- // This method is defined here instead of in `methods`
- // because we need to access the .cancel() method
- // lodash attaches to the function, which is
- // made inaccessible by Vue. More info:
- // https://stackoverflow.com/a/52988020/1063392
- this.debouncedSearchMilestones = debounce(this.searchMilestones, SEARCH_DEBOUNCE_MS);
- },
- mounted() {
- this.fetchMilestones();
- },
- methods: {
- focusSearchBox() {
- this.$refs.searchBox.$el.querySelector('input').focus();
- },
- fetchMilestones() {
- this.requestCount += 1;
-
- Api.projectMilestones(this.projectId)
- .then(({ data }) => {
- this.projectMilestones = this.getTitles(data);
- this.selectedMilestones = intersection(this.projectMilestones, this.milestoneTitles);
- })
- .catch(() => {
- createFlash(__('An error occurred while loading milestones'));
- })
- .finally(() => {
- this.requestCount -= 1;
- });
- },
- searchMilestones() {
- this.requestCount += 1;
- const options = {
- search: this.searchQuery,
- scope: 'milestones',
- };
-
- if (this.searchQuery.length < 3) {
- this.requestCount -= 1;
- this.searchResults = [];
- return;
- }
-
- Api.projectSearch(this.projectId, options)
- .then(({ data }) => {
- const searchResults = this.getTitles(data);
-
- this.searchResults = searchResults.length ? searchResults : [];
- })
- .catch(() => {
- createFlash(__('An error occurred while searching for milestones'));
- })
- .finally(() => {
- this.requestCount -= 1;
- });
- },
- onSearchBoxInput() {
- this.debouncedSearchMilestones();
- },
- onSearchBoxEnter() {
- this.debouncedSearchMilestones.cancel();
- this.searchMilestones();
- },
- toggleMilestoneSelection(clickedMilestone) {
- if (!clickedMilestone) return [];
-
- let milestones = [...this.preselectedMilestones];
- const hasMilestone = this.milestoneTitles.includes(clickedMilestone);
-
- if (hasMilestone) {
- milestones = milestones.filter(({ title }) => title !== clickedMilestone);
- } else {
- milestones.push({ title: clickedMilestone });
- }
-
- return milestones;
- },
- onMilestoneClicked(clickedMilestone) {
- const milestones = this.toggleMilestoneSelection(clickedMilestone);
- this.$emit('change', milestones);
-
- this.selectedMilestones = intersection(
- this.projectMilestones,
- milestones.map(milestone => milestone.title),
- );
- },
- isSelectedMilestone(milestoneTitle) {
- return this.selectedMilestones.includes(milestoneTitle);
- },
- getTitles(milestones) {
- return milestones.filter(({ state }) => state === 'active').map(({ title }) => title);
- },
- },
-};
-</script>
-
-<template>
- <gl-dropdown v-bind="$attrs" class="project-milestone-combobox" @shown="focusSearchBox">
- <template slot="button-content">
- <span ref="buttonText" class="flex-grow-1 ml-1 text-muted">{{
- selectedMilestonesLabel
- }}</span>
- <gl-icon name="chevron-down" />
- </template>
-
- <gl-dropdown-section-header>
- <span class="text-center d-block">{{ $options.translations.selectMilestone }}</span>
- </gl-dropdown-section-header>
-
- <gl-dropdown-divider />
-
- <gl-search-box-by-type
- ref="searchBox"
- v-model.trim="searchQuery"
- :placeholder="this.$options.translations.searchMilestones"
- @input="onSearchBoxInput"
- @keydown.enter.prevent="onSearchBoxEnter"
- />
-
- <gl-dropdown-item @click="onMilestoneClicked(null)">
- <span :class="{ 'pl-4': true, 'selected-item': selectedMilestones.length === 0 }">
- {{ $options.translations.noMilestone }}
- </span>
- </gl-dropdown-item>
-
- <gl-dropdown-divider />
-
- <template v-if="isLoading">
- <gl-loading-icon />
- <gl-dropdown-divider />
- </template>
- <template v-else-if="noResults">
- <div class="dropdown-item-space">
- <span ref="noResults" class="pl-4">{{ $options.translations.noResultsLabel }}</span>
- </div>
- <gl-dropdown-divider />
- </template>
- <template v-else-if="dropdownItems.length">
- <gl-dropdown-item
- v-for="item in dropdownItems"
- :key="item"
- role="milestone option"
- @click="onMilestoneClicked(item)"
- >
- <span :class="{ 'pl-4': true, 'selected-item': isSelectedMilestone(item) }">
- {{ item }}
- </span>
- </gl-dropdown-item>
- <gl-dropdown-divider />
- </template>
-
- <gl-dropdown-item v-for="(item, idx) in extraLinks" :key="idx" :href="item.url">
- <span class="pl-4">{{ item.text }}</span>
- </gl-dropdown-item>
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/milestones/stores/actions.js b/app/assets/javascripts/milestones/stores/actions.js
index 3859771aeba..df45c7156ad 100644
--- a/app/assets/javascripts/milestones/stores/actions.js
+++ b/app/assets/javascripts/milestones/stores/actions.js
@@ -2,10 +2,15 @@ import Api from '~/api';
import * as types from './mutation_types';
export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId);
+export const setGroupId = ({ commit }, groupId) => commit(types.SET_GROUP_ID, groupId);
+export const setGroupMilestonesAvailable = ({ commit }, groupMilestonesAvailable) =>
+ commit(types.SET_GROUP_MILESTONES_AVAILABLE, groupMilestonesAvailable);
export const setSelectedMilestones = ({ commit }, selectedMilestones) =>
commit(types.SET_SELECTED_MILESTONES, selectedMilestones);
+export const clearSelectedMilestones = ({ commit }) => commit(types.CLEAR_SELECTED_MILESTONES);
+
export const toggleMilestones = ({ commit, state }, selectedMilestone) => {
const removeMilestone = state.selectedMilestones.includes(selectedMilestone);
@@ -16,13 +21,23 @@ export const toggleMilestones = ({ commit, state }, selectedMilestone) => {
}
};
-export const search = ({ dispatch, commit }, query) => {
- commit(types.SET_QUERY, query);
+export const search = ({ dispatch, commit, getters }, searchQuery) => {
+ commit(types.SET_SEARCH_QUERY, searchQuery);
+
+ dispatch('searchProjectMilestones');
+ if (getters.groupMilestonesEnabled) {
+ dispatch('searchGroupMilestones');
+ }
+};
- dispatch('searchMilestones');
+export const fetchMilestones = ({ dispatch, getters }) => {
+ dispatch('fetchProjectMilestones');
+ if (getters.groupMilestonesEnabled) {
+ dispatch('fetchGroupMilestones');
+ }
};
-export const fetchMilestones = ({ commit, state }) => {
+export const fetchProjectMilestones = ({ commit, state }) => {
commit(types.REQUEST_START);
Api.projectMilestones(state.projectId)
@@ -37,14 +52,29 @@ export const fetchMilestones = ({ commit, state }) => {
});
};
-export const searchMilestones = ({ commit, state }) => {
+export const fetchGroupMilestones = ({ commit, state }) => {
commit(types.REQUEST_START);
+ Api.groupMilestones(state.groupId)
+ .then(response => {
+ commit(types.RECEIVE_GROUP_MILESTONES_SUCCESS, response);
+ })
+ .catch(error => {
+ commit(types.RECEIVE_GROUP_MILESTONES_ERROR, error);
+ })
+ .finally(() => {
+ commit(types.REQUEST_FINISH);
+ });
+};
+
+export const searchProjectMilestones = ({ commit, state }) => {
const options = {
- search: state.query,
+ search: state.searchQuery,
scope: 'milestones',
};
+ commit(types.REQUEST_START);
+
Api.projectSearch(state.projectId, options)
.then(response => {
commit(types.RECEIVE_PROJECT_MILESTONES_SUCCESS, response);
@@ -56,3 +86,22 @@ export const searchMilestones = ({ commit, state }) => {
commit(types.REQUEST_FINISH);
});
};
+
+export const searchGroupMilestones = ({ commit, state }) => {
+ const options = {
+ search: state.searchQuery,
+ };
+
+ commit(types.REQUEST_START);
+
+ Api.groupMilestones(state.groupId, options)
+ .then(response => {
+ commit(types.RECEIVE_GROUP_MILESTONES_SUCCESS, response);
+ })
+ .catch(error => {
+ commit(types.RECEIVE_GROUP_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
index d8a283403ec..b5fcfbe35d5 100644
--- a/app/assets/javascripts/milestones/stores/getters.js
+++ b/app/assets/javascripts/milestones/stores/getters.js
@@ -1,2 +1,6 @@
/** Returns `true` if there is at least one in-progress request */
export const isLoading = ({ requestCount }) => requestCount > 0;
+
+/** Returns `true` if there is a group ID and group milestones are available */
+export const groupMilestonesEnabled = ({ groupId, groupMilestonesAvailable }) =>
+ Boolean(groupId && groupMilestonesAvailable);
diff --git a/app/assets/javascripts/milestones/stores/mutation_types.js b/app/assets/javascripts/milestones/stores/mutation_types.js
index 370d386dba2..22e50571e34 100644
--- a/app/assets/javascripts/milestones/stores/mutation_types.js
+++ b/app/assets/javascripts/milestones/stores/mutation_types.js
@@ -1,13 +1,19 @@
export const SET_PROJECT_ID = 'SET_PROJECT_ID';
+export const SET_GROUP_ID = 'SET_GROUP_ID';
+export const SET_GROUP_MILESTONES_AVAILABLE = 'SET_GROUP_MILESTONES_AVAILABLE';
export const SET_SELECTED_MILESTONES = 'SET_SELECTED_MILESTONES';
+export const CLEAR_SELECTED_MILESTONES = 'CLEAR_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 SET_SEARCH_QUERY = 'SET_SEARCH_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';
+
+export const RECEIVE_GROUP_MILESTONES_SUCCESS = 'RECEIVE_GROUP_MILESTONES_SUCCESS';
+export const RECEIVE_GROUP_MILESTONES_ERROR = 'RECEIVE_GROUP_MILESTONES_ERROR';
diff --git a/app/assets/javascripts/milestones/stores/mutations.js b/app/assets/javascripts/milestones/stores/mutations.js
index 7c75d09766c..601b88cb62a 100644
--- a/app/assets/javascripts/milestones/stores/mutations.js
+++ b/app/assets/javascripts/milestones/stores/mutations.js
@@ -1,14 +1,22 @@
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_GROUP_ID](state, groupId) {
+ state.groupId = groupId;
+ },
+ [types.SET_GROUP_MILESTONES_AVAILABLE](state, groupMilestonesAvailable) {
+ state.groupMilestonesAvailable = groupMilestonesAvailable;
+ },
[types.SET_SELECTED_MILESTONES](state, selectedMilestones) {
Vue.set(state, 'selectedMilestones', selectedMilestones);
},
+ [types.CLEAR_SELECTED_MILESTONES](state) {
+ Vue.set(state, 'selectedMilestones', []);
+ },
[types.ADD_SELECTED_MILESTONE](state, selectedMilestone) {
state.selectedMilestones.push(selectedMilestone);
},
@@ -18,8 +26,8 @@ export default {
);
Vue.set(state, 'selectedMilestones', filteredMilestones);
},
- [types.SET_QUERY](state, query) {
- state.query = query;
+ [types.SET_SEARCH_QUERY](state, searchQuery) {
+ state.searchQuery = searchQuery;
},
[types.REQUEST_START](state) {
state.requestCount += 1;
@@ -29,7 +37,7 @@ export default {
},
[types.RECEIVE_PROJECT_MILESTONES_SUCCESS](state, response) {
state.matches.projectMilestones = {
- list: convertObjectPropsToCamelCase(response.data).map(({ title }) => ({ title })),
+ list: response.data.map(({ title }) => ({ title })),
totalCount: parseInt(response.headers['x-total'], 10),
error: null,
};
@@ -41,4 +49,18 @@ export default {
error,
};
},
+ [types.RECEIVE_GROUP_MILESTONES_SUCCESS](state, response) {
+ state.matches.groupMilestones = {
+ list: response.data.map(({ title }) => ({ title })),
+ totalCount: parseInt(response.headers['x-total'], 10),
+ error: null,
+ };
+ },
+ [types.RECEIVE_GROUP_MILESTONES_ERROR](state, error) {
+ state.matches.groupMilestones = {
+ list: [],
+ totalCount: 0,
+ error,
+ };
+ },
};
diff --git a/app/assets/javascripts/milestones/stores/state.js b/app/assets/javascripts/milestones/stores/state.js
index 0944539f367..82723ab32f9 100644
--- a/app/assets/javascripts/milestones/stores/state.js
+++ b/app/assets/javascripts/milestones/stores/state.js
@@ -1,13 +1,19 @@
export default () => ({
projectId: null,
groupId: null,
- query: '',
+ groupMilestonesAvailable: false,
+ searchQuery: '',
matches: {
projectMilestones: {
list: [],
totalCount: 0,
error: null,
},
+ groupMilestones: {
+ list: [],
+ totalCount: 0,
+ error: null,
+ },
},
selectedMilestones: [],
requestCount: 0,
diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue
index d7d01def45e..511f77a441b 100644
--- a/app/assets/javascripts/monitoring/components/charts/column.vue
+++ b/app/assets/javascripts/monitoring/components/charts/column.vue
@@ -35,18 +35,14 @@ export default {
};
},
computed: {
- chartData() {
- const queryData = this.graphData.metrics.reduce((acc, query) => {
+ barChartData() {
+ return this.graphData.metrics.reduce((acc, query) => {
const series = makeDataSeries(query.result || [], {
name: this.formatLegendLabel(query),
});
return acc.concat(series);
}, []);
-
- return {
- values: queryData[0].data,
- };
},
chartOptions() {
const xAxis = getTimeAxisOptions({ timezone: this.timezone });
@@ -109,7 +105,7 @@ export default {
<gl-column-chart
ref="columnChart"
v-bind="$attrs"
- :data="chartData"
+ :bars="barChartData"
:option="chartOptions"
:width="width"
:height="height"
diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
index 9bcd4419a14..66b4d0d86e6 100644
--- a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
+++ b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
@@ -61,14 +61,16 @@ export default {
},
computed: {
chartData() {
- return this.graphData.metrics.map(({ result }) => {
- // This needs a fix. Not only metrics[0] should be shown.
- // See https://gitlab.com/gitlab-org/gitlab/-/issues/220492
- if (!result || result.length === 0) {
- return [];
- }
- return result[0].values.map(val => val[1]);
- });
+ return this.graphData.metrics
+ .map(({ label: name, result }) => {
+ // This needs a fix. Not only metrics[0] should be shown.
+ // See https://gitlab.com/gitlab-org/gitlab/-/issues/220492
+ if (!result || result.length === 0) {
+ return [];
+ }
+ return { name, data: result[0].values.map(val => val[1]) };
+ })
+ .slice(0, 1);
},
xAxisTitle() {
return this.graphData.x_label !== undefined ? this.graphData.x_label : '';
@@ -136,7 +138,7 @@ export default {
<gl-stacked-column-chart
ref="chart"
v-bind="$attrs"
- :data="chartData"
+ :bars="chartData"
:option="chartOptions"
:x-axis-title="xAxisTitle"
:y-axis-title="yAxisTitle"
@@ -144,7 +146,6 @@ export default {
:group-by="groupBy"
:width="width"
:height="height"
- :series-names="seriesNames"
:legend-layout="legendLayout"
:legend-average-text="legendAverageText"
:legend-current-text="legendCurrentText"
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index 6bae3fdcc2e..bda2adeb62a 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -402,21 +402,21 @@ export default {
@updated="onChartUpdated"
>
<template v-if="tooltip.type === 'deployments'">
- <template slot="tooltipTitle">
+ <template slot="tooltip-title">
{{ __('Deployed') }}
</template>
- <div slot="tooltipContent" class="d-flex align-items-center">
+ <div slot="tooltip-content" class="d-flex align-items-center">
<gl-icon name="commit" class="mr-2" />
<gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
</div>
</template>
<template v-else>
- <template slot="tooltipTitle">
+ <template slot="tooltip-title">
<div class="text-nowrap">
{{ tooltip.title }}
</div>
</template>
- <template slot="tooltipContent" :tooltip="tooltip">
+ <template slot="tooltip-content" :tooltip="tooltip">
<div
v-for="(content, key) in tooltip.content"
:key="key"
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index cbfacd73b5b..16c2c87a4b7 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -423,7 +423,7 @@ export default {
:prometheus-alerts-available="prometheusAlertsAvailable"
@timerangezoom="onTimeRangeZoom"
>
- <template #topLeft>
+ <template #top-left>
<gl-button
ref="goBackBtn"
v-gl-tooltip
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
index 18310f7c71e..597600bba07 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
@@ -365,7 +365,7 @@ export default {
<template>
<div v-gl-resize-observer="onResize" class="prometheus-graph">
<div class="d-flex align-items-center">
- <slot name="topLeft"></slot>
+ <slot name="top-left"></slot>
<h5
ref="graphTitle"
class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate gl-mr-3"
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue
index 88d5a35146f..0a1b1cd2c08 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue
@@ -85,7 +85,7 @@ export default {
<template>
<div class="prometheus-panel-builder">
<div class="gl-xs-flex-direction-column gl-display-flex gl-mx-n3">
- <gl-card class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3">
+ <gl-card class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3 gl-mb-5">
<template #header>
<h2 class="gl-font-size-h2 gl-my-3">{{ s__('Metrics|1. Define and preview panel') }}</h2>
</template>
@@ -124,7 +124,7 @@ export default {
</gl-card>
<gl-card
- class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3"
+ class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3 gl-mb-5"
body-class="gl-display-flex gl-flex-direction-column"
>
<template #header>
diff --git a/app/assets/javascripts/monitoring/components/embeds/embed_group.vue b/app/assets/javascripts/monitoring/components/embeds/embed_group.vue
index f07483c34b8..481ba3636cb 100644
--- a/app/assets/javascripts/monitoring/components/embeds/embed_group.vue
+++ b/app/assets/javascripts/monitoring/components/embeds/embed_group.vue
@@ -73,7 +73,7 @@ export default {
<template>
<gl-card
v-show="numCharts > 0"
- class="collapsible-card border p-0 mb-3"
+ class="collapsible-card border p-0 gl-mb-5"
header-class="d-flex align-items-center border-bottom-0 py-2"
:body-class="bodyClass"
>
diff --git a/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue
index 5563a27301d..4e48292c48d 100644
--- a/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue
+++ b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue
@@ -1,11 +1,11 @@
<script>
-import { GlFormGroup, GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui';
+import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
export default {
components: {
GlFormGroup,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownItem,
},
props: {
name: {
@@ -41,16 +41,13 @@ export default {
</script>
<template>
<gl-form-group :label="label">
- <gl-deprecated-dropdown
- toggle-class="dropdown-menu-toggle"
- :text="text || s__('Metrics|Select a value')"
- >
- <gl-deprecated-dropdown-item
+ <gl-dropdown toggle-class="dropdown-menu-toggle" :text="text || s__('Metrics|Select a value')">
+ <gl-dropdown-item
v-for="val in options.values"
:key="val.value"
@click="onUpdate(val.value)"
- >{{ val.text }}</gl-deprecated-dropdown-item
+ >{{ val.text }}</gl-dropdown-item
>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
</gl-form-group>
</template>
diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue
index 4d527baf730..a3d7ddd5bad 100644
--- a/app/assets/javascripts/notebook/cells/output/html.vue
+++ b/app/assets/javascripts/notebook/cells/output/html.vue
@@ -37,6 +37,6 @@ export default {
<template>
<div class="output">
<prompt type="Out" :count="count" :show-output="showOutput" />
- <div v-html="sanitizedOutput"></div>
+ <div class="gl-overflow-auto" v-html="sanitizedOutput"></div>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index cfdadbceaf6..9cc53a320b8 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -3,7 +3,7 @@ import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
import { isEmpty } from 'lodash';
import Autosize from 'autosize';
-import { GlAlert, GlIntersperse, GlLink, GlSprintf, GlButton } from '@gitlab/ui';
+import { GlAlert, GlIntersperse, GlLink, GlSprintf, GlButton, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import { deprecatedCreateFlash as Flash } from '../../flash';
@@ -38,6 +38,7 @@ export default {
GlIntersperse,
GlLink,
GlSprintf,
+ GlIcon,
},
mixins: [issuableStateMixin],
props: {
@@ -342,7 +343,7 @@ export default {
<ul v-else-if="canCreateNote" class="notes notes-form timeline">
<timeline-entry-item class="note-form">
<div class="flash-container error-alert timeline-content"></div>
- <div class="timeline-icon d-none d-sm-none d-md-block">
+ <div class="timeline-icon d-none d-md-block">
<user-avatar-link
v-if="author"
:link-href="author.path"
@@ -457,7 +458,7 @@ export default {
class="btn btn-transparent"
@click.prevent="setNoteType('comment')"
>
- <i aria-hidden="true" class="fa fa-check icon"></i>
+ <gl-icon name="check" class="icon" />
<div class="description">
<strong>{{ __('Comment') }}</strong>
<p>
@@ -476,7 +477,7 @@ export default {
data-qa-selector="discussion_menu_item"
@click.prevent="setNoteType('discussion')"
>
- <i aria-hidden="true" class="fa fa-check icon"></i>
+ <gl-icon name="check" class="icon" />
<div class="description">
<strong>{{ __('Start thread') }}</strong>
<p>{{ startDiscussionDescription }}</p>
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index a4271852563..91cf682943e 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -7,6 +7,7 @@ import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue';
import { getDiffMode } from '~/diffs/store/utils';
import { diffViewerModes } from '~/ide/constants';
+import { isCollapsed } from '../../diffs/diff_file';
const FIRST_CHAR_REGEX = /^(\+|-| )/;
@@ -46,6 +47,9 @@ export default {
this.discussion.truncated_diff_lines && this.discussion.truncated_diff_lines.length !== 0
);
},
+ isCollapsed() {
+ return isCollapsed(this.discussion.diff_file);
+ },
},
mounted() {
if (this.isTextFile && !this.hasTruncatedDiffLines) {
@@ -76,7 +80,7 @@ export default {
:discussion-path="discussion.discussion_path"
:diff-file="discussion.diff_file"
:can-current-user-fork="false"
- :expanded="!discussion.diff_file.viewer.automaticallyCollapsed"
+ :expanded="!isCollapsed"
/>
<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_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
index 878a748e99a..0272790a75d 100644
--- a/app/assets/javascripts/notes/components/discussion_actions.vue
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -45,7 +45,7 @@ export default {
return this.discussion.notes.filter(x => x.resolvable);
},
userCanResolveDiscussion() {
- return this.resolvableNotes.every(note => note.current_user && note.current_user.can_resolve);
+ return this.resolvableNotes.every(note => note.current_user?.can_resolve_discussion);
},
},
};
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index e4b191b55a7..08c22f0b4c6 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -116,7 +116,8 @@ export default {
<gl-dropdown
v-if="displayFilters"
id="discussion-filter-dropdown"
- class="gl-mr-3 full-width-mobile discussion-filter-container js-discussion-filter-container qa-discussion-filter"
+ class="gl-mr-3 full-width-mobile discussion-filter-container js-discussion-filter-container"
+ data-qa-selector="discussion_filter_dropdown"
:text="currentFilter.title"
>
<div v-for="filter in filters" :key="filter.value" class="dropdown-item-wrapper">
@@ -125,7 +126,7 @@ export default {
:is-checked="filter.value === currentValue"
:class="{ 'is-active': filter.value === currentValue }"
:data-filter-type="filterType(filter.value)"
- class="qa-filter-options"
+ data-qa-selector="filter_menu_item"
@click.prevent="selectFilter(filter.value)"
>
{{ filter.title }}
diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue
index ae6646cf96c..83326279423 100644
--- a/app/assets/javascripts/notes/components/discussion_filter_note.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue
@@ -1,28 +1,19 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlButton, GlIcon } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
+import { GlButton, GlIcon, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
import notesEventHub from '../event_hub';
export default {
+ i18n: {
+ information: s__(
+ "Notes|You're only seeing %{boldStart}other activity%{boldEnd} in the feed. To add a comment, switch to one of the following options.",
+ ),
+ },
components: {
GlButton,
GlIcon,
- },
- computed: {
- timelineContent() {
- return sprintf(
- __(
- "You're only seeing %{startTag}other activity%{endTag} in the feed. To add a comment, switch to one of the following options.",
- ),
- {
- startTag: `<b>`,
- endTag: `</b>`,
- },
- false,
- );
- },
+ GlSprintf,
},
methods: {
selectFilter(value) {
@@ -33,17 +24,26 @@ export default {
</script>
<template>
- <li class="timeline-entry note note-wrapper discussion-filter-note js-discussion-filter-note">
+ <li
+ class="timeline-entry note note-wrapper discussion-filter-note js-discussion-filter-note"
+ data-qa-selector="discussion_filter_container"
+ >
<div class="timeline-icon d-none d-lg-flex">
<gl-icon name="comment" />
</div>
<div class="timeline-content">
- <div ref="timelineContent" v-html="timelineContent"></div>
+ <div data-testid="discussion-filter-timeline-content">
+ <gl-sprintf :message="$options.i18n.information">
+ <template #bold="{ content }">
+ <b>{{ content }}</b>
+ </template>
+ </gl-sprintf>
+ </div>
<div class="discussion-filter-actions mt-2">
- <gl-button ref="showAllActivity" variant="default" @click="selectFilter(0)">
+ <gl-button variant="default" @click="selectFilter(0)">
{{ __('Show all activity') }}
</gl-button>
- <gl-button ref="showComments" variant="default" @click="selectFilter(1)">
+ <gl-button variant="default" @click="selectFilter(1)">
{{ __('Show comments only') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index a1e887c47d0..8ac915c3c03 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -127,6 +127,7 @@ export default {
:help-page-path="helpPagePath"
:show-reply-button="userCanReply"
:discussion-root="true"
+ :discussion-resolve-path="discussion.resolve_path"
@handleDeleteNote="$emit('deleteNote')"
@startReplying="$emit('startReplying')"
>
@@ -171,6 +172,7 @@ export default {
:help-page-path="helpPagePath"
:line="diffLine"
:discussion-root="index === 0"
+ :discussion-resolve-path="discussion.resolve_path"
@handleDeleteNote="$emit('deleteNote')"
>
<slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index c2f40b2d21a..fc131f548b4 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -1,6 +1,6 @@
<script>
import { mapGetters } from 'vuex';
-import { GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status';
import ReplyButton from './note_actions/reply_button.vue';
@@ -14,7 +14,8 @@ export default {
components: {
GlIcon,
ReplyButton,
- GlLoadingIcon,
+ GlButton,
+ GlDropdownItem,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -170,6 +171,15 @@ export default {
name: this.projectName,
});
},
+ resolveIcon() {
+ if (!this.isResolving) {
+ return this.isResolved ? 'check-circle-filled' : 'check-circle';
+ }
+ return null;
+ },
+ resolveVariant() {
+ return this.isResolved ? 'success' : 'default';
+ },
},
methods: {
onEdit() {
@@ -233,24 +243,23 @@ export default {
:title="displayContributorBadgeText"
>{{ __('Contributor') }}</span
>
- <div v-if="canResolve" class="note-actions-item">
- <button
+ <div v-if="canResolve" class="gl-ml-2">
+ <gl-button
ref="resolveButton"
v-gl-tooltip
+ size="small"
+ category="tertiary"
+ :variant="resolveVariant"
:class="{ 'is-disabled': !resolvable, 'is-active': isResolved }"
:title="resolveButtonTitle"
:aria-label="resolveButtonTitle"
- type="button"
+ :icon="resolveIcon"
+ :loading="isResolving"
class="line-resolve-btn note-action-button"
@click="onResolve"
- >
- <template v-if="!isResolving">
- <gl-icon :name="isResolved ? 'check-circle-filled' : 'check-circle'" />
- </template>
- <gl-loading-icon v-else inline />
- </button>
+ />
</div>
- <div v-if="canAwardEmoji" class="note-actions-item">
+ <div v-if="canAwardEmoji" class="gl-ml-3 gl-mr-2">
<a
v-gl-tooltip
:class="{ 'js-user-authored': isAuthoredByCurrentUser }"
@@ -261,7 +270,7 @@ export default {
>
<gl-icon class="link-highlight award-control-icon-neutral" name="slight-smile" />
<gl-icon class="link-highlight award-control-icon-positive" name="smiley" />
- <gl-icon class="link-highlight award-control-icon-super-positive" name="smiley" />
+ <gl-icon class="link-highlight award-control-icon-super-positive" name="smile" />
</a>
</div>
<reply-button
@@ -270,72 +279,57 @@ export default {
class="js-reply-button"
@startReplying="$emit('startReplying')"
/>
- <div v-if="canEdit" class="note-actions-item">
- <button
+ <div v-if="canEdit" class="gl-ml-2">
+ <gl-button
v-gl-tooltip
- type="button"
title="Edit comment"
+ icon="pencil"
+ size="small"
+ category="tertiary"
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" />
- </button>
+ />
</div>
- <div v-if="showDeleteAction" class="note-actions-item">
- <button
+ <div v-if="showDeleteAction" class="gl-ml-2">
+ <gl-button
v-gl-tooltip
- type="button"
title="Delete comment"
+ size="small"
+ icon="remove"
+ category="tertiary"
class="note-action-button js-note-delete btn btn-transparent"
@click="onDelete"
- >
- <gl-icon name="remove" class="link-highlight" />
- </button>
+ />
</div>
- <div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions note-actions-item">
- <button
+ <div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions gl-ml-2">
+ <gl-button
v-gl-tooltip
- type="button"
title="More actions"
+ icon="ellipsis_v"
+ size="small"
+ category="tertiary"
class="note-action-button more-actions-toggle btn btn-transparent"
data-toggle="dropdown"
@click="closeTooltip"
- >
- <gl-icon class="icon" name="ellipsis_v" />
- </button>
+ />
<ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
- <li v-if="canReportAsAbuse">
- <a :href="reportAbusePath">{{ __('Report abuse to admin') }}</a>
- </li>
- <li v-if="noteUrl">
- <button
- :data-clipboard-text="noteUrl"
- type="button"
- class="btn-default btn-transparent js-btn-copy-note-link"
- >
- {{ __('Copy link') }}
- </button>
- </li>
- <li v-if="canAssign">
- <button
- class="btn-default btn-transparent"
- data-testid="assign-user"
- type="button"
- @click="assignUser"
- >
- {{ displayAssignUserText }}
- </button>
- </li>
- <li v-if="canEdit">
- <button
- class="btn btn-transparent js-note-delete js-note-delete"
- type="button"
- @click.prevent="onDelete"
- >
- <span class="text-danger">{{ __('Delete comment') }}</span>
- </button>
- </li>
+ <gl-dropdown-item v-if="canReportAsAbuse" :href="reportAbusePath">
+ {{ __('Report abuse to admin') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="noteUrl"
+ class="js-btn-copy-note-link"
+ :data-clipboard-text="noteUrl"
+ >
+ {{ __('Copy link') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="canAssign" data-testid="assign-user" @click="assignUser">
+ {{ displayAssignUserText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="canEdit" class="js-note-delete" @click.prevent="onDelete">
+ <span class="text-danger">{{ __('Delete comment') }}</span>
+ </gl-dropdown-item>
</ul>
</div>
</div>
diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
index f19b7667fb2..acbbee13a6d 100644
--- a/app/assets/javascripts/notes/components/note_actions/reply_button.vue
+++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
@@ -13,7 +13,7 @@ export default {
</script>
<template>
- <div class="note-actions-item">
+ <div class="gl-ml-2">
<gl-button
ref="button"
v-gl-tooltip
diff --git a/app/assets/javascripts/notes/components/note_attachment.vue b/app/assets/javascripts/notes/components/note_attachment.vue
index 72f9a4c7e74..b20facc4032 100644
--- a/app/assets/javascripts/notes/components/note_attachment.vue
+++ b/app/assets/javascripts/notes/components/note_attachment.vue
@@ -1,6 +1,11 @@
<script>
+import { GlIcon } from '@gitlab/ui';
+
export default {
name: 'NoteAttachment',
+ components: {
+ GlIcon,
+ },
props: {
attachment: {
type: Object,
@@ -29,7 +34,7 @@ export default {
target="_blank"
rel="noopener noreferrer"
>
- <i class="fa fa-paperclip" aria-hidden="true"> </i> {{ attachment.filename }}
+ <gl-icon name="paperclip" /> {{ attachment.filename }}
</a>
</div>
</div>
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 4b3f23e742d..43f17c5d65c 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -121,7 +121,13 @@ export default {
return this.withBatchComments && this.noteId === '' && !this.discussion.for_commit;
},
showResolveDiscussionToggle() {
- return (this.discussion?.id && this.discussion.resolvable) || this.isDraft;
+ if (!this.discussion?.notes) return false;
+
+ return (
+ this.discussion?.notes
+ .filter(n => n.resolvable)
+ .some(n => n.current_user?.can_resolve_discussion) || this.isDraft
+ );
},
noteHash() {
if (this.noteId) {
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index a13a0dbbf30..cacf209ed81 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -1,7 +1,8 @@
<script>
/* eslint-disable vue/no-v-html */
import { mapActions } from 'vuex';
-import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlLoadingIcon, GlTooltipDirective, GlSprintf } from '@gitlab/ui';
+import { isUserBusy } from '~/set_status_modal/utils';
import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
@@ -11,6 +12,7 @@ export default {
import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'),
GlIcon,
GlLoadingIcon,
+ GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -65,8 +67,8 @@ export default {
};
},
computed: {
- toggleChevronClass() {
- return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down';
+ toggleChevronIconName() {
+ return this.expanded ? 'chevron-up' : 'chevron-down';
},
noteTimestampLink() {
return this.noteId ? `#note_${this.noteId}` : undefined;
@@ -85,9 +87,16 @@ export default {
authorStatus() {
return this.author.status_tooltip_html;
},
+ authorIsBusy() {
+ const { status } = this.author;
+ return status?.availability && isUserBusy(status.availability);
+ },
emojiElement() {
return this.$refs?.authorStatus?.querySelector('gl-emoji');
},
+ authorName() {
+ return this.author.name;
+ },
},
mounted() {
this.emojiTitle = this.emojiElement ? this.emojiElement.getAttribute('title') : '';
@@ -133,7 +142,7 @@ export default {
type="button"
@click="handleToggle"
>
- <i ref="chevronIcon" :class="toggleChevronClass" class="fa" aria-hidden="true"></i>
+ <gl-icon ref="chevronIcon" :name="toggleChevronIconName" aria-hidden="true" />
{{ __('Toggle thread') }}
</button>
</div>
@@ -146,7 +155,12 @@ export default {
:data-username="author.username"
>
<slot name="note-header-info"></slot>
- <span class="note-header-author-name bold">{{ author.name }}</span>
+ <span class="note-header-author-name gl-font-weight-bold">
+ <gl-sprintf v-if="authorIsBusy" :message="s__('UserAvailability|%{author} (Busy)')">
+ <template #author>{{ authorName }}</template>
+ </gl-sprintf>
+ <template v-else>{{ authorName }}</template>
+ </span>
</a>
<span
v-if="authorStatus"
@@ -170,7 +184,9 @@ export default {
</template>
<span v-else>{{ __('A deleted user') }}</span>
<span class="note-headline-light note-headline-meta">
- <span class="system-note-message"> <slot></slot> </span>
+ <span class="system-note-message" data-qa-selector="system_note_content">
+ <slot></slot>
+ </span>
<template v-if="createdAt">
<span ref="actionText" class="system-note-separator">
<template v-if="actionText">{{ actionText }}</template>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 4f45fcb0062..9be53fe60f2 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -73,6 +73,11 @@ export default {
required: false,
default: false,
},
+ discussionResolvePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -81,6 +86,7 @@ export default {
isRequesting: false,
isResolving: false,
commentLineStart: {},
+ resolveAsThread: this.glFeatures.removeResolveNote,
};
},
computed: {
@@ -133,6 +139,10 @@ export default {
return this.note.isDraft;
},
canResolve() {
+ if (this.glFeatures.removeResolveNote && !this.discussionRoot) return false;
+
+ if (this.glFeatures.removeResolveNote) return this.note.current_user.can_resolve_discussion;
+
return (
this.note.current_user.can_resolve ||
(this.note.isDraft && this.note.discussion_id !== null)
@@ -345,7 +355,8 @@ export default {
:class="classNameBindings"
:data-award-url="note.toggle_award_path"
:data-note-id="note.id"
- class="note note-wrapper qa-noteable-note-item"
+ class="note note-wrapper"
+ data-qa-selector="noteable_note_container"
>
<div
v-if="showMultiLineComment"
diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
index f49fd2c3fa3..0628e1d8647 100644
--- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue
+++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
@@ -1,11 +1,12 @@
<script>
import { uniqBy } from 'lodash';
-import { GlIcon } from '@gitlab/ui';
+import { GlButton, GlIcon } from '@gitlab/ui';
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: {
+ GlButton,
GlIcon,
UserAvatarLink,
TimeAgoTooltip,
@@ -57,14 +58,15 @@ export default {
tooltip-placement="bottom"
/>
</div>
- <button
- class="btn btn-link js-replies-text"
+ <gl-button
+ class="js-replies-text"
+ category="tertiary"
+ variant="link"
data-qa-selector="expand_replies_button"
- type="button"
@click="toggle"
>
{{ replies.length }} {{ n__('reply', 'replies', replies.length) }}
- </button>
+ </gl-button>
{{ __('Last reply by') }}
<a :href="lastReply.author.path" class="btn btn-link author-link">
{{ lastReply.author.name }}
diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js
index 087b5828cce..cef4475ed1d 100644
--- a/app/assets/javascripts/notes/mixins/resolvable.js
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -1,12 +1,18 @@
import { deprecatedCreateFlash as Flash } from '~/flash';
import { __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
+ mixins: [glFeatureFlagsMixin()],
computed: {
discussionResolved() {
if (this.discussion) {
const { notes, resolved } = this.discussion;
+ if (this.glFeatures.removeResolveNote) {
+ return Boolean(resolved);
+ }
+
if (notes) {
// Decide resolved state using store. Only valid for discussions.
return notes.filter(note => !note.system).every(note => note.resolved);
@@ -38,7 +44,12 @@ export default {
this.isResolving = true;
const isResolved = this.discussionResolved || resolvedState;
const discussion = this.resolveAsThread;
- const endpoint = discussion ? this.discussion.resolve_path : `${this.note.path}/resolve`;
+ let endpoint =
+ discussion && this.discussion ? this.discussion.resolve_path : `${this.note.path}/resolve`;
+
+ if (this.glFeatures.removeResolveNote && this.discussionResolvePath) {
+ endpoint = this.discussionResolvePath;
+ }
return this.toggleResolveNote({ endpoint, isResolved, discussion })
.then(() => {
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 37986c8a02d..2c60b5ee84a 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -167,7 +167,7 @@ export const updateOrCreateNotes = ({ commit, state, getters, dispatch }, notes)
if (discussion) {
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note);
- } else if (note.type === constants.DIFF_NOTE) {
+ } else if (note.type === constants.DIFF_NOTE && !note.base_discussion) {
debouncedFetchDiscussions(state.currentlyFetchingDiscussions);
} else {
commit(types.ADD_NEW_NOTE, note);
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 6c11d53dba3..7cc619ec1c5 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -11,24 +11,30 @@ export default {
const isDiscussion = type === constants.DISCUSSION_NOTE || type === constants.DIFF_NOTE;
if (!exists) {
- const noteData = {
- expanded: true,
- id: discussion_id,
- individual_note: !isDiscussion,
- notes: [note],
- reply_id: discussion_id,
- };
-
- if (isDiscussion && isInMRPage()) {
- noteData.resolvable = note.resolvable;
- noteData.resolved = false;
- noteData.active = true;
- noteData.resolve_path = note.resolve_path;
- noteData.resolve_with_issue_path = note.resolve_with_issue_path;
- noteData.diff_discussion = false;
+ let discussion = data.discussion || note.base_discussion;
+
+ if (!discussion) {
+ discussion = {
+ expanded: true,
+ id: discussion_id,
+ individual_note: !isDiscussion,
+ reply_id: discussion_id,
+ };
+
+ if (isDiscussion && isInMRPage()) {
+ discussion.resolvable = note.resolvable;
+ discussion.resolved = false;
+ discussion.active = true;
+ discussion.resolve_path = note.resolve_path;
+ discussion.resolve_with_issue_path = note.resolve_with_issue_path;
+ discussion.diff_discussion = false;
+ }
}
- state.discussions.push(noteData);
+ note.base_discussion = undefined; // No point keeping a reference to this
+ discussion.notes = [note];
+
+ state.discussions.push(discussion);
}
},
diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js
index 0d1b95f75f8..1b12fece23a 100644
--- a/app/assets/javascripts/notifications_form.js
+++ b/app/assets/javascripts/notifications_form.js
@@ -22,12 +22,8 @@ export default class NotificationsForm {
// eslint-disable-next-line class-methods-use-this
showCheckboxLoadingSpinner($parent) {
- $parent
- .addClass('is-loading')
- .find('.custom-notification-event-loading')
- .removeClass('fa-check')
- .addClass('spinner align-middle')
- .removeClass('is-done');
+ $parent.find('.is-loading').removeClass('gl-display-none');
+ $parent.find('.is-done').addClass('gl-display-none');
}
saveEvent($checkbox, $parent) {
@@ -39,14 +35,11 @@ export default class NotificationsForm {
.then(({ data }) => {
$checkbox.enable();
if (data.saved) {
- $parent
- .find('.custom-notification-event-loading')
- .toggleClass('spinner fa-check is-done align-middle');
+ $parent.find('.is-loading').addClass('gl-display-none');
+ $parent.find('.is-done').removeClass('gl-display-none');
+
setTimeout(() => {
- $parent
- .removeClass('is-loading')
- .find('.custom-notification-event-loading')
- .toggleClass('spinner fa-check is-done align-middle');
+ $parent.find('.is-done').addClass('gl-display-none');
}, 2000);
}
})
diff --git a/app/assets/javascripts/packages/details/components/package_title.vue b/app/assets/javascripts/packages/details/components/package_title.vue
index 2789be30818..6b7eeacb964 100644
--- a/app/assets/javascripts/packages/details/components/package_title.vue
+++ b/app/assets/javascripts/packages/details/components/package_title.vue
@@ -1,6 +1,8 @@
<script>
+/* eslint-disable vue/v-slot-style */
import { mapState, mapGetters } from 'vuex';
-import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui';
+import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import PackageTags from '../../shared/components/package_tags.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -16,11 +18,20 @@ export default {
GlSprintf,
PackageTags,
MetadataItem,
+ GlBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
+ i18n: {
+ packageInfo: __('v%{version} published %{timeAgo}'),
+ },
+ data() {
+ return {
+ isDesktop: true,
+ };
+ },
computed: {
...mapState(['packageEntity', 'packageFiles']),
...mapGetters(['packageTypeDisplay', 'packagePipeline', 'packageIcon']),
@@ -31,8 +42,13 @@ export default {
return numberToHumanSize(this.packageFiles.reduce((acc, p) => acc + p.size, 0));
},
},
- i18n: {
- packageInfo: __('v%{version} published %{timeAgo}'),
+ mounted() {
+ this.isDesktop = GlBreakpointInstance.isDesktop();
+ },
+ methods: {
+ dynamicSlotName(index) {
+ return `metadata-tag${index}`;
+ },
},
};
</script>
@@ -75,10 +91,21 @@ export default {
<metadata-item data-testid="package-ref" icon="branch" :text="packagePipeline.ref" />
</template>
- <template v-if="hasTagsToDisplay" #metadata-tags>
+ <template v-if="isDesktop && hasTagsToDisplay" #metadata-tags>
<package-tags :tag-display-limit="2" :tags="packageEntity.tags" hide-label />
</template>
+ <!-- we need to duplicate the package tags on mobile to ensure proper styling inside the flex wrap -->
+ <template
+ v-for="(tag, index) in packageEntity.tags"
+ v-else-if="hasTagsToDisplay"
+ v-slot:[dynamicSlotName(index)]
+ >
+ <gl-badge :key="index" class="gl-my-1" data-testid="tag-badge" variant="info" size="sm">
+ {{ tag.name }}
+ </gl-badge>
+ </template>
+
<template #right-actions>
<slot name="delete-button"></slot>
</template>
diff --git a/app/assets/javascripts/pages/admin/admin.js b/app/assets/javascripts/pages/admin/admin.js
index 88967d82b2f..038bbe392ba 100644
--- a/app/assets/javascripts/pages/admin/admin.js
+++ b/app/assets/javascripts/pages/admin/admin.js
@@ -1,13 +1,13 @@
import $ from 'jquery';
import { refreshCurrentPage } from '../../lib/utils/url_utility';
-function showBlacklistType() {
- if ($('input[name="blacklist_type"]:checked').val() === 'file') {
- $('.blacklist-file').show();
- $('.blacklist-raw').hide();
+function showDenylistType() {
+ if ($('input[name="denylist_type"]:checked').val() === 'file') {
+ $('.js-denylist-file').show();
+ $('.js-denylist-raw').hide();
} else {
- $('.blacklist-file').hide();
- $('.blacklist-raw').show();
+ $('.js-denylist-file').hide();
+ $('.js-denylist-raw').show();
}
}
@@ -60,6 +60,6 @@ export default function adminInit() {
$('li.project_member, li.group_member').on('ajax:success', refreshCurrentPage);
- $("input[name='blacklist_type']").on('click', showBlacklistType);
- showBlacklistType();
+ $("input[name='denylist_type']").on('click', showDenylistType);
+ showDenylistType();
}
diff --git a/app/assets/javascripts/pages/admin/application_settings/general/index.js b/app/assets/javascripts/pages/admin/application_settings/general/index.js
index 8183e81fb02..af1595398a8 100644
--- a/app/assets/javascripts/pages/admin/application_settings/general/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js
@@ -1,3 +1,21 @@
+import Vue from 'vue';
import initUserInternalRegexPlaceholder from '../account_and_limits';
+import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
-document.addEventListener('DOMContentLoaded', initUserInternalRegexPlaceholder());
+document.addEventListener('DOMContentLoaded', () => {
+ initUserInternalRegexPlaceholder();
+
+ const gitpodSettingEl = document.querySelector('#js-gitpod-settings-help-text');
+ if (!gitpodSettingEl) {
+ return;
+ }
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: gitpodSettingEl,
+ name: 'GitpodSettings',
+ components: {
+ IntegrationHelpText,
+ },
+ });
+});
diff --git a/app/assets/javascripts/pages/admin/dev_ops_report/index.js b/app/assets/javascripts/pages/admin/dev_ops_report/index.js
index 643497003ba..220fc049562 100644
--- a/app/assets/javascripts/pages/admin/dev_ops_report/index.js
+++ b/app/assets/javascripts/pages/admin/dev_ops_report/index.js
@@ -1,27 +1,5 @@
-import Vue from 'vue';
-import UserCallout from '~/user_callout';
-import UsagePingDisabled from '~/admin/dev_ops_report/components/usage_ping_disabled.vue';
+import initDevopAdoption from 'ee_else_ce/admin/dev_ops_report/devops_adoption';
+import initDevOpsScoreEmptyState from '~/admin/dev_ops_report/devops_score_empty_state';
-document.addEventListener('DOMContentLoaded', () => {
- // eslint-disable-next-line no-new
- new UserCallout();
-
- const emptyStateContainer = document.getElementById('js-devops-empty-state');
-
- if (!emptyStateContainer) return false;
-
- const { emptyStateSvgPath, enableUsagePingLink, docsLink, isAdmin } = emptyStateContainer.dataset;
-
- return new Vue({
- el: emptyStateContainer,
- provide: {
- isAdmin: Boolean(isAdmin),
- svgPath: emptyStateSvgPath,
- primaryButtonPath: enableUsagePingLink,
- docsLink,
- },
- render(h) {
- return h(UsagePingDisabled);
- },
- });
-});
+initDevOpsScoreEmptyState();
+initDevopAdoption();
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
index 120512bf15e..4b6f52c09be 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
+++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
@@ -1,13 +1,13 @@
<script>
+import { GlModal } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as createFlash } from '~/flash';
-import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { redirectTo } from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
export default {
components: {
- GlModal: DeprecatedModal2,
+ GlModal,
},
props: {
url: {
@@ -36,17 +36,24 @@ export default {
});
},
},
+ primaryAction: {
+ text: s__('AdminArea|Stop jobs'),
+ attributes: [{ variant: 'danger' }],
+ },
+ cancelAction: {
+ text: __('Cancel'),
+ },
};
</script>
<template>
<gl-modal
- id="stop-jobs-modal"
- :header-title-text="s__('AdminArea|Stop all jobs?')"
- :footer-primary-button-text="s__('AdminArea|Stop jobs')"
- footer-primary-button-variant="danger"
- @submit="onSubmit"
+ modal-id="stop-jobs-modal"
+ :action-primary="$options.primaryAction"
+ :action-cancel="$options.cancelAction"
+ @primary="onSubmit"
>
+ <template #modal-title>{{ s__('AdminArea|Stop all jobs?') }}</template>
{{ text }}
</gl-modal>
</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js
index 5a4f8c6e745..4df210debb5 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/index.js
+++ b/app/assets/javascripts/pages/admin/jobs/index/index.js
@@ -5,19 +5,24 @@ import stopJobsModal from './components/stop_jobs_modal.vue';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => {
- const stopJobsButton = document.getElementById('stop-jobs-button');
+ const buttonId = 'js-stop-jobs-button';
+ const modalId = 'stop-jobs-modal';
+ const stopJobsButton = document.getElementById(buttonId);
if (stopJobsButton) {
// eslint-disable-next-line no-new
new Vue({
- el: '#stop-jobs-modal',
+ el: `#js-${modalId}`,
components: {
stopJobsModal,
},
mounted() {
stopJobsButton.classList.remove('disabled');
+ stopJobsButton.addEventListener('click', () => {
+ this.$root.$emit('bv::show::modal', modalId, `#${buttonId}`);
+ });
},
render(createElement) {
- return createElement('stop-jobs-modal', {
+ return createElement(modalId, {
props: {
url: stopJobsButton.dataset.url,
},
diff --git a/app/assets/javascripts/pages/admin/runners/index.js b/app/assets/javascripts/pages/admin/runners/index.js
index e60c6133c7c..104b7eeaf96 100644
--- a/app/assets/javascripts/pages/admin/runners/index.js
+++ b/app/assets/javascripts/pages/admin/runners/index.js
@@ -1,11 +1,12 @@
import initFilteredSearch from '~/pages/search/init_filtered_search';
import AdminRunnersFilteredSearchTokenKeys from '~/filtered_search/admin_runners_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
+import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
-document.addEventListener('DOMContentLoaded', () => {
- initFilteredSearch({
- page: FILTERED_SEARCH.ADMIN_RUNNERS,
- filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys,
- useDefaultState: true,
- });
+initFilteredSearch({
+ page: FILTERED_SEARCH.ADMIN_RUNNERS,
+ filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys,
+ useDefaultState: true,
});
+
+initInstallRunner();
diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js
index 86c4b4f4f48..5f3cdc0bfc6 100644
--- a/app/assets/javascripts/pages/admin/users/index.js
+++ b/app/assets/javascripts/pages/admin/users/index.js
@@ -5,12 +5,12 @@ import ModalManager from './components/user_modal_manager.vue';
import DeleteUserModal from './components/delete_user_modal.vue';
import UserOperationConfirmationModal from './components/user_operation_confirmation_modal.vue';
import csrf from '~/lib/utils/csrf';
+import initConfirmModal from '~/confirm_modal';
const MODAL_TEXTS_CONTAINER_SELECTOR = '#modal-texts';
const MODAL_MANAGER_SELECTOR = '#user-modal';
const ACTION_MODALS = {
deactivate: UserOperationConfirmationModal,
- block: UserOperationConfirmationModal,
delete: DeleteUserModal,
'delete-with-contributions': DeleteUserModal,
};
@@ -62,4 +62,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
},
});
+
+ initConfirmModal();
});
diff --git a/app/assets/javascripts/pages/groups/boards/index.js b/app/assets/javascripts/pages/groups/boards/index.js
index 79c3be771d0..922f39627c9 100644
--- a/app/assets/javascripts/pages/groups/boards/index.js
+++ b/app/assets/javascripts/pages/groups/boards/index.js
@@ -2,8 +2,6 @@ import UsersSelect from '~/users_select';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initBoards from '~/boards';
-document.addEventListener('DOMContentLoaded', () => {
- new UsersSelect(); // eslint-disable-line no-new
- new ShortcutsNavigation(); // eslint-disable-line no-new
- initBoards();
-});
+new UsersSelect(); // eslint-disable-line no-new
+new ShortcutsNavigation(); // eslint-disable-line no-new
+initBoards();
diff --git a/app/assets/javascripts/pages/groups/clusters/destroy/index.js b/app/assets/javascripts/pages/groups/clusters/destroy/index.js
index 8001d2dd1da..487e7a14a16 100644
--- a/app/assets/javascripts/pages/groups/clusters/destroy/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/destroy/index.js
@@ -1,5 +1,3 @@
import ClustersBundle from '~/clusters/clusters_bundle';
-document.addEventListener('DOMContentLoaded', () => {
- new ClustersBundle(); // eslint-disable-line no-new
-});
+new ClustersBundle(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/groups/clusters/edit/index.js b/app/assets/javascripts/pages/groups/clusters/edit/index.js
index 8001d2dd1da..487e7a14a16 100644
--- a/app/assets/javascripts/pages/groups/clusters/edit/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/edit/index.js
@@ -1,5 +1,3 @@
import ClustersBundle from '~/clusters/clusters_bundle';
-document.addEventListener('DOMContentLoaded', () => {
- new ClustersBundle(); // eslint-disable-line no-new
-});
+new ClustersBundle(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/groups/clusters/index.js b/app/assets/javascripts/pages/groups/clusters/index.js
index 9f466e0d60a..3b92c244346 100644
--- a/app/assets/javascripts/pages/groups/clusters/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/index.js
@@ -1,7 +1,5 @@
import initCreateCluster from '~/create_cluster/init_create_cluster';
import initIntegrationForm from '~/clusters/forms/show/index';
-document.addEventListener('DOMContentLoaded', () => {
- initCreateCluster(document, gon);
- initIntegrationForm();
-});
+initCreateCluster(document, gon);
+initIntegrationForm();
diff --git a/app/assets/javascripts/pages/groups/clusters/index/index.js b/app/assets/javascripts/pages/groups/clusters/index/index.js
index 744be65bfbe..3b71517f017 100644
--- a/app/assets/javascripts/pages/groups/clusters/index/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/index/index.js
@@ -1,8 +1,6 @@
import PersistentUserCallout from '~/persistent_user_callout';
import initClustersListApp from '~/clusters_list';
-document.addEventListener('DOMContentLoaded', () => {
- const callout = document.querySelector('.gcp-signup-offer');
- PersistentUserCallout.factory(callout);
- initClustersListApp();
-});
+const callout = document.querySelector('.gcp-signup-offer');
+PersistentUserCallout.factory(callout);
+initClustersListApp();
diff --git a/app/assets/javascripts/pages/groups/clusters/new/index.js b/app/assets/javascripts/pages/groups/clusters/new/index.js
index 876bab0b339..de9ded87ef3 100644
--- a/app/assets/javascripts/pages/groups/clusters/new/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/new/index.js
@@ -1,5 +1,3 @@
import initNewCluster from '~/clusters/new_cluster';
-document.addEventListener('DOMContentLoaded', () => {
- initNewCluster();
-});
+initNewCluster();
diff --git a/app/assets/javascripts/pages/groups/clusters/show/index.js b/app/assets/javascripts/pages/groups/clusters/show/index.js
index ccf631b2c53..5d202a8824f 100644
--- a/app/assets/javascripts/pages/groups/clusters/show/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/show/index.js
@@ -1,7 +1,5 @@
import ClustersBundle from '~/clusters/clusters_bundle';
import initClusterHealth from '~/pages/projects/clusters/show/cluster_health';
-document.addEventListener('DOMContentLoaded', () => {
- new ClustersBundle(); // eslint-disable-line no-new
- initClusterHealth();
-});
+new ClustersBundle(); // eslint-disable-line no-new
+initClusterHealth();
diff --git a/app/assets/javascripts/pages/groups/dependency_proxies/index.js b/app/assets/javascripts/pages/groups/dependency_proxies/index.js
new file mode 100644
index 00000000000..77c885d3858
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/dependency_proxies/index.js
@@ -0,0 +1,13 @@
+import $ from 'jquery';
+import initDependencyProxy from '~/dependency_proxy';
+
+initDependencyProxy();
+
+const form = document.querySelector('form.edit_dependency_proxy_group_setting');
+const toggleInput = $('input.js-project-feature-toggle-input');
+
+if (form && toggleInput) {
+ toggleInput.on('trigger-change', () => {
+ form.submit();
+ });
+}
diff --git a/app/assets/javascripts/pages/groups/details/index.js b/app/assets/javascripts/pages/groups/details/index.js
index 3bcaa0f0232..0417134f2a7 100644
--- a/app/assets/javascripts/pages/groups/details/index.js
+++ b/app/assets/javascripts/pages/groups/details/index.js
@@ -1,5 +1,3 @@
import initGroupDetails from '../shared/group_details';
-document.addEventListener('DOMContentLoaded', () => {
- initGroupDetails('details');
-});
+initGroupDetails('details');
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index dc647f5d3cb..009a3eee526 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -21,35 +21,36 @@ function mountRemoveMemberModal() {
});
}
-document.addEventListener('DOMContentLoaded', () => {
- groupsSelect();
- memberExpirationDate();
- memberExpirationDate('.js-access-expiration-date-groups');
- mountRemoveMemberModal();
+const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
+initGroupMembersApp(
+ document.querySelector('.js-group-members-list'),
+ SHARED_FIELDS.concat(['source', 'granted']),
+ { tr: { 'data-qa-selector': 'member_row' } },
+ memberRequestFormatter,
+);
+initGroupMembersApp(
+ document.querySelector('.js-group-linked-list'),
+ SHARED_FIELDS.concat('granted'),
+ { table: { 'data-qa-selector': 'groups_list' }, tr: { 'data-qa-selector': 'group_row' } },
+ 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,
+);
- const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
+groupsSelect();
+memberExpirationDate();
+memberExpirationDate('.js-access-expiration-date-groups');
+mountRemoveMemberModal();
- 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
-});
+new Members(); // eslint-disable-line no-new
+new UsersSelect(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/groups/labels/edit/index.js b/app/assets/javascripts/pages/groups/labels/edit/index.js
index 83d6ac9fd14..2e8308fe084 100644
--- a/app/assets/javascripts/pages/groups/labels/edit/index.js
+++ b/app/assets/javascripts/pages/groups/labels/edit/index.js
@@ -1,3 +1,4 @@
import Labels from 'ee_else_ce/labels';
-document.addEventListener('DOMContentLoaded', () => new Labels());
+// eslint-disable-next-line no-new
+new Labels();
diff --git a/app/assets/javascripts/pages/groups/labels/index/index.js b/app/assets/javascripts/pages/groups/labels/index/index.js
index 6e45de2a724..87d522d7654 100644
--- a/app/assets/javascripts/pages/groups/labels/index/index.js
+++ b/app/assets/javascripts/pages/groups/labels/index/index.js
@@ -1,3 +1,3 @@
import initLabels from '~/init_labels';
-document.addEventListener('DOMContentLoaded', initLabels);
+initLabels();
diff --git a/app/assets/javascripts/pages/groups/labels/new/index.js b/app/assets/javascripts/pages/groups/labels/new/index.js
index 83d6ac9fd14..2e8308fe084 100644
--- a/app/assets/javascripts/pages/groups/labels/new/index.js
+++ b/app/assets/javascripts/pages/groups/labels/new/index.js
@@ -1,3 +1,4 @@
import Labels from 'ee_else_ce/labels';
-document.addEventListener('DOMContentLoaded', () => new Labels());
+// eslint-disable-next-line no-new
+new Labels();
diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js
index 71c67ac74ed..2832cbed5ac 100644
--- a/app/assets/javascripts/pages/groups/merge_requests/index.js
+++ b/app/assets/javascripts/pages/groups/merge_requests/index.js
@@ -7,15 +7,13 @@ import { FILTERED_SEARCH } from '~/pages/constants';
const ISSUABLE_BULK_UPDATE_PREFIX = 'merge_request_';
-document.addEventListener('DOMContentLoaded', () => {
- addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
- issuableInitBulkUpdateSidebar.init(ISSUABLE_BULK_UPDATE_PREFIX);
+addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
+issuableInitBulkUpdateSidebar.init(ISSUABLE_BULK_UPDATE_PREFIX);
- initFilteredSearch({
- page: FILTERED_SEARCH.MERGE_REQUESTS,
- isGroupDecendent: true,
- useDefaultState: true,
- filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
- });
- projectSelect();
+initFilteredSearch({
+ page: FILTERED_SEARCH.MERGE_REQUESTS,
+ isGroupDecendent: true,
+ useDefaultState: true,
+ filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
+projectSelect();
diff --git a/app/assets/javascripts/pages/groups/milestones/edit/index.js b/app/assets/javascripts/pages/groups/milestones/edit/index.js
index ddd10fe5062..af0264c7992 100644
--- a/app/assets/javascripts/pages/groups/milestones/edit/index.js
+++ b/app/assets/javascripts/pages/groups/milestones/edit/index.js
@@ -1,3 +1,3 @@
import initForm from '../../../../shared/milestones/form';
-document.addEventListener('DOMContentLoaded', () => initForm(false));
+initForm(false);
diff --git a/app/assets/javascripts/pages/groups/milestones/new/index.js b/app/assets/javascripts/pages/groups/milestones/new/index.js
index ddd10fe5062..af0264c7992 100644
--- a/app/assets/javascripts/pages/groups/milestones/new/index.js
+++ b/app/assets/javascripts/pages/groups/milestones/new/index.js
@@ -1,3 +1,3 @@
import initForm from '../../../../shared/milestones/form';
-document.addEventListener('DOMContentLoaded', () => initForm(false));
+initForm(false);
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index 640e64b5d3e..7021473b380 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -4,13 +4,11 @@ import Group from '~/group';
import GroupPathValidator from './group_path_validator';
import initFilePickers from '~/file_pickers';
-document.addEventListener('DOMContentLoaded', () => {
- const parentId = $('#group_parent_id');
- if (!parentId.val()) {
- new GroupPathValidator(); // eslint-disable-line no-new
- }
- BindInOut.initAll();
- initFilePickers();
+const parentId = $('#group_parent_id');
+if (!parentId.val()) {
+ new GroupPathValidator(); // eslint-disable-line no-new
+}
+BindInOut.initAll();
+initFilePickers();
- return new Group();
-});
+new Group(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/groups/packages/index/index.js b/app/assets/javascripts/pages/groups/packages/index/index.js
index 4836900aa28..1c4a10fd653 100644
--- a/app/assets/javascripts/pages/groups/packages/index/index.js
+++ b/app/assets/javascripts/pages/groups/packages/index/index.js
@@ -1,7 +1,5 @@
import initPackageList from '~/packages/list/packages_list_app_bundle';
-document.addEventListener('DOMContentLoaded', () => {
- if (document.getElementById('js-vue-packages-list')) {
- initPackageList();
- }
-});
+if (document.getElementById('js-vue-packages-list')) {
+ initPackageList();
+}
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 67eb09da5e0..3456048d718 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
@@ -1,35 +1,21 @@
import initSettingsPanels from '~/settings_panels';
-import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
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';
+import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
-document.addEventListener('DOMContentLoaded', () => {
- // Initialize expandable settings panels
- initSettingsPanels();
+// Initialize expandable settings panels
+initSettingsPanels();
- initFilteredSearch({
- page: FILTERED_SEARCH.ADMIN_RUNNERS,
- filteredSearchTokenKeys: GroupRunnersFilteredSearchTokenKeys,
- anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR,
- useDefaultState: false,
- });
-
- if (gon.features.newVariablesUi) {
- initVariableList();
- } else {
- const variableListEl = document.querySelector('.js-ci-variable-list-section');
- // eslint-disable-next-line no-new
- new AjaxVariableList({
- container: variableListEl,
- saveButton: variableListEl.querySelector('.js-ci-variables-save-button'),
- errorBox: variableListEl.querySelector('.js-ci-variable-error-box'),
- saveEndpoint: variableListEl.dataset.saveEndpoint,
- maskableRegex: variableListEl.dataset.maskableRegex,
- });
- }
-
- initSharedRunnersForm();
+initFilteredSearch({
+ page: FILTERED_SEARCH.ADMIN_RUNNERS,
+ filteredSearchTokenKeys: GroupRunnersFilteredSearchTokenKeys,
+ anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR,
+ useDefaultState: false,
});
+
+initSharedRunnersForm();
+initVariableList();
+initInstallRunner();
diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js
index 8546b1f759f..8d956c694c0 100644
--- a/app/assets/javascripts/pages/groups/shared/group_details.js
+++ b/app/assets/javascripts/pages/groups/shared/group_details.js
@@ -2,7 +2,6 @@
import { getPagePath, getDashPath } from '~/lib/utils/common_utils';
import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants';
-import NewGroupChild from '~/groups/new_group_child';
import notificationsDropdown from '~/notifications_dropdown';
import NotificationsForm from '~/notifications_form';
import ProjectsList from '~/projects_list';
@@ -11,7 +10,6 @@ import GroupTabs from './group_tabs';
import initInviteMembersBanner from '~/groups/init_invite_members_banner';
export default function initGroupDetails(actionName = 'show') {
- const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
const loadableActions = [ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED];
const dashPath = getDashPath();
let action = loadableActions.includes(dashPath) ? dashPath : getPagePath(1);
@@ -25,8 +23,5 @@ export default function initGroupDetails(actionName = 'show') {
notificationsDropdown();
new ProjectsList();
- if (newGroupChildWrapper) {
- new NewGroupChild(newGroupChildWrapper);
- }
initInviteMembersBanner();
}
diff --git a/app/assets/javascripts/pages/profiles/preferences/show/index.js b/app/assets/javascripts/pages/profiles/preferences/show/index.js
new file mode 100644
index 00000000000..d489ed80f46
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/preferences/show/index.js
@@ -0,0 +1,3 @@
+import initProfilePreferences from '~/profile/preferences/profile_preferences_bundle';
+
+document.addEventListener('DOMContentLoaded', initProfilePreferences);
diff --git a/app/assets/javascripts/pages/projects/alert_management/details/index.js b/app/assets/javascripts/pages/projects/alert_management/details/index.js
index 0124795e1af..a20f6713c9d 100644
--- a/app/assets/javascripts/pages/projects/alert_management/details/index.js
+++ b/app/assets/javascripts/pages/projects/alert_management/details/index.js
@@ -1,5 +1,3 @@
import AlertDetails from '~/alert_management/details';
-document.addEventListener('DOMContentLoaded', () => {
- AlertDetails('#js-alert_details');
-});
+AlertDetails('#js-alert_details');
diff --git a/app/assets/javascripts/pages/projects/alert_management/index/index.js b/app/assets/javascripts/pages/projects/alert_management/index/index.js
index 1e98bcfd2eb..ed352f0ad7a 100644
--- a/app/assets/javascripts/pages/projects/alert_management/index/index.js
+++ b/app/assets/javascripts/pages/projects/alert_management/index/index.js
@@ -1,5 +1,3 @@
import AlertManagementList from '~/alert_management/list';
-document.addEventListener('DOMContentLoaded', () => {
- AlertManagementList();
-});
+AlertManagementList();
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index f2e8cb38ef5..1879e263ce7 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -7,7 +7,6 @@ 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);
@@ -74,7 +73,7 @@ document.addEventListener('DOMContentLoaded', () => {
);
}
- if (isExperimentEnabled('suggestPipeline')) {
+ if (gon.features?.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
deleted file mode 100644
index df635522e94..00000000000
--- a/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import EditorLite from '~/editor/editor_lite';
-
-export default class CILintEditor {
- constructor() {
- this.clearYml = document.querySelector('.clear-yml');
- this.clearYml.addEventListener('click', this.clear.bind(this));
-
- return this.initEditorLite();
- }
-
- clear() {
- this.editor.setValue('');
- }
-
- initEditorLite() {
- const editorEl = document.getElementById('editor');
- const fileContentEl = document.getElementById('content');
- const form = document.querySelector('.js-ci-lint-form');
-
- 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();
- });
- }
-}
diff --git a/app/assets/javascripts/pages/projects/ci/lints/new/index.js b/app/assets/javascripts/pages/projects/ci/lints/new/index.js
deleted file mode 100644
index 957801320c9..00000000000
--- a/app/assets/javascripts/pages/projects/ci/lints/new/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import createFlash from '~/flash';
-import { __ } from '~/locale';
-
-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/ci/lints/show/index.js b/app/assets/javascripts/pages/projects/ci/lints/show/index.js
index 957801320c9..6e1cdf557b5 100644
--- a/app/assets/javascripts/pages/projects/ci/lints/show/index.js
+++ b/app/assets/javascripts/pages/projects/ci/lints/show/index.js
@@ -1,17 +1,3 @@
-import createFlash from '~/flash';
-import { __ } from '~/locale';
+import initCiLint from '~/ci_lint';
-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));
- }
-});
+initCiLint();
diff --git a/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js b/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js
new file mode 100644
index 00000000000..67d32648ce8
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js
@@ -0,0 +1,3 @@
+import { initPipelineEditor } from '~/pipeline_editor';
+
+initPipelineEditor();
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index 32fb35f97e3..e0bd49bf6ef 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -40,7 +40,7 @@ document.addEventListener('DOMContentLoaded', () => {
new Diff();
})
.catch(() => {
- flash(__('An error occurred while retrieving diff files'));
+ flash({ message: __('An error occurred while retrieving diff files') });
});
} else {
new Diff();
diff --git a/app/assets/javascripts/pages/projects/error_tracking/details/index.js b/app/assets/javascripts/pages/projects/error_tracking/details/index.js
index 25d1c744e1b..a750b3bac87 100644
--- a/app/assets/javascripts/pages/projects/error_tracking/details/index.js
+++ b/app/assets/javascripts/pages/projects/error_tracking/details/index.js
@@ -1,5 +1,3 @@
import ErrorTrackingDetails from '~/error_tracking/details';
-document.addEventListener('DOMContentLoaded', () => {
- ErrorTrackingDetails();
-});
+ErrorTrackingDetails();
diff --git a/app/assets/javascripts/pages/projects/error_tracking/index/index.js b/app/assets/javascripts/pages/projects/error_tracking/index/index.js
index ead81cd5d2d..fda0a35de9c 100644
--- a/app/assets/javascripts/pages/projects/error_tracking/index/index.js
+++ b/app/assets/javascripts/pages/projects/error_tracking/index/index.js
@@ -1,5 +1,3 @@
import ErrorTrackingList from '~/error_tracking/list';
-document.addEventListener('DOMContentLoaded', () => {
- ErrorTrackingList();
-});
+ErrorTrackingList();
diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js
index 74abd1f67a5..6cf36463bda 100644
--- a/app/assets/javascripts/pages/projects/graphs/charts/index.js
+++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js
@@ -5,6 +5,8 @@ import { __ } from '~/locale';
import CodeCoverage from '../components/code_coverage.vue';
import SeriesDataMixin from './series_data_mixin';
+const seriesDataToBarData = raw => Object.entries(raw).map(([name, data]) => ({ name, data }));
+
document.addEventListener('DOMContentLoaded', () => {
waitForCSSLoaded(() => {
const languagesContainer = document.getElementById('js-languages-chart');
@@ -41,13 +43,13 @@ document.addEventListener('DOMContentLoaded', () => {
},
computed: {
seriesData() {
- return { full: this.chartData.map(d => [d.label, d.value]) };
+ return [{ name: 'full', data: this.chartData.map(d => [d.label, d.value]) }];
},
},
render(h) {
return h(GlColumnChart, {
props: {
- data: this.seriesData,
+ bars: this.seriesData,
xAxisTitle: __('Used programming language'),
yAxisTitle: __('Percentage'),
xAxisType: 'category',
@@ -86,7 +88,7 @@ document.addEventListener('DOMContentLoaded', () => {
render(h) {
return h(GlColumnChart, {
props: {
- data: this.seriesData,
+ bars: seriesDataToBarData(this.seriesData),
xAxisTitle: __('Day of month'),
yAxisTitle: __('No. of commits'),
xAxisType: 'category',
@@ -113,13 +115,13 @@ document.addEventListener('DOMContentLoaded', () => {
acc.push([key, weekDays[key]]);
return acc;
}, []);
- return { full: data };
+ return [{ name: 'full', data }];
},
},
render(h) {
return h(GlColumnChart, {
props: {
- data: this.seriesData,
+ bars: this.seriesData,
xAxisTitle: __('Weekday'),
yAxisTitle: __('No. of commits'),
xAxisType: 'category',
@@ -143,7 +145,7 @@ document.addEventListener('DOMContentLoaded', () => {
render(h) {
return h(GlColumnChart, {
props: {
- data: this.seriesData,
+ bars: seriesDataToBarData(this.seriesData),
xAxisTitle: __('Hour (UTC)'),
yAxisTitle: __('No. of commits'),
xAxisType: 'category',
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 a9079f91f50..6dd50958fa4 100644
--- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
+++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
@@ -153,10 +153,10 @@ export default {
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
>
- <template v-if="canShowData" #tooltipTitle>
+ <template v-if="canShowData" #tooltip-title>
{{ tooltipTitle }}
</template>
- <template v-if="canShowData" #tooltipContent>
+ <template v-if="canShowData" #tooltip-content>
<gl-sprintf :message="__('Code Coverage: %{coveragePercentage}%{percentSymbol}')">
<template #coveragePercentage>
{{ coveragePercentage }}
diff --git a/app/assets/javascripts/pages/projects/incidents/index/index.js b/app/assets/javascripts/pages/projects/incidents/index/index.js
index c37ae862a85..bbae605b31f 100644
--- a/app/assets/javascripts/pages/projects/incidents/index/index.js
+++ b/app/assets/javascripts/pages/projects/incidents/index/index.js
@@ -1,5 +1,3 @@
import IncidentsList from '~/incidents/list';
-document.addEventListener('DOMContentLoaded', () => {
- IncidentsList();
-});
+IncidentsList();
diff --git a/app/assets/javascripts/pages/projects/incidents/show/index.js b/app/assets/javascripts/pages/projects/incidents/show/index.js
index 3324cfc0335..5b3f03cd57e 100644
--- a/app/assets/javascripts/pages/projects/incidents/show/index.js
+++ b/app/assets/javascripts/pages/projects/incidents/show/index.js
@@ -2,10 +2,8 @@ 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();
-});
+initShow();
+if (!gon.features?.vueIssuableSidebar) {
+ initSidebarBundle();
+}
+initRelatedIssues();
diff --git a/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js b/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js
deleted file mode 100644
index 534614349bf..00000000000
--- a/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import initIssuablesList from '~/issues_list';
-
-document.addEventListener('DOMContentLoaded', () => {
- initIssuablesList();
-});
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index a58b5d3f37c..4b15e435f60 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -5,7 +5,7 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ZenMode from '~/zen_mode';
import '~/notes/index';
import { store } from '~/notes/stores';
-import initIssueApp from '~/issue_show/issue';
+import { initIssuableApp, initIssueHeaderActions } from '~/issue_show/issue';
import initIncidentApp from '~/issue_show/incident';
import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning';
import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace';
@@ -24,13 +24,14 @@ export default function() {
initIncidentApp(issuableData);
break;
case IssuableType.Issue:
- initIssueApp(issuableData);
+ initIssuableApp(issuableData, store);
break;
default:
break;
}
initIssuableHeaderWarning(store);
+ initIssueHeaderActions(store);
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
index f58e4909a08..7b5e0f70b7b 100644
--- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
+++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
@@ -1,15 +1,21 @@
<script>
-import { GlSprintf } from '@gitlab/ui';
+import { GlSprintf, GlModal } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as createFlash } from '~/flash';
-import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
-import { s__, sprintf } from '~/locale';
+import { s__, __, sprintf } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
export default {
+ primaryProps: {
+ text: s__('Labels|Promote Label'),
+ attributes: [{ variant: 'warning' }, { category: 'primary' }],
+ },
+ cancelProps: {
+ text: __('Cancel'),
+ },
components: {
- GlModal: DeprecatedModal2,
+ GlModal,
GlSprintf,
},
props: {
@@ -72,12 +78,12 @@ export default {
</script>
<template>
<gl-modal
- id="promote-label-modal"
- :footer-primary-button-text="s__('Labels|Promote Label')"
- footer-primary-button-variant="warning"
- @submit="onSubmit"
+ modal-id="promote-label-modal"
+ :action-primary="$options.primaryProps"
+ :action-cancel="$options.cancelProps"
+ @primary="onSubmit"
>
- <div slot="title" class="modal-title-with-label">
+ <div slot="modal-title" class="modal-title-with-label">
<gl-sprintf
:message="
s__(
diff --git a/app/assets/javascripts/pages/projects/labels/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js
index 36cf485f33d..ee129011f9a 100644
--- a/app/assets/javascripts/pages/projects/labels/index/index.js
+++ b/app/assets/javascripts/pages/projects/labels/index/index.js
@@ -27,71 +27,55 @@ const initLabelIndex = () => {
eventHub.$once('promoteLabelModal.requestFinished', onRequestFinished);
};
- const onDeleteButtonClick = event => {
- const button = event.currentTarget;
- const modalProps = {
- labelTitle: button.dataset.labelTitle,
- labelColor: button.dataset.labelColor,
- labelTextColor: button.dataset.labelTextColor,
- url: button.dataset.url,
- groupName: button.dataset.groupName,
- };
- eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted);
- eventHub.$emit('promoteLabelModal.props', modalProps);
- };
-
const promoteLabelButtons = document.querySelectorAll('.js-promote-project-label-button');
- promoteLabelButtons.forEach(button => {
- button.addEventListener('click', onDeleteButtonClick);
- });
- eventHub.$once('promoteLabelModal.mounted', () => {
- promoteLabelButtons.forEach(button => {
- button.removeAttribute('disabled');
- });
- });
+ return new Vue({
+ el: '#js-promote-label-modal',
+ data() {
+ return {
+ modalProps: {
+ labelTitle: '',
+ labelColor: '',
+ labelTextColor: '',
+ url: '',
+ groupName: '',
+ },
+ };
+ },
+ mounted() {
+ eventHub.$on('promoteLabelModal.props', this.setModalProps);
+ eventHub.$emit('promoteLabelModal.mounted');
- const promoteLabelModal = document.getElementById('promote-label-modal');
- let promoteLabelModalComponent;
+ promoteLabelButtons.forEach(button => {
+ button.removeAttribute('disabled');
+ button.addEventListener('click', () => {
+ this.$root.$emit('bv::show::modal', 'promote-label-modal');
+ eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted);
- if (promoteLabelModal) {
- promoteLabelModalComponent = new Vue({
- el: promoteLabelModal,
- components: {
- PromoteLabelModal,
- },
- data() {
- return {
- modalProps: {
- labelTitle: '',
- labelColor: '',
- labelTextColor: '',
- url: '',
- groupName: '',
- },
- };
- },
- mounted() {
- eventHub.$on('promoteLabelModal.props', this.setModalProps);
- eventHub.$emit('promoteLabelModal.mounted');
- },
- beforeDestroy() {
- eventHub.$off('promoteLabelModal.props', this.setModalProps);
- },
- methods: {
- setModalProps(modalProps) {
- this.modalProps = modalProps;
- },
- },
- render(createElement) {
- return createElement('promote-label-modal', {
- props: this.modalProps,
+ this.setModalProps({
+ labelTitle: button.dataset.labelTitle,
+ labelColor: button.dataset.labelColor,
+ labelTextColor: button.dataset.labelTextColor,
+ url: button.dataset.url,
+ groupName: button.dataset.groupName,
+ });
});
+ });
+ },
+ beforeDestroy() {
+ eventHub.$off('promoteLabelModal.props', this.setModalProps);
+ },
+ methods: {
+ setModalProps(modalProps) {
+ this.modalProps = modalProps;
},
- });
- }
-
- return promoteLabelModalComponent;
+ },
+ render(createElement) {
+ return createElement(PromoteLabelModal, {
+ props: this.modalProps,
+ });
+ },
+ });
};
document.addEventListener('DOMContentLoaded', initLabelIndex);
diff --git a/app/assets/javascripts/pages/projects/metrics_dashboard/index.js b/app/assets/javascripts/pages/projects/metrics_dashboard/index.js
index d3028aec313..606439866ea 100644
--- a/app/assets/javascripts/pages/projects/metrics_dashboard/index.js
+++ b/app/assets/javascripts/pages/projects/metrics_dashboard/index.js
@@ -1,3 +1,3 @@
import monitoringApp from '~/monitoring/monitoring_app';
-document.addEventListener('DOMContentLoaded', monitoringApp);
+monitoringApp();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
index 6197dc8a9db..90d2df50d5a 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
@@ -1,16 +1,24 @@
import Vue from 'vue';
import PipelineSchedulesCallout from '../shared/components/pipeline_schedules_callout.vue';
-document.addEventListener(
- 'DOMContentLoaded',
- () =>
- new Vue({
- el: '#pipeline-schedules-callout',
- components: {
- 'pipeline-schedules-callout': PipelineSchedulesCallout,
- },
- render(createElement) {
- return createElement('pipeline-schedules-callout');
- },
- }),
-);
+document.addEventListener('DOMContentLoaded', () => {
+ const el = document.getElementById('pipeline-schedules-callout');
+
+ if (!el) {
+ return;
+ }
+
+ const { docsUrl, illustrationUrl } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ render(createElement) {
+ return createElement(PipelineSchedulesCallout);
+ },
+ provide: {
+ docsUrl,
+ illustrationUrl,
+ },
+ });
+});
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 a138a3a3425..8ee9d481466 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,7 +1,7 @@
<script>
import Vue from 'vue';
import Cookies from 'js-cookie';
-import { GlIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import Translate from '../../../../../vue_shared/translate';
import { parseBoolean } from '~/lib/utils/common_utils';
@@ -12,12 +12,11 @@ const cookieKey = 'pipeline_schedules_callout_dismissed';
export default {
name: 'PipelineSchedulesCallout',
components: {
- GlIcon,
+ GlButton,
},
+ inject: ['docsUrl', 'illustrationUrl'],
data() {
return {
- docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl,
- imageUrl: document.getElementById('pipeline-schedules-callout').dataset.imageUrl,
calloutDismissed: parseBoolean(Cookies.get(cookieKey)),
};
},
@@ -31,12 +30,16 @@ export default {
</script>
<template>
<div v-if="!calloutDismissed" class="pipeline-schedules-user-callout user-callout">
- <div class="bordered-box landing content-block">
- <button id="dismiss-callout-btn" class="btn btn-default close" @click="dismissCallout">
- <gl-icon name="close" aria-hidden="true" />
- </button>
- <div class="svg-container">
- <img :src="imageUrl" />
+ <div class="bordered-box landing content-block" data-testid="innerContent">
+ <gl-button
+ category="tertiary"
+ icon="close"
+ :aria-label="__('Dismiss')"
+ class="gl-absolute gl-top-2 gl-right-2"
+ @click="dismissCallout"
+ />
+ <div class="svg-content">
+ <img :src="illustrationUrl" />
</div>
<div class="user-callout-copy">
<h4>{{ __('Scheduling Pipelines') }}</h4>
diff --git a/app/assets/javascripts/pages/projects/pipelines/show/index.js b/app/assets/javascripts/pages/projects/pipelines/show/index.js
index 7a57e417b41..d3f46b7e025 100644
--- a/app/assets/javascripts/pages/projects/pipelines/show/index.js
+++ b/app/assets/javascripts/pages/projects/pipelines/show/index.js
@@ -1,7 +1,5 @@
import initPipelineDetails from '~/pipelines/pipeline_details_bundle';
import initPipelines from '../init_pipelines';
-document.addEventListener('DOMContentLoaded', () => {
- initPipelines();
- initPipelineDetails();
-});
+initPipelines();
+initPipelineDetails();
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index 2f27814a692..5317093c4cf 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -57,7 +57,7 @@ export default class Project {
$('.project-refs-select').on('change', function() {
return $(this)
.parents('form')
- .submit();
+ .trigger('submit');
});
}
@@ -156,11 +156,32 @@ export default class Project {
},
clicked(options) {
const { e } = options;
- if (!shouldVisit) {
- e.preventDefault();
+ e.preventDefault();
+
+ // Since this page does not reload when changing directories in a repo
+ // the rendered links do not have the path to the current directory.
+ // This updates the path based on the current url and then opens
+ // the the url with the updated path parameter.
+ if (shouldVisit) {
+ const selectedUrl = new URL(e.target.href);
+ const loc = window.location.href;
+
+ if (loc.includes('/-/')) {
+ const refs = this.fullData.Branches.concat(this.fullData.Tags);
+ const currentRef = refs.find(ref => loc.indexOf(ref) > -1);
+ if (currentRef) {
+ const targetPath = loc.split(currentRef)[1].slice(1);
+ selectedUrl.searchParams.set('path', targetPath);
+ }
+ }
+
+ // Open in new window if "meta" key is pressed
+ if (e.metaKey) {
+ window.open(selectedUrl.href, '_blank');
+ } else {
+ window.location.href = selectedUrl.href;
+ }
}
- /* The actual process is removed since `link.href` in `RenderRow` contains the full target.
- * It makes the visitable link can be visited when opening on a new tab of browser */
},
});
});
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 40816420eef..5d4c1595342 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
@@ -1,48 +1,35 @@
import initSettingsPanels from '~/settings_panels';
import SecretValues from '~/behaviors/secret_values';
-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';
+import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
-document.addEventListener('DOMContentLoaded', () => {
- // Initialize expandable settings panels
- initSettingsPanels();
+// Initialize expandable settings panels
+initSettingsPanels();
- const runnerToken = document.querySelector('.js-secret-runner-token');
- if (runnerToken) {
- const runnerTokenSecretValue = new SecretValues({
- container: runnerToken,
- });
- runnerTokenSecretValue.init();
- }
-
- if (gon.features.newVariablesUi) {
- initVariableList();
- } else {
- const variableListEl = document.querySelector('.js-ci-variable-list-section');
- // eslint-disable-next-line no-new
- new AjaxVariableList({
- container: variableListEl,
- saveButton: variableListEl.querySelector('.js-ci-variables-save-button'),
- errorBox: variableListEl.querySelector('.js-ci-variable-error-box'),
- saveEndpoint: variableListEl.dataset.saveEndpoint,
- maskableRegex: variableListEl.dataset.maskableRegex,
- });
- }
-
- // hide extra auto devops settings based checkbox state
- const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings');
- const instanceDefaultBadge = document.querySelector('.js-instance-default-badge');
- document.querySelector('.js-toggle-extra-settings').addEventListener('click', event => {
- const { target } = event;
- if (instanceDefaultBadge) instanceDefaultBadge.style.display = 'none';
- autoDevOpsExtraSettings.classList.toggle('hidden', !target.checked);
+const runnerToken = document.querySelector('.js-secret-runner-token');
+if (runnerToken) {
+ const runnerTokenSecretValue = new SecretValues({
+ container: runnerToken,
});
+ runnerTokenSecretValue.init();
+}
- registrySettingsApp();
- initDeployFreeze();
+initVariableList();
- initSettingsPipelinesTriggers();
+// hide extra auto devops settings based checkbox state
+const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings');
+const instanceDefaultBadge = document.querySelector('.js-instance-default-badge');
+document.querySelector('.js-toggle-extra-settings').addEventListener('click', event => {
+ const { target } = event;
+ if (instanceDefaultBadge) instanceDefaultBadge.style.display = 'none';
+ autoDevOpsExtraSettings.classList.toggle('hidden', !target.checked);
});
+
+registrySettingsApp();
+initDeployFreeze();
+
+initSettingsPipelinesTriggers();
+initInstallRunner();
diff --git a/app/assets/javascripts/pages/projects/settings/integrations/show/index.js b/app/assets/javascripts/pages/projects/settings/integrations/show/index.js
index f2cf2eb9b28..bf9ccdbf9a8 100644
--- a/app/assets/javascripts/pages/projects/settings/integrations/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/integrations/show/index.js
@@ -1,6 +1,4 @@
import PersistentUserCallout from '~/persistent_user_callout';
-document.addEventListener('DOMContentLoaded', () => {
- const callout = document.querySelector('.js-webhooks-moved-alert');
- PersistentUserCallout.factory(callout);
-});
+const callout = document.querySelector('.js-webhooks-moved-alert');
+PersistentUserCallout.factory(callout);
diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
index 1b9ec44ed4a..153ccffd472 100644
--- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
@@ -5,13 +5,11 @@ import mountGrafanaIntegration from '~/grafana_integration';
import initSettingsPanels from '~/settings_panels';
import initIncidentsSettings from '~/incidents_settings';
-document.addEventListener('DOMContentLoaded', () => {
- initIncidentsSettings();
- mountErrorTrackingForm();
- mountOperationSettings();
- mountGrafanaIntegration();
- if (!IS_EE) {
- initSettingsPanels();
- }
- mountAlertsSettings(document.querySelector('.js-alerts-settings'));
-});
+initIncidentsSettings();
+mountErrorTrackingForm();
+mountOperationSettings();
+mountGrafanaIntegration();
+if (!IS_EE) {
+ initSettingsPanels();
+}
+mountAlertsSettings(document.querySelector('.js-alerts-settings'));
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 bcf82e264d1..e50add3b0a4 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
@@ -68,6 +68,11 @@ export default {
required: false,
default: false,
},
+ requirementsAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
visibilityHelpPath: {
type: String,
required: false,
@@ -132,6 +137,7 @@ export default {
snippetsAccessLevel: featureAccessLevel.EVERYONE,
pagesAccessLevel: featureAccessLevel.EVERYONE,
metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
+ requirementsAccessLevel: featureAccessLevel.EVERYONE,
containerRegistryEnabled: true,
lfsEnabled: true,
requestAccessEnabled: true,
@@ -234,6 +240,10 @@ export default {
featureAccessLevel.PROJECT_MEMBERS,
this.metricsDashboardAccessLevel,
);
+ this.requirementsAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.requirementsAccessLevel,
+ );
if (this.pagesAccessLevel === featureAccessLevel.EVERYONE) {
// When from Internal->Private narrow access for only members
this.pagesAccessLevel = featureAccessLevel.PROJECT_MEMBERS;
@@ -257,6 +267,9 @@ export default {
this.pagesAccessLevel = featureAccessLevel.EVERYONE;
if (this.metricsDashboardAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
this.metricsDashboardAccessLevel = featureAccessLevel.EVERYONE;
+ if (this.requirementsAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
+ this.requirementsAccessLevel = featureAccessLevel.EVERYONE;
+
this.highlightChanges();
}
},
@@ -482,6 +495,18 @@ export default {
</project-setting-row>
</div>
<project-setting-row
+ v-if="requirementsAvailable"
+ ref="requirements-settings"
+ :label="s__('ProjectSettings|Requirements')"
+ :help-text="s__('ProjectSettings|Requirements management system for this project')"
+ >
+ <project-feature-setting
+ v-model="requirementsAccessLevel"
+ :options="featureAccessLevelOptions"
+ name="project[project_feature_attributes][requirements_access_level]"
+ />
+ </project-setting-row>
+ <project-setting-row
ref="wiki-settings"
:label="s__('ProjectSettings|Wiki')"
:help-text="s__('ProjectSettings|Pages for project documentation')"
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js b/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js
index f69ca6e27b3..ae0936417ad 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js
@@ -2,6 +2,7 @@ export default {
data() {
return {
packagesEnabled: false,
+ requirementsEnabled: false,
};
},
watch: {
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index dd8141d34c7..413b2d01621 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -10,6 +10,8 @@ import leaveByUrl from '~/namespaces/leave_by_url';
import Star from '../../../star';
import notificationsDropdown from '../../../notifications_dropdown';
import { showLearnGitLabProjectPopover } from '~/onboarding_issues';
+import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
+import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
initReadMore();
new Star(); // eslint-disable-line no-new
@@ -42,3 +44,6 @@ showLearnGitLabProjectPopover();
notificationsDropdown();
new ShortcutsNavigation(); // eslint-disable-line no-new
+
+initInviteMembersTrigger();
+initInviteMembersModal();
diff --git a/app/assets/javascripts/pages/projects/terraform/index/index.js b/app/assets/javascripts/pages/projects/terraform/index/index.js
new file mode 100644
index 00000000000..6f9f820f8e1
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/terraform/index/index.js
@@ -0,0 +1,3 @@
+import loadTerraformVues from '~/terraform';
+
+loadTerraformVues();
diff --git a/app/assets/javascripts/pages/search/show/index.js b/app/assets/javascripts/pages/search/show/index.js
index 721219874cf..88f2f30aad9 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 initSearchApp from '~/search';
+import { initSearchApp } from '~/search';
document.addEventListener('DOMContentLoaded', () => {
initSearchApp();
- return new Search();
+ return new Search(); // Deprecated Dropdown (Projects)
});
diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js
index 2cd333f26e1..03675f1ce66 100644
--- a/app/assets/javascripts/pages/search/show/search.js
+++ b/app/assets/javascripts/pages/search/show/search.js
@@ -1,52 +1,26 @@
import $ from 'jquery';
+import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
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 { visitUrl, queryToObject } from '~/lib/utils/url_utility';
import refreshCounts from './refresh_counts';
-import setHighlightClass from './highlight_blob_search_result';
export default class Search {
constructor() {
- setHighlightClass();
- const $groupDropdown = $('.js-search-group-dropdown');
+ setHighlightClass(); // Code Highlighting
const $projectDropdown = $('.js-search-project-dropdown');
this.searchInput = '.js-search-input';
this.searchClear = '.js-search-clear';
- this.groupId = $groupDropdown.data('groupId');
+ const query = queryToObject(window.location.search);
+ this.groupId = query?.group_id;
this.eventListeners();
refreshCounts();
- initDeprecatedJQueryDropdown($groupDropdown, {
- selectable: true,
- filterable: true,
- filterRemote: true,
- fieldName: 'group_id',
- search: {
- fields: ['full_name'],
- },
- data(term, callback) {
- return Api.groups(term, {}, data => {
- data.unshift({
- full_name: __('Any'),
- });
- data.splice(1, 0, { type: 'divider' });
- return callback(data);
- });
- },
- id(obj) {
- return obj.id;
- },
- text(obj) {
- return obj.full_name;
- },
- clicked: () => Search.submitSearch(),
- });
-
initDeprecatedJQueryDropdown($projectDropdown, {
selectable: true,
filterable: true,
diff --git a/app/assets/javascripts/pages/shared/mount_runner_instructions.js b/app/assets/javascripts/pages/shared/mount_runner_instructions.js
new file mode 100644
index 00000000000..b7662155339
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/mount_runner_instructions.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import InstallRunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
+
+Vue.use(VueApollo);
+
+export function initInstallRunner(componentId = 'js-install-runner') {
+ const installRunnerEl = document.getElementById(componentId);
+ const { projectPath, groupPath } = installRunnerEl?.dataset;
+
+ if (installRunnerEl) {
+ const defaultClient = createDefaultClient();
+
+ const apolloProvider = new VueApollo({
+ defaultClient,
+ });
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: installRunnerEl,
+ apolloProvider,
+ provide: {
+ projectPath,
+ groupPath,
+ },
+ render(createElement) {
+ return createElement(InstallRunnerInstructions);
+ },
+ });
+ }
+}
diff --git a/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue
index 653aad3d2f5..3792dad376b 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
import { escape } from 'lodash';
-import { s__, sprintf } from '~/locale';
+import { s__, __, sprintf } from '~/locale';
export default {
components: {
@@ -29,12 +29,6 @@ export default {
},
},
computed: {
- modalId() {
- return 'delete-wiki-modal';
- },
- message() {
- return s__('WikiPageConfirmDelete|Are you sure you want to delete this page?');
- },
title() {
return sprintf(
s__('WikiPageConfirmDelete|Delete page %{pageTitle}?'),
@@ -44,6 +38,21 @@ export default {
false,
);
},
+ primaryProps() {
+ return {
+ text: this.$options.i18n.deletePageText,
+ attributes: {
+ variant: 'danger',
+ 'data-qa-selector': 'confirm_deletion_button',
+ 'data-testid': 'confirm_deletion_button',
+ },
+ };
+ },
+ cancelProps() {
+ return {
+ text: this.$options.i18n.cancelButtonText,
+ };
+ },
},
methods: {
onSubmit() {
@@ -51,30 +60,36 @@ export default {
this.$refs.form.submit();
},
},
+ i18n: {
+ deletePageText: s__('WikiPageConfirmDelete|Delete page'),
+ modalBody: s__('WikiPageConfirmDelete|Are you sure you want to delete this page?'),
+ cancelButtonText: __('Cancel'),
+ },
+ modal: {
+ modalId: 'delete-wiki-modal',
+ },
};
</script>
<template>
<div class="d-inline-block">
<gl-button
- v-gl-modal="modalId"
- category="primary"
+ v-gl-modal="$options.modal.modalId"
+ category="secondary"
variant="danger"
data-qa-selector="delete_button"
>
- {{ __('Delete') }}
+ {{ $options.i18n.deletePageText }}
</gl-button>
<gl-modal
:title="title"
- :action-primary="{
- text: s__('WikiPageConfirmDelete|Delete page'),
- attributes: { variant: 'danger', 'data-qa-selector': 'confirm_deletion_button' },
- }"
- :modal-id="modalId"
- title-tag="h4"
+ :action-primary="primaryProps"
+ :action-cancel="cancelProps"
+ :modal-id="$options.modal.modalId"
+ size="sm"
@ok="onSubmit"
>
- {{ message }}
+ {{ $options.i18n.modalBody }}
<form ref="form" :action="deleteWikiUrl" method="post" class="js-requires-input">
<input ref="method" type="hidden" name="_method" value="delete" />
<input :value="csrfToken" type="hidden" name="authenticity_token" />
diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js
index ab948fd106f..fe9caba351e 100644
--- a/app/assets/javascripts/pages/shared/wikis/wikis.js
+++ b/app/assets/javascripts/pages/shared/wikis/wikis.js
@@ -1,6 +1,7 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { s__, sprintf } from '~/locale';
import Tracking from '~/tracking';
+import showToast from '~/vue_shared/plugins/global_toast';
const MARKDOWN_LINK_TEXT = {
markdown: '[Link Title](page-slug)',
@@ -63,6 +64,7 @@ export default class Wikis {
}
Wikis.trackPageView();
+ Wikis.showToasts();
}
handleWikiTitleChange(e) {
@@ -116,4 +118,9 @@ export default class Wikis {
},
});
}
+
+ static showToasts() {
+ const toasts = document.querySelectorAll('.js-toast-message');
+ toasts.forEach(toast => showToast(toast.dataset.message));
+ }
}
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index eb0a5efe75c..54666af540e 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -125,9 +125,6 @@ export default class ActivityCalendar {
this.renderMonths();
this.renderDayTitles();
this.renderKey();
-
- // Init tooltips
- $(`${container} .js-tooltip`).tooltip({ html: true });
}
// Add extra padding for the last month label if it is also the last column
@@ -191,7 +188,8 @@ export default class ActivityCalendar {
stamp.count !== 0 ? this.color(Math.min(stamp.count, 40)) : '#ededed',
)
.attr('title', stamp => formatTooltipText(stamp))
- .attr('class', 'user-contrib-cell js-tooltip')
+ .attr('class', 'user-contrib-cell has-tooltip')
+ .attr('data-html', true)
.attr('data-container', 'body')
.on('click', this.clickDay);
}
@@ -279,9 +277,10 @@ export default class ActivityCalendar {
.attr('x', (color, i) => this.daySizeWithSpace * i)
.attr('y', 0)
.attr('fill', color => color)
- .attr('class', 'js-tooltip')
+ .attr('class', 'has-tooltip')
.attr('title', (color, i) => keyValues[i])
- .attr('data-container', 'body');
+ .attr('data-container', 'body')
+ .attr('data-html', true);
}
initColor() {
diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js
index cfc6dc61f9f..8adbc2a8168 100644
--- a/app/assets/javascripts/pages/users/index.js
+++ b/app/assets/javascripts/pages/users/index.js
@@ -4,11 +4,6 @@ import UserCallout from '~/user_callout';
import UserTabs from './user_tabs';
function initUserProfile(action) {
- // place profile avatars to top
- $('.profile-groups-avatars').tooltip({
- placement: 'top',
- });
-
// eslint-disable-next-line no-new
new UserTabs({ parentEl: '.user-profile', action });
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index 9d66c784750..2485853afc7 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -5,7 +5,6 @@ import Activities from '~/activities';
import { localTimeAgo } from '~/lib/utils/datetime_utility';
import AjaxCache from '~/lib/utils/ajax_cache';
import { __ } from '~/locale';
-import { deprecatedCreateFlash as flash } from '~/flash';
import ActivityCalendar from './activity_calendar';
import UserOverviewBlock from './user_overview_block';
@@ -63,9 +62,9 @@ import UserOverviewBlock from './user_overview_block';
*/
const CALENDAR_TEMPLATE = `
- <div class="clearfix calendar">
+ <div class="calendar">
<div class="js-contrib-calendar"></div>
- <div class="calendar-hint bottom-right"></div>
+ <div class="calendar-hint"></div>
</div>
`;
@@ -214,7 +213,17 @@ export default class UserTabs {
AjaxCache.retrieve(calendarPath)
.then(data => UserTabs.renderActivityCalendar(data, $calendarWrap))
- .catch(() => flash(__('There was an error loading users activity calendar.')));
+ .catch(() => {
+ const cWrap = $calendarWrap[0];
+ cWrap.querySelector('.spinner').classList.add('invisible');
+ cWrap.querySelector('.user-calendar-error').classList.remove('invisible');
+ cWrap.querySelector('.user-calendar-error .js-retry-load').addEventListener('click', e => {
+ e.preventDefault();
+ cWrap.querySelector('.user-calendar-error').classList.add('invisible');
+ cWrap.querySelector('.spinner').classList.remove('invisible');
+ this.loadActivityCalendar();
+ });
+ });
}
static renderActivityCalendar(data, $calendarWrap) {
diff --git a/app/assets/javascripts/performance_constants.js b/app/assets/javascripts/performance/constants.js
index 6b6b6f1da40..816eb9b3a66 100644
--- a/app/assets/javascripts/performance_constants.js
+++ b/app/assets/javascripts/performance/constants.js
@@ -29,3 +29,17 @@ export const WEBIDE_MARK_FILE_FINISH = 'webide-file-finished';
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';
+
+//
+// MR Diffs namespace
+
+// Marks
+export const MR_DIFFS_MARK_FILE_TREE_START = 'mr-diffs-mark-file-tree-start';
+export const MR_DIFFS_MARK_FILE_TREE_END = 'mr-diffs-mark-file-tree-end';
+export const MR_DIFFS_MARK_DIFF_FILES_START = 'mr-diffs-mark-diff-files-start';
+export const MR_DIFFS_MARK_FIRST_DIFF_FILE_SHOWN = 'mr-diffs-mark-first-diff-file-shown';
+export const MR_DIFFS_MARK_DIFF_FILES_END = 'mr-diffs-mark-diff-files-end';
+
+// Measures
+export const MR_DIFFS_MEASURE_FILE_TREE_DONE = 'mr-diffs-measure-file-tree-done';
+export const MR_DIFFS_MEASURE_DIFF_FILES_DONE = 'mr-diffs-measure-diff-files-done';
diff --git a/app/assets/javascripts/performance_utils.js b/app/assets/javascripts/performance/utils.js
index 1c87ee2086e..1c87ee2086e 100644
--- a/app/assets/javascripts/performance_utils.js
+++ b/app/assets/javascripts/performance/utils.js
diff --git a/app/assets/javascripts/performance/vue_performance_plugin.js b/app/assets/javascripts/performance/vue_performance_plugin.js
new file mode 100644
index 00000000000..7329b83b1d1
--- /dev/null
+++ b/app/assets/javascripts/performance/vue_performance_plugin.js
@@ -0,0 +1,53 @@
+const ComponentPerformancePlugin = {
+ install(Vue, options) {
+ Vue.mixin({
+ beforeCreate() {
+ /** Make sure the component you want to measure has `name` option defined
+ * and it matches the one you pass as the plugin option. Example:
+ *
+ * my_component.vue:
+ *
+ * ```
+ * export default {
+ * name: 'MyComponent'
+ * ...
+ * }
+ * ```
+ *
+ * index.js (where you initialize your Vue app containing <my-component>):
+ *
+ * ```
+ * Vue.use(PerformancePlugin, {
+ * components: [
+ * 'MyComponent',
+ * ]
+ * });
+ * ```
+ */
+ const componentName = this.$options.name;
+ if (options?.components?.indexOf(componentName) !== -1) {
+ const tagName = `<${componentName}>`;
+ if (!performance.getEntriesByName(`${tagName}-start`).length) {
+ performance.mark(`${tagName}-start`);
+ }
+ }
+ },
+ mounted() {
+ const componentName = this.$options.name;
+ if (options?.components?.indexOf(componentName) !== -1) {
+ this.$nextTick(() => {
+ window.requestAnimationFrame(() => {
+ const tagName = `<${componentName}>`;
+ if (!performance.getEntriesByName(`${tagName}-end`).length) {
+ performance.mark(`${tagName}-end`);
+ performance.measure(`${tagName}`, `${tagName}-start`);
+ }
+ });
+ });
+ }
+ },
+ });
+ },
+};
+
+export default ComponentPerformancePlugin;
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index 9f05ee5c7c2..90e14d8325f 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -1,15 +1,17 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlButton, GlIcon, GlModal, GlModalDirective } from '@gitlab/ui';
import RequestWarning from './request_warning.vue';
-import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
-
export default {
components: {
RequestWarning,
- GlModal: DeprecatedModal2,
+ GlButton,
+ GlModal,
GlIcon,
},
+ directives: {
+ 'gl-modal': GlModalDirective,
+ },
props: {
currentRequest: {
type: Object,
@@ -35,7 +37,15 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ openedBacktraces: [],
+ };
+ },
computed: {
+ modalId() {
+ return `modal-peek-${this.metric}-details`;
+ },
metricDetails() {
return this.currentRequest.details[this.metric];
},
@@ -58,29 +68,35 @@ export default {
return '';
},
},
+ methods: {
+ toggleBacktrace(toggledIndex) {
+ const toggledOpenedIndex = this.openedBacktraces.indexOf(toggledIndex);
+
+ if (toggledOpenedIndex === -1) {
+ this.openedBacktraces = [...this.openedBacktraces, toggledIndex];
+ } else {
+ this.openedBacktraces = this.openedBacktraces.filter(
+ openedIndex => openedIndex !== toggledIndex,
+ );
+ }
+ },
+ itemHasOpenedBacktrace(toggledIndex) {
+ return this.openedBacktraces.find(openedIndex => openedIndex === toggledIndex) >= 0;
+ },
+ },
};
</script>
<template>
<div
v-if="currentRequest.details && metricDetails"
:id="`peek-view-${metric}`"
- class="view"
+ class="gl-display-flex gl-align-items-center view"
data-qa-selector="detailed_metric_content"
>
- <button
- :data-target="`#modal-peek-${metric}-details`"
- class="btn-blank btn-link bold"
- type="button"
- data-toggle="modal"
- >
+ <gl-button v-gl-modal="modalId" class="gl-mr-2" type="button" variant="link">
{{ metricDetailsLabel }}
- </button>
- <gl-modal
- :id="`modal-peek-${metric}-details`"
- :header-title-text="header"
- modal-size="xl"
- class="performance-bar-modal"
- >
+ </gl-button>
+ <gl-modal :modal-id="modalId" :title="header" size="lg" modal-class="gl-mt-7" scrollable>
<table class="table">
<template v-if="detailsList.length">
<tr v-for="(item, index) in detailsList" :key="index">
@@ -90,7 +106,7 @@ export default {
}}</span>
</td>
<td>
- <div class="js-toggle-container">
+ <div>
<div
v-for="(key, keyIndex) in keys"
:key="key"
@@ -98,16 +114,18 @@ export default {
:class="{ 'mb-3 bold': keyIndex == 0 }"
>
{{ item[key] }}
- <button
+ <gl-button
v-if="keyIndex == 0 && item.backtrace"
- class="text-expander js-toggle-button"
+ class="gl-ml-3"
+ data-testid="backtrace-expand-btn"
type="button"
:aria-label="__('Toggle backtrace')"
+ @click="toggleBacktrace(index)"
>
<gl-icon :size="12" name="ellipsis_h" />
- </button>
+ </gl-button>
</div>
- <pre v-if="item.backtrace" class="backtrace-row js-toggle-content mt-2">{{
+ <pre v-if="itemHasOpenedBacktrace(index)" class="backtrace-row mt-2">{{
item.backtrace
}}</pre>
</div>
diff --git a/app/assets/javascripts/performance_bar/performance_bar_log.js b/app/assets/javascripts/performance_bar/performance_bar_log.js
index 55b4d626e56..3ba7ff1c221 100644
--- a/app/assets/javascripts/performance_bar/performance_bar_log.js
+++ b/app/assets/javascripts/performance_bar/performance_bar_log.js
@@ -1,6 +1,6 @@
/* eslint-disable no-console */
import { getCLS, getFID, getLCP } from 'web-vitals';
-import { PERFORMANCE_TYPE_MARK, PERFORMANCE_TYPE_MEASURE } from '~/performance_constants';
+import { PERFORMANCE_TYPE_MARK, PERFORMANCE_TYPE_MEASURE } from '~/performance/constants';
const initVitalsLog = () => {
const reportVital = data => {
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index ef4d5338046..8c5f45e9d34 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -6,6 +6,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-admin-licensed-user-count-threshold',
'.js-buy-pipeline-minutes-notification-callout',
'.js-token-expiry-callout',
+ '.js-registration-enabled-callout',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_editor/components/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/text_editor.vue
new file mode 100644
index 00000000000..a925077c906
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/text_editor.vue
@@ -0,0 +1,26 @@
+<script>
+import EditorLite from '~/vue_shared/components/editor_lite.vue';
+
+export default {
+ components: {
+ EditorLite,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-border-solid gl-border-gray-100 gl-border-1">
+ <editor-lite
+ v-model="value"
+ file-name="*.yml"
+ :editor-options="{ readOnly: true }"
+ @editor-ready="$emit('editor-ready')"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql
new file mode 100644
index 00000000000..9f1b5b13088
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql
@@ -0,0 +1,5 @@
+query getBlobContent($projectPath: ID!, $path: String, $ref: String!) {
+ blobContent(projectPath: $projectPath, path: $path, ref: $ref) @client {
+ rawData
+ }
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
new file mode 100644
index 00000000000..7b8c70ac93e
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
@@ -0,0 +1,16 @@
+import Api from '~/api';
+
+export const resolvers = {
+ Query: {
+ blobContent(_, { projectPath, path, ref }) {
+ return {
+ __typename: 'BlobContent',
+ rawData: Api.getRawFile(projectPath, path, { ref }).then(({ data }) => {
+ return data;
+ }),
+ };
+ },
+ },
+};
+
+export default resolvers;
diff --git a/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql b/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql
new file mode 100644
index 00000000000..f4f65262158
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql
@@ -0,0 +1,7 @@
+type BlobContent {
+ rawData: String!
+}
+
+extend type Query {
+ blobContent: BlobContent
+}
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
new file mode 100644
index 00000000000..ccd7b74064f
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -0,0 +1,34 @@
+import Vue from 'vue';
+
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import typeDefs from './graphql/typedefs.graphql';
+import { resolvers } from './graphql/resolvers';
+
+import PipelineEditorApp from './pipeline_editor_app.vue';
+
+export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
+ const el = document.querySelector(selector);
+
+ const { projectPath, defaultBranch, ciConfigPath } = el?.dataset;
+
+ Vue.use(VueApollo);
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(resolvers, { typeDefs }),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(PipelineEditorApp, {
+ props: {
+ projectPath,
+ defaultBranch,
+ ciConfigPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
new file mode 100644
index 00000000000..50b946af456
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -0,0 +1,108 @@
+<script>
+import { GlLoadingIcon, GlAlert, GlTabs, GlTab } from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
+
+import TextEditor from './components/text_editor.vue';
+import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
+
+import getBlobContent from './graphql/queries/blob_content.graphql';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlAlert,
+ GlTabs,
+ GlTab,
+ TextEditor,
+ PipelineGraph,
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ defaultBranch: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ ciConfigPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ error: null,
+ content: '',
+ editorIsReady: false,
+ };
+ },
+ apollo: {
+ content: {
+ query: getBlobContent,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ path: this.ciConfigPath,
+ ref: this.defaultBranch,
+ };
+ },
+ update(data) {
+ return data?.blobContent?.rawData;
+ },
+ error(error) {
+ this.error = error;
+ },
+ },
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.content.loading;
+ },
+ errorMessage() {
+ const { message: generalReason, networkError } = this.error ?? {};
+
+ const { data } = networkError?.response ?? {};
+ // 404 for missing file uses `message`
+ // 400 for a missing ref uses `error`
+ const networkReason = data?.message ?? data?.error;
+
+ const reason = networkReason ?? generalReason ?? this.$options.i18n.unknownError;
+ return sprintf(this.$options.i18n.errorMessageWithReason, { reason });
+ },
+ pipelineData() {
+ // Note data will loaded as part of https://gitlab.com/gitlab-org/gitlab/-/issues/263141
+ return {};
+ },
+ },
+ i18n: {
+ unknownError: __('Unknown Error'),
+ errorMessageWithReason: s__('Pipelines|CI file could not be loaded: %{reason}'),
+ tabEdit: s__('Pipelines|Write pipeline configuration'),
+ tabGraph: s__('Pipelines|Visualize'),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mt-4">
+ <gl-alert v-if="error" :dismissible="false" variant="danger">{{ errorMessage }}</gl-alert>
+ <div class="gl-mt-4">
+ <gl-loading-icon v-if="loading" size="lg" />
+ <div v-else class="file-editor">
+ <gl-tabs>
+ <!-- editor should be mounted when its tab is visible, so the container has a size -->
+ <gl-tab :title="$options.i18n.tabEdit" :lazy="!editorIsReady">
+ <!-- editor should be mounted only once, when the tab is displayed -->
+ <text-editor v-model="content" @editor-ready="editorIsReady = true" />
+ </gl-tab>
+
+ <gl-tab :title="$options.i18n.tabGraph">
+ <pipeline-graph :pipeline-data="pipelineData" />
+ </gl-tab>
+ </gl-tabs>
+ </div>
+ </div>
+ </div>
+</template>
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 20067f6646f..6552665100a 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
@@ -14,6 +14,7 @@ import {
GlDropdownItem,
GlSearchBoxByType,
GlSprintf,
+ GlLoadingIcon,
} from '@gitlab/ui';
import { s__, __, n__ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
@@ -45,6 +46,7 @@ export default {
GlDropdownItem,
GlSearchBoxByType,
GlSprintf,
+ GlLoadingIcon,
},
props: {
pipelinesPath: {
@@ -96,6 +98,7 @@ export default {
warnings: [],
totalWarnings: 0,
isWarningDismissed: false,
+ isLoading: false,
};
},
computed: {
@@ -209,6 +212,8 @@ export default {
fetchConfigVariables(refValue) {
if (gon?.features?.newPipelineFormPrefilledVars) {
+ this.isLoading = true;
+
return axios
.get(this.configVariablesPath, {
params: {
@@ -226,6 +231,8 @@ export default {
}
});
+ this.isLoading = false;
+
return { params, descriptions };
});
}
@@ -324,7 +331,9 @@ export default {
>
</gl-form-group>
- <gl-form-group :label="s__('Pipeline|Variables')">
+ <gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" />
+
+ <gl-form-group v-else :label="s__('Pipeline|Variables')">
<div
v-for="(variable, index) in variables"
:key="variable.uniqueId"
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue
index 6267b63328c..85171263f08 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlButton, GlEmptyState, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { __ } from '~/locale';
import { fetchPolicies } from '~/lib/graphql';
@@ -17,11 +17,15 @@ export default {
DagAnnotations,
DagGraph,
GlAlert,
- GlSprintf,
- GlEmptyState,
GlButton,
+ GlEmptyState,
+ GlLink,
+ GlSprintf,
},
inject: {
+ aboutDagDocPath: {
+ default: null,
+ },
dagDocPath: {
default: null,
},
@@ -89,14 +93,14 @@ export default {
[DEFAULT]: __('An unknown error occurred while loading this graph.'),
},
emptyStateTexts: {
- title: __('Start using Directed Acyclic Graphs (DAG)'),
+ title: __('Speed up your pipelines with Needs relationships'),
firstDescription: __(
- "This pipeline does not use the %{codeStart}needs%{codeEnd} keyword and can't be represented as a directed acyclic graph.",
+ 'Using the %{codeStart}needs%{codeEnd} keyword makes jobs run before their stage is reached. Jobs run as soon as their %{codeStart}needs%{codeEnd} relationships are met, which speeds up your pipelines.',
),
secondDescription: __(
- 'Using %{codeStart}needs%{codeEnd} allows jobs to run before their stage is reached, as soon as their individual dependencies are met, which speeds up your pipelines.',
+ "If you add %{codeStart}needs%{codeEnd} to jobs in your pipeline you'll be able to view the %{codeStart}needs%{codeEnd} relationships between jobs in this tab as a %{linkStart}Directed Acyclic Graph (DAG)%{linkEnd}.",
),
- button: __('Learn more about job dependencies'),
+ button: __('Learn more about Needs relationships'),
},
computed: {
failure() {
@@ -222,6 +226,9 @@ export default {
<template #code="{ content }">
<code>{{ content }}</code>
</template>
+ <template #link="{ content }">
+ <gl-link :href="aboutDagDocPath">{{ content }}</gl-link>
+ </template>
</gl-sprintf>
</p>
</div>
diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js
new file mode 100644
index 00000000000..ba1922b6dae
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/constants.js
@@ -0,0 +1,3 @@
+export const DOWNSTREAM = 'downstream';
+export const MAIN = 'main';
+export const UPSTREAM = 'upstream';
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 0f5a8cb8fbf..16ce279a591 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -5,6 +5,7 @@ import StageColumnComponent from './stage_column_component.vue';
import GraphWidthMixin from '../../mixins/graph_width_mixin';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
+import { UPSTREAM, DOWNSTREAM, MAIN } from './constants';
export default {
name: 'PipelineGraph',
@@ -35,11 +36,11 @@ export default {
type: {
type: String,
required: false,
- default: 'main',
+ default: MAIN,
},
},
- upstream: 'upstream',
- downstream: 'downstream',
+ upstream: UPSTREAM,
+ downstream: DOWNSTREAM,
data() {
return {
downstreamMarginTop: null,
@@ -54,41 +55,41 @@ export default {
graph() {
return this.pipeline.details?.stages;
},
- hasTriggeredBy() {
+ hasUpstream() {
return (
this.type !== this.$options.downstream &&
- this.triggeredByPipelines &&
+ this.upstreamPipelines &&
this.pipeline.triggered_by !== null
);
},
- triggeredByPipelines() {
+ upstreamPipelines() {
return this.pipeline.triggered_by;
},
- hasTriggered() {
+ hasDownstream() {
return (
this.type !== this.$options.upstream &&
- this.triggeredPipelines &&
+ this.downstreamPipelines &&
this.pipeline.triggered.length > 0
);
},
- triggeredPipelines() {
+ downstreamPipelines() {
return this.pipeline.triggered;
},
- expandedTriggeredBy() {
+ expandedUpstream() {
return (
this.pipeline.triggered_by &&
Array.isArray(this.pipeline.triggered_by) &&
this.pipeline.triggered_by.find(el => el.isExpanded)
);
},
- expandedTriggered() {
+ expandedDownstream() {
return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded);
},
pipelineTypeUpstream() {
- return this.type !== this.$options.downstream && this.expandedTriggeredBy;
+ return this.type !== this.$options.downstream && this.expandedUpstream;
},
pipelineTypeDownstream() {
- return this.type !== this.$options.upstream && this.expandedTriggered;
+ return this.type !== this.$options.upstream && this.expandedDownstream;
},
pipelineProjectId() {
return this.pipeline.project.id;
@@ -142,11 +143,11 @@ export default {
* and we want to reset the pipeline store. Triggering the reset without
* this condition would mean not allowing downstreams of downstreams to expand
*/
- if (this.expandedTriggered?.id !== pipeline.id) {
- this.$emit('onResetTriggered', this.pipeline, pipeline);
+ if (this.expandedDownstream?.id !== pipeline.id) {
+ this.$emit('onResetDownstream', this.pipeline, pipeline);
}
- this.$emit('onClickTriggered', pipeline);
+ this.$emit('onClickDownstreamPipeline', pipeline);
},
calculateMarginTop(downstreamNode, pixelDiff) {
return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`;
@@ -154,8 +155,8 @@ export default {
hasOnlyOneJob(stage) {
return stage.groups.length === 1;
},
- hasUpstream(index) {
- return index === 0 && this.hasTriggeredBy;
+ hasUpstreamColumn(index) {
+ return index === 0 && this.hasUpstream;
},
setJob(jobName) {
this.jobName = jobName;
@@ -192,30 +193,30 @@ export default {
<pipeline-graph
v-if="pipelineTypeUpstream"
- type="upstream"
+ :type="$options.upstream"
class="d-inline-block upstream-pipeline"
- :class="`js-upstream-pipeline-${expandedTriggeredBy.id}`"
+ :class="`js-upstream-pipeline-${expandedUpstream.id}`"
:is-loading="false"
- :pipeline="expandedTriggeredBy"
+ :pipeline="expandedUpstream"
:is-linked-pipeline="true"
:mediator="mediator"
- @onClickTriggeredBy="clickTriggeredByPipeline"
+ @onClickUpstreamPipeline="clickUpstreamPipeline"
@refreshPipelineGraph="requestRefreshPipelineGraph"
/>
<linked-pipelines-column
- v-if="hasTriggeredBy"
- :linked-pipelines="triggeredByPipelines"
+ v-if="hasUpstream"
+ :type="$options.upstream"
+ :linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:project-id="pipelineProjectId"
- graph-position="left"
- @linkedPipelineClick="$emit('onClickTriggeredBy', $event)"
+ @linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)"
/>
<ul
v-if="!isLoading"
:class="{
- 'inline js-has-linked-pipelines': hasTriggered || hasTriggeredBy,
+ 'inline js-has-linked-pipelines': hasDownstream || hasUpstream,
}"
class="stage-column-list align-top"
>
@@ -223,7 +224,7 @@ export default {
v-for="(stage, index) in graph"
:key="stage.name"
:class="{
- 'has-upstream gl-ml-11': hasUpstream(index),
+ 'has-upstream gl-ml-11': hasUpstreamColumn(index),
'has-only-one-job': hasOnlyOneJob(stage),
'gl-mr-26': shouldAddRightMargin(index),
}"
@@ -231,7 +232,7 @@ export default {
:groups="stage.groups"
:stage-connector-class="stageConnectorClass(index, stage)"
:is-first-column="isFirstColumn(index)"
- :has-triggered-by="hasTriggeredBy"
+ :has-upstream="hasUpstream"
:action="stage.status.action"
:job-hovered="jobName"
:pipeline-expanded="pipelineExpanded"
@@ -240,11 +241,11 @@ export default {
</ul>
<linked-pipelines-column
- v-if="hasTriggered"
- :linked-pipelines="triggeredPipelines"
+ v-if="hasDownstream"
+ :type="$options.downstream"
+ :linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:project-id="pipelineProjectId"
- graph-position="right"
@linkedPipelineClick="handleClickedDownstream"
@downstreamHovered="setJob"
@pipelineExpandToggle="setPipelineExpanded"
@@ -252,15 +253,15 @@ export default {
<pipeline-graph
v-if="pipelineTypeDownstream"
- type="downstream"
+ :type="$options.downstream"
class="d-inline-block"
- :class="`js-downstream-pipeline-${expandedTriggered.id}`"
+ :class="`js-downstream-pipeline-${expandedDownstream.id}`"
:is-loading="false"
- :pipeline="expandedTriggered"
+ :pipeline="expandedDownstream"
:is-linked-pipeline="true"
:style="{ 'margin-top': downstreamMarginTop }"
:mediator="mediator"
- @onClickTriggered="clickTriggeredPipeline"
+ @onClickDownstreamPipeline="clickDownstreamPipeline"
@refreshPipelineGraph="requestRefreshPipelineGraph"
/>
</div>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 7aee2573ce1..4ed0aae0d1e 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -119,6 +119,9 @@ export default {
},
},
methods: {
+ hideTooltips() {
+ this.$root.$emit('bv::hide::tooltip');
+ },
pipelineActionRequestComplete() {
this.$emit('pipelineActionRequestComplete');
},
@@ -129,24 +132,26 @@ export default {
<div class="ci-job-component" data-qa-selector="job_item_container">
<gl-link
v-if="status.has_details"
- v-gl-tooltip="{ boundary, placement: 'bottom' }"
+ v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
:href="status.details_path"
:title="tooltipText"
:class="jobClasses"
class="js-pipeline-graph-job-link qa-job-link menu-item"
data-testid="job-with-link"
- @click.stop
+ @click.stop="hideTooltips"
+ @mouseout="hideTooltips"
>
<job-name-component :name="job.name" :status="job.status" />
</gl-link>
<div
v-else
- v-gl-tooltip="{ boundary, placement: 'bottom' }"
+ v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
:title="tooltipText"
:class="jobClasses"
class="js-job-component-tooltip non-details-job-component"
data-testid="job-without-link"
+ @mouseout="hideTooltips"
>
<job-name-component :name="job.name" :status="job.status" />
</div>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index e359fc787c5..11f06a25984 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -2,6 +2,7 @@
import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
import { __, sprintf } from '~/locale';
+import { UPSTREAM, DOWNSTREAM } from './constants';
export default {
directives: {
@@ -14,6 +15,10 @@ export default {
GlLoadingIcon,
},
props: {
+ columnTitle: {
+ type: String,
+ required: true,
+ },
pipeline: {
type: Object,
required: true,
@@ -22,7 +27,7 @@ export default {
type: Number,
required: true,
},
- columnTitle: {
+ type: {
type: String,
required: true,
},
@@ -50,12 +55,10 @@ export default {
return this.childPipeline ? __('child-pipeline') : this.pipeline.project.name;
},
parentPipeline() {
- // Refactor string match when BE returns Upstream/Downstream indicators
- return this.projectId === this.pipeline.project.id && this.columnTitle === __('Upstream');
+ return this.isUpstream && this.isSameProject;
},
childPipeline() {
- // Refactor string match when BE returns Upstream/Downstream indicators
- return this.projectId === this.pipeline.project.id && this.isDownstream;
+ return this.isDownstream && this.isSameProject;
},
label() {
if (this.parentPipeline) {
@@ -66,7 +69,13 @@ export default {
return __('Multi-project');
},
isDownstream() {
- return this.columnTitle === __('Downstream');
+ return this.type === DOWNSTREAM;
+ },
+ isUpstream() {
+ return this.type === UPSTREAM;
+ },
+ isSameProject() {
+ return this.projectId === this.pipeline.project.id;
},
sourceJobInfo() {
return this.isDownstream
@@ -74,13 +83,13 @@ export default {
: '';
},
expandedIcon() {
- if (this.parentPipeline) {
+ if (this.isUpstream) {
return this.expanded ? 'angle-right' : 'angle-left';
}
return this.expanded ? 'angle-left' : 'angle-right';
},
expandButtonPosition() {
- return this.parentPipeline ? 'gl-left-0 gl-border-r-1!' : 'gl-right-0 gl-border-l-1!';
+ return this.isUpstream ? 'gl-left-0 gl-border-r-1!' : 'gl-right-0 gl-border-l-1!';
},
},
methods: {
@@ -116,7 +125,7 @@ export default {
>
<div
class="gl-relative gl-bg-white gl-p-3 gl-border-solid gl-border-gray-100 gl-border-1"
- :class="{ 'gl-pl-9': parentPipeline }"
+ :class="{ 'gl-pl-9': isUpstream }"
>
<div class="gl-display-flex">
<ci-status
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
index 3ad28d88345..2ca33e6d33e 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -1,6 +1,6 @@
<script>
import LinkedPipeline from './linked_pipeline.vue';
-import { __ } from '~/locale';
+import { UPSTREAM } from './constants';
export default {
components: {
@@ -15,7 +15,7 @@ export default {
type: Array,
required: true,
},
- graphPosition: {
+ type: {
type: String,
required: true,
},
@@ -32,9 +32,12 @@ export default {
};
return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`;
},
+ graphPosition() {
+ return this.isUpstream ? 'left' : 'right';
+ },
// Refactor string match when BE returns Upstream/Downstream indicators
isUpstream() {
- return this.columnTitle === __('Upstream');
+ return this.type === UPSTREAM;
},
},
methods: {
@@ -45,6 +48,11 @@ export default {
this.$emit('downstreamHovered', jobName);
},
onPipelineExpandToggle(jobName, expanded) {
+ // Highlighting only applies to downstream pipelines
+ if (this.isUpstream) {
+ return;
+ }
+
this.$emit('pipelineExpandToggle', jobName, expanded);
},
},
@@ -66,6 +74,7 @@ export default {
:pipeline="pipeline"
:column-title="columnTitle"
:project-id="projectId"
+ :type="type"
@pipelineClicked="onPipelineClick($event, pipeline, index)"
@downstreamHovered="onDownstreamHovered"
@pipelineExpandToggle="onPipelineExpandToggle"
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index b26f28fa6af..741609c908a 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -1,16 +1,22 @@
<script>
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 deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql';
+import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql';
+import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql';
import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants';
const DELETE_MODAL_ID = 'pipeline-delete-modal';
+const POLL_INTERVAL = 10000;
export default {
name: 'PipelineHeaderSection',
+ pipelineCancel: 'pipelineCancel',
+ pipelineRetry: 'pipelineRetry',
+ finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'],
components: {
ciHeader,
GlAlert,
@@ -28,7 +34,7 @@ export default {
[DEFAULT]: __('An unknown error occurred.'),
},
inject: {
- // Receive `cancel`, `delete`, `fullProject` and `retry`
+ // Receive `fullProject` and `pipelinesPath`
paths: {
default: {},
},
@@ -52,7 +58,7 @@ export default {
error() {
this.reportFailure(LOAD_FAILURE);
},
- pollInterval: 10000,
+ pollInterval: POLL_INTERVAL,
watchLoading(isLoading) {
if (!isLoading) {
// To ensure apollo has updated the cache,
@@ -90,6 +96,9 @@ export default {
status() {
return this.pipeline?.status;
},
+ isFinished() {
+ return this.$options.finishedStatuses.includes(this.status);
+ },
shouldRenderContent() {
return !this.isLoadingInitialQuery && this.hasPipelineData;
},
@@ -118,35 +127,72 @@ export default {
}
},
},
+ watch: {
+ isFinished(finished) {
+ if (finished) {
+ this.$apollo.queries.pipeline.stopPolling();
+ }
+ },
+ },
methods: {
reportFailure(errorType) {
this.failureType = errorType;
},
- async postAction(path) {
+ async postPipelineAction(name, mutation) {
try {
- await axios.post(path);
- this.$apollo.queries.pipeline.refetch();
+ const {
+ data: {
+ [name]: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation,
+ variables: { id: this.pipeline.id },
+ });
+
+ if (errors.length > 0) {
+ this.reportFailure(POST_FAILURE);
+ } else {
+ await this.$apollo.queries.pipeline.refetch();
+ if (!this.isFinished) {
+ this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL);
+ }
+ }
} catch {
this.reportFailure(POST_FAILURE);
}
},
- async cancelPipeline() {
+ cancelPipeline() {
this.isCanceling = true;
- this.postAction(this.paths.cancel);
+ this.postPipelineAction(this.$options.pipelineCancel, cancelPipelineMutation);
},
- async retryPipeline() {
+ retryPipeline() {
this.isRetrying = true;
- this.postAction(this.paths.retry);
+ this.postPipelineAction(this.$options.pipelineRetry, retryPipelineMutation);
},
async deletePipeline() {
this.isDeleting = true;
this.$apollo.queries.pipeline.stopPolling();
try {
- const { request } = await axios.delete(this.paths.delete);
- redirectTo(setUrlFragment(request.responseURL, 'delete_success'));
+ const {
+ data: {
+ pipelineDestroy: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: deletePipelineMutation,
+ variables: {
+ id: this.pipeline.id,
+ },
+ });
+
+ if (errors.length > 0) {
+ this.reportFailure(DELETE_FAILURE);
+ this.isDeleting = false;
+ } else {
+ redirectTo(setUrlFragment(this.paths.pipelinesPath, 'delete_success'));
+ }
} catch {
- this.$apollo.queries.pipeline.startPolling();
+ this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL);
this.reportFailure(DELETE_FAILURE);
this.isDeleting = false;
}
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 8eec0110865..a0c35f54c0e 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
@@ -57,7 +57,7 @@ export default {
<tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
<div
: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="gl-w-15 gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease"
:class="jobPillClasses"
@mouseover="onMouseEnter"
@mouseleave="onMouseLeave"
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 3a2b8a20bae..11ad2f2a3b6 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
@@ -97,15 +97,20 @@ export default {
this.reportFailure(DRAW_FAILURE);
}
},
- getStageBackgroundClass(index) {
+ getStageBackgroundClasses(index) {
const { length } = this.pipelineData.stages;
-
+ // It's possible for a graph to have only one stage, in which
+ // case we concatenate both the left and right rounding classes
if (length === 1) {
- return 'stage-rounded';
- } else if (index === 0) {
- return 'stage-left-rounded';
- } else if (index === length - 1) {
- return 'stage-right-rounded';
+ return 'gl-rounded-bottom-left-6 gl-rounded-top-left-6 gl-rounded-bottom-right-6 gl-rounded-top-right-6';
+ }
+
+ if (index === 0) {
+ return 'gl-rounded-bottom-left-6 gl-rounded-top-left-6';
+ }
+
+ if (index === length - 1) {
+ return 'gl-rounded-bottom-right-6 gl-rounded-top-right-6';
}
return '';
@@ -162,7 +167,11 @@ export default {
{{ failure.text }}
</gl-alert>
<gl-alert v-if="isPipelineDataEmpty" variant="tip" :dismissible="false">
- {{ __('No content to show') }}
+ {{
+ __(
+ 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.',
+ )
+ }}
</gl-alert>
<div
v-else
@@ -190,7 +199,8 @@ 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="getStageBackgroundClass(index)"
+ :class="getStageBackgroundClasses(index)"
+ data-testid="stage-background"
>
<stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" />
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_pill.vue
index 7b2458db725..df48426f24e 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_pill.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_pill.vue
@@ -26,7 +26,7 @@ export default {
<template>
<tooltip-on-truncate :title="stageName" truncate-target="child" placement="top">
<div
- class="gl-px-5 gl-py-2 gl-text-white gl-text-center gl-text-truncate gl-rounded-pill pipeline-stage-pill"
+ class="gl-px-5 gl-py-2 gl-text-white gl-text-center gl-text-truncate gl-rounded-pill gl-w-20"
:class="emptyClass"
>
{{ stageName }}
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index adba86d384b..9ee427d01fd 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -13,7 +13,6 @@ import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin';
import PipelinesFilteredSearch from './pipelines_filtered_search.vue';
import { validateParams } from '../../utils';
import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../../constants';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -23,7 +22,7 @@ export default {
PipelinesFilteredSearch,
GlIcon,
},
- mixins: [pipelinesMixin, CIPaginationMixin, glFeatureFlagsMixin()],
+ mixins: [pipelinesMixin, CIPaginationMixin],
props: {
store: {
type: Object,
@@ -209,9 +208,6 @@ export default {
},
];
},
- canFilterPipelines() {
- return this.glFeatures.filterPipelinesSearch;
- },
validatedParams() {
return validateParams(this.params);
},
@@ -306,7 +302,6 @@ export default {
</div>
<pipelines-filtered-search
- v-if="canFilterPipelines"
:project-id="projectId"
:params="validatedParams"
@filterPipelines="filterPipelines"
@@ -342,7 +337,7 @@ export default {
:message="emptyTabMessage"
/>
- <div v-else-if="stateToRender === $options.stateMap.tableList" class="table-holder">
+ <div v-else-if="stateToRender === $options.stateMap.tableList">
<pipelines-table-component
:pipelines="state.pipelines"
:pipeline-schedule-url="pipelineScheduleUrl"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue
index 97595e5d2ce..e52afe08336 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue
@@ -87,7 +87,7 @@ export default {
:aria-label="__('Run manual or delayed jobs')"
>
<gl-icon name="play" class="icon-play" />
- <i class="fa fa-caret-down" aria-hidden="true"></i>
+ <gl-icon name="chevron-down" />
<gl-loading-icon v-if="isLoading" />
</button>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
index 4a3d134685e..55c71e299be 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
@@ -29,7 +29,7 @@ export default {
:aria-label="__('Artifacts')"
>
<gl-icon name="download" />
- <i class="fa fa-caret-down" aria-hidden="true"></i>
+ <gl-icon name="chevron-down" />
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li v-for="(artifact, i) in artifacts" :key="i">
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue
index 4045f450104..581ea5fbb35 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue
@@ -168,7 +168,7 @@ export default {
aria-expanded="false"
@click="onClickStage"
>
- <span :aria-label="stage.title" aria-hidden="true" class="no-pointer-events">
+ <span :aria-label="stage.title" aria-hidden="true" class="gl-pointer-events-none">
<gl-icon :name="borderlessIcon" />
</span>
</button>
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 dd09247337c..1d117cfe34a 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -1,6 +1,5 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import '~/lib/utils/datetime_utility';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
new file mode 100644
index 00000000000..504cf138d07
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { __ } from '~/locale';
+import CodeBlock from '~/vue_shared/components/code_block.vue';
+
+export default {
+ name: 'TestCaseDetails',
+ components: {
+ CodeBlock,
+ GlModal,
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ testCase: {
+ type: Object,
+ required: true,
+ validator: ({ classname, formattedTime, name }) =>
+ Boolean(classname) && Boolean(formattedTime) && Boolean(name),
+ },
+ },
+ text: {
+ name: __('Name'),
+ duration: __('Execution time'),
+ trace: __('System output'),
+ },
+ modalCloseButton: {
+ text: __('Close'),
+ attributes: [{ variant: 'info' }],
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ :modal-id="modalId"
+ :title="testCase.classname"
+ :action-primary="$options.modalCloseButton"
+ >
+ <div class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3">
+ <strong class="gl-text-right col-sm-3">{{ $options.text.name }}</strong>
+ <div class="col-sm-9" data-testid="test-case-name">
+ {{ testCase.name }}
+ </div>
+ </div>
+
+ <div class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3">
+ <strong class="gl-text-right col-sm-3">{{ $options.text.duration }}</strong>
+ <div class="col-sm-9" data-testid="test-case-duration">
+ {{ testCase.formattedTime }}
+ </div>
+ </div>
+
+ <div
+ v-if="testCase.system_output"
+ class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3"
+ data-testid="test-case-trace"
+ >
+ <strong class="gl-text-right col-sm-3">{{ $options.text.trace }}</strong>
+ <div class="col-sm-9">
+ <code-block :code="testCase.system_output" />
+ </div>
+ </div>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
index c3398e90895..a56dcf48d92 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
@@ -61,7 +61,7 @@ export default {
<div
v-else-if="!isLoading && showTests"
ref="container"
- class="tests-detail position-relative"
+ class="position-relative"
data-testid="tests-detail"
>
<transition
@@ -69,13 +69,13 @@ export default {
@before-enter="beforeEnterTransition"
@after-leave="afterLeaveTransition"
>
- <div v-if="showSuite" key="detail" class="w-100 position-absolute slide-enter-to-element">
+ <div v-if="showSuite" key="detail" class="w-100 slide-enter-to-element">
<test-summary :report="getSelectedSuite" show-back @on-back-click="summaryBackClick" />
<test-suite-table />
</div>
- <div v-else key="summary" class="w-100 position-absolute slide-enter-from-element">
+ <div v-else key="summary" class="w-100 slide-enter-from-element">
<test-summary :report="testReports" />
<test-summary-table @row-click="summaryTableRowClick" />
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 2b92ffc3f26..7afbb59cbd6 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,7 +1,8 @@
<script>
import { mapGetters } from 'vuex';
-import { GlTooltipDirective, GlFriendlyWrap, GlIcon, GlButton } from '@gitlab/ui';
+import { GlModalDirective, GlTooltipDirective, GlFriendlyWrap, GlIcon, GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
+import TestCaseDetails from './test_case_details.vue';
export default {
name: 'TestsSuiteTable',
@@ -9,9 +10,11 @@ export default {
GlIcon,
GlFriendlyWrap,
GlButton,
+ TestCaseDetails,
},
directives: {
GlTooltip: GlTooltipDirective,
+ GlModalDirective,
},
props: {
heading: {
@@ -43,7 +46,7 @@ export default {
<div role="rowheader" class="table-section section-20">
{{ __('Suite') }}
</div>
- <div role="rowheader" class="table-section section-20">
+ <div role="rowheader" class="table-section section-40">
{{ __('Name') }}
</div>
<div role="rowheader" class="table-section section-10">
@@ -52,12 +55,12 @@ export default {
<div role="rowheader" class="table-section section-10 text-center">
{{ __('Status') }}
</div>
- <div role="rowheader" class="table-section flex-grow-1">
- {{ __('Trace'), }}
- </div>
- <div role="rowheader" class="table-section section-10 text-right">
+ <div role="rowheader" class="table-section section-10">
{{ __('Duration') }}
</div>
+ <div role="rowheader" class="table-section section-10">
+ {{ __('Details'), }}
+ </div>
</div>
<div
@@ -72,7 +75,7 @@ export default {
</div>
</div>
- <div class="table-section section-20 section-wrap">
+ <div class="table-section section-40 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div>
<div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break">
<gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.name" />
@@ -107,24 +110,24 @@ export default {
</div>
</div>
- <div class="table-section flex-grow-1">
- <div role="rowheader" class="table-mobile-header">{{ __('Trace'), }}</div>
- <div class="table-mobile-content">
- <pre
- v-if="testCase.system_output"
- class="build-trace build-trace-rounded text-left"
- ><code class="bash p-0">{{testCase.system_output}}</code></pre>
- </div>
- </div>
-
<div class="table-section section-10 section-wrap">
<div role="rowheader" class="table-mobile-header">
{{ __('Duration') }}
</div>
- <div class="table-mobile-content text-right pr-sm-1">
+ <div class="table-mobile-content pr-sm-1">
{{ testCase.formattedTime }}
</div>
</div>
+
+ <div class="table-section section-10 section-wrap">
+ <div role="rowheader" class="table-mobile-header">{{ __('Details'), }}</div>
+ <div class="table-mobile-content">
+ <gl-button v-gl-modal-directive="`test-case-details-${index}`">{{
+ __('View details')
+ }}</gl-button>
+ <test-case-details :modal-id="`test-case-details-${index}`" :test-case="testCase" />
+ </div>
+ </div>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql b/app/assets/javascripts/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql
new file mode 100644
index 00000000000..9afb474cb17
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql
@@ -0,0 +1,5 @@
+mutation cancelPipeline($id: CiPipelineID!) {
+ pipelineCancel(input: { id: $id }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/pipelines/graphql/mutations/delete_pipeline.mutation.graphql b/app/assets/javascripts/pipelines/graphql/mutations/delete_pipeline.mutation.graphql
new file mode 100644
index 00000000000..a52cecafcaf
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/mutations/delete_pipeline.mutation.graphql
@@ -0,0 +1,5 @@
+mutation deletePipeline($id: CiPipelineID!) {
+ pipelineDestroy(input: { id: $id }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/pipelines/graphql/mutations/retry_pipeline.mutation.graphql b/app/assets/javascripts/pipelines/graphql/mutations/retry_pipeline.mutation.graphql
new file mode 100644
index 00000000000..2b3b0822653
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/mutations/retry_pipeline.mutation.graphql
@@ -0,0 +1,5 @@
+mutation retryPipeline($id: CiPipelineID!) {
+ pipelineRetry(input: { id: $id }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
index 886a8a78448..bd1b1664a1e 100644
--- a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
@@ -41,13 +41,13 @@ export default {
this.mediator.poll.enable({ data: this.mediator.getExpandedParameters() });
}
},
- resetTriggeredPipelines(parentPipeline, pipeline) {
+ resetDownstreamPipelines(parentPipeline, pipeline) {
this.mediator.store.resetTriggeredPipelines(parentPipeline, pipeline);
},
- clickTriggeredByPipeline(pipeline) {
+ clickUpstreamPipeline(pipeline) {
this.clickPipeline(pipeline, 'openPipeline', 'closePipeline');
},
- clickTriggeredPipeline(pipeline) {
+ clickDownstreamPipeline(pipeline) {
this.clickPipeline(pipeline, 'openPipeline', 'closePipeline');
},
requestRefreshPipelineGraph() {
diff --git a/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js
index 3f3007ba11a..578ff498358 100644
--- a/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js
@@ -1,6 +1,6 @@
export default {
props: {
- hasTriggeredBy: {
+ hasUpstream: {
type: Boolean,
required: false,
default: false,
@@ -8,7 +8,7 @@ export default {
},
methods: {
buildConnnectorClass(index) {
- return index === 0 && (!this.isFirstColumn || this.hasTriggeredBy) ? 'left-connector' : '';
+ return index === 0 && (!this.isFirstColumn || this.hasUpstream) ? 'left-connector' : '';
},
},
};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 67aec12655a..29dec2309a7 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -6,12 +6,10 @@ import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
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 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);
@@ -22,7 +20,7 @@ const SELECTORS = {
PIPELINE_TESTS: '#js-pipeline-tests-detail',
};
-const createPipelinesDetailApp = mediator => {
+const createLegacyPipelinesDetailApp = mediator => {
if (!document.querySelector(SELECTORS.PIPELINE_GRAPH)) {
return;
}
@@ -47,10 +45,10 @@ const createPipelinesDetailApp = mediator => {
},
on: {
refreshPipelineGraph: this.requestRefreshPipelineGraph,
- onResetTriggered: (parentPipeline, pipeline) =>
- this.resetTriggeredPipelines(parentPipeline, pipeline),
- onClickTriggeredBy: pipeline => this.clickTriggeredByPipeline(pipeline),
- onClickTriggered: pipeline => this.clickTriggeredPipeline(pipeline),
+ onResetDownstream: (parentPipeline, pipeline) =>
+ this.resetDownstreamPipelines(parentPipeline, pipeline),
+ onClickUpstreamPipeline: pipeline => this.clickUpstreamPipeline(pipeline),
+ onClickDownstreamPipeline: pipeline => this.clickDownstreamPipeline(pipeline),
},
});
},
@@ -127,18 +125,48 @@ const createTestDetails = () => {
});
};
-export default () => {
+export default async function() {
+ createTestDetails();
+ createDagApp();
+
const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS);
- const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
- mediator.fetchPipeline();
+ let mediator;
+
+ if (!gon.features.graphqlPipelineHeader || !gon.features.graphqlPipelineDetails) {
+ try {
+ const { default: PipelinesMediator } = await import(
+ /* webpackChunkName: 'PipelinesMediator' */ './pipeline_details_mediator'
+ );
+ mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
+ mediator.fetchPipeline();
+ } catch {
+ Flash(__('An error occurred while loading the pipeline.'));
+ }
+ }
- createPipelinesDetailApp(mediator);
+ if (gon.features.graphqlPipelineDetails) {
+ try {
+ const { createPipelinesDetailApp } = await import(
+ /* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph'
+ );
+ createPipelinesDetailApp();
+ } catch {
+ Flash(__('An error occurred while loading the pipeline.'));
+ }
+ } else {
+ createLegacyPipelinesDetailApp(mediator);
+ }
if (gon.features.graphqlPipelineHeader) {
- createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER);
+ try {
+ const { createPipelineHeaderApp } = await import(
+ /* webpackChunkName: 'createPipelineHeaderApp' */ './pipeline_details_header'
+ );
+ createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER);
+ } catch {
+ Flash(__('An error occurred while loading a section of this page.'));
+ }
} else {
createLegacyPipelineHeaderApp(mediator);
}
- createTestDetails();
- createDagApp();
-};
+}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_dag.js b/app/assets/javascripts/pipelines/pipeline_details_dag.js
index dc03b457265..d37c72a4f2a 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_dag.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_dag.js
@@ -10,12 +10,19 @@ const apolloProvider = new VueApollo({
});
const createDagApp = () => {
- if (!window.gon?.features?.dagPipelineTab) {
+ const el = document.querySelector('#js-pipeline-dag-vue');
+
+ if (!window.gon?.features?.dagPipelineTab || !el) {
return;
}
- const el = document.querySelector('#js-pipeline-dag-vue');
- const { pipelineProjectPath, pipelineIid, emptySvgPath, dagDocPath } = el?.dataset;
+ const {
+ aboutDagDocPath,
+ dagDocPath,
+ emptySvgPath,
+ pipelineProjectPath,
+ pipelineIid,
+ } = el?.dataset;
// eslint-disable-next-line no-new
new Vue({
@@ -25,10 +32,11 @@ const createDagApp = () => {
},
apolloProvider,
provide: {
+ aboutDagDocPath,
+ dagDocPath,
+ emptySvgPath,
pipelineProjectPath,
pipelineIid,
- emptySvgPath,
- dagDocPath,
},
render(createElement) {
return createElement('dag', {});
diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js
new file mode 100644
index 00000000000..880855cf21d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js
@@ -0,0 +1,7 @@
+const createPipelinesDetailApp = () => {
+ // Placeholder. See: https://gitlab.com/gitlab-org/gitlab/-/issues/223262
+ // eslint-disable-next-line no-useless-return
+ return;
+};
+
+export { createPipelinesDetailApp };
diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js
index 27fe9ba3f19..744a8272709 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_header.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_header.js
@@ -16,7 +16,7 @@ export const createPipelineHeaderApp = elSelector => {
return;
}
- const { cancelPath, deletePath, fullPath, pipelineId, pipelineIid, retryPath } = el?.dataset;
+ const { fullPath, pipelineId, pipelineIid, pipelinesPath } = el?.dataset;
// eslint-disable-next-line no-new
new Vue({
el,
@@ -26,10 +26,8 @@ export const createPipelineHeaderApp = elSelector => {
apolloProvider,
provide: {
paths: {
- cancel: cancelPath,
- delete: deletePath,
fullProject: fullPath,
- retry: retryPath,
+ pipelinesPath,
},
pipelineId,
pipelineIid,
diff --git a/app/assets/javascripts/popovers/components/popovers.vue b/app/assets/javascripts/popovers/components/popovers.vue
new file mode 100644
index 00000000000..3bb6d284264
--- /dev/null
+++ b/app/assets/javascripts/popovers/components/popovers.vue
@@ -0,0 +1,92 @@
+<script>
+// We can't use v-safe-html here as the popover's title or content might contains SVGs that would
+// be stripped by the directive's sanitizer. Instead, we fallback on v-html and we use GitLab's
+// dompurify config that lets SVGs be rendered properly.
+// Context: https://gitlab.com/gitlab-org/gitlab/-/issues/247207
+/* eslint-disable vue/no-v-html */
+import { GlPopover } from '@gitlab/ui';
+import { sanitize } from '~/lib/dompurify';
+
+const newPopover = element => {
+ const { content, html, placement, title, triggers = 'focus' } = element.dataset;
+
+ return {
+ target: element,
+ content,
+ html,
+ placement,
+ title,
+ triggers,
+ };
+};
+
+export default {
+ components: {
+ GlPopover,
+ },
+ data() {
+ return {
+ popovers: [],
+ };
+ },
+ created() {
+ this.observer = new MutationObserver(mutations => {
+ mutations.forEach(mutation => {
+ mutation.removedNodes.forEach(this.dispose);
+ });
+ });
+ },
+ beforeDestroy() {
+ this.observer.disconnect();
+ },
+ methods: {
+ addPopovers(elements) {
+ const newPopovers = elements.reduce((acc, element) => {
+ if (this.popoverExists(element)) {
+ return acc;
+ }
+ const popover = newPopover(element);
+ this.observe(popover);
+ return [...acc, popover];
+ }, []);
+
+ this.popovers.push(...newPopovers);
+ },
+ observe(popover) {
+ this.observer.observe(popover.target.parentElement, {
+ childList: true,
+ });
+ },
+ dispose(target) {
+ if (!target) {
+ this.popovers = [];
+ } else {
+ const index = this.popovers.findIndex(popover => popover.target === target);
+
+ if (index > -1) {
+ this.popovers.splice(index, 1);
+ }
+ }
+ },
+ popoverExists(element) {
+ return this.popovers.some(popover => popover.target === element);
+ },
+ getSafeHtml(html) {
+ return sanitize(html);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-popover v-for="(popover, index) in popovers" :key="index" v-bind="popover">
+ <template #title>
+ <span v-if="popover.html" v-html="getSafeHtml(popover.title)"></span>
+ <span v-else>{{ popover.title }}</span>
+ </template>
+ <span v-if="popover.html" v-html="getSafeHtml(popover.content)"></span>
+ <span v-else>{{ popover.content }}</span>
+ </gl-popover>
+ </div>
+</template>
diff --git a/app/assets/javascripts/popovers/index.js b/app/assets/javascripts/popovers/index.js
new file mode 100644
index 00000000000..bfb61f02a3a
--- /dev/null
+++ b/app/assets/javascripts/popovers/index.js
@@ -0,0 +1,51 @@
+import Vue from 'vue';
+import { toArray } from 'lodash';
+import PopoversComponent from './components/popovers.vue';
+
+let app;
+
+const APP_ELEMENT_ID = 'gl-popovers-app';
+
+const getPopoversApp = () => {
+ if (!app) {
+ const container = document.createElement('div');
+ container.setAttribute('id', APP_ELEMENT_ID);
+ document.body.appendChild(container);
+
+ const Popovers = Vue.extend(PopoversComponent);
+ app = new Popovers();
+ app.$mount(`#${APP_ELEMENT_ID}`);
+ }
+
+ return app;
+};
+
+const isPopover = (node, selector) => node.matches && node.matches(selector);
+
+const handlePopoverEvent = (rootTarget, e, selector) => {
+ for (let { target } = e; target && target !== rootTarget; target = target.parentNode) {
+ if (isPopover(target, selector)) {
+ getPopoversApp().addPopovers([target]);
+ break;
+ }
+ }
+};
+
+export const initPopovers = () => {
+ ['mouseenter', 'focus', 'click'].forEach(event => {
+ document.addEventListener(
+ event,
+ e => handlePopoverEvent(document, e, '[data-toggle="popover"]'),
+ true,
+ );
+ });
+
+ return getPopoversApp();
+};
+
+export const dispose = elements => toArray(elements).forEach(getPopoversApp().dispose);
+
+export const destroy = () => {
+ getPopoversApp().$destroy();
+ app = null;
+};
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
index 200e5ba255f..5feac7485ad 100644
--- a/app/assets/javascripts/profile/account/components/update_username.vue
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -1,17 +1,19 @@
<script>
-/* eslint-disable vue/no-v-html */
import { escape } from 'lodash';
-import { GlButton } from '@gitlab/ui';
+import { GlSafeHtmlDirective as SafeHtml, GlButton, GlModal, GlModalDirective } 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';
import { deprecatedCreateFlash as Flash } from '~/flash';
export default {
components: {
- GlModal: DeprecatedModal2,
+ GlModal,
GlButton,
},
+ directives: {
+ GlModalDirective,
+ SafeHtml,
+ },
props: {
actionUrl: {
type: String,
@@ -54,6 +56,21 @@ Please update your Git repository remotes as soon as possible.`),
false,
);
},
+ primaryProps() {
+ return {
+ text: s__('Update username'),
+ attributes: [
+ { variant: 'warning' },
+ { category: 'primary' },
+ { disabled: this.isRequestPending },
+ ],
+ };
+ },
+ cancelProps() {
+ return {
+ text: s__('Cancel'),
+ };
+ },
},
methods: {
onConfirm() {
@@ -103,22 +120,21 @@ Please update your Git repository remotes as soon as possible.`),
<p class="form-text text-muted">{{ path }}</p>
</div>
<gl-button
- :data-target="`#${$options.modalId}`"
+ v-gl-modal-directive="$options.modalId"
:disabled="isRequestPending || newUsername === username"
category="primary"
variant="warning"
- data-toggle="modal"
+ data-testid="username-change-confirmation-modal"
+ >{{ $options.buttonText }}</gl-button
>
- {{ $options.buttonText }}
- </gl-button>
<gl-modal
- :id="$options.modalId"
- :header-title-text="s__('Profiles|Change username') + '?'"
- :footer-primary-button-text="$options.buttonText"
- footer-primary-button-variant="warning"
- @submit="onConfirm"
+ :modal-id="$options.modalId"
+ :title="s__('Profiles|Change username') + '?'"
+ :action-primary="primaryProps"
+ :action-cancel="cancelProps"
+ @primary="onConfirm"
>
- <span v-html="modalText"></span>
+ <span v-safe-html="modalText"></span>
</gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/profile/preferences/components/integration_view.vue b/app/assets/javascripts/profile/preferences/components/integration_view.vue
new file mode 100644
index 00000000000..c2952629a5d
--- /dev/null
+++ b/app/assets/javascripts/profile/preferences/components/integration_view.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlFormText, GlIcon, GlLink } from '@gitlab/ui';
+import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
+
+export default {
+ name: 'IntegrationView',
+ components: {
+ GlFormText,
+ GlIcon,
+ GlLink,
+ IntegrationHelpText,
+ },
+ inject: ['userFields'],
+ props: {
+ helpLink: {
+ type: String,
+ required: true,
+ },
+ message: {
+ type: String,
+ required: true,
+ },
+ messageUrl: {
+ type: String,
+ required: true,
+ },
+ config: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isEnabled: this.userFields[this.config.formName],
+ };
+ },
+ computed: {
+ formName() {
+ return `user[${this.config.formName}]`;
+ },
+ formId() {
+ return `user_${this.config.formName}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <label class="label-bold">
+ {{ config.title }}
+ </label>
+ <gl-link class="has-tooltip" title="More information" :href="helpLink">
+ <gl-icon name="question-o" class="vertical-align-middle" />
+ </gl-link>
+ <div class="form-group form-check" data-testid="profile-preferences-integration-form-group">
+ <!-- Necessary for Rails to receive the value when not checked -->
+ <input
+ :name="formName"
+ type="hidden"
+ value="0"
+ data-testid="profile-preferences-integration-hidden-field"
+ />
+ <input
+ :id="formId"
+ v-model="isEnabled"
+ type="checkbox"
+ class="form-check-input"
+ :name="formName"
+ value="1"
+ data-testid="profile-preferences-integration-checkbox"
+ />
+ <label class="form-check-label" :for="formId">
+ {{ config.label }}
+ </label>
+ <gl-form-text tag="div">
+ <integration-help-text :message="message" :message-url="messageUrl" />
+ </gl-form-text>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
new file mode 100644
index 00000000000..8b2006b7c5b
--- /dev/null
+++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
@@ -0,0 +1,56 @@
+<script>
+import { s__ } from '~/locale';
+import IntegrationView from './integration_view.vue';
+
+const INTEGRATION_VIEW_CONFIGS = {
+ sourcegraph: {
+ title: s__('ProfilePreferences|Sourcegraph'),
+ label: s__('ProfilePreferences|Enable integrated code intelligence on code views'),
+ formName: 'sourcegraph_enabled',
+ },
+ gitpod: {
+ title: s__('ProfilePreferences|Gitpod'),
+ label: s__('ProfilePreferences|Enable Gitpod integration'),
+ formName: 'gitpod_enabled',
+ },
+};
+
+export default {
+ name: 'ProfilePreferences',
+ components: {
+ IntegrationView,
+ },
+ inject: {
+ integrationViews: {
+ default: [],
+ },
+ },
+ integrationViewConfigs: INTEGRATION_VIEW_CONFIGS,
+};
+</script>
+
+<template>
+ <div class="row gl-mt-3 js-preferences-form">
+ <div v-if="integrationViews.length" class="col-sm-12">
+ <hr data-testid="profile-preferences-integrations-rule" />
+ </div>
+ <div v-if="integrationViews.length" class="col-lg-4 profile-settings-sidebar">
+ <h4 class="gl-mt-0" data-testid="profile-preferences-integrations-heading">
+ {{ s__('ProfilePreferences|Integrations') }}
+ </h4>
+ <p>
+ {{ s__('ProfilePreferences|Customize integrations with third party services.') }}
+ </p>
+ </div>
+ <div v-if="integrationViews.length" class="col-lg-8">
+ <integration-view
+ v-for="view in integrationViews"
+ :key="view.name"
+ :help-link="view.help_link"
+ :message="view.message"
+ :message-url="view.message_url"
+ :config="$options.integrationViewConfigs[view.name]"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js b/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js
new file mode 100644
index 00000000000..bcca3140717
--- /dev/null
+++ b/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import ProfilePreferences from './components/profile_preferences.vue';
+
+export default () => {
+ const el = document.querySelector('#js-profile-preferences-app');
+ const shouldParse = ['integrationViews', 'userFields'];
+
+ const provide = Object.keys(el.dataset).reduce((memo, key) => {
+ let value = el.dataset[key];
+ if (shouldParse.includes(key)) {
+ value = JSON.parse(value);
+ }
+
+ return { ...memo, [key]: value };
+ }, {});
+
+ return new Vue({
+ el,
+ name: 'ProfilePreferencesApp',
+ provide,
+ render: createElement => createElement(ProfilePreferences),
+ });
+};
diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js
index 352ac39f3c4..254d178f013 100644
--- a/app/assets/javascripts/projects/commit_box/info/index.js
+++ b/app/assets/javascripts/projects/commit_box/info/index.js
@@ -1,4 +1,5 @@
import { loadBranches } from './load_branches';
+import { initDetailsButton } from './init_details_button';
import { fetchCommitMergeRequests } from '~/commit_merge_requests';
import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
@@ -15,4 +16,6 @@ export const initCommitBoxInfo = (containerSelector = '.js-commit-box-info') =>
new MiniPipelineGraph({
container: '.js-commit-pipeline-graph',
}).bindEvents();
+
+ initDetailsButton();
};
diff --git a/app/assets/javascripts/projects/commit_box/info/init_details_button.js b/app/assets/javascripts/projects/commit_box/info/init_details_button.js
new file mode 100644
index 00000000000..032fbf5316a
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/init_details_button.js
@@ -0,0 +1,11 @@
+import $ from 'jquery';
+
+export const initDetailsButton = () => {
+ $('body').on('click', '.js-details-expand', function expand(e) {
+ e.preventDefault();
+ $(this)
+ .next('.js-details-content')
+ .removeClass('hide');
+ $(this).hide();
+ });
+};
diff --git a/app/assets/javascripts/projects/components/project_delete_button.vue b/app/assets/javascripts/projects/components/project_delete_button.vue
index 2f3ff92d7ae..5429d51dae0 100644
--- a/app/assets/javascripts/projects/components/project_delete_button.vue
+++ b/app/assets/javascripts/projects/components/project_delete_button.vue
@@ -25,7 +25,7 @@ export default {
'Once a project is permanently deleted it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd} including issues, merge requests etc.',
),
modalBody: __(
- "This action cannot be undone. You will lose the project's repository and all content: issues, merge requests, etc.",
+ "This action cannot be undone. You will lose this project's repository and all content: issues, merge requests, etc.",
),
},
};
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
index 0777dddfc19..c6e2b2e1140 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
@@ -45,9 +45,12 @@ export default {
},
data() {
return {
- timesChartTransformedData: {
- full: this.mergeLabelsAndValues(this.timesChartData.labels, this.timesChartData.values),
- },
+ timesChartTransformedData: [
+ {
+ name: 'full',
+ data: this.mergeLabelsAndValues(this.timesChartData.labels, this.timesChartData.values),
+ },
+ ],
};
},
computed: {
@@ -128,7 +131,7 @@ export default {
<gl-column-chart
:height="$options.chartContainerHeight"
:option="$options.timesChartOptions"
- :data="timesChartTransformedData"
+ :bars="timesChartTransformedData"
:y-axis-title="__('Minutes')"
:x-axis-title="__('Commit')"
x-axis-type="category"
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue
index cd9e464c5ac..aa59717ddcd 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue
@@ -1,4 +1,7 @@
<script>
+import { formatTime } from '~/lib/utils/datetime_utility';
+import { s__, n__ } from '~/locale';
+
export default {
props: {
counts: {
@@ -6,25 +9,44 @@ export default {
required: true,
},
},
+ computed: {
+ totalDuration() {
+ return formatTime(this.counts.totalDuration);
+ },
+ statistics() {
+ return [
+ {
+ title: s__('PipelineCharts|Total:'),
+ value: n__('1 pipeline', '%d pipelines', this.counts.total),
+ },
+ {
+ title: s__('PipelineCharts|Successful:'),
+ value: n__('1 pipeline', '%d pipelines', this.counts.success),
+ },
+ {
+ title: s__('PipelineCharts|Failed:'),
+ value: n__('1 pipeline', '%d pipelines', this.counts.failed),
+ },
+ {
+ title: s__('PipelineCharts|Success ratio:'),
+ value: `${this.counts.successRatio}%`,
+ },
+ {
+ title: s__('PipelineCharts|Total duration:'),
+ value: this.totalDuration,
+ },
+ ];
+ },
+ },
};
</script>
<template>
<ul>
- <li>
- <span>{{ s__('PipelineCharts|Total:') }}</span>
- <strong>{{ n__('1 pipeline', '%d pipelines', counts.total) }}</strong>
- </li>
- <li>
- <span>{{ s__('PipelineCharts|Successful:') }}</span>
- <strong>{{ n__('1 pipeline', '%d pipelines', counts.success) }}</strong>
- </li>
- <li>
- <span>{{ s__('PipelineCharts|Failed:') }}</span>
- <strong>{{ n__('1 pipeline', '%d pipelines', counts.failed) }}</strong>
- </li>
- <li>
- <span>{{ s__('PipelineCharts|Success ratio:') }}</span>
- <strong>{{ counts.successRatio }}%</strong>
- </li>
+ <template v-for="({ title, value }, index) in statistics">
+ <li :key="index">
+ <span>{{ title }}</span>
+ <strong>{{ value }}</strong>
+ </li>
+ </template>
</ul>
</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js
index 4ae2b729200..eef1bc2d28b 100644
--- a/app/assets/javascripts/projects/pipelines/charts/index.js
+++ b/app/assets/javascripts/projects/pipelines/charts/index.js
@@ -7,6 +7,7 @@ export default () => {
countsFailed,
countsSuccess,
countsTotal,
+ countsTotalDuration,
successRatio,
timesChartLabels,
timesChartValues,
@@ -41,6 +42,7 @@ export default () => {
success: countsSuccess,
total: countsTotal,
successRatio,
+ totalDuration: countsTotalDuration,
},
timesChartData: {
labels: JSON.parse(timesChartLabels),
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 4bfed6d489d..df7d9b56aed 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
@@ -20,7 +20,12 @@ export default {
type: String,
required: true,
},
- initialIncomingEmail: {
+ incomingEmail: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ customEmail: {
type: String,
required: false,
default: '',
@@ -50,23 +55,18 @@ export default {
data() {
return {
isEnabled: this.initialIsEnabled,
- incomingEmail: this.initialIncomingEmail,
isTemplateSaving: false,
isAlertShowing: false,
alertVariant: 'danger',
alertMessage: '',
+ updatedCustomEmail: this.customEmail,
};
},
created() {
eventHub.$on('serviceDeskEnabledCheckboxToggled', this.onEnableToggled);
eventHub.$on('serviceDeskTemplateSave', this.onSaveTemplate);
-
this.service = new ServiceDeskService(this.endpoint);
-
- if (this.isEnabled && !this.incomingEmail) {
- this.fetchIncomingEmail();
- }
},
beforeDestroy() {
@@ -75,22 +75,6 @@ export default {
},
methods: {
- fetchIncomingEmail() {
- this.service
- .fetchIncomingEmail()
- .then(({ data }) => {
- const email = data.service_desk_address;
- if (!email) {
- throw new Error(__("Response didn't include `service_desk_address`"));
- }
-
- this.incomingEmail = email;
- })
- .catch(() =>
- this.showAlert(__('An error occurred while fetching the Service Desk address.')),
- );
- },
-
onEnableToggled(isChecked) {
this.isEnabled = isChecked;
this.incomingEmail = '';
@@ -119,7 +103,7 @@ export default {
this.service
.updateTemplate({ selectedTemplate, outgoingName, projectKey }, this.isEnabled)
.then(({ data }) => {
- this.incomingEmail = data?.service_desk_address;
+ this.updatedCustomEmail = data?.service_desk_address;
this.showAlert(__('Changes were successfully made.'), 'success');
})
.catch(err => {
@@ -155,6 +139,7 @@ export default {
<service-desk-setting
:is-enabled="isEnabled"
:incoming-email="incomingEmail"
+ :custom-email="updatedCustomEmail"
:initial-selected-template="selectedTemplate"
:initial-outgoing-name="outgoingName"
:initial-project-key="projectKey"
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 e18cfefc3ca..5d120fd0b3f 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
@@ -26,6 +26,11 @@ export default {
required: false,
default: '',
},
+ customEmail: {
+ type: String,
+ required: false,
+ default: '',
+ },
initialSelectedTemplate: {
type: String,
required: false,
@@ -57,7 +62,6 @@ export default {
selectedTemplate: this.initialSelectedTemplate,
outgoingName: this.initialOutgoingName || __('GitLab Support Bot'),
projectKey: this.initialProjectKey,
- baseEmail: this.incomingEmail.replace(this.initialProjectKey, ''),
};
},
computed: {
@@ -67,6 +71,12 @@ export default {
hasProjectKeySupport() {
return Boolean(this.glFeatures.serviceDeskCustomAddress);
},
+ email() {
+ return this.customEmail || this.incomingEmail;
+ },
+ hasCustomEmail() {
+ return this.customEmail && this.customEmail !== this.incomingEmail;
+ },
},
methods: {
onCheckboxToggle(isChecked) {
@@ -101,30 +111,31 @@ export default {
<strong id="incoming-email-describer" class="d-block mb-1">
{{ __('Forward external support email address to') }}
</strong>
- <template v-if="incomingEmail">
+ <template v-if="email">
<div class="input-group">
<input
ref="service-desk-incoming-email"
type="text"
- class="form-control incoming-email"
+ class="form-control"
+ data-testid="incoming-email"
:placeholder="__('Incoming email')"
:aria-label="__('Incoming email')"
aria-describedby="incoming-email-describer"
- :value="incomingEmail"
+ :value="email"
disabled="true"
/>
<div class="input-group-append">
<clipboard-button
:title="__('Copy')"
- :text="incomingEmail"
+ :text="email"
css-class="input-group-text qa-clipboard-button"
/>
</div>
</div>
- <span v-if="projectKey" class="form-text text-muted">
+ <span v-if="hasCustomEmail" class="form-text text-muted">
<gl-sprintf :message="__('Emails sent to %{email} will still be supported')">
<template #email>
- <code>{{ baseEmail }}</code>
+ <code>{{ incomingEmail }}</code>
</template>
</gl-sprintf>
</span>
diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js
index 15c077de72e..c73163788ef 100644
--- a/app/assets/javascripts/projects/settings_service_desk/index.js
+++ b/app/assets/javascripts/projects/settings_service_desk/index.js
@@ -17,6 +17,7 @@ export default () => {
initialIsEnabled: parseBoolean(dataset.enabled),
endpoint: dataset.endpoint,
incomingEmail: dataset.incomingEmail,
+ customEmail: dataset.customEmail,
selectedTemplate: dataset.selectedTemplate,
outgoingName: dataset.outgoingName,
projectKey: dataset.projectKey,
@@ -28,7 +29,8 @@ export default () => {
props: {
initialIsEnabled: this.initialIsEnabled,
endpoint: this.endpoint,
- initialIncomingEmail: this.incomingEmail,
+ incomingEmail: this.incomingEmail,
+ customEmail: this.customEmail,
selectedTemplate: this.selectedTemplate,
outgoingName: this.outgoingName,
projectKey: this.projectKey,
diff --git a/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js b/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js
index d707763c64e..b68c5bb876f 100644
--- a/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js
+++ b/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js
@@ -5,10 +5,6 @@ class ServiceDeskService {
this.endpoint = endpoint;
}
- fetchIncomingEmail() {
- return axios.get(this.endpoint);
- }
-
toggleServiceDesk(enable) {
return axios.put(this.endpoint, { service_desk_enabled: enable });
}
diff --git a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
index 7fc1b18bf71..bb9689f09a1 100644
--- a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
+++ b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js
@@ -18,29 +18,32 @@ export default class PrometheusMetrics {
this.$monitoredMetricsList = this.$monitoredMetricsPanel.find('.js-metrics-list');
this.$missingEnvVarPanel = this.$wrapper.find('.js-panel-missing-env-vars');
- this.$panelToggle = this.$missingEnvVarPanel.find('.js-panel-toggle');
+ this.$panelToggleRight = this.$missingEnvVarPanel.find('.js-panel-toggle-right');
+ this.$panelToggleDown = this.$missingEnvVarPanel.find('.js-panel-toggle-down');
this.$missingEnvVarMetricCount = this.$missingEnvVarPanel.find('.js-env-var-count');
this.$missingEnvVarMetricsList = this.$missingEnvVarPanel.find('.js-missing-var-metrics-list');
this.activeMetricsEndpoint = this.$monitoredMetricsPanel.data('activeMetrics');
this.helpMetricsPath = this.$monitoredMetricsPanel.data('metrics-help-path');
- this.$panelToggle.on('click', e => this.handlePanelToggle(e));
+ this.$panelToggleRight.on('click', e => this.handlePanelToggle(e));
+ this.$panelToggleDown.on('click', e => this.handlePanelToggle(e));
}
init() {
this.loadActiveMetrics();
}
- /* eslint-disable class-methods-use-this */
handlePanelToggle(e) {
const $toggleBtn = $(e.currentTarget);
const $currentPanelBody = $toggleBtn.closest('.card').find('.card-body');
$currentPanelBody.toggleClass('hidden');
- if ($toggleBtn.hasClass('fa-caret-down')) {
- $toggleBtn.removeClass('fa-caret-down').addClass('fa-caret-right');
- } else {
- $toggleBtn.removeClass('fa-caret-right').addClass('fa-caret-down');
+ if ($toggleBtn.hasClass('js-panel-toggle-right')) {
+ $toggleBtn.addClass('hidden');
+ this.$panelToggleDown.removeClass('hidden');
+ } else if ($toggleBtn.hasClass('js-panel-toggle-down')) {
+ $toggleBtn.addClass('hidden');
+ this.$panelToggleRight.removeClass('hidden');
}
}
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue
index 328026d0953..2844b4ffde3 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue
@@ -14,9 +14,9 @@ export default {
required: false,
default: () => [],
},
- isDesktop: {
+ isMobile: {
type: Boolean,
- default: false,
+ default: true,
required: false,
},
},
@@ -34,7 +34,7 @@ export default {
return this.tags.some(tag => this.selectedItems[tag.name]);
},
showMultiDeleteButton() {
- return this.tags.some(tag => tag.destroy_path) && this.isDesktop;
+ return this.tags.some(tag => tag.destroy_path) && !this.isMobile;
},
},
methods: {
@@ -68,7 +68,7 @@ export default {
:tag="tag"
:first="index === 0"
:selected="selectedItems[tag.name]"
- :is-desktop="isDesktop"
+ :is-mobile="isMobile"
@select="updateSelectedItems(tag.name)"
@delete="$emit('delete', { [tag.name]: true })"
/>
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 0f6297ca406..2edeac1144f 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
@@ -40,9 +40,9 @@ export default {
type: Object,
required: true,
},
- isDesktop: {
+ isMobile: {
type: Boolean,
- default: false,
+ default: true,
required: false,
},
selected: {
@@ -69,7 +69,7 @@ export default {
return this.tag.layers ? n__('%d layer', '%d layers', this.tag.layers) : '';
},
mobileClasses() {
- return this.isDesktop ? '' : 'mw-s';
+ return this.isMobile ? 'mw-s' : '';
},
shortDigest() {
// remove sha256: from the string, and show only the first 7 char
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue
index 85d87dab042..ba55822f0ca 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDeprecatedDropdown } from '@gitlab/ui';
+import { GlDropdown } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import Tracking from '~/tracking';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
@@ -17,7 +17,7 @@ const trackingLabel = 'quickstart_dropdown';
export default {
components: {
- GlDeprecatedDropdown,
+ GlDropdown,
CodeInstruction,
},
mixins: [Tracking.mixin({ label: trackingLabel })],
@@ -37,15 +37,14 @@ export default {
};
</script>
<template>
- <gl-deprecated-dropdown
+ <gl-dropdown
:text="$options.i18n.QUICK_START"
- variant="primary"
- size="sm"
+ variant="info"
right
@shown="track('click_dropdown')"
>
<!-- This li is used as a container since gl-dropdown produces a root ul, this mimics the functionality exposed by b-dropdown-form -->
- <li role="presentation" class="px-2 py-1 dropdown-menu-large">
+ <li role="presentation" class="px-2 py-1">
<code-instruction
:label="$options.i18n.LOGIN_COMMAND_LABEL"
:instruction="dockerLoginCommand"
@@ -71,5 +70,5 @@ export default {
:tracking-label="$options.trackingLabel"
/>
</li>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
</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 cfd787b3f52..b0a7c4824bd 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
@@ -37,15 +37,6 @@ export default {
ROW_SCHEDULED_FOR_DELETION,
},
computed: {
- encodedItem() {
- const params = JSON.stringify({
- 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);
- },
disabledDelete() {
return !this.item.destroy_path || this.item.deleting;
},
@@ -81,8 +72,8 @@ export default {
<template #left-primary>
<router-link
class="gl-text-body gl-font-weight-bold"
- data-testid="detailsLink"
- :to="{ name: 'details', params: { id: encodedItem } }"
+ data-testid="details-link"
+ :to="{ name: 'details', params: { id: item.id } }"
>
{{ item.path }}
</router-link>
diff --git a/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue b/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue
index 146d1434b18..666d8b042da 100644
--- a/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue
+++ b/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue
@@ -30,7 +30,7 @@ export default {
return {
tagName,
className,
- text: this.$route.meta.nameGenerator(this.$route),
+ text: this.$route.meta.nameGenerator(this.$store.state),
path: { to: this.$route.name },
};
},
@@ -48,7 +48,7 @@ export default {
></li>
<li v-if="!isRootRoute">
<router-link ref="rootRouteLink" :to="rootRoute.path">
- {{ rootRoute.meta.nameGenerator(rootRoute) }}
+ {{ rootRoute.meta.nameGenerator($store.state) }}
</router-link>
<component :is="divider.tagName" v-safe-html="divider.innerHTML" :class="divider.classList" />
</li>
diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js
index 1dc5882d415..306e6903a4f 100644
--- a/app/assets/javascripts/registry/explorer/constants/details.js
+++ b/app/assets/javascripts/registry/explorer/constants/details.js
@@ -15,6 +15,10 @@ export const DELETE_TAGS_SUCCESS_MESSAGE = s__(
'ContainerRegistry|Tags successfully marked for deletion.',
);
+export const FETCH_IMAGE_DETAILS_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while fetching the image details.',
+);
+
export const TAGS_LIST_TITLE = s__('ContainerRegistry|Image tags');
export const DIGEST_LABEL = s__('ContainerRegistry|Digest: %{imageId}');
export const CREATED_AT_LABEL = s__('ContainerRegistry|Published %{timeInfo}');
diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue
index d2fb695dbfa..a60ef5c4982 100644
--- a/app/assets/javascripts/registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/registry/explorer/pages/details.vue
@@ -11,7 +11,6 @@ import TagsList from '../components/details_page/tags_list.vue';
import TagsLoader from '../components/details_page/tags_loader.vue';
import EmptyTagsState from '../components/details_page/empty_tags_state.vue';
-import { decodeAndParse } from '../utils';
import {
ALERT_SUCCESS_TAG,
ALERT_DANGER_TAG,
@@ -37,18 +36,15 @@ export default {
data() {
return {
itemsToBeDeleted: [],
- isDesktop: true,
+ isMobile: false,
deleteAlertType: null,
dismissPartialCleanupWarning: false,
};
},
computed: {
- ...mapState(['tagsPagination', 'isLoading', 'config', 'tags']),
- queryParameters() {
- return decodeAndParse(this.$route.params.id);
- },
+ ...mapState(['tagsPagination', 'isLoading', 'config', 'tags', 'imageDetails']),
showPartialCleanupWarning() {
- return this.queryParameters.cleanup_policy_started_at && !this.dismissPartialCleanupWarning;
+ return this.imageDetails?.cleanup_policy_started_at && !this.dismissPartialCleanupWarning;
},
tracking() {
return {
@@ -61,15 +57,20 @@ export default {
return this.tagsPagination.page;
},
set(page) {
- this.requestTagsList({ pagination: { page }, params: this.$route.params.id });
+ this.requestTagsList({ page });
},
},
},
mounted() {
- this.requestTagsList({ params: this.$route.params.id });
+ this.requestImageDetailsAndTagsList(this.$route.params.id);
},
methods: {
- ...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']),
+ ...mapActions([
+ 'requestTagsList',
+ 'requestDeleteTag',
+ 'requestDeleteTags',
+ 'requestImageDetailsAndTagsList',
+ ]),
deleteTags(toBeDeleted) {
this.itemsToBeDeleted = this.tags.filter(tag => toBeDeleted[tag.name]);
this.track('click_button');
@@ -78,7 +79,7 @@ export default {
handleSingleDelete() {
const [itemToDelete] = this.itemsToBeDeleted;
this.itemsToBeDeleted = [];
- return this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id })
+ return this.requestDeleteTag({ tag: itemToDelete })
.then(() => {
this.deleteAlertType = ALERT_SUCCESS_TAG;
})
@@ -92,7 +93,6 @@ export default {
return this.requestDeleteTags({
ids: itemsToBeDeleted.map(x => x.name),
- params: this.$route.params.id,
})
.then(() => {
this.deleteAlertType = ALERT_SUCCESS_TAGS;
@@ -110,7 +110,7 @@ export default {
}
},
handleResize() {
- this.isDesktop = GlBreakpointInstance.isDesktop();
+ this.isMobile = GlBreakpointInstance.getBreakpointSize() === 'xs';
},
},
};
@@ -132,12 +132,12 @@ export default {
@dismiss="dismissPartialCleanupWarning = true"
/>
- <details-header :image-name="queryParameters.name" />
+ <details-header :image-name="imageDetails.name" />
<tags-loader v-if="isLoading" />
<template v-else>
<empty-tags-state v-if="tags.length === 0" :no-containers-image="config.noContainersImage" />
- <tags-list v-else :tags="tags" :is-desktop="isDesktop" @delete="deleteTags" />
+ <tags-list v-else :tags="tags" :is-mobile="isMobile" @delete="deleteTags" />
</template>
<gl-pagination
diff --git a/app/assets/javascripts/registry/explorer/router.js b/app/assets/javascripts/registry/explorer/router.js
index f570987023b..dcf1c77329d 100644
--- a/app/assets/javascripts/registry/explorer/router.js
+++ b/app/assets/javascripts/registry/explorer/router.js
@@ -2,7 +2,6 @@ import Vue from 'vue';
import VueRouter from 'vue-router';
import List from './pages/list.vue';
import Details from './pages/details.vue';
-import { decodeAndParse } from './utils';
import { CONTAINER_REGISTRY_TITLE } from './constants/index';
Vue.use(VueRouter);
@@ -26,7 +25,7 @@ export default function createRouter(base) {
path: '/:id',
component: Details,
meta: {
- nameGenerator: route => decodeAndParse(route.params.id).name,
+ nameGenerator: ({ imageDetails }) => imageDetails?.name,
},
},
],
diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js
index 9125f573aa4..c1883095097 100644
--- a/app/assets/javascripts/registry/explorer/stores/actions.js
+++ b/app/assets/javascripts/registry/explorer/stores/actions.js
@@ -1,13 +1,15 @@
import axios from '~/lib/utils/axios_utils';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
+import createFlash from '~/flash';
+import Api from '~/api';
import * as types from './mutation_types';
import {
FETCH_IMAGES_LIST_ERROR_MESSAGE,
DEFAULT_PAGE,
DEFAULT_PAGE_SIZE,
FETCH_TAGS_LIST_ERROR_MESSAGE,
+ FETCH_IMAGE_DETAILS_ERROR_MESSAGE,
} from '../constants/index';
-import { decodeAndParse } from '../utils';
+import { pathGenerator } from '../utils';
export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
export const setShowGarbageCollectionTip = ({ commit }, data) =>
@@ -36,55 +38,68 @@ export const requestImagesList = (
dispatch('receiveImagesListSuccess', { data, headers });
})
.catch(() => {
- createFlash(FETCH_IMAGES_LIST_ERROR_MESSAGE);
+ createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false);
});
};
-export const requestTagsList = ({ commit, dispatch }, { pagination = {}, params }) => {
+export const requestTagsList = ({ commit, dispatch, state: { imageDetails } }, pagination = {}) => {
commit(types.SET_MAIN_LOADING, true);
- const { tags_path } = decodeAndParse(params);
+ const tagsPath = pathGenerator(imageDetails);
const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
return axios
- .get(tags_path, { params: { page, per_page: perPage } })
+ .get(tagsPath, { params: { page, per_page: perPage } })
.then(({ data, headers }) => {
dispatch('receiveTagsListSuccess', { data, headers });
})
.catch(() => {
- createFlash(FETCH_TAGS_LIST_ERROR_MESSAGE);
+ createFlash({ message: FETCH_TAGS_LIST_ERROR_MESSAGE });
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false);
});
};
-export const requestDeleteTag = ({ commit, dispatch, state }, { tag, params }) => {
+export const requestImageDetailsAndTagsList = ({ dispatch, commit }, id) => {
+ commit(types.SET_MAIN_LOADING, true);
+ return Api.containerRegistryDetails(id)
+ .then(({ data }) => {
+ commit(types.SET_IMAGE_DETAILS, data);
+ dispatch('requestTagsList');
+ })
+ .catch(() => {
+ createFlash({ message: FETCH_IMAGE_DETAILS_ERROR_MESSAGE });
+ commit(types.SET_MAIN_LOADING, false);
+ });
+};
+
+export const requestDeleteTag = ({ commit, dispatch, state }, { tag }) => {
commit(types.SET_MAIN_LOADING, true);
return axios
.delete(tag.destroy_path)
.then(() => {
dispatch('setShowGarbageCollectionTip', true);
- return dispatch('requestTagsList', { pagination: state.tagsPagination, params });
+
+ return dispatch('requestTagsList', state.tagsPagination);
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false);
});
};
-export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params }) => {
+export const requestDeleteTags = ({ commit, dispatch, state }, { ids }) => {
commit(types.SET_MAIN_LOADING, true);
- const { tags_path } = decodeAndParse(params);
- const url = tags_path.replace('?format=json', '/bulk_destroy');
+ const tagsPath = pathGenerator(state.imageDetails, '/bulk_destroy');
return axios
- .delete(url, { params: { ids } })
+ .delete(tagsPath, { params: { ids } })
.then(() => {
dispatch('setShowGarbageCollectionTip', true);
- return dispatch('requestTagsList', { pagination: state.tagsPagination, params });
+ return dispatch('requestTagsList', state.tagsPagination);
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false);
diff --git a/app/assets/javascripts/registry/explorer/stores/mutation_types.js b/app/assets/javascripts/registry/explorer/stores/mutation_types.js
index f32cdf90783..5dd0cec52eb 100644
--- a/app/assets/javascripts/registry/explorer/stores/mutation_types.js
+++ b/app/assets/javascripts/registry/explorer/stores/mutation_types.js
@@ -7,3 +7,4 @@ export const SET_MAIN_LOADING = 'SET_MAIN_LOADING';
export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION';
export const SET_TAGS_LIST_SUCCESS = 'SET_TAGS_LIST_SUCCESS';
export const SET_SHOW_GARBAGE_COLLECTION_TIP = 'SET_SHOW_GARBAGE_COLLECTION_TIP';
+export const SET_IMAGE_DETAILS = 'SET_IMAGE_DETAILS';
diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js
index 706f6489287..5bdb431ad2e 100644
--- a/app/assets/javascripts/registry/explorer/stores/mutations.js
+++ b/app/assets/javascripts/registry/explorer/stores/mutations.js
@@ -47,4 +47,8 @@ export default {
const normalizedHeaders = normalizeHeaders(headers);
state.tagsPagination = parseIntPagination(normalizedHeaders);
},
+
+ [types.SET_IMAGE_DETAILS](state, details) {
+ state.imageDetails = details;
+ },
};
diff --git a/app/assets/javascripts/registry/explorer/stores/state.js b/app/assets/javascripts/registry/explorer/stores/state.js
index 694006aac81..66ee56eb47b 100644
--- a/app/assets/javascripts/registry/explorer/stores/state.js
+++ b/app/assets/javascripts/registry/explorer/stores/state.js
@@ -3,6 +3,7 @@ export default () => ({
showGarbageCollectionTip: false,
config: {},
images: [],
+ imageDetails: {},
tags: [],
pagination: {},
tagsPagination: {},
diff --git a/app/assets/javascripts/registry/explorer/utils.js b/app/assets/javascripts/registry/explorer/utils.js
index 44262a6cbb6..2c89d508c31 100644
--- a/app/assets/javascripts/registry/explorer/utils.js
+++ b/app/assets/javascripts/registry/explorer/utils.js
@@ -1 +1,16 @@
-export const decodeAndParse = param => JSON.parse(window.atob(param));
+export const pathGenerator = (imageDetails, ending = '?format=json') => {
+ // this method is a temporary workaround, to be removed with graphql implementation
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/276432
+
+ const splitPath = imageDetails.path.split('/').reverse();
+ const splitName = imageDetails.name ? imageDetails.name.split('/').reverse() : [];
+ const basePath = splitPath
+ .reduce((acc, curr, index) => {
+ if (splitPath[index] !== splitName[index]) {
+ acc.unshift(curr);
+ }
+ return acc;
+ }, [])
+ .join('/');
+ return `/${basePath}/registry/repository/${imageDetails.id}/tags${ending}`;
+};
diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue
index a9b35d4e29f..fe4aee6806e 100644
--- a/app/assets/javascripts/registry/settings/components/settings_form.vue
+++ b/app/assets/javascripts/registry/settings/components/settings_form.vue
@@ -117,8 +117,9 @@ export default {
const errorMessage = data?.updateContainerExpirationPolicy?.errors[0];
if (errorMessage) {
this.$toast.show(errorMessage, { type: 'error' });
+ } else {
+ this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' });
}
- this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' });
})
.catch(error => {
this.setApiErrors(error);
diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js
index 735d72972e6..d1e3d93938b 100644
--- a/app/assets/javascripts/registry/shared/constants.js
+++ b/app/assets/javascripts/registry/shared/constants.js
@@ -32,7 +32,7 @@ export const KEEP_N_LABEL = s__('ContainerRegistry|Number of tags to retain:');
export const NAME_REGEX_LABEL = s__(
'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}expire:%{italicEnd}',
);
-export const NAME_REGEX_PLACEHOLDER = '.*';
+export const NAME_REGEX_PLACEHOLDER = '';
export const NAME_REGEX_DESCRIPTION = s__(
'ContainerRegistry|Wildcards such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}',
);
diff --git a/app/assets/javascripts/related_issues/components/issue_token.vue b/app/assets/javascripts/related_issues/components/issue_token.vue
index bbbdf2cdb49..7f12c10f6a1 100644
--- a/app/assets/javascripts/related_issues/components/issue_token.vue
+++ b/app/assets/javascripts/related_issues/components/issue_token.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import relatedIssuableMixin from '~/vue_shared/mixins/related_issuable_mixin';
@@ -8,6 +8,9 @@ export default {
components: {
GlIcon,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
mixins: [relatedIssuableMixin],
props: {
isCondensed: {
@@ -52,7 +55,7 @@ export default {
<component
:is="computedLinkElementType"
ref="link"
- v-tooltip
+ v-gl-tooltip
:class="{
'issue-token-link': isCondensed,
'issuable-main-info': !isCondensed,
@@ -84,7 +87,7 @@ export default {
>
<gl-icon
v-if="hasState"
- v-tooltip
+ v-gl-tooltip
:class="iconClass"
:name="iconName"
:size="12"
@@ -98,7 +101,7 @@ export default {
<button
v-if="canRemove"
ref="removeButton"
- v-tooltip
+ v-gl-tooltip
:class="{
'issue-token-remove-button': isCondensed,
'btn btn-default': !isCondensed,
diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue
index f7a79c62716..c913745a8e1 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -138,7 +138,7 @@ export default {
href="#related-issues"
aria-hidden="true"
/>
- <slot name="headerText">{{ __('Linked issues') }}</slot>
+ <slot name="header-text">{{ __('Linked issues') }}</slot>
<gl-link
v-if="hasHelpPath"
:href="helpPath"
@@ -167,7 +167,7 @@ export default {
/>
</div>
</h3>
- <slot name="headerActions"></slot>
+ <slot name="header-actions"></slot>
</div>
<div
class="linked-issues-card-body bg-gray-light"
diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue
index a75fe4397bb..8021d390d95 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_list.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue
@@ -3,13 +3,9 @@ import { GlLoadingIcon } from '@gitlab/ui';
import Sortable from 'sortablejs';
import sortableConfig from 'ee_else_ce/sortable/sortable_config';
import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
-import tooltip from '~/vue_shared/directives/tooltip';
export default {
name: 'RelatedIssuesList',
- directives: {
- tooltip,
- },
components: {
GlLoadingIcon,
RelatedIssuableItem,
@@ -101,7 +97,11 @@ export default {
class="related-issues-token-body bordered-box bg-white"
:class="{ 'sortable-container': canReorder }"
>
- <div v-if="isFetching" class="related-issues-loading-icon qa-related-issues-loading-icon">
+ <div
+ v-if="isFetching"
+ class="related-issues-loading-icon"
+ data-qa-selector="related_issues_loading_placeholder"
+ >
<gl-loading-icon ref="loadingIcon" label="Fetching linked issues" class="gl-mt-2" />
</div>
<ul ref="list" :class="{ 'content-list': !canReorder }" class="related-items-list">
@@ -136,7 +136,7 @@ export default {
:is-locked="issue.lockIssueRemoval"
:locked-message="issue.lockedMessage"
event-namespace="relatedIssue"
- class="qa-related-issuable-item"
+ data-qa-selector="related_issuable_content"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)"
/>
</li>
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index 1a07e0ed762..8d1bc44cba0 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -6,7 +6,7 @@ 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 MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
+import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue';
import TagField from './tag_field.vue';
export default {
@@ -29,11 +29,12 @@ export default {
'markdownDocsPath',
'markdownPreviewPath',
'releasesPagePath',
- 'updateReleaseApiDocsPath',
'release',
'newMilestonePath',
'manageMilestonesPath',
'projectId',
+ 'groupId',
+ 'groupMilestonesAvailable',
]),
...mapGetters('detail', ['isValid', 'isExistingRelease']),
showForm() {
@@ -141,6 +142,8 @@ export default {
<milestone-combobox
v-model="releaseMilestones"
:project-id="projectId"
+ :group-id="groupId"
+ :group-milestones-available="groupMilestonesAvailable"
:extra-links="milestoneComboboxExtraLinks"
/>
</div>
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index 422d8bf630d..5064b7dd6ad 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -6,6 +6,7 @@ import { __ } from '~/locale';
import ReleaseBlock from './release_block.vue';
import ReleasesPagination from './releases_pagination.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
+import ReleasesSort from './releases_sort.vue';
export default {
name: 'ReleasesApp',
@@ -16,6 +17,7 @@ export default {
ReleaseBlock,
ReleasesPagination,
ReleaseSkeletonLoader,
+ ReleasesSort,
},
computed: {
...mapState('list', [
@@ -62,16 +64,20 @@ export default {
</script>
<template>
<div class="flex flex-column mt-2">
- <gl-button
- v-if="newReleasePath"
- :href="newReleasePath"
- :aria-describedby="shouldRenderEmptyState && 'releases-description'"
- category="primary"
- variant="success"
- class="align-self-end mb-2 js-new-release-btn"
- >
- {{ __('New release') }}
- </gl-button>
+ <div class="gl-align-self-end gl-mb-3">
+ <releases-sort class="gl-mr-2" @sort:changed="fetchReleases" />
+
+ <gl-button
+ v-if="newReleasePath"
+ :href="newReleasePath"
+ :aria-describedby="shouldRenderEmptyState && 'releases-description'"
+ category="primary"
+ variant="success"
+ class="js-new-release-btn"
+ >
+ {{ __('New release') }}
+ </gl-button>
+ </div>
<release-skeleton-loader v-if="isLoading" class="js-loading" />
diff --git a/app/assets/javascripts/releases/components/issuable_stats.vue b/app/assets/javascripts/releases/components/issuable_stats.vue
new file mode 100644
index 00000000000..d005d8e10dd
--- /dev/null
+++ b/app/assets/javascripts/releases/components/issuable_stats.vue
@@ -0,0 +1,97 @@
+<script>
+import { GlLink, GlBadge, GlSprintf } from '@gitlab/ui';
+
+export default {
+ name: 'IssuableStats',
+ components: {
+ GlLink,
+ GlBadge,
+ GlSprintf,
+ },
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ total: {
+ type: Number,
+ required: true,
+ },
+ closed: {
+ type: Number,
+ required: true,
+ },
+ merged: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ openedPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ closedPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ mergedPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ opened() {
+ return this.total - (this.closed + (this.merged || 0));
+ },
+ showMerged() {
+ return this.merged != null;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column gl-flex-shrink-0 gl-mr-6 gl-mb-5">
+ <span class="gl-mb-2">
+ {{ label }}
+ <gl-badge variant="muted" size="sm">{{ total }}</gl-badge>
+ </span>
+ <div class="gl-display-flex">
+ <span class="gl-white-space-pre-wrap" data-testid="open-stat">
+ <gl-sprintf :message="__('Open: %{open}')">
+ <template #open>
+ <gl-link v-if="openedPath" :href="openedPath">{{ opened }}</gl-link>
+ <template v-else>{{ opened }}</template>
+ </template>
+ </gl-sprintf>
+ </span>
+
+ <template v-if="showMerged">
+ <span class="gl-mx-2">&bull;</span>
+
+ <span class="gl-white-space-pre-wrap" data-testid="merged-stat">
+ <gl-sprintf :message="__('Merged: %{merged}')">
+ <template #merged>
+ <gl-link v-if="mergedPath" :href="mergedPath">{{ merged }}</gl-link>
+ <template v-else>{{ merged }}</template>
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+
+ <span class="gl-mx-2">&bull;</span>
+
+ <span class="gl-white-space-pre-wrap" data-testid="closed-stat">
+ <gl-sprintf :message="__('Closed: %{closed}')">
+ <template #closed>
+ <gl-link v-if="closedPath" :href="closedPath">{{ closed }}</gl-link>
+ <template v-else>{{ closed }}</template>
+ </template>
+ </gl-sprintf>
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue
index e9163a52792..b89e5f2df3f 100644
--- a/app/assets/javascripts/releases/components/release_block.vue
+++ b/app/assets/javascripts/releases/components/release_block.vue
@@ -87,9 +87,14 @@ export default {
<release-block-header :release="release" />
<div class="card-body">
<div v-if="shouldRenderMilestoneInfo">
+ <!-- TODO: Switch open* links to opened* once fields have been updated in GraphQL -->
<release-block-milestone-info
:milestones="milestones"
- :open-issues-path="release._links.issuesUrl"
+ :opened-issues-path="release._links.openedIssuesUrl"
+ :closed-issues-path="release._links.closedIssuesUrl"
+ :opened-merge-requests-path="release._links.openedMergeRequestsUrl"
+ :merged-merge-requests-path="release._links.mergedMergeRequestsUrl"
+ :closed-merge-requests-path="release._links.closedMergeRequestsUrl"
/>
<hr class="mb-3 mt-0" />
</div>
diff --git a/app/assets/javascripts/releases/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/components/release_block_milestone_info.vue
index deff673cc17..daa9c3480f4 100644
--- a/app/assets/javascripts/releases/components/release_block_milestone_info.vue
+++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue
@@ -1,24 +1,16 @@
<script>
-import {
- GlProgressBar,
- GlLink,
- GlBadge,
- GlButton,
- GlTooltipDirective,
- GlSprintf,
-} from '@gitlab/ui';
-import { sum } from 'lodash';
+import { GlProgressBar, GlLink, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __, n__, sprintf } from '~/locale';
import { MAX_MILESTONES_TO_DISPLAY } from '../constants';
+import IssuableStats from './issuable_stats.vue';
export default {
name: 'ReleaseBlockMilestoneInfo',
components: {
GlProgressBar,
GlLink,
- GlBadge,
GlButton,
- GlSprintf,
+ IssuableStats,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -28,7 +20,7 @@ export default {
type: Array,
required: true,
},
- openIssuesPath: {
+ openedIssuesPath: {
type: String,
required: false,
default: '',
@@ -38,6 +30,21 @@ export default {
required: false,
default: '',
},
+ openedMergeRequestsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ mergedMergeRequestsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ closedMergeRequestsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -52,30 +59,49 @@ export default {
});
},
percentComplete() {
- const percent = Math.round((this.closedIssuesCount / this.totalIssuesCount) * 100);
+ const percent = Math.round((this.issueCounts.closed / this.issueCounts.total) * 100);
return Number.isNaN(percent) ? 0 : percent;
},
- allIssueStats() {
- return this.milestones.map(m => m.issueStats || {});
- },
- totalIssuesCount() {
- return sum(this.allIssueStats.map(stats => stats.total || 0));
- },
- closedIssuesCount() {
- return sum(this.allIssueStats.map(stats => stats.closed || 0));
- },
- openIssuesCount() {
- return this.totalIssuesCount - this.closedIssuesCount;
+ issueCounts() {
+ return this.milestones
+ .map(m => m.issueStats || {})
+ .reduce(
+ (acc, current) => {
+ acc.total += current.total || 0;
+ acc.closed += current.closed || 0;
+
+ return acc;
+ },
+ {
+ total: 0,
+ closed: 0,
+ },
+ );
+ },
+ showMergeRequestStats() {
+ return this.milestones.some(m => m.mrStats);
+ },
+ mergeRequestCounts() {
+ return this.milestones
+ .map(m => m.mrStats || {})
+ .reduce(
+ (acc, current) => {
+ acc.total += current.total || 0;
+ acc.merged += current.merged || 0;
+ acc.closed += current.closed || 0;
+
+ return acc;
+ },
+ {
+ total: 0,
+ merged: 0,
+ closed: 0,
+ },
+ );
},
milestoneLabelText() {
return n__('Milestone', 'Milestones', this.milestones.length);
},
- issueCountsText() {
- return sprintf(__('Open: %{open} • Closed: %{closed}'), {
- open: this.openIssuesCount,
- closed: this.closedIssuesCount,
- });
- },
milestonesToDisplay() {
return this.showAllMilestones
? this.milestones
@@ -106,20 +132,22 @@ export default {
};
</script>
<template>
- <div class="release-block-milestone-info d-flex align-items-start flex-wrap">
+ <div class="release-block-milestone-info gl-display-flex gl-flex-wrap">
<div
v-gl-tooltip
- class="milestone-progress-bar-container js-milestone-progress-bar-container d-flex flex-column align-items-start flex-shrink-1 mr-4 mb-3"
+ class="milestone-progress-bar-container js-milestone-progress-bar-container gl-display-flex gl-flex-direction-column gl-mr-6 gl-mb-5"
:title="__('Closed issues')"
>
- <span class="mb-2">{{ percentCompleteText }}</span>
- <span class="w-100">
- <gl-progress-bar :value="closedIssuesCount" :max="totalIssuesCount" variant="success" />
+ <span class="gl-mb-3">{{ percentCompleteText }}</span>
+ <span class="gl-w-full">
+ <gl-progress-bar :value="issueCounts.closed" :max="issueCounts.total" variant="success" />
</span>
</div>
- <div class="d-flex flex-column align-items-start mr-4 mb-3 js-milestone-list-container">
- <span class="mb-1">{{ milestoneLabelText }}</span>
- <div class="d-flex flex-wrap align-items-end">
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-mr-6 gl-mb-5 js-milestone-list-container"
+ >
+ <span class="gl-mb-2">{{ milestoneLabelText }}</span>
+ <div class="gl-display-flex gl-flex-wrap gl-align-items-flex-end">
<template v-for="(milestone, index) in milestonesToDisplay">
<gl-link
:key="milestone.id"
@@ -141,32 +169,24 @@ export default {
</template>
</div>
</div>
- <div class="d-flex flex-column align-items-start flex-shrink-0 mr-4 mb-3 js-issues-container">
- <span class="mb-1">
- {{ __('Issues') }}
- <gl-badge variant="muted" size="sm">{{ totalIssuesCount }}</gl-badge>
- </span>
- <div class="d-flex">
- <gl-link v-if="openIssuesPath" ref="openIssuesLink" :href="openIssuesPath">
- <gl-sprintf :message="__('Open: %{openIssuesCount}')">
- <template #openIssuesCount>{{ openIssuesCount }}</template>
- </gl-sprintf>
- </gl-link>
- <span v-else ref="openIssuesText">
- {{ sprintf(__('Open: %{openIssuesCount}'), { openIssuesCount }) }}
- </span>
-
- <span class="mx-1">&bull;</span>
-
- <gl-link v-if="closedIssuesPath" ref="closedIssuesLink" :href="closedIssuesPath">
- <gl-sprintf :message="__('Closed: %{closedIssuesCount}')">
- <template #closedIssuesCount>{{ closedIssuesCount }}</template>
- </gl-sprintf>
- </gl-link>
- <span v-else ref="closedIssuesText">
- {{ sprintf(__('Closed: %{closedIssuesCount}'), { closedIssuesCount }) }}
- </span>
- </div>
- </div>
+ <issuable-stats
+ :label="__('Issues')"
+ :total="issueCounts.total"
+ :closed="issueCounts.closed"
+ :opened-path="openedIssuesPath"
+ :closed-path="closedIssuesPath"
+ data-testid="issue-stats"
+ />
+ <issuable-stats
+ v-if="showMergeRequestStats"
+ :label="__('Merge Requests')"
+ :total="mergeRequestCounts.total"
+ :merged="mergeRequestCounts.merged"
+ :closed="mergeRequestCounts.closed"
+ :opened-path="openedMergeRequestsPath"
+ :merged-path="mergedMergeRequestsPath"
+ :closed-path="closedMergeRequestsPath"
+ data-testid="merge-request-stats"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/releases/components/releases_sort.vue b/app/assets/javascripts/releases/components/releases_sort.vue
new file mode 100644
index 00000000000..50f6f3c19bd
--- /dev/null
+++ b/app/assets/javascripts/releases/components/releases_sort.vue
@@ -0,0 +1,62 @@
+<script>
+import { GlSorting, GlSortingItem } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { ASCENDING_ODER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants';
+
+export default {
+ name: 'ReleasesSort',
+ components: {
+ GlSorting,
+ GlSortingItem,
+ },
+ computed: {
+ ...mapState('list', {
+ orderBy: state => state.sorting.orderBy,
+ sort: state => state.sorting.sort,
+ }),
+ sortOptions() {
+ return SORT_OPTIONS;
+ },
+ sortText() {
+ const option = this.sortOptions.find(s => s.orderBy === this.orderBy);
+ return option.label;
+ },
+ isSortAscending() {
+ return this.sort === ASCENDING_ODER;
+ },
+ },
+ methods: {
+ ...mapActions('list', ['setSorting']),
+ onDirectionChange() {
+ const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER;
+ this.setSorting({ sort });
+ this.$emit('sort:changed');
+ },
+ onSortItemClick(item) {
+ this.setSorting({ orderBy: item });
+ this.$emit('sort:changed');
+ },
+ isActiveSortItem(item) {
+ return this.orderBy === item;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-sorting
+ :text="sortText"
+ :is-ascending="isSortAscending"
+ data-testid="releases-sort"
+ @sortDirectionChange="onDirectionChange"
+ >
+ <gl-sorting-item
+ v-for="item in sortOptions"
+ :key="item.orderBy"
+ :active="isActiveSortItem(item.orderBy)"
+ @click="onSortItemClick(item.orderBy)"
+ >
+ {{ item.label }}
+ </gl-sorting-item>
+ </gl-sorting>
+</template>
diff --git a/app/assets/javascripts/releases/components/tag_field_existing.vue b/app/assets/javascripts/releases/components/tag_field_existing.vue
index b84e713df26..046885fe2f6 100644
--- a/app/assets/javascripts/releases/components/tag_field_existing.vue
+++ b/app/assets/javascripts/releases/components/tag_field_existing.vue
@@ -1,14 +1,14 @@
<script>
import { mapState } from 'vuex';
import { uniqueId } from 'lodash';
-import { GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
import FormFieldContainer from './form_field_container.vue';
export default {
name: 'TagFieldExisting',
- components: { GlFormGroup, GlFormInput, GlSprintf, GlLink, FormFieldContainer },
+ components: { GlFormGroup, GlFormInput, FormFieldContainer },
computed: {
- ...mapState('detail', ['release', 'updateReleaseApiDocsPath']),
+ ...mapState('detail', ['release']),
inputId() {
return uniqueId('tag-name-input-');
},
@@ -32,19 +32,7 @@ export default {
</form-field-container>
<template #description>
<div :id="helpId" data-testid="tag-name-help">
- <gl-sprintf
- :message="
- __(
- 'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}',
- )
- "
- >
- <template #link="{ content }">
- <gl-link :href="updateReleaseApiDocsPath" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
+ {{ __("The tag name can't be changed for an existing release.") }}
</div>
</template>
</gl-form-group>
diff --git a/app/assets/javascripts/releases/constants.js b/app/assets/javascripts/releases/constants.js
index 953e7b4189c..8979aa1394d 100644
--- a/app/assets/javascripts/releases/constants.js
+++ b/app/assets/javascripts/releases/constants.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export const MAX_MILESTONES_TO_DISPLAY = 5;
export const BACK_URL_PARAM = 'back_url';
@@ -12,3 +14,19 @@ export const ASSET_LINK_TYPE = Object.freeze({
export const DEFAULT_ASSET_LINK_TYPE = ASSET_LINK_TYPE.OTHER;
export const PAGE_SIZE = 20;
+
+export const ASCENDING_ODER = 'asc';
+export const DESCENDING_ORDER = 'desc';
+export const RELEASED_AT = 'released_at';
+export const CREATED_AT = 'created_at';
+
+export const SORT_OPTIONS = [
+ {
+ orderBy: RELEASED_AT,
+ label: __('Released date'),
+ },
+ {
+ orderBy: CREATED_AT,
+ label: __('Created date'),
+ },
+];
diff --git a/app/assets/javascripts/releases/queries/all_releases.query.graphql b/app/assets/javascripts/releases/queries/all_releases.query.graphql
index c35306f163d..a07dabb9fd6 100644
--- a/app/assets/javascripts/releases/queries/all_releases.query.graphql
+++ b/app/assets/javascripts/releases/queries/all_releases.query.graphql
@@ -1,8 +1,15 @@
#import "./release.fragment.graphql"
-query allReleases($fullPath: ID!, $first: Int, $last: Int, $before: String, $after: String) {
+query allReleases(
+ $fullPath: ID!
+ $first: Int
+ $last: Int
+ $before: String
+ $after: String
+ $sort: ReleaseSort
+) {
project(fullPath: $fullPath) {
- releases(first: $first, last: $last, before: $before, after: $after) {
+ releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) {
nodes {
...Release
}
diff --git a/app/assets/javascripts/releases/queries/release.fragment.graphql b/app/assets/javascripts/releases/queries/release.fragment.graphql
index 445ed616348..3a742db7d9e 100644
--- a/app/assets/javascripts/releases/queries/release.fragment.graphql
+++ b/app/assets/javascripts/releases/queries/release.fragment.graphql
@@ -4,6 +4,7 @@ fragment Release on Release {
tagPath
descriptionHtml
releasedAt
+ createdAt
upcomingRelease
assets {
count
@@ -33,9 +34,12 @@ fragment Release on Release {
}
links {
editUrl
- issuesUrl
- mergeRequestsUrl
selfUrl
+ openedIssuesUrl
+ closedIssuesUrl
+ openedMergeRequestsUrl
+ mergedMergeRequestsUrl
+ closedMergeRequestsUrl
}
commit {
sha
diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js
index 782a5c46d6c..315d07ac664 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/state.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/state.js
@@ -1,9 +1,10 @@
export default ({
projectId,
+ groupId,
+ groupMilestonesAvailable = false,
projectPath,
markdownDocsPath,
markdownPreviewPath,
- updateReleaseApiDocsPath,
releaseAssetsDocsPath,
manageMilestonesPath,
newMilestonePath,
@@ -13,10 +14,11 @@ export default ({
defaultBranch = null,
}) => ({
projectId,
+ groupId,
+ groupMilestonesAvailable: Boolean(groupMilestonesAvailable),
projectPath,
markdownDocsPath,
markdownPreviewPath,
- updateReleaseApiDocsPath,
releaseAssetsDocsPath,
manageMilestonesPath,
newMilestonePath,
diff --git a/app/assets/javascripts/releases/stores/modules/list/actions.js b/app/assets/javascripts/releases/stores/modules/list/actions.js
index 02e67415e63..a62f7c25464 100644
--- a/app/assets/javascripts/releases/stores/modules/list/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/list/actions.js
@@ -42,6 +42,10 @@ export const fetchReleasesGraphQl = (
) => {
commit(types.REQUEST_RELEASES);
+ const { sort, orderBy } = state.sorting;
+ const orderByParam = orderBy === 'created_at' ? 'created' : orderBy;
+ const sortParams = `${orderByParam}_${sort}`.toUpperCase();
+
let paginationParams;
if (!before && !after) {
paginationParams = { first: PAGE_SIZE };
@@ -60,6 +64,7 @@ export const fetchReleasesGraphQl = (
query: allReleasesQuery,
variables: {
fullPath: state.projectPath,
+ sort: sortParams,
...paginationParams,
},
})
@@ -80,8 +85,10 @@ export const fetchReleasesGraphQl = (
export const fetchReleasesRest = ({ dispatch, commit, state }, { page }) => {
commit(types.REQUEST_RELEASES);
+ const { sort, orderBy } = state.sorting;
+
api
- .releases(state.projectId, { page })
+ .releases(state.projectId, { page, sort, order_by: orderBy })
.then(({ data, headers }) => {
const restPageInfo = parseIntPagination(normalizeHeaders(headers));
const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true });
@@ -98,3 +105,5 @@ export const receiveReleasesError = ({ commit }) => {
commit(types.RECEIVE_RELEASES_ERROR);
createFlash(__('An error occurred while fetching the releases. Please try again.'));
};
+
+export const setSorting = ({ commit }, data) => commit(types.SET_SORTING, data);
diff --git a/app/assets/javascripts/releases/stores/modules/list/mutation_types.js b/app/assets/javascripts/releases/stores/modules/list/mutation_types.js
index a74bf15c515..669168efb88 100644
--- a/app/assets/javascripts/releases/stores/modules/list/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/list/mutation_types.js
@@ -1,3 +1,4 @@
export const REQUEST_RELEASES = 'REQUEST_RELEASES';
export const RECEIVE_RELEASES_SUCCESS = 'RECEIVE_RELEASES_SUCCESS';
export const RECEIVE_RELEASES_ERROR = 'RECEIVE_RELEASES_ERROR';
+export const SET_SORTING = 'SET_SORTING';
diff --git a/app/assets/javascripts/releases/stores/modules/list/mutations.js b/app/assets/javascripts/releases/stores/modules/list/mutations.js
index 296487cfee2..e1aaa2e2a19 100644
--- a/app/assets/javascripts/releases/stores/modules/list/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/list/mutations.js
@@ -39,4 +39,8 @@ export default {
state.restPageInfo = {};
state.graphQlPageInfo = {};
},
+
+ [types.SET_SORTING](state, sorting) {
+ state.sorting = { ...state.sorting, ...sorting };
+ },
};
diff --git a/app/assets/javascripts/releases/stores/modules/list/state.js b/app/assets/javascripts/releases/stores/modules/list/state.js
index 0bffaa0f9db..164a496d450 100644
--- a/app/assets/javascripts/releases/stores/modules/list/state.js
+++ b/app/assets/javascripts/releases/stores/modules/list/state.js
@@ -1,3 +1,5 @@
+import { DESCENDING_ORDER, RELEASED_AT } from '../../../constants';
+
export default ({
projectId,
projectPath,
@@ -16,4 +18,8 @@ export default ({
releases: [],
restPageInfo: {},
graphQlPageInfo: {},
+ sorting: {
+ sort: DESCENDING_ORDER,
+ orderBy: RELEASED_AT,
+ },
});
diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js
index 445c429fd96..464f0594b8d 100644
--- a/app/assets/javascripts/releases/util.js
+++ b/app/assets/javascripts/releases/util.js
@@ -15,7 +15,9 @@ import {
export const releaseToApiJson = (release, createFrom = null) => {
const name = release.name?.trim().length > 0 ? release.name.trim() : null;
- const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
+ // Milestones may be either a list of milestone objects OR just a list
+ // of milestone titles. The API requires only the titles be sent.
+ const milestones = (release.milestones || []).map(m => m.title || m);
return convertObjectPropsToSnakeCase(
{
diff --git a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue
index 0c758ee2b5c..d0a5615bb57 100644
--- a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue
+++ b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue
@@ -3,15 +3,21 @@
* Renders Code quality body text
* Fixed: [name] in [link]:[line]
*/
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import ReportLink from '~/reports/components/report_link.vue';
import { STATUS_SUCCESS } from '~/reports/constants';
+import { s__ } from '~/locale';
+import { SEVERITY_CLASSES, SEVERITY_ICONS } from '../constants';
export default {
name: 'CodequalityIssueBody',
-
components: {
+ GlIcon,
ReportLink,
},
+ directives: {
+ tooltip: GlTooltipDirective,
+ },
props: {
status: {
type: String,
@@ -23,20 +29,44 @@ export default {
},
},
computed: {
+ issueName() {
+ return `${this.severityLabel} - ${this.issue.name}`;
+ },
isStatusSuccess() {
return this.status === STATUS_SUCCESS;
},
+ severityClass() {
+ return SEVERITY_CLASSES[this.issue.severity] || SEVERITY_CLASSES.unknown;
+ },
+ severityIcon() {
+ return SEVERITY_ICONS[this.issue.severity] || SEVERITY_ICONS.unknown;
+ },
+ severityLabel() {
+ return this.$options.severityText[this.issue.severity] || this.$options.severityText.unknown;
+ },
+ },
+ severityText: {
+ info: s__('severity|Info'),
+ minor: s__('severity|Minor'),
+ major: s__('severity|Major'),
+ critical: s__('severity|Critical'),
+ blocker: s__('severity|Blocker'),
+ unknown: s__('severity|Unknown'),
},
};
</script>
<template>
- <div class="report-block-list-issue-description gl-mt-2 gl-mb-2">
- <div class="report-block-list-issue-description-text">
- <template v-if="isStatusSuccess">{{ s__('ciReport|Fixed:') }}</template>
+ <div class="gl-display-flex gl-mt-2 gl-mb-2 gl-w-full">
+ <span :class="severityClass" class="gl-mr-5" data-testid="codequality-severity-icon">
+ <gl-icon v-tooltip="severityLabel" :name="severityIcon" :size="12" />
+ </span>
+ <div class="gl-flex-fill-1">
+ <div>
+ <strong v-if="isStatusSuccess">{{ s__('ciReport|Fixed:') }}</strong>
+ {{ issueName }}
+ </div>
- {{ issue.name }}
+ <report-link v-if="issue.path" :issue="issue" />
</div>
-
- <report-link v-if="issue.path" :issue="issue" />
</div>
</template>
diff --git a/app/assets/javascripts/reports/codequality_report/constants.js b/app/assets/javascripts/reports/codequality_report/constants.js
new file mode 100644
index 00000000000..502977e714c
--- /dev/null
+++ b/app/assets/javascripts/reports/codequality_report/constants.js
@@ -0,0 +1,17 @@
+export const SEVERITY_CLASSES = {
+ info: 'text-primary-400',
+ minor: 'text-warning-200',
+ major: 'text-warning-400',
+ critical: 'text-danger-600',
+ blocker: 'text-danger-800',
+ unknown: 'text-secondary-400',
+};
+
+export const SEVERITY_ICONS = {
+ info: 'severity-info',
+ minor: 'severity-low',
+ major: 'severity-medium',
+ critical: 'severity-high',
+ blocker: 'severity-critical',
+ unknown: 'severity-unknown',
+};
diff --git a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue
index f3d5b1a80f8..5c8f31d7da0 100644
--- a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue
+++ b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue
@@ -78,6 +78,7 @@ export default {
:has-issues="hasCodequalityIssues"
:component="$options.componentNames.CodequalityIssueBody"
:popover-options="codequalityPopover"
+ :show-report-section-status-icon="false"
class="js-codequality-widget mr-widget-border-top mr-report"
/>
</template>
diff --git a/app/assets/javascripts/reports/codequality_report/store/getters.js b/app/assets/javascripts/reports/codequality_report/store/getters.js
index 5df58c7f85f..d7c31bcf459 100644
--- a/app/assets/javascripts/reports/codequality_report/store/getters.js
+++ b/app/assets/javascripts/reports/codequality_report/store/getters.js
@@ -1,5 +1,6 @@
import { LOADING, ERROR, SUCCESS } from '../../constants';
import { sprintf, __, s__, n__ } from '~/locale';
+import { spriteIcon } from '~/lib/utils/common_utils';
export const hasCodequalityIssues = state =>
Boolean(state.newIssues?.length || state.resolvedIssues?.length);
@@ -48,7 +49,7 @@ export const codequalityPopover = state => {
s__('ciReport|%{linkStartTag}Learn more about codequality reports %{linkEndTag}'),
{
linkStartTag: `<a href="${state.helpPath}" target="_blank" rel="noopener noreferrer">`,
- linkEndTag: '<i class="fa fa-external-link" aria-hidden="true"></i></a>',
+ linkEndTag: `${spriteIcon('external-link', 's16')}</a>`,
},
false,
),
diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
index 47f04019595..c13df60198b 100644
--- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
+++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
@@ -1,5 +1,6 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
+import { once } from 'lodash';
import { GlButton } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import { componentNames } from './issue_body';
@@ -8,8 +9,14 @@ import SummaryRow from './summary_row.vue';
import IssuesList from './issues_list.vue';
import Modal from './modal.vue';
import createStore from '../store';
+import Tracking from '~/tracking';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { summaryTextBuilder, reportTextBuilder, statusIcon } from '../store/utils';
+import {
+ summaryTextBuilder,
+ reportTextBuilder,
+ statusIcon,
+ recentFailuresTextBuilder,
+} from '../store/utils';
export default {
name: 'GroupedTestReportsApp',
@@ -21,7 +28,7 @@ export default {
Modal,
GlButton,
},
- mixins: [glFeatureFlagsMixin()],
+ mixins: [glFeatureFlagsMixin(), Tracking.mixin()],
props: {
endpoint: {
type: String,
@@ -58,6 +65,11 @@ export default {
showViewFullReport() {
return this.pipelinePath.length;
},
+ handleToggleEvent() {
+ return once(() => {
+ this.track(this.$options.expandEvent);
+ });
+ },
},
created() {
this.setEndpoint(this.endpoint);
@@ -79,6 +91,12 @@ export default {
return reportTextBuilder(name, summary);
},
+ hasRecentFailures(summary) {
+ return this.glFeatures.testFailureHistory && summary?.recentlyFailed > 0;
+ },
+ recentFailuresText(summary) {
+ return recentFailuresTextBuilder(summary);
+ },
getReportIcon(report) {
return statusIcon(report.status);
},
@@ -102,6 +120,7 @@ export default {
return report.resolved_failures.concat(report.resolved_errors);
},
},
+ expandEvent: 'expand_test_report_widget',
};
</script>
<template>
@@ -111,9 +130,11 @@ export default {
:loading-text="groupedSummaryText"
:error-text="groupedSummaryText"
:has-issues="reports.length > 0"
+ :should-emit-toggle-event="true"
class="mr-widget-section grouped-security-reports mr-report"
+ @toggleEvent="handleToggleEvent"
>
- <template v-if="showViewFullReport" #actionButtons>
+ <template v-if="showViewFullReport" #action-buttons>
<gl-button
:href="testTabURL"
target="_blank"
@@ -124,14 +145,22 @@ export default {
{{ s__('ciReport|View full report') }}
</gl-button>
</template>
+ <template v-if="hasRecentFailures(summary)" #sub-heading>
+ {{ recentFailuresText(summary) }}
+ </template>
<template #body>
<div class="mr-widget-grouped-section report-block">
<template v-for="(report, i) in reports">
- <summary-row
- :key="`summary-row-${i}`"
- :summary="reportText(report)"
- :status-icon="getReportIcon(report)"
- />
+ <summary-row :key="`summary-row-${i}`" :status-icon="getReportIcon(report)">
+ <template #summary>
+ <div class="gl-display-inline-flex gl-flex-direction-column">
+ <div>{{ reportText(report) }}</div>
+ <div v-if="hasRecentFailures(report.summary)">
+ {{ recentFailuresText(report.summary) }}
+ </div>
+ </div>
+ </template>
+ </summary-row>
<issues-list
v-if="shouldRenderIssuesList(report)"
:key="`issues-list-${i}`"
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index 63af8a5a9ac..f245e2bfd2f 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -181,14 +181,15 @@ export default {
<slot :name="slotName"></slot>
<popover v-if="hasPopover" :options="popoverOptions" class="gl-ml-2" />
</div>
- <slot name="subHeading"></slot>
+ <slot name="sub-heading"></slot>
</div>
- <slot name="actionButtons"></slot>
+ <slot name="action-buttons"></slot>
<button
v-if="isCollapsible"
type="button"
+ data-testid="report-section-expand-button"
class="js-collapse-btn btn float-right btn-sm align-self-center qa-expand-report-button"
@click="toggleCollapsed"
>
diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue
index 4e0631740d8..5e9a5b03543 100644
--- a/app/assets/javascripts/reports/components/test_issue_body.vue
+++ b/app/assets/javascripts/reports/components/test_issue_body.vue
@@ -1,8 +1,15 @@
<script>
import { mapActions } from 'vuex';
+import { GlBadge } from '@gitlab/ui';
+import { n__ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'TestIssueBody',
+ components: {
+ GlBadge,
+ },
+ mixins: [glFeatureFlagsMixin()],
props: {
issue: {
type: Object,
@@ -19,8 +26,20 @@ export default {
default: false,
},
},
+ computed: {
+ showRecentFailures() {
+ return this.glFeatures.testFailureHistory && this.issue.recent_failures;
+ },
+ },
methods: {
...mapActions(['openModal']),
+ recentFailuresText(count) {
+ return n__(
+ 'Failed %d time in the last 14 days',
+ 'Failed %d times in the last 14 days',
+ count,
+ );
+ },
},
};
</script>
@@ -32,7 +51,10 @@ export default {
class="btn-link btn-blank text-left break-link vulnerability-name-button"
@click="openModal({ issue })"
>
- <div v-if="isNew" class="badge badge-danger gl-mr-2">{{ s__('New') }}</div>
+ <gl-badge v-if="isNew" variant="danger" class="gl-mr-2">{{ s__('New') }}</gl-badge>
+ <gl-badge v-if="showRecentFailures" variant="warning" class="gl-mr-2">
+ {{ recentFailuresText(issue.recent_failures) }}
+ </gl-badge>
{{ issue.name }}
</button>
</div>
diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js
index 35ab72bf694..acaa98754b0 100644
--- a/app/assets/javascripts/reports/store/mutations.js
+++ b/app/assets/javascripts/reports/store/mutations.js
@@ -1,4 +1,5 @@
import * as types from './mutation_types';
+import { countRecentlyFailedTests } from './utils';
export default {
[types.SET_ENDPOINT](state, endpoint) {
@@ -16,9 +17,15 @@ export default {
state.summary.resolved = response.summary.resolved;
state.summary.failed = response.summary.failed;
state.summary.errored = response.summary.errored;
+ state.summary.recentlyFailed = countRecentlyFailedTests(response.suites);
state.status = response.status;
state.reports = response.suites;
+
+ state.reports.forEach((report, i) => {
+ if (!state.reports[i].summary) return;
+ state.reports[i].summary.recentlyFailed = countRecentlyFailedTests(report);
+ });
},
[types.RECEIVE_REPORTS_ERROR](state) {
state.isLoading = false;
@@ -30,6 +37,7 @@ export default {
resolved: 0,
failed: 0,
errored: 0,
+ recentlyFailed: 0,
};
state.status = null;
},
diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js
index 5d3d9ddda3b..fd6f4933cfa 100644
--- a/app/assets/javascripts/reports/store/utils.js
+++ b/app/assets/javascripts/reports/store/utils.js
@@ -48,6 +48,48 @@ export const reportTextBuilder = (name = '', results = {}) => {
return sprintf(__('%{name} found %{resultsString}'), { name, resultsString });
};
+export const recentFailuresTextBuilder = (summary = {}) => {
+ const { failed, recentlyFailed } = summary;
+ if (!failed || !recentlyFailed) return '';
+
+ if (failed < 2) {
+ return sprintf(
+ s__(
+ 'Reports|%{recentlyFailed} out of %{failed} failed test has failed more than once in the last 14 days',
+ ),
+ { recentlyFailed, failed },
+ );
+ }
+ return sprintf(
+ n__(
+ s__(
+ 'Reports|%{recentlyFailed} out of %{failed} failed tests has failed more than once in the last 14 days',
+ ),
+ s__(
+ 'Reports|%{recentlyFailed} out of %{failed} failed tests have failed more than once in the last 14 days',
+ ),
+ recentlyFailed,
+ ),
+ { recentlyFailed, failed },
+ );
+};
+
+export const countRecentlyFailedTests = subject => {
+ // handle either a single report or an array of reports
+ const reports = !subject.length ? [subject] : subject;
+
+ return reports
+ .map(report => {
+ return (
+ [report.new_failures, report.existing_failures, report.resolved_failures]
+ // only count tests which have failed more than once
+ .map(failureArray => failureArray.filter(failure => failure.recent_failures > 1).length)
+ .reduce((total, count) => total + count, 0)
+ );
+ })
+ .reduce((total, count) => total + count, 0);
+};
+
export const statusIcon = status => {
if (status === STATUS_FAILED) {
return ICON_WARNING;
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index 677cb265942..a1f1c77df2f 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -6,12 +6,12 @@ import {
GlDropdownItem,
GlIcon,
} from '@gitlab/ui';
+import permissionsQuery from 'shared_queries/repository/permissions.query.graphql';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
import { __ } from '../../locale';
import getRefMixin from '../mixins/get_ref';
import projectShortPathQuery from '../queries/project_short_path.query.graphql';
import projectPathQuery from '../queries/project_path.query.graphql';
-import permissionsQuery from '../queries/permissions.query.graphql';
const ROW_TYPES = {
header: 'header',
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 0e2bccfabdd..2626bace363 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -137,8 +137,8 @@ export default {
:href="commit.author.webPath"
class="commit-author-link js-user-link"
>
- {{ commit.author.name }}
- </gl-link>
+ {{ commit.author.name }}</gl-link
+ >
<template v-else>
{{ commit.authorName }}
</template>
diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue
index 4e2c8332f37..c9c5aa37645 100644
--- a/app/assets/javascripts/repository/components/preview/index.vue
+++ b/app/assets/javascripts/repository/components/preview/index.vue
@@ -58,7 +58,7 @@ export default {
</gl-link>
</div>
</div>
- <div class="blob-viewer" data-qa-selector="blob_viewer_content">
+ <div class="blob-viewer" data-qa-selector="blob_viewer_content" itemprop="about">
<gl-loading-icon v-if="loading > 0" size="md" color="dark" class="my-4 mx-auto" />
<div v-else-if="readme" ref="readme" v-html="readme.html"></div>
</div>
diff --git a/app/assets/javascripts/repository/components/tree_action_link.vue b/app/assets/javascripts/repository/components/tree_action_link.vue
index 72764f3ccc9..c5ab150adaf 100644
--- a/app/assets/javascripts/repository/components/tree_action_link.vue
+++ b/app/assets/javascripts/repository/components/tree_action_link.vue
@@ -24,5 +24,5 @@ export default {
</script>
<template>
- <gl-link :href="path" :class="cssClass" class="btn">{{ text }}</gl-link>
+ <gl-link :href="path" :class="cssClass" class="btn gl-button">{{ text }}</gl-link>
</template>
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index 78b8baaa75e..b42f88631b5 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -1,9 +1,9 @@
<script>
+import filesQuery from 'shared_queries/repository/files.query.graphql';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __ } from '../../locale';
import FileTable from './table/index.vue';
import getRefMixin from '../mixins/get_ref';
-import filesQuery from '../queries/files.query.graphql';
import projectPathQuery from '../queries/project_path.query.graphql';
import FilePreview from './preview/index.vue';
import { readmeFile } from '../utils/readme';
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index a62b2d96c54..f56b141fe5c 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -1,5 +1,4 @@
import Vue from 'vue';
-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';
@@ -19,10 +18,6 @@ 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: {
@@ -48,28 +43,7 @@ export default function setupVueRepositoryList() {
},
});
- 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();
- }
+ initLastCommitApp();
router.afterEach(({ params: { path } }) => {
setTitle(path, ref, fullName);
diff --git a/app/assets/javascripts/repository/mixins/preload.js b/app/assets/javascripts/repository/mixins/preload.js
index cb1d7f3aac9..c1607866941 100644
--- a/app/assets/javascripts/repository/mixins/preload.js
+++ b/app/assets/javascripts/repository/mixins/preload.js
@@ -1,4 +1,4 @@
-import filesQuery from '../queries/files.query.graphql';
+import filesQuery from 'shared_queries/repository/files.query.graphql';
import getRefMixin from './get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
diff --git a/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue b/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue
deleted file mode 100644
index b6e2dd46358..00000000000
--- a/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue
+++ /dev/null
@@ -1,100 +0,0 @@
-<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/index.js b/app/assets/javascripts/search/dropdown_filter/index.js
deleted file mode 100644
index e5e0745d990..00000000000
--- a/app/assets/javascripts/search/dropdown_filter/index.js
+++ /dev/null
@@ -1,38 +0,0 @@
-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/group_filter/components/group_filter.vue b/app/assets/javascripts/search/group_filter/components/group_filter.vue
new file mode 100644
index 00000000000..4b7963c5187
--- /dev/null
+++ b/app/assets/javascripts/search/group_filter/components/group_filter.vue
@@ -0,0 +1,124 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ GlIcon,
+ GlSkeletonLoader,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { isEmpty } from 'lodash';
+import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
+import { ANY_GROUP, GROUP_QUERY_PARAM, PROJECT_QUERY_PARAM } from '../constants';
+
+export default {
+ name: 'GroupFilter',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ GlIcon,
+ GlSkeletonLoader,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ initialGroup: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ groupSearch: '',
+ };
+ },
+ computed: {
+ ...mapState(['groups', 'fetchingGroups']),
+ selectedGroup: {
+ get() {
+ return isEmpty(this.initialGroup) ? ANY_GROUP : this.initialGroup;
+ },
+ set(group) {
+ visitUrl(setUrlParams({ [GROUP_QUERY_PARAM]: group.id, [PROJECT_QUERY_PARAM]: null }));
+ },
+ },
+ },
+ methods: {
+ ...mapActions(['fetchGroups']),
+ isGroupSelected(group) {
+ return group.id === this.selectedGroup.id;
+ },
+ handleGroupChange(group) {
+ this.selectedGroup = group;
+ },
+ },
+ ANY_GROUP,
+};
+</script>
+
+<template>
+ <gl-dropdown
+ ref="groupFilter"
+ class="gl-w-full"
+ menu-class="gl-w-full!"
+ toggle-class="gl-text-truncate gl-reset-line-height!"
+ :header-text="__('Filter results by group')"
+ @show="fetchGroups(groupSearch)"
+ >
+ <template #button-content>
+ <span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate">
+ {{ selectedGroup.name }}
+ </span>
+ <gl-loading-icon v-if="fetchingGroups" inline class="mr-2" />
+ <gl-icon
+ v-if="!isGroupSelected($options.ANY_GROUP)"
+ v-gl-tooltip
+ name="clear"
+ :title="__('Clear')"
+ class="gl-text-gray-200! gl-hover-text-blue-800!"
+ @click.stop="handleGroupChange($options.ANY_GROUP)"
+ />
+ <gl-icon name="chevron-down" />
+ </template>
+ <div class="gl-sticky gl-top-0 gl-z-index-1 gl-bg-white">
+ <gl-search-box-by-type
+ v-model="groupSearch"
+ class="m-2"
+ :debounce="500"
+ @input="fetchGroups"
+ />
+ <gl-dropdown-item
+ class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2"
+ :is-check-item="true"
+ :is-checked="isGroupSelected($options.ANY_GROUP)"
+ @click="handleGroupChange($options.ANY_GROUP)"
+ >
+ {{ $options.ANY_GROUP.name }}
+ </gl-dropdown-item>
+ </div>
+ <div v-if="!fetchingGroups">
+ <gl-dropdown-item
+ v-for="group in groups"
+ :key="group.id"
+ :is-check-item="true"
+ :is-checked="isGroupSelected(group)"
+ @click="handleGroupChange(group)"
+ >
+ {{ group.full_name }}
+ </gl-dropdown-item>
+ </div>
+ <div v-if="fetchingGroups" class="mx-3 mt-2">
+ <gl-skeleton-loader :height="100">
+ <rect y="0" width="90%" height="20" rx="4" />
+ <rect y="40" width="70%" height="20" rx="4" />
+ <rect y="80" width="80%" height="20" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/search/group_filter/constants.js b/app/assets/javascripts/search/group_filter/constants.js
new file mode 100644
index 00000000000..9bd92eaa130
--- /dev/null
+++ b/app/assets/javascripts/search/group_filter/constants.js
@@ -0,0 +1,10 @@
+import { __ } from '~/locale';
+
+export const ANY_GROUP = Object.freeze({
+ id: null,
+ name: __('Any'),
+});
+
+export const GROUP_QUERY_PARAM = 'group_id';
+
+export const PROJECT_QUERY_PARAM = 'project_id';
diff --git a/app/assets/javascripts/search/group_filter/index.js b/app/assets/javascripts/search/group_filter/index.js
new file mode 100644
index 00000000000..9b009bc0305
--- /dev/null
+++ b/app/assets/javascripts/search/group_filter/index.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import GroupFilter from './components/group_filter.vue';
+
+Vue.use(Translate);
+
+export default store => {
+ let initialGroup;
+ const el = document.getElementById('js-search-group-dropdown');
+
+ const { initialGroupData } = el.dataset;
+
+ initialGroup = JSON.parse(initialGroupData);
+ initialGroup = convertObjectPropsToCamelCase(initialGroup, { deep: true });
+
+ return new Vue({
+ el,
+ store,
+ render(createElement) {
+ return createElement(GroupFilter, {
+ props: {
+ initialGroup,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/pages/search/show/highlight_blob_search_result.js b/app/assets/javascripts/search/highlight_blob_search_result.js
index e17c87735b4..e17c87735b4 100644
--- a/app/assets/javascripts/pages/search/show/highlight_blob_search_result.js
+++ b/app/assets/javascripts/search/highlight_blob_search_result.js
diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js
index 780d3ff0d25..781a564d077 100644
--- a/app/assets/javascripts/search/index.js
+++ b/app/assets/javascripts/search/index.js
@@ -1,9 +1,14 @@
import { queryToObject } from '~/lib/utils/url_utility';
import createStore from './store';
-import initDropdownFilters from './dropdown_filter';
+import { initSidebar } from './sidebar';
+import initGroupFilter from './group_filter';
-export default () => {
- const store = createStore({ query: queryToObject(window.location.search) });
+export const initSearchApp = () => {
+ // Similar to url_utility.decodeUrlParameter
+ // Our query treats + as %20. This replaces the query + symbols with %20.
+ const sanitizedSearch = window.location.search.replace(/\+/g, '%20');
+ const store = createStore({ query: queryToObject(sanitizedSearch) });
- initDropdownFilters(store);
+ initSidebar(store);
+ initGroupFilter(store);
};
diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue
new file mode 100644
index 00000000000..aa11b2025f2
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/app.vue
@@ -0,0 +1,41 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { GlButton, GlLink } from '@gitlab/ui';
+import StatusFilter from './status_filter.vue';
+import ConfidentialityFilter from './confidentiality_filter.vue';
+
+export default {
+ name: 'GlobalSearchSidebar',
+ components: {
+ GlButton,
+ GlLink,
+ StatusFilter,
+ ConfidentialityFilter,
+ },
+ computed: {
+ ...mapState(['query']),
+ showReset() {
+ return this.query.state || this.query.confidential;
+ },
+ },
+ methods: {
+ ...mapActions(['applyQuery', 'resetQuery']),
+ },
+};
+</script>
+
+<template>
+ <form
+ class="gl-display-flex gl-flex-direction-column col-md-3 gl-mr-4 gl-mb-6 gl-mt-5"
+ @submit.prevent="applyQuery"
+ >
+ <status-filter />
+ <confidentiality-filter />
+ <div class="gl-display-flex gl-align-items-center gl-mt-3">
+ <gl-button variant="success" type="submit">{{ __('Apply') }}</gl-button>
+ <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{
+ __('Reset filters')
+ }}</gl-link>
+ </div>
+ </form>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
new file mode 100644
index 00000000000..38dccb9675d
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
@@ -0,0 +1,26 @@
+<script>
+import { mapState } from 'vuex';
+import { confidentialFilterData } from '../constants/confidential_filter_data';
+import RadioFilter from './radio_filter.vue';
+
+export default {
+ name: 'ConfidentialityFilter',
+ components: {
+ RadioFilter,
+ },
+ computed: {
+ ...mapState(['query']),
+ showDropdown() {
+ return Object.values(confidentialFilterData.scopes).includes(this.query.scope);
+ },
+ },
+ confidentialFilterData,
+};
+</script>
+
+<template>
+ <div v-if="showDropdown">
+ <radio-filter :filter-data="$options.confidentialFilterData" />
+ <hr class="gl-my-5 gl-border-gray-100" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
new file mode 100644
index 00000000000..b27c4e26fb5
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
@@ -0,0 +1,68 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
+
+export default {
+ name: 'RadioFilter',
+ components: {
+ GlFormRadioGroup,
+ GlFormRadio,
+ },
+ props: {
+ filterData: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['query']),
+ ANY() {
+ return this.filterData.filters.ANY;
+ },
+ scope() {
+ return this.query.scope;
+ },
+ initialFilter() {
+ return this.query[this.filterData.filterParam];
+ },
+ filter() {
+ return this.initialFilter || this.ANY.value;
+ },
+ filtersArray() {
+ return this.filterData.filterByScope[this.scope];
+ },
+ selectedFilter: {
+ get() {
+ if (this.filtersArray.some(({ value }) => value === this.filter)) {
+ return this.filter;
+ }
+
+ return this.ANY.value;
+ },
+ set(value) {
+ this.setQuery({ key: this.filterData.filterParam, value });
+ },
+ },
+ },
+ methods: {
+ ...mapActions(['setQuery']),
+ radioLabel(filter) {
+ return filter.value === this.ANY.value
+ ? sprintf(s__('Any %{header}'), { header: this.filterData.header.toLowerCase() })
+ : filter.label;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h5 class="gl-mt-0">{{ filterData.header }}</h5>
+ <gl-form-radio-group v-model="selectedFilter">
+ <gl-form-radio v-for="f in filtersArray" :key="f.value" :value="f.value">
+ {{ radioLabel(f) }}
+ </gl-form-radio>
+ </gl-form-radio-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue
new file mode 100644
index 00000000000..5cec2090906
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue
@@ -0,0 +1,26 @@
+<script>
+import { mapState } from 'vuex';
+import { stateFilterData } from '../constants/state_filter_data';
+import RadioFilter from './radio_filter.vue';
+
+export default {
+ name: 'StatusFilter',
+ components: {
+ RadioFilter,
+ },
+ computed: {
+ ...mapState(['query']),
+ showDropdown() {
+ return Object.values(stateFilterData.scopes).includes(this.query.scope);
+ },
+ },
+ stateFilterData,
+};
+</script>
+
+<template>
+ <div v-if="showDropdown">
+ <radio-filter :filter-data="$options.stateFilterData" />
+ <hr class="gl-my-5 gl-border-gray-100" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js b/app/assets/javascripts/search/sidebar/constants/confidential_filter_data.js
index b29daca89cb..ecb63ed9eea 100644
--- a/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js
+++ b/app/assets/javascripts/search/sidebar/constants/confidential_filter_data.js
@@ -27,7 +27,7 @@ const filterByScope = {
const filterParam = 'confidential';
-export default {
+export const confidentialFilterData = {
header,
filters,
scopes,
diff --git a/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js b/app/assets/javascripts/search/sidebar/constants/state_filter_data.js
index 0b93aa0be29..7c9a029ffe4 100644
--- a/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js
+++ b/app/assets/javascripts/search/sidebar/constants/state_filter_data.js
@@ -33,7 +33,7 @@ const filterByScope = {
const filterParam = 'state';
-export default {
+export const stateFilterData = {
header,
filters,
scopes,
diff --git a/app/assets/javascripts/search/sidebar/index.js b/app/assets/javascripts/search/sidebar/index.js
new file mode 100644
index 00000000000..6419e8ac2c6
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/index.js
@@ -0,0 +1,19 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import GlobalSearchSidebar from './components/app.vue';
+
+Vue.use(Translate);
+
+export const initSidebar = store => {
+ const el = document.getElementById('js-search-sidebar');
+
+ if (!el) return false;
+
+ return new Vue({
+ el,
+ store,
+ render(createElement) {
+ return createElement(GlobalSearchSidebar);
+ },
+ });
+};
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
new file mode 100644
index 00000000000..447278aa223
--- /dev/null
+++ b/app/assets/javascripts/search/store/actions.js
@@ -0,0 +1,29 @@
+import Api from '~/api';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
+import * as types from './mutation_types';
+
+export const fetchGroups = ({ commit }, search) => {
+ commit(types.REQUEST_GROUPS);
+ Api.groups(search)
+ .then(data => {
+ commit(types.RECEIVE_GROUPS_SUCCESS, data);
+ })
+ .catch(() => {
+ createFlash({ message: __('There was a problem fetching groups.') });
+ commit(types.RECEIVE_GROUPS_ERROR);
+ });
+};
+
+export const setQuery = ({ commit }, { key, value }) => {
+ commit(types.SET_QUERY, { key, value });
+};
+
+export const applyQuery = ({ state }) => {
+ visitUrl(setUrlParams({ ...state.query, page: null }));
+};
+
+export const resetQuery = ({ state }) => {
+ visitUrl(setUrlParams({ ...state.query, page: null, state: null, confidential: null }));
+};
diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js
index 10cfb647a92..e0a7e488f9f 100644
--- a/app/assets/javascripts/search/store/index.js
+++ b/app/assets/javascripts/search/store/index.js
@@ -1,10 +1,14 @@
import Vue from 'vue';
import Vuex from 'vuex';
+import * as actions from './actions';
+import mutations from './mutations';
import createState from './state';
Vue.use(Vuex);
export const getStoreConfig = ({ query }) => ({
+ actions,
+ mutations,
state: createState({ query }),
});
diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js
new file mode 100644
index 00000000000..2482621d4d7
--- /dev/null
+++ b/app/assets/javascripts/search/store/mutation_types.js
@@ -0,0 +1,5 @@
+export const REQUEST_GROUPS = 'REQUEST_GROUPS';
+export const RECEIVE_GROUPS_SUCCESS = 'RECEIVE_GROUPS_SUCCESS';
+export const RECEIVE_GROUPS_ERROR = 'RECEIVE_GROUPS_ERROR';
+
+export const SET_QUERY = 'SET_QUERY';
diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js
new file mode 100644
index 00000000000..e57850b870e
--- /dev/null
+++ b/app/assets/javascripts/search/store/mutations.js
@@ -0,0 +1,18 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_GROUPS](state) {
+ state.fetchingGroups = true;
+ },
+ [types.RECEIVE_GROUPS_SUCCESS](state, data) {
+ state.fetchingGroups = false;
+ state.groups = data;
+ },
+ [types.RECEIVE_GROUPS_ERROR](state) {
+ state.fetchingGroups = false;
+ state.groups = [];
+ },
+ [types.SET_QUERY](state, { key, value }) {
+ state.query[key] = value;
+ },
+};
diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js
index 9115a613767..70a8aab9998 100644
--- a/app/assets/javascripts/search/store/state.js
+++ b/app/assets/javascripts/search/store/state.js
@@ -1,4 +1,6 @@
const createState = ({ query }) => ({
query,
+ groups: [],
+ fetchingGroups: false,
});
export default createState;
diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue
index 29a61cfbbfe..71f2e948917 100644
--- a/app/assets/javascripts/serverless/components/area.vue
+++ b/app/assets/javascripts/serverless/components/area.vue
@@ -138,8 +138,8 @@ export default {
:width="width"
:include-legend-avg-max="false"
>
- <template #tooltipTitle>{{ tooltipPopoverTitle }}</template>
- <template #tooltipContent>{{ tooltipPopoverContent }}</template>
+ <template #tooltip-title>{{ tooltipPopoverTitle }}</template>
+ <template #tooltip-content>{{ tooltipPopoverContent }}</template>
</gl-area-chart>
</div>
</template>
diff --git a/app/assets/javascripts/set_status_modal/components/user_availability_status.vue b/app/assets/javascripts/set_status_modal/components/user_availability_status.vue
new file mode 100644
index 00000000000..e86d94f86c6
--- /dev/null
+++ b/app/assets/javascripts/set_status_modal/components/user_availability_status.vue
@@ -0,0 +1,26 @@
+<script>
+import { AVAILABILITY_STATUS, isUserBusy, isValidAvailibility } from '../utils';
+
+export default {
+ name: 'UserAvailabilityStatus',
+ props: {
+ availability: {
+ type: String,
+ required: true,
+ validator: isValidAvailibility,
+ },
+ },
+ computed: {
+ isBusy() {
+ const { availability = AVAILABILITY_STATUS.NOT_SET } = this;
+ return isUserBusy(availability);
+ },
+ },
+};
+</script>
+
+<template>
+ <span v-if="isBusy" class="gl-font-weight-normal gl-text-gray-500">{{
+ s__('UserAvailability|(Busy)')
+ }}</span>
+</template>
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index 09e893ff285..30e4e92d0cc 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -2,24 +2,35 @@
/* eslint-disable vue/no-v-html */
import $ from 'jquery';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
-import { GlModal, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlModal, GlTooltipDirective, GlIcon, GlFormCheckbox } from '@gitlab/ui';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __, s__ } from '~/locale';
import Api from '~/api';
import EmojiMenuInModal from './emoji_menu_in_modal';
+import { isUserBusy, isValidAvailibility } from './utils';
import * as Emoji from '~/emoji';
const emojiMenuClass = 'js-modal-status-emoji-menu';
+export const AVAILABILITY_STATUS = {
+ BUSY: 'busy',
+ NOT_SET: 'not_set',
+};
export default {
components: {
GlIcon,
GlModal,
+ GlFormCheckbox,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
+ defaultEmoji: {
+ type: String,
+ required: false,
+ default: '',
+ },
currentEmoji: {
type: String,
required: true,
@@ -28,6 +39,17 @@ export default {
type: String,
required: true,
},
+ currentAvailability: {
+ type: String,
+ required: false,
+ validator: isValidAvailibility,
+ default: '',
+ },
+ canSetUserAvailability: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -39,11 +61,15 @@ export default {
message: this.currentMessage,
modalId: 'set-user-status-modal',
noEmoji: true,
+ availability: isUserBusy(this.currentAvailability),
};
},
computed: {
+ isCustomEmoji() {
+ return this.emoji !== this.defaultEmoji;
+ },
isDirty() {
- return this.message.length || this.emoji.length;
+ return Boolean(this.message.length || this.isCustomEmoji);
},
},
mounted() {
@@ -67,7 +93,7 @@ export default {
this.emojiTag = Emoji.glEmojiTag(this.emoji);
}
this.noEmoji = this.emoji === '';
- this.defaultEmojiTag = Emoji.glEmojiTag('speech_balloon');
+ this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji);
this.emojiMenu = new EmojiMenuInModal(
Emoji,
@@ -76,6 +102,7 @@ export default {
this.setEmoji,
this.$refs.userStatusForm,
);
+ this.setDefaultEmoji();
})
.catch(() => createFlash(__('Failed to load emoji list.')));
},
@@ -94,7 +121,7 @@ export default {
},
setDefaultEmoji() {
const { emojiTag } = this;
- const hasStatusMessage = this.message;
+ const hasStatusMessage = Boolean(this.message.length);
if (hasStatusMessage && emojiTag) {
return;
}
@@ -126,20 +153,26 @@ export default {
this.hideEmojiMenu();
},
removeStatus() {
+ this.availability = false;
this.clearStatusInputs();
this.setStatus();
},
setStatus() {
- const { emoji, message } = this;
+ const { emoji, message, availability } = this;
Api.postUserStatus({
emoji,
message,
+ availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET,
})
.then(this.onUpdateSuccess)
.catch(this.onUpdateFail);
},
onUpdateSuccess() {
+ this.$toast.show(s__('SetStatusModal|Status updated'), {
+ type: 'success',
+ position: 'top-center',
+ });
this.closeModal();
window.location.reload();
},
@@ -175,11 +208,11 @@ export default {
name="user[status][emoji]"
/>
<div ref="userStatusForm" class="form-group position-relative m-0">
- <div class="input-group">
+ <div class="input-group gl-mb-5">
<span class="input-group-prepend">
<button
ref="toggleEmojiMenuButton"
- v-gl-tooltip.bottom
+ v-gl-tooltip.bottom.hover
:title="s__('SetStatusModal|Add status emoji')"
:aria-label="s__('SetStatusModal|Add status emoji')"
name="button"
@@ -223,6 +256,22 @@ export default {
</button>
</span>
</div>
+ <div v-if="canSetUserAvailability" class="form-group">
+ <div class="gl-display-flex">
+ <gl-form-checkbox
+ v-model="availability"
+ data-testid="user-availability-checkbox"
+ class="gl-mb-0"
+ >
+ <span class="gl-font-weight-bold">{{ s__('SetStatusModal|Busy') }}</span>
+ </gl-form-checkbox>
+ </div>
+ <div class="gl-display-flex">
+ <span class="gl-text-gray-600 gl-ml-5">
+ {{ s__('SetStatusModal|"Busy" will be shown next to your name') }}
+ </span>
+ </div>
+ </div>
</div>
</div>
</gl-modal>
diff --git a/app/assets/javascripts/set_status_modal/utils.js b/app/assets/javascripts/set_status_modal/utils.js
new file mode 100644
index 00000000000..dccb66be11f
--- /dev/null
+++ b/app/assets/javascripts/set_status_modal/utils.js
@@ -0,0 +1,9 @@
+export const AVAILABILITY_STATUS = {
+ BUSY: 'busy',
+ NOT_SET: 'not_set',
+};
+
+export const isUserBusy = status => status === AVAILABILITY_STATUS.BUSY;
+
+export const isValidAvailibility = availability =>
+ availability.length ? Object.values(AVAILABILITY_STATUS).includes(availability) : true;
diff --git a/app/assets/javascripts/shared/milestones/form.js b/app/assets/javascripts/shared/milestones/form.js
index 0ff84dc4667..9ee02f923d5 100644
--- a/app/assets/javascripts/shared/milestones/form.js
+++ b/app/assets/javascripts/shared/milestones/form.js
@@ -16,5 +16,6 @@ export default (initGFM = true) => {
milestones: initGFM,
labels: initGFM,
snippets: initGFM,
+ vulnerabilities: initGFM,
});
};
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
index 052bb3dcb53..00f1339d7f2 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
@@ -22,7 +22,9 @@ export default {
return sprintf(__("%{userName}'s avatar"), { userName: this.user.name });
},
avatarUrl() {
- return this.user.avatar || this.user.avatar_url || gon.default_avatar_url;
+ return (
+ this.user.avatarUrl || this.user.avatar || this.user.avatar_url || gon.default_avatar_url
+ );
},
isMergeRequest() {
return this.issuableType === 'merge_request';
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
index 878b331fb3c..fbbe2e341a7 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
@@ -16,10 +16,6 @@ export default {
type: Object,
required: true,
},
- rootPath: {
- type: String,
- required: true,
- },
tooltipPlacement: {
type: String,
default: 'bottom',
@@ -76,7 +72,7 @@ export default {
<!-- use d-flex so that slot can be appropriately styled -->
<span class="d-flex">
<assignee-avatar :user="user" :img-size="32" :issuable-type="issuableType" />
- <slot :user="user"></slot>
+ <slot></slot>
</span>
</gl-link>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
index 20dc7cb07e7..5f8ba844218 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
@@ -29,7 +29,8 @@ export default {
},
changing: {
type: Boolean,
- required: true,
+ required: false,
+ default: false,
},
},
computed: {
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index 4697d85472b..cf6a0a4a151 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -26,7 +26,6 @@ export default {
<template>
<div class="gl-display-flex gl-flex-direction-column">
- <label data-testid="assigneeLabel">{{ assigneesText }}</label>
<div v-if="emptyUsers" data-testid="none">
<span>
{{ __('None') }}
diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
index 95934c0ef2a..31d5d7c0077 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -13,10 +13,6 @@ export default {
type: Array,
required: true,
},
- rootPath: {
- type: String,
- required: true,
- },
issuableType: {
type: String,
required: false,
@@ -66,22 +62,20 @@ export default {
<template>
<assignee-avatar-link
v-if="hasOneUser"
- #default="{ user }"
tooltip-placement="left"
:tooltip-has-name="false"
:user="firstUser"
- :root-path="rootPath"
:issuable-type="issuableType"
>
<div class="ml-2 gl-line-height-normal">
- <div>{{ user.name }}</div>
+ <div>{{ firstUser.name }}</div>
<div>{{ username }}</div>
</div>
</assignee-avatar-link>
<div v-else>
<div class="user-list">
<div v-for="user in uncollapsedUsers" :key="user.id" class="user-item">
- <assignee-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType" />
+ <assignee-avatar-link :user="user" :issuable-type="issuableType" />
</div>
</div>
<div v-if="renderShowMoreSection" class="user-list-more">
diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
index 1af1bc18e3e..1785174e8d7 100644
--- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
+++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
@@ -1,11 +1,26 @@
<script>
import $ from 'jquery';
-import { difference, union } from 'lodash';
-import flash from '~/flash';
-import axios from '~/lib/utils/axios_utils';
+import { camelCase, difference, union } from 'lodash';
+import updateIssueLabelsMutation from '~/boards/queries/issue_set_labels.mutation.graphql';
+import createFlash from '~/flash';
+import { IssuableType } from '~/issue_show/constants';
import { __ } from '~/locale';
+import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql';
+import { toLabelGid } from '~/sidebar/utils';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
+import { getIdFromGraphQLId, MutationOperationMode } from '~/graphql_shared/utils';
+
+const mutationMap = {
+ [IssuableType.Issue]: {
+ mutation: updateIssueLabelsMutation,
+ mutationName: 'updateIssue',
+ },
+ [IssuableType.MergeRequest]: {
+ mutation: updateMergeRequestLabelsMutation,
+ mutationName: 'mergeRequestSetLabels',
+ },
+};
export default {
components: {
@@ -21,7 +36,6 @@ export default {
'issuableType',
'labelsFetchPath',
'labelsManagePath',
- 'labelsUpdatePath',
'projectIssuesPath',
'projectPath',
],
@@ -35,37 +49,79 @@ export default {
handleDropdownClose() {
$(this.$el).trigger('hidden.gl.dropdown');
},
- handleUpdateSelectedLabels(dropdownLabels) {
+ getUpdateVariables(dropdownLabels) {
const currentLabelIds = this.selectedLabels.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 labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds);
- this.updateSelectedLabels(labelIds);
+ switch (this.issuableType) {
+ case IssuableType.Issue:
+ return {
+ addLabelIds: userAddedLabelIds,
+ iid: this.iid,
+ projectPath: this.projectPath,
+ removeLabelIds: userRemovedLabelIds,
+ };
+ case IssuableType.MergeRequest:
+ return {
+ iid: this.iid,
+ labelIds: labelIds.map(toLabelGid),
+ operationMode: MutationOperationMode.Replace,
+ projectPath: this.projectPath,
+ };
+ default:
+ return {};
+ }
+ },
+ handleUpdateSelectedLabels(dropdownLabels) {
+ this.updateSelectedLabels(this.getUpdateVariables(dropdownLabels));
+ },
+ getRemoveVariables(labelId) {
+ switch (this.issuableType) {
+ case IssuableType.Issue:
+ return {
+ iid: this.iid,
+ projectPath: this.projectPath,
+ removeLabelIds: [labelId],
+ };
+ case IssuableType.MergeRequest:
+ return {
+ iid: this.iid,
+ labelIds: [toLabelGid(labelId)],
+ operationMode: MutationOperationMode.Remove,
+ projectPath: this.projectPath,
+ };
+ default:
+ return {};
+ }
},
handleLabelRemove(labelId) {
- const currentLabelIds = this.selectedLabels.map(label => label.id);
- const labelIds = difference(currentLabelIds, [labelId]);
-
- this.updateSelectedLabels(labelIds);
+ this.updateSelectedLabels(this.getRemoveVariables(labelId));
},
- updateSelectedLabels(labelIds) {
+ updateSelectedLabels(inputVariables) {
this.isLabelsSelectInProgress = true;
- axios({
- data: {
- [this.issuableType]: {
- label_ids: labelIds,
- },
- },
- method: 'put',
- url: this.labelsUpdatePath,
- })
+ this.$apollo
+ .mutate({
+ mutation: mutationMap[this.issuableType].mutation,
+ variables: { input: inputVariables },
+ })
.then(({ data }) => {
- this.selectedLabels = data.labels;
+ const { mutationName } = mutationMap[this.issuableType];
+
+ if (data[mutationName]?.errors?.length) {
+ throw new Error();
+ }
+
+ const issuableType = camelCase(this.issuableType);
+ this.selectedLabels = data[mutationName]?.[issuableType]?.labels?.nodes?.map(label => ({
+ ...label,
+ id: getIdFromGraphQLId(label.id),
+ }));
})
- .catch(() => flash(__('An error occurred while updating labels.')))
+ .catch(() => createFlash({ message: __('An error occurred while updating labels.') }))
.finally(() => {
this.isLabelsSelectInProgress = false;
});
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
index 0457aad8795..6e004084077 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
@@ -1,9 +1,8 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import Tracking from '~/tracking';
import toggleButton from '~/vue_shared/components/toggle_button.vue';
-import tooltip from '~/vue_shared/directives/tooltip';
import eventHub from '../../event_hub';
const ICON_ON = 'notifications';
@@ -13,7 +12,7 @@ const LABEL_OFF = __('Notifications off');
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
@@ -110,12 +109,9 @@ export default {
<div>
<span
ref="tooltip"
- v-tooltip
- class="sidebar-collapsed-icon"
+ v-gl-tooltip.viewport.left
:title="notificationTooltip"
- data-container="body"
- data-placement="left"
- data-boundary="viewport"
+ class="sidebar-collapsed-icon"
@click="onClickCollapsedIcon"
>
<gl-icon
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 9d72bf4394e..7b67c34ded6 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
@@ -96,7 +96,12 @@ export default {
</script>
<template>
- <div v-gl-tooltip:body.viewport.left :title="tooltipText" class="sidebar-collapsed-icon">
+ <div
+ v-gl-tooltip:body.viewport.left
+ :title="tooltipText"
+ data-testid="collapsedState"
+ 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 d4cc98e3743..99302993b9a 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
@@ -70,14 +70,19 @@ export default {
</script>
<template>
- <div class="time-tracking-comparison-pane">
+ <div data-testid="timeTrackingComparisonPane">
<div
v-gl-tooltip
+ data-testid="compareMeter"
:title="timeRemainingTooltip"
:class="timeRemainingStatusClass"
class="compare-meter"
>
- <gl-progress-bar :value="timeRemainingPercent" :variant="progressBarVariant" />
+ <gl-progress-bar
+ data-testid="timeRemainingProgress"
+ :value="timeRemainingPercent"
+ :variant="progressBarVariant"
+ />
<div class="compare-display-container">
<div class="compare-display float-left">
<span class="compare-label">{{ s__('TimeTracking|Spent') }}</span>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue
index 305726d9725..8a80b1bf13f 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue
@@ -11,7 +11,8 @@ export default {
</script>
<template>
- <div class="time-tracking-estimate-only-pane">
- <span class="bold"> {{ s__('TimeTracking|Estimated:') }} </span> {{ timeEstimateHumanReadable }}
+ <div data-testid="estimateOnlyPane">
+ <span class="gl-font-weight-bold">{{ s__('TimeTracking|Estimated:') }} </span
+ >{{ timeEstimateHumanReadable }}
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
index b45746e789d..8bc828091c0 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue
@@ -34,7 +34,7 @@ export default {
</script>
<template>
- <div class="time-tracking-help-state">
+ <div data-testid="helpPane" class="time-tracking-help-state">
<div class="time-tracking-info">
<h4>{{ __('Track time with quick actions') }}</h4>
<p>{{ __('Quick actions can be used in the issues description and comment boxes.') }}</p>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.vue
index 45552589e50..2d3d0ce8dc5 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.vue
@@ -5,7 +5,7 @@ export default {
</script>
<template>
- <div class="time-tracking-no-tracking-pane">
- <span class="no-value"> {{ __('No estimate or time spent') }} </span>
+ <div data-testid="noTrackingPane">
+ <span class="no-value">{{ __('No estimate or time spent') }}</span>
</div>
</template>
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 406677941b7..6bef5ed67a4 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
@@ -57,7 +57,6 @@ export default {
:human-time-estimate="store.humanTimeEstimate"
:human-time-spent="store.humanTotalTimeSpent"
:limit-to-hours="store.timeTrackingLimitToHours"
- :root-path="store.rootPath"
/>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
index b2b3b289c5c..33c6ac6e2ba 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
@@ -15,7 +15,7 @@ export default {
return sprintf(
s__('TimeTracking|%{startTag}Spent: %{endTag}%{timeSpentHumanReadable}'),
{
- startTag: '<span class="bold">',
+ startTag: '<span class="gl-font-weight-bold">',
endTag: '</span>',
timeSpentHumanReadable: this.timeSpentHumanReadable,
},
@@ -27,5 +27,5 @@ export default {
</script>
<template>
- <div class="time-tracking-spend-only-pane" v-html="timeSpent"></div>
+ <div data-testid="spentOnlyPane" v-html="timeSpent"></div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index a2fb0ebcbc6..3199ed1e615 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -44,6 +44,21 @@ export default {
default: false,
required: false,
},
+ /*
+ In issue list, "time-tracking-collapsed-state" is always rendered even if the sidebar isn't collapsed.
+ The actual hiding is controlled with css classes:
+ Hide "time-tracking-collapsed-state"
+ if .right-sidebar .right-sidebar-collapsed .sidebar-collapsed-icon
+ Show "time-tracking-collapsed-state"
+ if .right-sidebar .right-sidebar-expanded .sidebar-collapsed-icon
+
+ In Swimlanes sidebar, we do not use collapsed state at all.
+ */
+ showCollapsed: {
+ type: Boolean,
+ default: true,
+ required: false,
+ },
},
data() {
return {
@@ -93,8 +108,9 @@ export default {
</script>
<template>
- <div v-cloak class="time_tracker time-tracking-component-wrap">
+ <div v-cloak class="time-tracker time-tracking-component-wrap" data-testid="time-tracker">
<time-tracking-collapsed-state
+ v-if="showCollapsed"
:show-comparison-state="showComparisonState"
:show-no-time-tracking-state="showNoTimeTrackingState"
:show-help-state="showHelpState"
@@ -103,13 +119,19 @@ export default {
:time-spent-human-readable="humanTimeSpent"
:time-estimate-human-readable="humanTimeEstimate"
/>
- <div class="title hide-collapsed">
+ <div class="title hide-collapsed gl-mb-3">
{{ __('Time tracking') }}
- <div v-if="!showHelpState" class="help-button float-right" @click="toggleHelpState(true)">
+ <div
+ v-if="!showHelpState"
+ data-testid="helpButton"
+ class="help-button float-right"
+ @click="toggleHelpState(true)"
+ >
<gl-icon name="question-o" />
</div>
<div
- v-if="showHelpState"
+ v-else
+ data-testid="closeHelpButton"
class="close-help-button float-right"
@click="toggleHelpState(false)"
>
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 00b4e2de5e5..984cd8a3b1d 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -91,8 +91,13 @@ export function mountSidebarLabels() {
return false;
}
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
return new Vue({
el,
+ apolloProvider,
provide: {
...el.dataset,
allowLabelCreate: parseBoolean(el.dataset.allowLabelCreate),
diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql
new file mode 100644
index 00000000000..3c09daad793
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql
@@ -0,0 +1,15 @@
+mutation mergeRequestSetLabels($input: MergeRequestSetLabelsInput!) {
+ mergeRequestSetLabels(input: $input) {
+ errors
+ mergeRequest {
+ labels {
+ nodes {
+ color
+ description
+ id
+ title
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/utils.js b/app/assets/javascripts/sidebar/utils.js
new file mode 100644
index 00000000000..23730508b56
--- /dev/null
+++ b/app/assets/javascripts/sidebar/utils.js
@@ -0,0 +1 @@
+export const toLabelGid = id => `gid://gitlab/Label/${id}`;
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index 5fa6cef7195..3492f19c996 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -7,11 +7,14 @@ import { deprecatedCreateFlash as createFlash } from './flash';
import FilesCommentButton from './files_comment_button';
import initImageDiffHelper from './image_diff/helpers/init_image_diff';
import syntaxHighlight from './syntax_highlight';
+import { spriteIcon } from '~/lib/utils/common_utils';
const WRAPPER = '<div class="diff-content"></div>';
const LOADING_HTML = '<span class="spinner"></span>';
-const ERROR_HTML =
- '<div class="nothing-here-block"><i class="fa fa-warning"></i> Could not load diff</div>';
+const ERROR_HTML = `<div class="nothing-here-block">${spriteIcon(
+ 'warning-solid',
+ 's16',
+)} Could not load diff</div>`;
const COLLAPSED_HTML =
'<div class="nothing-here-block diff-collapsed">This diff is collapsed. <button class="click-to-expand btn btn-link">Click to expand it.</button></div>';
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index dd77d49803f..08683f25651 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -9,19 +9,14 @@ import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.
import {
SNIPPET_MARK_EDIT_APP_START,
SNIPPET_MEASURE_BLOBS_CONTENT,
-} from '~/performance_constants';
+} from '~/performance/constants';
import eventHub from '~/blob/components/eventhub';
-import { performanceMarkAndMeasure } from '~/performance_utils';
+import { performanceMarkAndMeasure } from '~/performance/utils';
import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql';
import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql';
import { getSnippetMixin } from '../mixins/snippets';
-import {
- SNIPPET_CREATE_MUTATION_ERROR,
- SNIPPET_UPDATE_MUTATION_ERROR,
- SNIPPET_VISIBILITY_PRIVATE,
-} from '../constants';
-import defaultVisibilityQuery from '../queries/snippet_visibility.query.graphql';
+import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '../constants';
import { markBlobPerformance } from '../utils/blob';
import SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue';
@@ -41,15 +36,7 @@ export default {
GlLoadingIcon,
},
mixins: [getSnippetMixin],
- apollo: {
- defaultVisibility: {
- query: defaultVisibilityQuery,
- manual: true,
- result({ data: { selectedLevel } }) {
- this.selectedLevelDefault = selectedLevel;
- },
- },
- },
+ inject: ['selectedLevel'],
props: {
markdownPreviewPath: {
type: String,
@@ -73,9 +60,12 @@ export default {
data() {
return {
isUpdating: false,
- newSnippet: false,
actions: [],
- selectedLevelDefault: SNIPPET_VISIBILITY_PRIVATE,
+ snippet: {
+ title: '',
+ description: '',
+ visibilityLevel: this.selectedLevel,
+ },
};
},
computed: {
@@ -112,13 +102,6 @@ export default {
}
return this.snippet.webUrl;
},
- newSnippetSchema() {
- return {
- title: '',
- description: '',
- visibilityLevel: this.selectedLevelDefault,
- };
- },
},
beforeCreate() {
performanceMarkAndMeasure({ mark: SNIPPET_MARK_EDIT_APP_START });
@@ -145,20 +128,6 @@ export default {
Flash(sprintf(defaultErrorMsg, { err }));
this.isUpdating = false;
},
- onNewSnippetFetched() {
- this.newSnippet = true;
- this.snippet = this.newSnippetSchema;
- },
- onExistingSnippetFetched() {
- this.newSnippet = false;
- },
- onSnippetFetch(snippetRes) {
- if (snippetRes.data.snippets.nodes.length === 0) {
- this.onNewSnippetFetched();
- } else {
- this.onExistingSnippetFetched();
- }
- },
getAttachedFiles() {
const fileInputs = Array.from(this.$el.querySelectorAll('[name="files[]"]'));
return fileInputs.map(node => node.value);
@@ -209,7 +178,7 @@ export default {
</script>
<template>
<form
- class="snippet-form js-requires-input js-quick-submit common-note-form"
+ class="snippet-form js-quick-submit common-note-form"
:data-snippet-type="isProjectSnippet ? 'project' : 'personal'"
data-testid="snippet-edit-form"
@submit.prevent="handleFormSubmit"
diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue
index 4a2f060ff7c..a3e5535c5fa 100644
--- a/app/assets/javascripts/snippets/components/show.vue
+++ b/app/assets/javascripts/snippets/components/show.vue
@@ -9,8 +9,8 @@ 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';
+} from '~/performance/constants';
+import { performanceMarkAndMeasure } from '~/performance/utils';
import eventHub from '~/blob/components/eventhub';
import { getSnippetMixin } from '../mixins/snippets';
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
index e88126ea56a..b965c15306d 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
@@ -1,9 +1,9 @@
<script>
+import GetBlobContent from 'shared_queries/snippet/snippet_blob_content.query.graphql';
+
import BlobHeader from '~/blob/components/blob_header.vue';
import BlobContent from '~/blob/components/blob_content.vue';
-import GetBlobContent from '../queries/snippet.blob.content.query.graphql';
-
import {
SIMPLE_BLOB_VIEWER,
RICH_BLOB_VIEWER,
@@ -21,7 +21,7 @@ export default {
query: GetBlobContent,
variables() {
return {
- ids: this.snippet.id,
+ ids: [this.snippet.id],
rich: this.activeViewerType === RICH_BLOB_VIEWER,
paths: [this.blob.path],
};
@@ -51,6 +51,13 @@ export default {
required: true,
},
},
+ provide() {
+ return {
+ blobHash: Math.random()
+ .toString()
+ .split('.')[1],
+ };
+ },
data() {
return {
blobContent: '',
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
index 30de5a9d0e0..32c4c1039f5 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -6,17 +6,17 @@ import {
GlModal,
GlAlert,
GlLoadingIcon,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownItem,
GlButton,
GlTooltipDirective,
} from '@gitlab/ui';
+import CanCreatePersonalSnippet from 'shared_queries/snippet/user_permissions.query.graphql';
+import CanCreateProjectSnippet from 'shared_queries/snippet/project_permissions.query.graphql';
import { __ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
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';
@@ -28,8 +28,8 @@ export default {
GlModal,
GlAlert,
GlLoadingIcon,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownItem,
TimeAgoTooltip,
GlButton,
},
@@ -120,7 +120,7 @@ export default {
? __('The snippet is visible only to project members.')
: __('The snippet is visible only to me.');
case 'internal':
- return __('The snippet is visible to any logged in user.');
+ return __('The snippet is visible to any logged in user except external users.');
default:
return __('The snippet can be accessed without any authentication.');
}
@@ -231,17 +231,17 @@ export default {
</template>
</div>
<div class="d-block d-sm-none dropdown">
- <gl-deprecated-dropdown :text="__('Options')" class="w-100" toggle-class="text-center">
- <gl-deprecated-dropdown-item
+ <gl-dropdown :text="__('Options')" block>
+ <gl-dropdown-item
v-for="(action, index) in personalSnippetActions"
:key="index"
:disabled="action.disabled"
:title="action.title"
:href="action.href"
@click="action.click ? action.click() : undefined"
- >{{ action.text }}</gl-deprecated-dropdown-item
+ >{{ action.text }}</gl-dropdown-item
>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
</div>
</div>
diff --git a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
index 25ad7c214b2..ee5076835ab 100644
--- a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
@@ -1,6 +1,5 @@
<script>
import { GlIcon, GlFormGroup, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui';
-import defaultVisibilityQuery from '../queries/snippet_visibility.query.graphql';
import { defaultSnippetVisibilityLevels } from '../utils/blob';
import { SNIPPET_LEVELS_RESTRICTED, SNIPPET_LEVELS_DISABLED } from '~/snippets/constants';
@@ -12,16 +11,7 @@ export default {
GlFormRadioGroup,
GlLink,
},
- apollo: {
- defaultVisibility: {
- query: defaultVisibilityQuery,
- manual: true,
- result({ data: { visibilityLevels, multipleLevelsRestricted } }) {
- this.visibilityLevels = defaultSnippetVisibilityLevels(visibilityLevels);
- this.multipleLevelsRestricted = multipleLevelsRestricted;
- },
- },
- },
+ inject: ['visibilityLevels', 'multipleLevelsRestricted'],
props: {
helpLink: {
type: String,
@@ -38,11 +28,10 @@ export default {
required: true,
},
},
- data() {
- return {
- visibilityLevels: [],
- multipleLevelsRestricted: false,
- };
+ computed: {
+ defaultVisibilityLevels() {
+ return defaultSnippetVisibilityLevels(this.visibilityLevels);
+ },
},
SNIPPET_LEVELS_DISABLED,
SNIPPET_LEVELS_RESTRICTED,
@@ -59,7 +48,7 @@ export default {
<gl-form-group id="visibility-level-setting" class="gl-mb-0">
<gl-form-radio-group :checked="value" stacked v-bind="$attrs" v-on="$listeners">
<gl-form-radio
- v-for="option in visibilityLevels"
+ v-for="option in defaultVisibilityLevels"
:key="option.value"
:value="option.value"
class="mb-3"
@@ -78,7 +67,9 @@ export default {
</gl-form-group>
<div class="text-muted" data-testid="restricted-levels-info">
- <template v-if="!visibilityLevels.length">{{ $options.SNIPPET_LEVELS_DISABLED }}</template>
+ <template v-if="!defaultVisibilityLevels.length">{{
+ $options.SNIPPET_LEVELS_DISABLED
+ }}</template>
<template v-else-if="multipleLevelsRestricted">{{
$options.SNIPPET_LEVELS_RESTRICTED
}}</template>
diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js
index e75922df15f..2a9ecbc27dc 100644
--- a/app/assets/javascripts/snippets/constants.js
+++ b/app/assets/javascripts/snippets/constants.js
@@ -14,7 +14,7 @@ export const SNIPPET_VISIBILITY = {
[SNIPPET_VISIBILITY_INTERNAL]: {
label: __('Internal'),
icon: 'shield',
- description: __('The snippet is visible to any logged in user.'),
+ description: __('The snippet is visible to any logged in user except external users.'),
},
[SNIPPET_VISIBILITY_PUBLIC]: {
label: __('Public'),
diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js
index b55e1baf41e..853ccb0c2fe 100644
--- a/app/assets/javascripts/snippets/index.js
+++ b/app/assets/javascripts/snippets/index.js
@@ -24,17 +24,14 @@ export default function appFactory(el, Component) {
...restDataset
} = el.dataset;
- apolloProvider.clients.defaultClient.cache.writeData({
- data: {
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: {
visibilityLevels: JSON.parse(visibilityLevels),
selectedLevel: SNIPPET_LEVELS_MAP[selectedLevel] ?? SNIPPET_VISIBILITY_PRIVATE,
multipleLevelsRestricted: 'multipleLevelsRestricted' in el.dataset,
},
- });
-
- return new Vue({
- el,
- apolloProvider,
render(createElement) {
return createElement(Component, {
props: {
diff --git a/app/assets/javascripts/snippets/mixins/snippets.js b/app/assets/javascripts/snippets/mixins/snippets.js
index d5e69e2a889..5844a55e4f5 100644
--- a/app/assets/javascripts/snippets/mixins/snippets.js
+++ b/app/assets/javascripts/snippets/mixins/snippets.js
@@ -1,4 +1,4 @@
-import GetSnippetQuery from '../queries/snippet.query.graphql';
+import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql';
const blobsDefault = [];
@@ -8,7 +8,7 @@ export const getSnippetMixin = {
query: GetSnippetQuery,
variables() {
return {
- ids: this.snippetGid,
+ ids: [this.snippetGid],
};
},
update: data => {
@@ -21,9 +21,9 @@ export const getSnippetMixin = {
},
result(res) {
this.blobs = res.data.snippets.nodes[0]?.blobs || blobsDefault;
- if (this.onSnippetFetch) {
- this.onSnippetFetch(res);
- }
+ },
+ skip() {
+ return this.newSnippet;
},
},
},
@@ -36,7 +36,7 @@ export const getSnippetMixin = {
data() {
return {
snippet: {},
- newSnippet: false,
+ newSnippet: !this.snippetGid,
blobs: blobsDefault,
};
},
diff --git a/app/assets/javascripts/snippets/queries/snippet.query.graphql b/app/assets/javascripts/snippets/queries/snippet.query.graphql
deleted file mode 100644
index 2f385050d89..00000000000
--- a/app/assets/javascripts/snippets/queries/snippet.query.graphql
+++ /dev/null
@@ -1,15 +0,0 @@
-#import '../fragments/snippetBase.fragment.graphql'
-#import '../fragments/project.fragment.graphql'
-#import "~/graphql_shared/fragments/author.fragment.graphql"
-
-query GetSnippetQuery($ids: [ID!]) {
- snippets(ids: $ids) {
- nodes {
- ...SnippetBase
- ...SnippetProject
- author {
- ...Author
- }
- }
- }
-}
diff --git a/app/assets/javascripts/snippets/queries/snippet_visibility.query.graphql b/app/assets/javascripts/snippets/queries/snippet_visibility.query.graphql
deleted file mode 100644
index 5bd6c131bab..00000000000
--- a/app/assets/javascripts/snippets/queries/snippet_visibility.query.graphql
+++ /dev/null
@@ -1,5 +0,0 @@
-query defaultSnippetVisibility {
- visibilityLevels @client
- selectedLevel @client
- multipleLevelsRestricted @client
-}
diff --git a/app/assets/javascripts/snippets/utils/blob.js b/app/assets/javascripts/snippets/utils/blob.js
index c47559b82b8..5081c648e36 100644
--- a/app/assets/javascripts/snippets/utils/blob.js
+++ b/app/assets/javascripts/snippets/utils/blob.js
@@ -7,8 +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';
+import { performanceMarkAndMeasure } from '~/performance/utils';
+import { SNIPPET_MARK_BLOBS_CONTENT, SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance/constants';
const createLocalId = () => uniqueId('blob_local_');
diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue
index e602f26acdf..69eabfe5339 100644
--- a/app/assets/javascripts/static_site_editor/components/edit_area.vue
+++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue
@@ -6,10 +6,10 @@ import EditDrawer from './edit_drawer.vue';
import UnsavedChangesConfirmDialog from './unsaved_changes_confirm_dialog.vue';
import parseSourceFile from '~/static_site_editor/services/parse_source_file';
import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants';
-import { DEFAULT_IMAGE_UPLOAD_PATH } from '../constants';
import imageRepository from '../image_repository';
import formatter from '../services/formatter';
import templater from '../services/templater';
+import renderImage from '../services/renderers/render_image';
export default {
components: {
@@ -37,21 +37,35 @@ export default {
required: false,
default: '',
},
+ branch: {
+ type: String,
+ required: true,
+ },
+ baseUrl: {
+ type: String,
+ required: true,
+ },
+ mounts: {
+ type: Array,
+ required: true,
+ },
+ project: {
+ type: String,
+ required: true,
+ },
imageRoot: {
type: String,
- required: false,
- default: DEFAULT_IMAGE_UPLOAD_PATH,
- validator: prop => prop.endsWith('/'),
+ required: true,
},
},
data() {
return {
- saveable: false,
parsedSource: parseSourceFile(this.preProcess(true, this.content)),
editorMode: EDITOR_TYPES.wysiwyg,
- isModified: false,
hasMatter: false,
isDrawerOpen: false,
+ isModified: false,
+ isSaveable: false,
};
},
imageRepository: imageRepository(),
@@ -68,6 +82,18 @@ export default {
isWysiwygMode() {
return this.editorMode === EDITOR_TYPES.wysiwyg;
},
+ customRenderers() {
+ const imageRenderer = renderImage.build(
+ this.mounts,
+ this.project,
+ this.branch,
+ this.baseUrl,
+ this.$options.imageRepository,
+ );
+ return {
+ image: [imageRenderer],
+ };
+ },
},
created() {
this.refreshEditHelpers();
@@ -81,8 +107,11 @@ export default {
return templatedContent;
},
refreshEditHelpers() {
- this.isModified = this.parsedSource.isModified();
- this.hasMatter = this.parsedSource.hasMatter();
+ const { isModified, hasMatter, isMatterValid } = this.parsedSource;
+ this.isModified = isModified();
+ this.hasMatter = hasMatter();
+ const hasValidMatter = this.hasMatter ? isMatterValid() : true;
+ this.isSaveable = this.isModified && hasValidMatter;
},
onDrawerOpen() {
this.isDrawerOpen = true;
@@ -133,17 +162,18 @@ export default {
:content="editableContent"
:initial-edit-type="editorMode"
:image-root="imageRoot"
+ :options="{ customRenderers }"
class="mb-9 pb-6 h-100"
@modeChange="onModeChange"
@input="onInputChange"
@uploadImage="onUploadImage"
/>
- <unsaved-changes-confirm-dialog :modified="isModified" />
+ <unsaved-changes-confirm-dialog :modified="isSaveable" />
<publish-toolbar
class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full"
:has-settings="hasSettings"
:return-url="returnUrl"
- :saveable="isModified"
+ :saveable="isSaveable"
:saving-changes="savingChanges"
@editSettings="onDrawerOpen"
@submit="onSubmit"
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
index 9f75c65a316..c6247632b6e 100644
--- a/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue
+++ b/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue
@@ -1,9 +1,21 @@
<script>
-import { GlForm, GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui';
-import AccessorUtilities from '~/lib/utils/accessor';
+import {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+} from '@gitlab/ui';
+
+import { __ } from '~/locale';
export default {
components: {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
GlForm,
GlFormGroup,
GlFormInput,
@@ -18,56 +30,47 @@ export default {
type: String,
required: true,
},
- },
- data() {
- return {
- editable: {
- title: this.title,
- description: this.description,
- },
- };
+ templates: {
+ type: Array,
+ required: false,
+ default: null,
+ },
+ currentTemplate: {
+ type: Object,
+ required: false,
+ default: null,
+ },
},
computed: {
- editableStorageKey() {
- return this.getId('local-storage', 'editable');
+ dropdownLabel() {
+ return this.currentTemplate ? this.currentTemplate.name : __('None');
},
- hasLocalStorage() {
- return AccessorUtilities.isLocalStorageAccessSafe();
+ hasTemplates() {
+ return this.templates?.length > 0;
},
},
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);
- }
+ onChangeTemplate(template) {
+ this.$emit('changeTemplate', template || null);
},
- onUpdate() {
- const payload = { ...this.editable };
+ onUpdate(field, value) {
+ const payload = {
+ title: this.title,
+ description: this.description,
+ [field]: value,
+ };
this.$emit('updateSettings', payload);
-
- if (this.hasLocalStorage) {
- window.localStorage.setItem(this.editableStorageKey, JSON.stringify(payload));
- }
},
},
};
@@ -83,21 +86,44 @@ export default {
<gl-form-input
:id="getId('control', 'title')"
ref="title"
- v-model.lazy="editable.title"
+ :value="title"
type="text"
- @input="onUpdate"
+ @input="onUpdate('title', $event)"
/>
</gl-form-group>
<gl-form-group
+ v-if="hasTemplates"
+ key="template"
+ :label="__('Description template')"
+ :label-for="getId('control', 'template')"
+ >
+ <gl-dropdown :text="dropdownLabel">
+ <gl-dropdown-item key="none" @click="onChangeTemplate(null)">
+ {{ __('None') }}
+ </gl-dropdown-item>
+
+ <gl-dropdown-divider />
+
+ <gl-dropdown-item
+ v-for="template in templates"
+ :key="template.key"
+ @click="onChangeTemplate(template)"
+ >
+ {{ template.name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </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"
+ :value="description"
+ @input="onUpdate('description', $event)"
/>
</gl-form-group>
</gl-form>
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
index 4e5245bd892..f583d2049af 100644
--- a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue
+++ b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue
@@ -1,22 +1,38 @@
<script>
import { GlModal } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
+import Api from '~/api';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import EditMetaControls from './edit_meta_controls.vue';
+import { ISSUABLE_TYPE, MR_META_LOCAL_STORAGE_KEY } from '../constants';
+
export default {
components: {
GlModal,
EditMetaControls,
+ LocalStorageSync,
},
props: {
sourcePath: {
type: String,
required: true,
},
+ namespace: {
+ type: String,
+ required: true,
+ },
+ project: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
+ clearStorage: false,
+ currentTemplate: null,
+ mergeRequestTemplates: null,
mergeRequestMeta: {
title: sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), {
sourcePath: this.sourcePath,
@@ -42,24 +58,42 @@ export default {
};
},
},
+ mounted() {
+ this.initTemplates();
+ },
methods: {
hide() {
this.$refs.modal.hide();
},
+ initTemplates() {
+ const { namespace, project } = this;
+ Api.issueTemplates(namespace, project, ISSUABLE_TYPE, (err, templates) => {
+ if (err) return; // Error handled by global AJAX error handler
+ this.mergeRequestTemplates = templates;
+ });
+ },
show() {
this.$refs.modal.show();
},
onPrimary() {
this.$emit('primary', this.mergeRequestMeta);
- this.$refs.editMetaControls.resetCachedEditable();
+ this.clearStorage = true;
},
onSecondary() {
this.hide();
},
+ onChangeTemplate(template) {
+ this.currentTemplate = template;
+
+ const description = this.currentTemplate ? this.currentTemplate.content : '';
+ const mergeRequestMeta = { ...this.mergeRequestMeta, description };
+ this.onUpdateSettings(mergeRequestMeta);
+ },
onUpdateSettings(mergeRequestMeta) {
this.mergeRequestMeta = { ...mergeRequestMeta };
},
},
+ storageKey: MR_META_LOCAL_STORAGE_KEY,
};
</script>
@@ -75,11 +109,20 @@ export default {
@secondary="onSecondary"
@hide="() => $emit('hide')"
>
+ <local-storage-sync
+ v-model="mergeRequestMeta"
+ :storage-key="$options.storageKey"
+ :clear="clearStorage"
+ as-json
+ />
<edit-meta-controls
ref="editMetaControls"
:title="mergeRequestMeta.title"
:description="mergeRequestMeta.description"
+ :templates="mergeRequestTemplates"
+ :current-template="currentTemplate"
@updateSettings="onUpdateSettings"
+ @changeTemplate="onChangeTemplate"
/>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js
index 49db9ab7ca5..faa4026c064 100644
--- a/app/assets/javascripts/static_site_editor/constants.js
+++ b/app/assets/javascripts/static_site_editor/constants.js
@@ -2,6 +2,7 @@ import { s__, __ } from '~/locale';
export const BRANCH_SUFFIX_COUNT = 8;
export const DEFAULT_TARGET_BRANCH = 'master';
+export const ISSUABLE_TYPE = 'merge_request';
export const SUBMIT_CHANGES_BRANCH_ERROR = s__('StaticSiteEditor|Branch could not be created.');
export const SUBMIT_CHANGES_COMMIT_ERROR = s__(
@@ -20,4 +21,4 @@ export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit';
export const TRACKING_ACTION_CREATE_MERGE_REQUEST = 'create_merge_request';
export const TRACKING_ACTION_INITIALIZE_EDITOR = 'initialize_editor';
-export const DEFAULT_IMAGE_UPLOAD_PATH = 'source/images/uploads/';
+export const MR_META_LOCAL_STORAGE_KEY = 'sse-merge-request-meta-storage-key';
diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js
index cc68bc57bb0..a13f7d3ad53 100644
--- a/app/assets/javascripts/static_site_editor/graphql/index.js
+++ b/app/assets/javascripts/static_site_editor/graphql/index.js
@@ -25,11 +25,15 @@ const createApolloProvider = appData => {
},
);
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ const mounts = appData.mounts.map(mount => ({ __typename: 'Mount', ...mount }));
+
defaultClient.cache.writeData({
data: {
appData: {
__typename: 'AppData',
...appData,
+ mounts,
},
},
});
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 9f4b0afe55f..e422a4b6036 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
@@ -6,5 +6,12 @@ query appData {
sourcePath
username
returnUrl
+ branch
+ baseUrl
+ mounts {
+ source
+ target
+ }
+ imageUploadPath
}
}
diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
index 0ded1722d26..00af6c10359 100644
--- a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
+++ b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
@@ -14,6 +14,11 @@ type SavedContentMeta {
branch: SavedContentField!
}
+type Mount {
+ source: String!
+ target: String
+}
+
type AppData {
isSupportedContent: Boolean!
hasSubmittedChanges: Boolean!
@@ -21,6 +26,10 @@ type AppData {
returnUrl: String
sourcePath: String!
username: String!
+ branch: String!
+ baseUrl: String!
+ mounts: [Mount]!
+ imageUploadPath: String!
}
input HasSubmittedChangesInput {
diff --git a/app/assets/javascripts/static_site_editor/image_repository.js b/app/assets/javascripts/static_site_editor/image_repository.js
index 02285ccdba3..b5ff4385d3c 100644
--- a/app/assets/javascripts/static_site_editor/image_repository.js
+++ b/app/assets/javascripts/static_site_editor/image_repository.js
@@ -12,9 +12,11 @@ const imageRepository = () => {
.catch(() => flash(__('Something went wrong while inserting your image. Please try again.')));
};
+ const get = path => images.get(path);
+
const getAll = () => images;
- return { add, getAll };
+ return { add, get, getAll };
};
export default imageRepository;
diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js
index fceef8f9084..b58564388de 100644
--- a/app/assets/javascripts/static_site_editor/index.js
+++ b/app/assets/javascripts/static_site_editor/index.js
@@ -9,6 +9,7 @@ const initStaticSiteEditor = el => {
isSupportedContent,
path: sourcePath,
baseUrl,
+ branch,
namespace,
project,
mergeRequestsIllustrationPath,
@@ -16,13 +17,9 @@ const initStaticSiteEditor = el => {
// 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);
@@ -30,9 +27,13 @@ const initStaticSiteEditor = el => {
isSupportedContent: parseBoolean(isSupportedContent),
hasSubmittedChanges: false,
project: `${namespace}/${project}`,
+ mounts: JSON.parse(mounts), // NOTE that the object in 'mounts' is a JSON string from the data attribute, so it must be parsed into an object.
+ branch,
+ baseUrl,
returnUrl,
sourcePath,
username,
+ imageUploadPath,
});
return new Vue({
diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue
index 27bd1c99ae2..68943113c14 100644
--- a/app/assets/javascripts/static_site_editor/pages/home.vue
+++ b/app/assets/javascripts/static_site_editor/pages/home.vue
@@ -64,6 +64,9 @@ export default {
isContentLoaded() {
return Boolean(this.sourceContent);
},
+ projectSplit() {
+ return this.appData.project.split('/'); // TODO: refactor so `namespace` and `project` remain distinct
+ },
},
mounted() {
Tracking.event(document.body.dataset.page, TRACKING_ACTION_INITIALIZE_EDITOR);
@@ -138,11 +141,18 @@ export default {
:content="sourceContent.content"
:saving-changes="isSavingChanges"
:return-url="appData.returnUrl"
+ :mounts="appData.mounts"
+ :branch="appData.branch"
+ :base-url="appData.baseUrl"
+ :project="appData.project"
+ :image-root="appData.imageUploadPath"
@submit="onPrepareSubmit"
/>
<edit-meta-modal
ref="editMetaModal"
:source-path="appData.sourcePath"
+ :namespace="projectSplit[0]"
+ :project="projectSplit[1]"
@primary="onSubmit"
@hide="onHideModal"
/>
diff --git a/app/assets/javascripts/static_site_editor/services/front_matterify.js b/app/assets/javascripts/static_site_editor/services/front_matterify.js
index cbf0fffd515..60a5d799d11 100644
--- a/app/assets/javascripts/static_site_editor/services/front_matterify.js
+++ b/app/assets/javascripts/static_site_editor/services/front_matterify.js
@@ -16,6 +16,7 @@ export const frontMatterify = source => {
const NO_FRONTMATTER = {
source,
matter: null,
+ hasMatter: false,
spacing: null,
content: source,
delimiter: null,
@@ -53,6 +54,7 @@ export const frontMatterify = source => {
return {
source,
matter,
+ hasMatter: true,
spacing,
content,
delimiter,
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 d4fc8b2edb6..39126eb7bcc 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,15 +1,18 @@
import { frontMatterify, stringify } from './front_matterify';
const parseSourceFile = raw => {
- const remake = source => frontMatterify(source);
-
- let editable = remake(raw);
+ let editable;
const syncContent = (newVal, isBody) => {
if (isBody) {
editable.content = newVal;
} else {
- editable = remake(newVal);
+ try {
+ editable = frontMatterify(newVal);
+ editable.isMatterValid = true;
+ } catch (e) {
+ editable.isMatterValid = false;
+ }
}
};
@@ -23,10 +26,15 @@ const parseSourceFile = raw => {
const isModified = () => stringify(editable) !== raw;
- const hasMatter = () => Boolean(editable.matter);
+ const hasMatter = () => editable.hasMatter;
+
+ const isMatterValid = () => editable.isMatterValid;
+
+ syncContent(raw);
return {
matter,
+ isMatterValid,
syncMatter,
content,
syncContent,
diff --git a/app/assets/javascripts/static_site_editor/services/renderers/render_image.js b/app/assets/javascripts/static_site_editor/services/renderers/render_image.js
new file mode 100644
index 00000000000..b0d863bdb5a
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/services/renderers/render_image.js
@@ -0,0 +1,89 @@
+import { isAbsolute, getBaseURL, joinPaths } from '~/lib/utils/url_utility';
+
+const canRender = ({ type }) => type === 'image';
+
+let metadata;
+
+const getCachedContent = basePath => metadata.imageRepository.get(basePath);
+
+const isRelativeToCurrentDirectory = basePath => !basePath.startsWith('/');
+
+const extractSourceDirectory = url => {
+ const sourceDir = /^(.+)\/([^/]+)$/.exec(url); // Extracts the base path and fileName from an image path
+ return sourceDir || [null, null, url]; // If no source directory was extracted it means only a fileName was specified (e.g. url='file.png')
+};
+
+const parseCurrentDirectory = basePath => {
+ const baseUrl = decodeURIComponent(metadata.baseUrl);
+ const sourceDirectory = extractSourceDirectory(baseUrl)[1];
+ const currentDirectory = sourceDirectory.split(`/-/sse/${metadata.branch}`)[1];
+
+ return joinPaths(currentDirectory, basePath);
+};
+
+// For more context around this logic, please see the following comment:
+// https://gitlab.com/gitlab-org/gitlab/-/issues/241166#note_409413500
+const generateSourceDirectory = basePath => {
+ let sourceDir = '';
+ let defaultSourceDir = '';
+
+ if (!basePath || isRelativeToCurrentDirectory(basePath)) {
+ return parseCurrentDirectory(basePath);
+ }
+
+ if (!metadata.mounts.length) {
+ return basePath;
+ }
+
+ metadata.mounts.forEach(({ source, target }) => {
+ const hasTarget = target !== '';
+
+ if (hasTarget && basePath.includes(target)) {
+ sourceDir = source;
+ } else if (!hasTarget) {
+ defaultSourceDir = joinPaths(source, basePath);
+ }
+ });
+
+ return sourceDir || defaultSourceDir;
+};
+
+const resolveFullPath = (originalSrc, cachedContent) => {
+ if (cachedContent) {
+ return `data:image;base64,${cachedContent}`;
+ }
+
+ if (isAbsolute(originalSrc)) {
+ return originalSrc;
+ }
+
+ const sourceDirectory = extractSourceDirectory(originalSrc);
+ const [, basePath, fileName] = sourceDirectory;
+ const sourceDir = generateSourceDirectory(basePath);
+
+ return joinPaths(getBaseURL(), metadata.project, '/-/raw/', metadata.branch, sourceDir, fileName);
+};
+
+const render = ({ destination: originalSrc, firstChild }, { skipChildren }) => {
+ skipChildren();
+
+ const cachedContent = getCachedContent(originalSrc);
+
+ return {
+ type: 'openTag',
+ tagName: 'img',
+ selfClose: true,
+ attributes: {
+ 'data-original-src': !isAbsolute(originalSrc) || cachedContent ? originalSrc : '',
+ src: resolveFullPath(originalSrc, cachedContent),
+ alt: firstChild.literal,
+ },
+ };
+};
+
+const build = (mounts = [], project, branch, baseUrl, imageRepository) => {
+ metadata = { mounts, project, branch, baseUrl, imageRepository };
+ return { canRender, render };
+};
+
+export default { build };
diff --git a/app/assets/javascripts/terraform/components/empty_state.vue b/app/assets/javascripts/terraform/components/empty_state.vue
new file mode 100644
index 00000000000..d86ba3af2b1
--- /dev/null
+++ b/app/assets/javascripts/terraform/components/empty_state.vue
@@ -0,0 +1,44 @@
+<script>
+import { GlEmptyState, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlEmptyState,
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ image: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state :svg-path="image" :title="s__('Terraform|Get started with Terraform')">
+ <template #description>
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ 'Terraform|Find out how to use the %{linkStart}GitLab managed Terraform State%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link
+ href="https://docs.gitlab.com/ee/user/infrastructure/index.html"
+ target="_blank"
+ >
+ {{ content }}
+ <gl-icon name="external-link" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue
new file mode 100644
index 00000000000..2e4c18c5a5b
--- /dev/null
+++ b/app/assets/javascripts/terraform/components/states_table.vue
@@ -0,0 +1,101 @@
+<script>
+import { GlBadge, GlIcon, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+
+export default {
+ components: {
+ GlBadge,
+ GlIcon,
+ GlSprintf,
+ GlTable,
+ GlTooltip,
+ TimeAgoTooltip,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ states: {
+ required: true,
+ type: Array,
+ },
+ },
+ computed: {
+ fields() {
+ return [
+ {
+ key: 'name',
+ thClass: 'gl-display-none',
+ },
+ {
+ key: 'updated',
+ thClass: 'gl-display-none',
+ tdClass: 'gl-text-right',
+ },
+ ];
+ },
+ },
+ methods: {
+ createdByUserName(item) {
+ return item.latestVersion?.createdByUser?.name;
+ },
+ lockedByUserName(item) {
+ return item.lockedByUser?.name || s__('Terraform|Unknown User');
+ },
+ updatedTime(item) {
+ return item.latestVersion?.updatedAt || item.updatedAt;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-table :items="states" :fields="fields" data-testid="terraform-states-table">
+ <template #cell(name)="{ item }">
+ <div class="gl-display-flex align-items-center" data-testid="terraform-states-table-name">
+ <p class="gl-font-weight-bold gl-m-0 gl-text-gray-900">
+ {{ item.name }}
+ </p>
+
+ <div v-if="item.lockedAt" id="terraformLockedBadgeContainer" class="gl-mx-2">
+ <gl-badge id="terraformLockedBadge">
+ <gl-icon name="lock" />
+ {{ s__('Terraform|Locked') }}
+ </gl-badge>
+
+ <gl-tooltip
+ container="terraformLockedBadgeContainer"
+ placement="right"
+ target="terraformLockedBadge"
+ >
+ <gl-sprintf :message="s__('Terraform|Locked by %{user} %{timeAgo}')">
+ <template #user>
+ {{ lockedByUserName(item) }}
+ </template>
+
+ <template #timeAgo>
+ {{ timeFormatted(item.lockedAt) }}
+ </template>
+ </gl-sprintf>
+ </gl-tooltip>
+ </div>
+ </div>
+ </template>
+
+ <template #cell(updated)="{ item }">
+ <p class="gl-m-0" data-testid="terraform-states-table-updated">
+ <gl-sprintf :message="s__('Terraform|%{user} updated %{timeAgo}')">
+ <template #user>
+ <span v-if="item.latestVersion">
+ {{ createdByUserName(item) }}
+ </span>
+ </template>
+
+ <template #timeAgo>
+ <time-ago-tooltip :time="updatedTime(item)" />
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </gl-table>
+</template>
diff --git a/app/assets/javascripts/terraform/components/terraform_list.vue b/app/assets/javascripts/terraform/components/terraform_list.vue
new file mode 100644
index 00000000000..f614bdc8d43
--- /dev/null
+++ b/app/assets/javascripts/terraform/components/terraform_list.vue
@@ -0,0 +1,134 @@
+<script>
+import { GlAlert, GlBadge, GlKeysetPagination, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
+import getStatesQuery from '../graphql/queries/get_states.query.graphql';
+import EmptyState from './empty_state.vue';
+import StatesTable from './states_table.vue';
+import { MAX_LIST_COUNT } from '../constants';
+
+export default {
+ apollo: {
+ states: {
+ query: getStatesQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ ...this.cursor,
+ };
+ },
+ update: data => {
+ return {
+ count: data?.project?.terraformStates?.count,
+ list: data?.project?.terraformStates?.nodes,
+ pageInfo: data?.project?.terraformStates?.pageInfo,
+ };
+ },
+ error() {
+ this.states = null;
+ },
+ },
+ },
+ components: {
+ EmptyState,
+ GlAlert,
+ GlBadge,
+ GlKeysetPagination,
+ GlLoadingIcon,
+ GlTab,
+ GlTabs,
+ StatesTable,
+ },
+ props: {
+ emptyStateImage: {
+ required: true,
+ type: String,
+ },
+ projectPath: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ cursor: {
+ first: MAX_LIST_COUNT,
+ after: null,
+ last: null,
+ before: null,
+ },
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.states.loading;
+ },
+ pageInfo() {
+ return this.states?.pageInfo || {};
+ },
+ showPagination() {
+ return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
+ },
+ statesCount() {
+ return this.states?.count;
+ },
+ statesList() {
+ return this.states?.list;
+ },
+ },
+ methods: {
+ updatePagination(item) {
+ if (item === this.pageInfo.endCursor) {
+ this.cursor = {
+ first: MAX_LIST_COUNT,
+ after: item,
+ last: null,
+ before: null,
+ };
+ } else {
+ this.cursor = {
+ first: null,
+ after: null,
+ last: MAX_LIST_COUNT,
+ before: item,
+ };
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <section>
+ <gl-tabs>
+ <gl-tab>
+ <template slot="title">
+ <p class="gl-m-0">
+ {{ s__('Terraform|States') }}
+ <gl-badge v-if="statesCount">{{ statesCount }}</gl-badge>
+ </p>
+ </template>
+
+ <gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" />
+
+ <div v-else-if="statesList">
+ <div v-if="statesCount">
+ <states-table :states="statesList" />
+
+ <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
+ <gl-keyset-pagination
+ v-bind="pageInfo"
+ @prev="updatePagination"
+ @next="updatePagination"
+ />
+ </div>
+ </div>
+
+ <empty-state v-else :image="emptyStateImage" />
+ </div>
+
+ <gl-alert v-else variant="danger" :dismissible="false">
+ {{ s__('Terraform|An error occurred while loading your Terraform States') }}
+ </gl-alert>
+ </gl-tab>
+ </gl-tabs>
+ </section>
+</template>
diff --git a/app/assets/javascripts/terraform/constants.js b/app/assets/javascripts/terraform/constants.js
new file mode 100644
index 00000000000..bbc4630f83b
--- /dev/null
+++ b/app/assets/javascripts/terraform/constants.js
@@ -0,0 +1 @@
+export const MAX_LIST_COUNT = 25;
diff --git a/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql b/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql
new file mode 100644
index 00000000000..49f9ae3dd97
--- /dev/null
+++ b/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql
@@ -0,0 +1,17 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "./state_version.fragment.graphql"
+
+fragment State on TerraformState {
+ id
+ name
+ lockedAt
+ updatedAt
+
+ lockedByUser {
+ ...User
+ }
+
+ latestVersion {
+ ...StateVersion
+ }
+}
diff --git a/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql b/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql
new file mode 100644
index 00000000000..c7e9700c696
--- /dev/null
+++ b/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql
@@ -0,0 +1,9 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+
+fragment StateVersion on TerraformStateVersion {
+ updatedAt
+
+ createdByUser {
+ ...User
+ }
+}
diff --git a/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql
new file mode 100644
index 00000000000..9453e32b1b5
--- /dev/null
+++ b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql
@@ -0,0 +1,18 @@
+#import "../fragments/state.fragment.graphql"
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getStates($projectPath: ID!, $first: Int, $last: Int, $before: String, $after: String) {
+ project(fullPath: $projectPath) {
+ terraformStates(first: $first, last: $last, before: $before, after: $after) {
+ count
+
+ nodes {
+ ...State
+ }
+
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/terraform/index.js b/app/assets/javascripts/terraform/index.js
new file mode 100644
index 00000000000..579d2d14023
--- /dev/null
+++ b/app/assets/javascripts/terraform/index.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import TerraformList from './components/terraform_list.vue';
+import createDefaultClient from '~/lib/graphql';
+
+Vue.use(VueApollo);
+
+export default () => {
+ const el = document.querySelector('#js-terraform-list');
+
+ if (!el) {
+ return null;
+ }
+
+ const defaultClient = createDefaultClient();
+
+ const { emptyStateImage, projectPath } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider: new VueApollo({ defaultClient }),
+ render(createElement) {
+ return createElement(TerraformList, {
+ props: {
+ emptyStateImage,
+ projectPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/tooltips/components/tooltips.vue b/app/assets/javascripts/tooltips/components/tooltips.vue
index 8307f878def..05927006ea6 100644
--- a/app/assets/javascripts/tooltips/components/tooltips.vue
+++ b/app/assets/javascripts/tooltips/components/tooltips.vue
@@ -108,6 +108,7 @@ export default {
:container="tooltip.container"
:boundary="tooltip.boundary"
:disabled="tooltip.disabled"
+ :show="tooltip.show"
>
<span v-if="tooltip.html" v-safe-html="tooltip.title"></span>
<span v-else>{{ tooltip.title }}</span>
diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js
index 9f5dce4183c..f7cad6639ae 100644
--- a/app/assets/javascripts/tooltips/index.js
+++ b/app/assets/javascripts/tooltips/index.js
@@ -96,6 +96,12 @@ export const initTooltips = (config = {}) => {
return invokeBootstrapApi(document.body, config);
};
+export const add = (elements, config = {}) => {
+ if (isGlTooltipsEnabled()) {
+ return addTooltips(elements, config);
+ }
+ return invokeBootstrapApi(elements, config);
+};
export const dispose = tooltipApiInvoker({
glHandler: element => tooltipsApp().dispose(element),
bsHandler: elements => invokeBootstrapApi(elements, 'dispose'),
diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js
index c1521882682..0a1211d0a76 100644
--- a/app/assets/javascripts/tracking.js
+++ b/app/assets/javascripts/tracking.js
@@ -12,6 +12,7 @@ const DEFAULT_SNOWPLOW_OPTIONS = {
contexts: { webPage: true, performanceTiming: true },
formTracking: false,
linkClickTracking: false,
+ pageUnloadTimer: 10,
};
const createEventPayload = (el, { suffix = '' } = {}) => {
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index 20d1a3c1fcd..dccd6807f13 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -14,6 +14,7 @@ import ModalStore from '../boards/stores/modal_store';
import { parseBoolean, spriteIcon } from '../lib/utils/common_utils';
import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from './utils';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
+import { fixTitle, dispose } from '~/tooltips';
// TODO: remove eventHub hack after code splitting refactor
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
@@ -229,7 +230,9 @@ function UsersSelect(currentUser, els, options = {}) {
tooltipTitle = s__('UsersSelect|Assignee');
}
$value.html(assigneeTemplate(user));
- $collapsedSidebar.attr('title', tooltipTitle).tooltip('_fixTitle');
+ $collapsedSidebar.attr('title', tooltipTitle);
+ fixTitle($collapsedSidebar);
+
return $collapsedSidebar.html(collapsedAssigneeTemplate(user));
});
};
@@ -423,7 +426,7 @@ function UsersSelect(currentUser, els, options = {}) {
const { $el, e, isMarking } = options;
const user = options.selectedObj;
- $el.tooltip('dispose');
+ dispose($el);
if ($dropdown.hasClass('js-multiselect')) {
const isActive = $el.hasClass('is-active');
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
new file mode 100644
index 00000000000..eff26729fa7
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
@@ -0,0 +1,157 @@
+<script>
+import { GlButton, GlLoadingIcon, GlIcon, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui';
+import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
+import StatusIcon from '../mr_widget_status_icon.vue';
+
+export const LOADING_STATES = {
+ collapsedLoading: 'collapsedLoading',
+ collapsedError: 'collapsedError',
+ expandedLoading: 'expandedLoading',
+};
+
+export default {
+ components: {
+ GlButton,
+ GlLoadingIcon,
+ GlIcon,
+ GlLink,
+ GlBadge,
+ SmartVirtualList,
+ StatusIcon,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ data() {
+ return {
+ loadingState: LOADING_STATES.collapsedLoading,
+ collapsedData: null,
+ fullData: null,
+ isCollapsed: true,
+ };
+ },
+ computed: {
+ isLoadingSummary() {
+ return this.loadingState === LOADING_STATES.collapsedLoading;
+ },
+ isLoadingExpanded() {
+ return this.loadingState === LOADING_STATES.expandedLoading;
+ },
+ isCollapsible() {
+ if (this.isLoadingSummary) {
+ return false;
+ }
+
+ return true;
+ },
+ statusIconName() {
+ if (this.isLoadingSummary) {
+ return 'loading';
+ }
+
+ if (this.loadingState === LOADING_STATES.collapsedError) {
+ return 'warning';
+ }
+
+ return this.statusIcon(this.collapsedData);
+ },
+ },
+ watch: {
+ isCollapsed(newVal) {
+ if (!newVal) {
+ this.loadAllData();
+ } else {
+ this.loadingState = null;
+ }
+ },
+ },
+ mounted() {
+ this.fetchCollapsedData(this.$props)
+ .then(data => {
+ this.collapsedData = data;
+ this.loadingState = null;
+ })
+ .catch(e => {
+ this.loadingState = LOADING_STATES.collapsedError;
+ throw e;
+ });
+ },
+ methods: {
+ toggleCollapsed() {
+ this.isCollapsed = !this.isCollapsed;
+ },
+ loadAllData() {
+ if (this.fullData) return;
+
+ this.loadingState = LOADING_STATES.expandedLoading;
+
+ this.fetchFullData(this.$props)
+ .then(data => {
+ this.loadingState = null;
+ this.fullData = data;
+ })
+ .catch(e => {
+ this.loadingState = null;
+ throw e;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <section class="media-section mr-widget-border-top">
+ <div class="media gl-p-5">
+ <status-icon :status="statusIconName" class="align-self-center" />
+ <div class="media-body d-flex flex-align-self-center align-items-center">
+ <div class="code-text">
+ <template v-if="isLoadingSummary">
+ {{ __('Loading...') }}
+ </template>
+ <div v-else v-safe-html="summary(collapsedData)"></div>
+ </div>
+ <gl-button
+ v-if="isCollapsible"
+ size="small"
+ class="float-right align-self-center"
+ @click="toggleCollapsed"
+ >
+ {{ isCollapsed ? __('Expand') : __('Collapse') }}
+ </gl-button>
+ </div>
+ </div>
+ <div v-if="!isCollapsed" class="mr-widget-grouped-section">
+ <div v-if="isLoadingExpanded" class="report-block-container">
+ <gl-loading-icon inline /> {{ __('Loading...') }}
+ </div>
+ <smart-virtual-list
+ v-else-if="fullData"
+ :length="fullData.length"
+ :remain="20"
+ :size="32"
+ wtag="ul"
+ wclass="report-block-list"
+ class="report-block-container"
+ >
+ <li v-for="data in fullData" :key="data.id" class="d-flex align-items-center">
+ <div v-if="data.icon" :class="data.icon.class" class="d-flex">
+ <gl-icon :name="data.icon.name" :size="24" />
+ </div>
+ <div
+ class="gl-mt-2 gl-mb-2 align-content-around align-items-start flex-wrap align-self-center d-flex"
+ >
+ <div class="gl-mr-4">
+ {{ data.text }}
+ </div>
+ <div v-if="data.link">
+ <gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
+ </div>
+ <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
+ {{ data.badge.text }}
+ </gl-badge>
+ </div>
+ </li>
+ </smart-virtual-list>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
new file mode 100644
index 00000000000..5014c12dc30
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
@@ -0,0 +1,27 @@
+import { extensions } from './index';
+
+export default {
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ render(h) {
+ return h(
+ 'div',
+ {},
+ extensions.map(extension =>
+ h(extension, {
+ props: extensions[0].props.reduce(
+ (acc, key) => ({
+ ...acc,
+ [key]: this.mr[key],
+ }),
+ {},
+ ),
+ }),
+ ),
+ );
+ },
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
new file mode 100644
index 00000000000..2bfaec8a1c9
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
@@ -0,0 +1,30 @@
+import ExtensionBase from './base.vue';
+
+// Holds all the currently registered extensions
+export const extensions = [];
+
+export const registerExtension = extension => {
+ // Pushes into the extenions array a dynamically created Vue component
+ // that gets exteneded from `base.vue`
+ extensions.push({
+ extends: ExtensionBase,
+ name: extension.name,
+ props: extension.props,
+ computed: {
+ ...Object.keys(extension.computed).reduce(
+ (acc, computedKey) => ({
+ ...acc,
+ // Making the computed property a method allows us to pass in arguments
+ // this allows for each computed property to recieve some data
+ [computedKey]() {
+ return extension.computed[computedKey];
+ },
+ }),
+ {},
+ ),
+ },
+ methods: {
+ ...extension.methods,
+ },
+ });
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue
index 598b08f4c16..5ed699acddf 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue
@@ -1,10 +1,10 @@
<script>
-import tooltip from '../../vue_shared/directives/tooltip';
+import { GlTooltipDirective } from '@gitlab/ui';
export default {
name: 'MrWidgetAuthor',
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
author: {
@@ -16,11 +16,6 @@ export default {
required: false,
default: true,
},
- showAuthorTooltip: {
- type: Boolean,
- required: false,
- default: false,
- },
},
computed: {
authorUrl() {
@@ -33,12 +28,7 @@ export default {
};
</script>
<template>
- <a
- :href="authorUrl"
- :v-tooltip="showAuthorTooltip"
- :title="author.name"
- class="author-link inline"
- >
+ <a v-gl-tooltip :href="authorUrl" :title="author.name" class="author-link inline">
<img :src="avatarUrl" class="avatar avatar-inline s16" />
<span v-if="showAuthorName" class="author">{{ author.name }}</span>
</a>
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 eb8989adb2a..d5fdbe726e9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -1,6 +1,5 @@
<script>
/* eslint-disable vue/no-v-html */
-import Mousetrap from 'mousetrap';
import { escape } from 'lodash';
import {
GlButton,
@@ -84,17 +83,6 @@ export default {
: '';
},
},
- mounted() {
- Mousetrap.bind('b', this.copyBranchName);
- },
- beforeDestroy() {
- Mousetrap.unbind('b');
- },
- methods: {
- copyBranchName() {
- this.$refs.copyBranchNameButton.$el.click();
- },
- },
};
</script>
<template>
@@ -110,7 +98,6 @@ export default {
class="label-branch label-truncate js-source-branch"
v-html="mr.sourceBranchLink"
/><clipboard-button
- ref="copyBranchNameButton"
data-testid="mr-widget-copy-clipboard"
:text="branchNameClipboardData"
:title="__('Copy branch name')"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
index f17e409d996..b6722de5277 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
@@ -1,10 +1,10 @@
<script>
-import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
export default {
components: {
- GlDeprecatedDropdown,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownItem,
},
props: {
commits: {
@@ -18,20 +18,20 @@ export default {
<template>
<div>
- <gl-deprecated-dropdown
+ <gl-dropdown
right
text="Use an existing commit message"
variant="link"
class="mr-commit-dropdown"
>
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
v-for="commit in commits"
:key="commit.short_id"
class="text-nowrap text-truncate"
@click="$emit('input', commit.message)"
>
<span class="monospace mr-2">{{ commit.short_id }}</span> {{ commit.title }}
- </gl-deprecated-dropdown-item>
- </gl-deprecated-dropdown>
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
index 12f65a4c235..750014c599a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
@@ -1,4 +1,5 @@
<script>
+import { GlLoadingIcon } from '@gitlab/ui';
import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge';
import { deprecatedCreateFlash as Flash } from '../../../flash';
import statusIcon from '../mr_widget_status_icon.vue';
@@ -12,6 +13,7 @@ export default {
components: {
MrWidgetAuthor,
statusIcon,
+ GlLoadingIcon,
},
mixins: [autoMergeMixin],
props: {
@@ -100,7 +102,7 @@ export default {
class="btn btn-sm btn-default js-cancel-auto-merge"
@click.prevent="cancelAutomaticMerge"
>
- <i v-if="isCancellingAutoMerge" class="fa fa-spinner fa-spin" aria-hidden="true"> </i>
+ <gl-loading-icon v-if="isCancellingAutoMerge" inline class="gl-mr-1" />
{{ cancelButtonText }}
</a>
</h4>
@@ -122,7 +124,7 @@ export default {
href="#"
@click.prevent="removeSourceBranch"
>
- <i v-if="isRemovingSourceBranch" class="fa fa-spinner fa-spin" aria-hidden="true"> </i>
+ <gl-loading-icon v-if="isRemovingSourceBranch" inline class="gl-mr-1" />
{{ s__('mrWidget|Delete source branch') }}
</a>
</p>
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 835f7b9e9a9..2c1f2285dda 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,6 +1,15 @@
<script>
import { isEmpty } from 'lodash';
-import { GlIcon, GlButton, GlSprintf, GlLink } from '@gitlab/ui';
+import {
+ GlIcon,
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlSprintf,
+ GlLink,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import simplePoll from '~/lib/utils/simple_poll';
import { __ } from '~/locale';
@@ -36,6 +45,9 @@ export default {
GlSprintf,
GlLink,
GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
MergeTrainHelperText: () =>
import('ee_component/vue_merge_request_widget/components/merge_train_helper_text.vue'),
MergeImmediatelyConfirmationDialog: () =>
@@ -43,6 +55,9 @@ export default {
'ee_component/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue'
),
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
mixins: [readyToMergeMixin],
props: {
mr: { type: Object, required: true },
@@ -283,7 +298,7 @@ export default {
<status-icon :status="iconClass" />
<div class="media-body">
<div class="mr-widget-body-controls media space-children">
- <span class="btn-group">
+ <gl-button-group>
<gl-button
size="medium"
category="primary"
@@ -294,54 +309,33 @@ export default {
@click="handleMergeButtonClick(isAutoMergeAvailable)"
>{{ mergeButtonText }}</gl-button
>
- <button
+ <gl-dropdown
v-if="shouldShowMergeImmediatelyDropdown"
+ v-gl-tooltip.hover.focus="__('Select merge moment')"
:disabled="isMergeButtonDisabled"
- type="button"
- class="btn btn-sm btn-info dropdown-toggle js-merge-moment"
- data-toggle="dropdown"
+ variant="info"
data-qa-selector="merge_moment_dropdown"
- :aria-label="__('Select merge moment')"
- >
- <i class="fa fa-chevron-down" aria-hidden="true"></i>
- </button>
- <ul
- v-if="shouldShowMergeImmediatelyDropdown"
- class="dropdown-menu dropdown-menu-right"
- role="menu"
+ toggle-class="btn-icon js-merge-moment"
>
- <li>
- <a
- class="auto_merge_enabled qa-merge-when-pipeline-succeeds-option"
- href="#"
- @click.prevent="handleMergeButtonClick(true)"
- >
- <span class="media">
- <gl-icon name="status_success" class="merge-opt-icon" aria-hidden="true" />
- <span class="media-body merge-opt-title">{{ autoMergeText }}</span>
- </span>
- </a>
- </li>
- <li>
- <merge-immediately-confirmation-dialog
- ref="confirmationDialog"
- :docs-url="mr.mergeImmediatelyDocsPath"
- @mergeImmediately="onMergeImmediatelyConfirmation"
- />
- <a
- class="accept-merge-request js-merge-immediately-button"
- data-qa-selector="merge_immediately_option"
- href="#"
- @click.prevent="handleMergeImmediatelyButtonClick"
- >
- <span class="media">
- <gl-icon name="status_warning" class="merge-opt-icon" aria-hidden="true" />
- <span class="media-body merge-opt-title">{{ __('Merge immediately') }}</span>
- </span>
- </a>
- </li>
- </ul>
- </span>
+ <template #button-content>
+ <gl-icon name="chevron-down" class="mr-0" />
+ <span class="sr-only">{{ __('Select merge moment') }}</span>
+ </template>
+ <gl-dropdown-item
+ icon-name="warning"
+ button-class="accept-merge-request js-merge-immediately-button"
+ data-qa-selector="merge_immediately_option"
+ @click="handleMergeImmediatelyButtonClick"
+ >
+ {{ __('Merge immediately') }}
+ </gl-dropdown-item>
+ <merge-immediately-confirmation-dialog
+ ref="confirmationDialog"
+ :docs-url="mr.mergeImmediatelyDocsPath"
+ @mergeImmediately="onMergeImmediatelyConfirmation"
+ />
+ </gl-dropdown>
+ </gl-button-group>
<div class="media-body-wrap space-children">
<template v-if="shouldShowMergeControls">
<label v-if="mr.canRemoveSourceBranch">
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 eba3d50fdc9..1d591168a17 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
@@ -1,6 +1,7 @@
<script>
import $ from 'jquery';
import { GlButton } from '@gitlab/ui';
+import { produce } from 'immer';
import { __ } from '~/locale';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import MergeRequest from '~/merge_request';
@@ -80,12 +81,18 @@ export default {
return;
}
- const data = store.readQuery({
+ const sourceData = store.readQuery({
query: getStateQuery,
variables: mergeRequestQueryVariables,
});
- data.project.mergeRequest.workInProgress = workInProgress;
- data.project.mergeRequest.title = title;
+
+ const data = produce(sourceData, draftState => {
+ // eslint-disable-next-line no-param-reassign
+ draftState.project.mergeRequest.workInProgress = workInProgress;
+ // eslint-disable-next-line no-param-reassign
+ draftState.project.mergeRequest.title = title;
+ });
+
store.writeQuery({
query: getStateQuery,
data,
@@ -143,7 +150,7 @@ export default {
<div class="media-body">
<div class="gl-ml-3 float-left">
<span class="gl-font-weight-bold">
- {{ __('This merge request is still a work in progress.') }}
+ {{ __('This merge request is still a draft.') }}
</span>
<span class="gl-display-block text-muted">{{
__("Draft merge requests can't be merged.")
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
new file mode 100644
index 00000000000..2d21ced1b28
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -0,0 +1,66 @@
+/* eslint-disable */
+import issuesCollapsedQuery from './issues_collapsed.query.graphql';
+import issuesQuery from './issues.query.graphql';
+
+export default {
+ // Give the extension a name
+ // Make it easier to track in Vue dev tools
+ name: 'WidgetIssues',
+ // Add an array of props
+ // These then get mapped to values stored in the MR Widget store
+ props: ['targetProjectFullPath'],
+ // Add any extra computed props in here
+ computed: {
+ // Small summary text to be displayed in the collapsed state
+ // Receives the collapsed data as an argument
+ summary(count) {
+ return `<strong>${count}</strong> open issue`;
+ },
+ // Status icon to be used next to the summary text
+ // Receives the collapsed data as an argument
+ statusIcon(count) {
+ return count > 0 ? 'warning' : 'success';
+ },
+ },
+ methods: {
+ // Fetches the collapsed data
+ // Ideally, this request should return the smallest amount of data possible
+ // Receives an object of all the props passed in to the extension
+ fetchCollapsedData({ targetProjectFullPath }) {
+ return this.$apollo
+ .query({ query: issuesCollapsedQuery, variables: { projectPath: targetProjectFullPath } })
+ .then(({ data }) => data.project.issues.count);
+ },
+ // Fetches the full data when the extension is expanded
+ // Receives an object of all the props passed in to the extension
+ fetchFullData({ targetProjectFullPath }) {
+ return this.$apollo
+ .query({ query: issuesQuery, variables: { projectPath: targetProjectFullPath } })
+ .then(({ data }) => {
+ // Return some transformed data to be rendered in the expanded state
+ return data.project.issues.nodes.map(issue => ({
+ id: issue.id, // Required: The ID of the object
+ text: issue.title, // Required: The text to get used on each row
+ // Icon to get rendered on the side of each row
+ icon: {
+ // Required: Name maps to an icon in GitLabs SVG
+ name:
+ issue.state === 'closed' ? 'status_failed_borderless' : 'status_success_borderless',
+ // Optional: An extra class to be added to the icon for additional styling
+ class: issue.state === 'closed' ? 'text-danger' : 'text-success',
+ },
+ // Badges get rendered next to the text on each row
+ badge: issue.state === 'closed' && {
+ text: 'Closed', // Required: Text to be used inside of the badge
+ // variant: 'info', // Optional: The variant of the badge, maps to GitLab UI variants
+ },
+ // Each row can have its own link that will take the user elsewhere
+ // link: {
+ // href: 'https://google.com', // Required: href for the link
+ // text: 'Link text', // Required: Text to be used inside the link
+ // },
+ }));
+ });
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql
new file mode 100644
index 00000000000..690f571c083
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql
@@ -0,0 +1,13 @@
+query getAllIssues($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ issues {
+ nodes {
+ id
+ title
+ webPath
+ webUrl
+ state
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql b/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql
new file mode 100644
index 00000000000..389a81e0a61
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql
@@ -0,0 +1,7 @@
+query getIssues($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ issues {
+ count
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index 87e56dfcbdf..8f2cca3309a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -3,12 +3,19 @@ import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_optio
import VueApollo from 'vue-apollo';
import Translate from '../vue_shared/translate';
import createDefaultClient from '~/lib/graphql';
+import { registerExtension } from './components/extensions';
+import issueExtension from './extensions/issues';
Vue.use(Translate);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
});
export default () => {
@@ -17,6 +24,8 @@ export default () => {
gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url;
+ registerExtension(issueExtension);
+
const vm = new Vue({ ...MrWidgetOptions, apolloProvider });
window.gl.mrWidget = {
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 46749fc5e87..190d790f584 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
@@ -37,6 +37,7 @@ import FailedToMerge from './components/states/mr_widget_failed_to_merge.vue';
import MrWidgetAutoMergeEnabled from './components/states/mr_widget_auto_merge_enabled.vue';
import AutoMergeFailed from './components/states/mr_widget_auto_merge_failed.vue';
import CheckingState from './components/states/mr_widget_checking.vue';
+// import ExtensionsContainer from './components/extensions/container';
import eventHub from './event_hub';
import notify from '~/lib/utils/notify';
import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue';
@@ -46,7 +47,6 @@ 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',
@@ -58,6 +58,7 @@ export default {
},
components: {
Loading,
+ // ExtensionsContainer,
'mr-widget-header': WidgetHeader,
'mr-widget-suggest-pipeline': WidgetSuggestPipeline,
'mr-widget-merge-help': WidgetMergeHelp,
@@ -154,7 +155,7 @@ export default {
},
shouldSuggestPipelines() {
return (
- isExperimentEnabled('suggestPipeline') &&
+ gon.features?.suggestPipeline &&
!this.mr.hasCI &&
this.mr.mergeRequestAddCiConfigPath &&
!this.mr.isDismissedSuggestPipeline
@@ -455,6 +456,7 @@ export default {
:service="service"
/>
<div class="mr-section-container mr-widget-workflow">
+ <!-- <extensions-container :mr="mr" /> -->
<grouped-codequality-reports-app
v-if="shouldRenderCodeQuality"
:base-path="mr.codeclimate.base_path"
diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue
index 9b21de19185..cb4c5f20377 100644
--- a/app/assets/javascripts/vue_shared/components/actions_button.vue
+++ b/app/assets/javascripts/vue_shared/components/actions_button.vue
@@ -61,7 +61,6 @@ export default {
<gl-dropdown
v-if="hasMultipleActions"
v-gl-tooltip="selectedAction.tooltip"
- class="gl-button-deprecated-adapter"
:text="selectedAction.text"
:split-href="selectedAction.href"
:variant="variant"
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 34f6d384f7b..3e2b4cd35ab 100644
--- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue
+++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
@@ -7,7 +7,6 @@ import {
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!';
@@ -25,6 +24,7 @@ const allowedFields = [
'endedAt',
'details',
'hosts',
+ 'environment',
];
export default {
@@ -32,7 +32,6 @@ export default {
GlLoadingIcon,
GlTable,
},
- mixins: [glFeatureFlagsMixin()],
props: {
alert: {
type: Object,
@@ -60,9 +59,6 @@ export default {
},
],
computed: {
- flaggedAllowedFields() {
- return this.shouldDisplayEnvironment ? [...allowedFields, 'environment'] : allowedFields;
- },
items() {
if (!this.alert) {
return [];
@@ -84,13 +80,10 @@ export default {
[],
);
},
- shouldDisplayEnvironment() {
- return this.glFeatures.exposeEnvironmentPathInAlertDetails;
- },
},
methods: {
isAllowed(fieldName) {
- return this.flaggedAllowedFields.includes(fieldName);
+ return allowedFields.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 2e4b9b9a135..7a687ea4ad0 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -1,8 +1,7 @@
<script>
/* eslint-disable vue/no-v-html */
import { groupBy } from 'lodash';
-import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
-import tooltip from '~/vue_shared/directives/tooltip';
+import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { glEmojiTag } from '../../emoji';
import { __, sprintf } from '~/locale';
@@ -15,7 +14,7 @@ export default {
GlLoadingIcon,
},
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
awards: {
@@ -154,10 +153,9 @@ export default {
<button
v-for="awardList in groupedAwards"
:key="awardList.name"
- v-tooltip
+ v-gl-tooltip.viewport
:class="awardList.classes"
:title="awardList.title"
- data-boundary="viewport"
data-testid="award-button"
class="btn award-control"
type="button"
@@ -168,12 +166,11 @@ export default {
</button>
<div v-if="canAwardEmoji" class="award-menu-holder">
<button
- v-tooltip
+ v-gl-tooltip.viewport
:class="addButtonClass"
class="award-control btn js-add-award"
title="Add reaction"
:aria-label="__('Add reaction')"
- data-boundary="viewport"
type="button"
>
<span class="award-control-icon award-control-icon-neutral">
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 7a76888c916..6f7723955bf 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
@@ -1,4 +1,4 @@
-import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance_constants';
+import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance/constants';
import eventHub from '~/blob/components/eventhub';
export default {
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
index bbe72a2b122..646e1703f1e 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
@@ -9,6 +9,7 @@ export default {
GlIcon,
},
mixins: [ViewerMixin],
+ inject: ['blobHash'],
data() {
return {
highlightedLine: null,
@@ -64,7 +65,7 @@ export default {
</a>
</div>
<div class="blob-content">
- <pre class="code highlight"><code id="blob-code-content" v-html="content"></code></pre>
+ <pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index ff665d9cc58..d775a093f5f 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -6,11 +6,8 @@ import { GlIcon } from '@gitlab/ui';
*
* Receives status object containing:
* status: {
- * details_path: "/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
- * text:"running" // text rendered
* }
*
* Used in:
diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
index a42a606d446..96f800511d2 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
@@ -1,5 +1,5 @@
<script>
-import { GlModal } from '@gitlab/ui';
+import { GlModal, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import csrf from '~/lib/utils/csrf';
@@ -7,6 +7,9 @@ export default {
components: {
GlModal,
},
+ directives: {
+ SafeHtml,
+ },
props: {
selector: {
type: String,
@@ -71,7 +74,8 @@ export default {
-->
<input type="hidden" name="_method" :value="method" />
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
- <div>{{ modalAttributes.message }}</div>
+ <div v-if="modalAttributes.messageHtml" v-safe-html="modalAttributes.messageHtml"></div>
+ <div v-else>{{ modalAttributes.message }}</div>
</form>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
index 494df2d7a37..7e82d8f3f9c 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
@@ -1,10 +1,11 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlLoadingIcon,
+ GlIcon,
},
props: {
isDisabled: {
@@ -39,8 +40,10 @@ export default {
<slot v-if="$slots.default"></slot>
<span v-else class="dropdown-toggle-text"> {{ toggleText }} </span>
</template>
- <span v-show="!isLoading" class="dropdown-toggle-icon">
- <i class="fa fa-chevron-down" aria-hidden="true" data-hidden="true"></i>
- </span>
+ <gl-icon
+ v-show="!isLoading"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
+ name="chevron-down"
+ />
</button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index c1c4f437dee..b4115b0c6a4 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -1,4 +1,5 @@
<script>
+import { GlTruncate } from '@gitlab/ui';
import FileHeader from '~/vue_shared/components/file_row_header.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import { escapeFileUrl } from '~/lib/utils/url_utility';
@@ -8,6 +9,7 @@ export default {
components: {
FileHeader,
FileIcon,
+ GlTruncate,
},
props: {
file: {
@@ -28,6 +30,11 @@ export default {
required: false,
default: '',
},
+ truncateMiddle: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
isTree() {
@@ -134,9 +141,9 @@ export default {
<span
ref="textOutput"
:style="levelIndentation"
- class="file-row-name str-truncated"
+ class="file-row-name"
data-qa-selector="file_name_content"
- :class="fileClasses"
+ :class="[fileClasses, { 'str-truncated': !truncateMiddle, 'gl-min-w-0': truncateMiddle }]"
>
<file-icon
class="file-row-icon"
@@ -146,8 +153,10 @@ export default {
:folder="isTree"
:opened="file.opened"
:size="16"
+ :submodule="file.submodule"
/>
- {{ file.name }}
+ <gl-truncate v-if="truncateMiddle" :text="file.name" position="middle" class="gl-pr-7" />
+ <template v-else>{{ file.name }}</template>
</span>
<slot></slot>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/file_row_header.vue b/app/assets/javascripts/vue_shared/components/file_row_header.vue
index 2c3e2a3a433..5afb2408c7e 100644
--- a/app/assets/javascripts/vue_shared/components/file_row_header.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row_header.vue
@@ -1,25 +1,21 @@
<script>
-import { truncatePathMiddleToLength } from '~/lib/utils/text_utility';
-
-const MAX_PATH_LENGTH = 40;
+import { GlTruncate } from '@gitlab/ui';
export default {
+ components: {
+ GlTruncate,
+ },
props: {
path: {
type: String,
required: true,
},
},
- computed: {
- truncatedPath() {
- return truncatePathMiddleToLength(this.path, MAX_PATH_LENGTH);
- },
- },
};
</script>
<template>
<div class="file-row-header bg-white sticky-top p-2 js-file-row-header" :title="path">
- <span class="bold">{{ truncatedPath }}</span>
+ <gl-truncate :text="path" position="middle" class="bold" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
index 25478ad6f4f..97b4ceda033 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -5,6 +5,7 @@ import {
GlButton,
GlDropdown,
GlDropdownItem,
+ GlFormCheckbox,
GlTooltipDirective,
} from '@gitlab/ui';
@@ -25,6 +26,7 @@ export default {
GlButton,
GlDropdown,
GlDropdownItem,
+ GlFormCheckbox,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -59,10 +61,25 @@ export default {
default: '',
validator: value => value === '' || /(_desc)|(_asc)/g.test(value),
},
+ showCheckbox: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ checkboxChecked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
searchInputPlaceholder: {
type: String,
required: true,
},
+ suggestionsListClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
let selectedSortOption = this.sortOptions[0]?.sortDirection?.descending;
@@ -291,12 +308,19 @@ export default {
<template>
<div class="vue-filtered-search-bar-container d-md-flex">
+ <gl-form-checkbox
+ v-if="showCheckbox"
+ class="gl-align-self-center"
+ :checked="checkboxChecked"
+ @input="$emit('checked-input', $event)"
+ />
<gl-filtered-search
ref="filteredSearchInput"
v-model="filterValue"
:placeholder="searchInputPlaceholder"
:available-tokens="tokens"
:history-items="filteredRecentSearches"
+ :suggestions-list-class="suggestionsListClass"
class="flex-grow-1"
@history-item-selected="handleHistoryItemSelected"
@clear-history="handleClearHistory"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
index 89952623d0d..c24df5e081d 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
@@ -65,7 +65,7 @@ export default {
.then(({ data }) => {
this.milestones = data;
})
- .catch(() => createFlash(__('There was a problem fetching milestones.')))
+ .catch(() => createFlash({ message: __('There was a problem fetching milestones.') }))
.finally(() => {
this.loading = false;
});
diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
index e895a7a52ab..dde7e3ebe13 100644
--- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
@@ -10,6 +10,8 @@ const AutoComplete = {
Labels: 'labels',
Members: 'members',
MergeRequests: 'mergeRequests',
+ Milestones: 'milestones',
+ Snippets: 'snippets',
};
const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings
@@ -120,6 +122,22 @@ const autoCompleteMap = {
return `<small>${original.reference || original.iid}</small> ${escape(original.title)}`;
},
},
+ [AutoComplete.Milestones]: {
+ filterValues() {
+ return this[AutoComplete.Milestones];
+ },
+ menuItemTemplate({ original }) {
+ return escape(original.title);
+ },
+ },
+ [AutoComplete.Snippets]: {
+ filterValues() {
+ return this[AutoComplete.Snippets];
+ },
+ menuItemTemplate({ original }) {
+ return `<small>${original.id}</small> ${escape(original.title)}`;
+ },
+ },
};
export default {
@@ -157,8 +175,8 @@ export default {
menuItemTemplate: autoCompleteMap[AutoComplete.Labels].menuItemTemplate,
selectTemplate: ({ original }) =>
NON_WORD_OR_INTEGER.test(original.title)
- ? `~"${original.title}"`
- : `~${original.title}`,
+ ? `~"${escape(original.title)}"`
+ : `~${escape(original.title)}`,
values: this.getValues(AutoComplete.Labels),
},
{
@@ -168,6 +186,20 @@ export default {
selectTemplate: ({ original }) => original.reference || `!${original.iid}`,
values: this.getValues(AutoComplete.MergeRequests),
},
+ {
+ trigger: '%',
+ lookup: 'title',
+ menuItemTemplate: autoCompleteMap[AutoComplete.Milestones].menuItemTemplate,
+ selectTemplate: ({ original }) => `%"${escape(original.title)}"`,
+ values: this.getValues(AutoComplete.Milestones),
+ },
+ {
+ trigger: '$',
+ fillAttr: 'id',
+ lookup: value => value.id + value.title,
+ menuItemTemplate: autoCompleteMap[AutoComplete.Snippets].menuItemTemplate,
+ values: this.getValues(AutoComplete.Snippets),
+ },
],
});
diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue
deleted file mode 100644
index 4b91d4c00e3..00000000000
--- a/app/assets/javascripts/vue_shared/components/gl_modal.vue
+++ /dev/null
@@ -1,6 +0,0 @@
-<script>
-// This file was only introduced to not break master and shall be delete soon.
-import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
-
-export default DeprecatedModal2;
-</script>
diff --git a/app/assets/javascripts/vue_shared/components/integrations_help_text.vue b/app/assets/javascripts/vue_shared/components/integrations_help_text.vue
new file mode 100644
index 00000000000..4939b5aa98c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/integrations_help_text.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+
+export default {
+ name: 'IntegrationsHelpText',
+ components: {
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ message: {
+ type: String,
+ required: true,
+ },
+ messageUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-sprintf :message="message">
+ <template #link="{ content }">
+ <gl-link :href="messageUrl" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" :size="12" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+</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 80c03342f11..33e77b6510c 100644
--- a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
+++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
@@ -22,11 +22,21 @@ export default {
required: false,
default: true,
},
+ clear: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
watch: {
value(newVal) {
this.saveValue(this.serialize(newVal));
},
+ clear(newVal) {
+ if (newVal) {
+ localStorage.removeItem(this.storageKey);
+ }
+ },
},
mounted() {
// On mount, trigger update if we actually have a localStorageValue
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 65116ed8ca3..9cfba85e0d8 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -141,10 +141,9 @@ export default {
addMultipleToDiscussionWarning() {
return sprintf(
__(
- '%{icon}You are about to add %{usersTag} people to the discussion. They will all receive a notification.',
+ 'You are about to add %{usersTag} people to the discussion. They will all receive a notification.',
),
{
- icon: '<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>',
usersTag: `<strong><span class="js-referenced-users-count">${this.referencedUsers.length}</span></strong>`,
},
false,
@@ -175,9 +174,10 @@ export default {
issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
epics: this.enableAutocomplete,
- milestones: this.enableAutocomplete,
+ milestones: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- snippets: this.enableAutocomplete,
+ snippets: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
+ vulnerabilities: this.enableAutocomplete,
},
true,
);
@@ -293,6 +293,7 @@ export default {
<template v-if="previewMarkdown && !markdownPreviewLoading">
<div v-if="referencedCommands" class="referenced-commands" v-html="referencedCommands"></div>
<div v-if="shouldShowReferencedUsers" class="referenced-users">
+ <gl-icon name="warning-solid" />
<span v-html="addMultipleToDiscussionWarning"></span>
</div>
</template>
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 fb9636ba734..fb51840b689 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
@@ -72,6 +72,9 @@ export default {
}
return __('Applying suggestions...');
},
+ isLoggedIn() {
+ return Boolean(gon.current_user_id);
+ },
},
methods: {
applySuggestion() {
@@ -141,6 +144,7 @@ export default {
</gl-button>
<span v-gl-tooltip.viewport="tooltipMessage" tabindex="0">
<gl-button
+ v-if="isLoggedIn"
class="btn-inverted js-apply-btn btn-grouped"
:disabled="isDisableButton"
variant="success"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 5d47aed9643..5824cb9438f 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -61,43 +61,59 @@ export default {
<span v-if="canAttachFile" class="uploading-container">
<span class="uploading-progress-container hide">
<template>
- <gl-icon name="media" :size="16" class="gl-vertical-align-text-bottom" />
+ <gl-icon name="media" />
</template>
<span class="attaching-file-message"></span>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<span class="uploading-progress">0%</span>
- <gl-loading-icon inline class="align-text-bottom" />
+ <gl-loading-icon inline />
</span>
<span class="uploading-error-container hide">
<span class="uploading-error-icon">
- <template>
- <gl-icon name="media" :size="16" class="gl-vertical-align-text-bottom" />
- </template>
+ <gl-icon name="media" />
</span>
<span class="uploading-error-message"></span>
<gl-sprintf
:message="
__(
- '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}',
+ '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}.',
)
"
>
<template #retryButton="{content}">
- <button class="retry-uploading-link" type="button">{{ content }}</button>
+ <gl-button
+ variant="link"
+ category="primary"
+ class="retry-uploading-link gl-vertical-align-baseline"
+ >
+ {{ content }}
+ </gl-button>
</template>
<template #newFileButton="{content}">
- <button class="attach-new-file markdown-selector" type="button">{{ content }}</button>
+ <gl-button
+ variant="link"
+ category="primary"
+ class="markdown-selector attach-new-file gl-vertical-align-baseline"
+ >
+ {{ content }}
+ </gl-button>
</template>
</gl-sprintf>
</span>
- <gl-button class="markdown-selector button-attach-file" variant="link">
- <template>
- <gl-icon name="media" :size="16" />
- </template>
- <span class="text-attach-file">{{ __('Attach a file') }}</span>
+ <gl-button
+ icon="media"
+ variant="link"
+ category="primary"
+ class="markdown-selector button-attach-file gl-vertical-align-text-bottom"
+ >
+ {{ __('Attach a file') }}
</gl-button>
- <gl-button class="btn btn-default btn-sm hide button-cancel-uploading-files" variant="link">
+ <gl-button
+ variant="link"
+ category="primary"
+ class="button-cancel-uploading-files gl-vertical-align-baseline hide"
+ >
{{ __('Cancel') }}
</gl-button>
</span>
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
index 8fa3d439fc1..484dbb8fef5 100644
--- 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
@@ -6,7 +6,13 @@ import { s__, sprintf } from '~/locale';
export default {
name: 'UserActionButtons',
- components: { ActionButtonGroup, RemoveMemberButton, LeaveButton },
+ components: {
+ ActionButtonGroup,
+ RemoveMemberButton,
+ LeaveButton,
+ LdapOverrideButton: () =>
+ import('ee_component/vue_shared/components/members/ldap/ldap_override_button.vue'),
+ },
props: {
member: {
type: Object,
@@ -57,5 +63,8 @@ export default {
:title="s__('Member|Remove member')"
/>
</div>
+ <div v-else-if="permissions.canOverride && !member.isOverridden" class="gl-px-1">
+ <ldap-override-button :member="member" />
+ </div>
</action-button-group>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/constants.js b/app/assets/javascripts/vue_shared/components/members/constants.js
index 6509779053e..5885420a122 100644
--- a/app/assets/javascripts/vue_shared/components/members/constants.js
+++ b/app/assets/javascripts/vue_shared/components/members/constants.js
@@ -51,6 +51,7 @@ export const FIELDS = [
key: 'actions',
thClass: 'col-actions',
tdClass: 'col-actions',
+ showFunction: 'showActionsField',
},
];
diff --git a/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue b/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue
new file mode 100644
index 00000000000..0a8af81c1d1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue
@@ -0,0 +1,99 @@
+<script>
+import { GlDatepicker } from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import { getDateInFuture } from '~/lib/utils/datetime_utility';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'ExpirationDatepicker',
+ components: { GlDatepicker },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ permissions: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedDate: null,
+ busy: false,
+ };
+ },
+ computed: {
+ minDate() {
+ // Members expire at the beginning of the day.
+ // The first selectable day should be tomorrow.
+ const today = new Date();
+ const beginningOfToday = new Date(today.setHours(0, 0, 0, 0));
+
+ return getDateInFuture(beginningOfToday, 1);
+ },
+ disabled() {
+ return (
+ this.busy ||
+ !this.permissions.canUpdate ||
+ (this.permissions.canOverride && !this.member.isOverridden)
+ );
+ },
+ },
+ mounted() {
+ if (this.member.expiresAt) {
+ this.selectedDate = new Date(this.member.expiresAt);
+ }
+ },
+ methods: {
+ ...mapActions(['updateMemberExpiration']),
+ handleInput(date) {
+ this.busy = true;
+ this.updateMemberExpiration({
+ memberId: this.member.id,
+ expiresAt: date,
+ })
+ .then(() => {
+ this.$toast.show(s__('Members|Expiration date updated successfully.'));
+ this.busy = false;
+ })
+ .catch(() => {
+ this.busy = false;
+ });
+ },
+ handleClear() {
+ this.busy = true;
+
+ this.updateMemberExpiration({
+ memberId: this.member.id,
+ expiresAt: null,
+ })
+ .then(() => {
+ this.$toast.show(s__('Members|Expiration date removed successfully.'));
+ this.selectedDate = null;
+ this.busy = false;
+ })
+ .catch(() => {
+ this.busy = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <!-- `:target="null"` allows the datepicker to be opened on focus -->
+ <!-- `:container="null"` renders the datepicker in the body to prevent conflicting CSS table styles -->
+ <gl-datepicker
+ v-model="selectedDate"
+ class="gl-max-w-full"
+ show-clear-button
+ :target="null"
+ :container="null"
+ :min-date="minDate"
+ :placeholder="__('Expiration date')"
+ :disabled="disabled"
+ @input="handleInput"
+ @clear="handleClear"
+ />
+</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
index c1a80a85dbe..a4f67caff31 100644
--- a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue
+++ b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue
@@ -1,6 +1,13 @@
<script>
import { mapState } from 'vuex';
import { GlTable, GlBadge } from '@gitlab/ui';
+import MembersTableCell from 'ee_else_ce/vue_shared/components/members/table/members_table_cell.vue';
+import {
+ canOverride,
+ canRemove,
+ canResend,
+ canUpdate,
+} from 'ee_else_ce/vue_shared/components/members/utils';
import { FIELDS } from '../constants';
import initUserPopovers from '~/user_popovers';
import MemberAvatar from './member_avatar.vue';
@@ -8,9 +15,9 @@ 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';
+import ExpirationDatepicker from './expiration_datepicker.vue';
export default {
name: 'MembersTable',
@@ -25,23 +32,56 @@ export default {
MemberActionButtons,
RoleDropdown,
RemoveGroupLinkModal,
+ ExpirationDatepicker,
+ LdapOverrideConfirmationModal: () =>
+ import(
+ 'ee_component/vue_shared/components/members/ldap/ldap_override_confirmation_modal.vue'
+ ),
},
computed: {
- ...mapState(['members', 'tableFields']),
+ ...mapState(['members', 'tableFields', 'tableAttrs', 'currentUserId', 'sourceId']),
filteredFields() {
- return FIELDS.filter(field => this.tableFields.includes(field.key));
+ return FIELDS.filter(field => this.tableFields.includes(field.key) && this.showField(field));
+ },
+ userIsLoggedIn() {
+ return this.currentUserId !== null;
},
},
mounted() {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
},
+ methods: {
+ showField(field) {
+ if (!Object.prototype.hasOwnProperty.call(field, 'showFunction')) {
+ return true;
+ }
+
+ return this[field.showFunction]();
+ },
+ showActionsField() {
+ if (!this.userIsLoggedIn) {
+ return false;
+ }
+
+ return this.members.some(member => {
+ return (
+ canRemove(member, this.sourceId) ||
+ canResend(member) ||
+ canUpdate(member, this.currentUserId, this.sourceId) ||
+ canOverride(member)
+ );
+ });
+ },
+ },
};
</script>
<template>
<div>
<gl-table
+ v-bind="tableAttrs.table"
class="members-table"
+ data-testid="members-table"
head-variant="white"
stacked="lg"
:fields="filteredFields"
@@ -50,6 +90,7 @@ export default {
thead-class="border-bottom"
:empty-text="__('No members found')"
show-empty
+ :tbody-tr-attr="tableAttrs.tr"
>
<template #cell(account)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser }" :member="member">
@@ -85,11 +126,17 @@ export default {
<template #cell(maxRole)="{ item: member }">
<members-table-cell #default="{ permissions }" :member="member">
- <role-dropdown v-if="permissions.canUpdate" :member="member" />
+ <role-dropdown v-if="permissions.canUpdate" :permissions="permissions" :member="member" />
<gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge>
</members-table-cell>
</template>
+ <template #cell(expiration)="{ item: member }">
+ <members-table-cell #default="{ permissions }" :member="member">
+ <expiration-datepicker :permissions="permissions" :member="member" />
+ </members-table-cell>
+ </template>
+
<template #cell(actions)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member">
<member-action-buttons
@@ -106,5 +153,6 @@ export default {
</template>
</gl-table>
<remove-group-link-modal />
+ <ldap-override-confirmation-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
index 5602978bb6c..11e1aef9803 100644
--- 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
@@ -1,6 +1,7 @@
<script>
import { mapState } from 'vuex';
import { MEMBER_TYPES } from '../constants';
+import { isGroup, isDirectMember, isCurrentUser, canRemove, canResend, canUpdate } from '../utils';
export default {
name: 'MembersTableCell',
@@ -13,7 +14,7 @@ export default {
computed: {
...mapState(['sourceId', 'currentUserId']),
isGroup() {
- return Boolean(this.member.sharedWithGroup);
+ return isGroup(this.member);
},
isInvite() {
return Boolean(this.member.invite);
@@ -33,19 +34,19 @@ export default {
return MEMBER_TYPES.user;
},
isDirectMember() {
- return this.isGroup || this.member.source?.id === this.sourceId;
+ return isDirectMember(this.member, this.sourceId);
},
isCurrentUser() {
- return this.member.user?.id === this.currentUserId;
+ return isCurrentUser(this.member, this.currentUserId);
},
canRemove() {
- return this.isDirectMember && this.member.canRemove;
+ return canRemove(this.member, this.sourceId);
},
canResend() {
- return Boolean(this.member.invite?.canResend);
+ return canResend(this.member);
},
canUpdate() {
- return !this.isCurrentUser && this.isDirectMember && this.member.canUpdate;
+ return canUpdate(this.member, this.currentUserId, this.sourceId);
},
},
render() {
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
index 2b40ccc3a9d..6f6cae6072d 100644
--- a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue
@@ -9,12 +9,18 @@ export default {
components: {
GlDropdown,
GlDropdownItem,
+ LdapDropdownItem: () =>
+ import('ee_component/vue_shared/components/members/ldap/ldap_dropdown_item.vue'),
},
props: {
member: {
type: Object,
required: true,
},
+ permissions: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -22,8 +28,21 @@ export default {
busy: false,
};
},
+ computed: {
+ disabled() {
+ return this.busy || (this.permissions.canOverride && !this.member.isOverridden);
+ },
+ },
mounted() {
this.isDesktop = bp.isDesktop();
+
+ // Bootstrap Vue and GlDropdown to not support adding attributes to the dropdown toggle
+ // This can be changed once https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1060 is implemented
+ const dropdownToggle = this.$refs.glDropdown.$el.querySelector('.dropdown-toggle');
+
+ if (dropdownToggle) {
+ dropdownToggle.setAttribute('data-qa-selector', 'access_level_dropdown');
+ }
},
methods: {
...mapActions(['updateMemberRole']),
@@ -52,19 +71,25 @@ export default {
<template>
<gl-dropdown
+ ref="glDropdown"
:right="!isDesktop"
:text="member.accessLevel.stringValue"
:header-text="__('Change permissions')"
- :disabled="busy"
+ :disabled="disabled"
>
<gl-dropdown-item
v-for="(value, name) in member.validRoles"
:key="value"
is-check-item
:is-checked="value === member.accessLevel.integerValue"
+ data-qa-selector="access_level_link"
@click="handleSelect(value, name)"
>
{{ name }}
</gl-dropdown-item>
+ <ldap-dropdown-item
+ v-if="permissions.canOverride && member.isOverridden"
+ :member-id="member.id"
+ />
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/utils.js b/app/assets/javascripts/vue_shared/components/members/utils.js
index 782a0b7f96b..4229a62c0a7 100644
--- a/app/assets/javascripts/vue_shared/components/members/utils.js
+++ b/app/assets/javascripts/vue_shared/components/members/utils.js
@@ -17,3 +17,32 @@ export const generateBadges = (member, isCurrentUser) => [
variant: 'info',
},
];
+
+export const isGroup = member => {
+ return Boolean(member.sharedWithGroup);
+};
+
+export const isDirectMember = (member, sourceId) => {
+ return isGroup(member) || member.source?.id === sourceId;
+};
+
+export const isCurrentUser = (member, currentUserId) => {
+ return member.user?.id === currentUserId;
+};
+
+export const canRemove = (member, sourceId) => {
+ return isDirectMember(member, sourceId) && member.canRemove;
+};
+
+export const canResend = member => {
+ return Boolean(member.invite?.canResend);
+};
+
+export const canUpdate = (member, currentUserId, sourceId) => {
+ return (
+ !isCurrentUser(member, currentUserId) && isDirectMember(member, sourceId) && member.canUpdate
+ );
+};
+
+// Defined in `ee/app/assets/javascripts/vue_shared/components/members/utils.js`
+export const canOverride = () => false;
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 cad4439ecea..de9c84dd157 100644
--- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
+++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
@@ -1,8 +1,7 @@
<script>
-import $ from 'jquery';
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import Clipboard from 'clipboard';
-import { __ } from '~/locale';
+import { uniqueId } from 'lodash';
export default {
components: {
@@ -17,6 +16,11 @@ export default {
required: false,
default: '',
},
+ id: {
+ type: String,
+ required: false,
+ default: () => uniqueId('modal-copy-button-'),
+ },
container: {
type: String,
required: false,
@@ -52,7 +56,6 @@ export default {
default: null,
},
},
- copySuccessText: __('Copied'),
computed: {
modalDomId() {
return this.modalId ? `#${this.modalId}` : '';
@@ -68,11 +71,11 @@ export default {
});
this.clipboard
.on('success', e => {
- this.updateTooltip(e.trigger);
+ this.$root.$emit('bv::hide::tooltip', this.id);
this.$emit('success', e);
// Clear the selection and blur the trigger so it loses its border
e.clearSelection();
- $(e.trigger).blur();
+ e.trigger.blur();
})
.on('error', e => this.$emit('error', e));
});
@@ -82,29 +85,11 @@ export default {
this.clipboard.destroy();
}
},
- methods: {
- updateTooltip(target) {
- const $target = $(target);
- const originalTitle = $target.data('originalTitle');
-
- if ($target.tooltip) {
- /**
- * The original tooltip will continue staying there unless we remove it by hand.
- * $target.tooltip('hide') isn't working.
- */
- $('.tooltip').remove();
- $target.attr('title', this.$options.copySuccessText);
- $target.tooltip('_fixTitle');
- $target.tooltip('show');
- $target.attr('title', originalTitle);
- $target.tooltip('_fixTitle');
- }
- },
- },
};
</script>
<template>
<gl-button
+ :id="id"
v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }"
:class="cssClasses"
:data-clipboard-target="target"
diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
index 8e85d93e6d1..1fc39c7cb8e 100644
--- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
+++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
@@ -308,6 +308,6 @@ export default {
@input="handlePageChange"
/>
- <slot v-if="!showItems" name="emtpy-state"></slot>
+ <slot v-if="!showItems" name="empty-state"></slot>
</div>
</template>
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 06b4309ad42..4d47a34c9a3 100644
--- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
@@ -30,8 +30,13 @@ export default {
metadataSlots: [],
};
},
- mounted() {
- this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith('metadata-'));
+ async mounted() {
+ const METADATA_PREFIX = 'metadata-';
+ this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith(METADATA_PREFIX));
+
+ // we need to wait for next tick to ensure that dynamic names slots are picked up
+ await this.$nextTick();
+ this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith(METADATA_PREFIX));
},
};
</script>
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 e1652f54982..82060d2e4ad 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
@@ -1,8 +1,7 @@
<script>
import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui';
-import { isSafeURL } from '~/lib/utils/url_utility';
+import { isSafeURL, joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IMAGE_TABS } from '../../constants';
import UploadImageTab from './upload_image_tab.vue';
@@ -15,7 +14,6 @@ export default {
GlTabs,
GlTab,
},
- mixins: [glFeatureFlagMixin()],
props: {
imageRoot: {
type: String,
@@ -34,10 +32,10 @@ export default {
},
modalTitle: __('Image details'),
okTitle: __('Insert image'),
- urlTabTitle: __('By URL'),
+ urlTabTitle: __('Link to an image'),
urlLabel: __('Image URL'),
descriptionLabel: __('Description'),
- uploadTabTitle: __('Upload file'),
+ uploadTabTitle: __('Upload an image'),
computed: {
altText() {
return this.description;
@@ -54,7 +52,7 @@ export default {
this.$refs.modal.show();
},
onOk(event) {
- if (this.glFeatures.sseImageUploads && this.tabIndex === IMAGE_TABS.UPLOAD_TAB) {
+ if (this.tabIndex === IMAGE_TABS.UPLOAD_TAB) {
this.submitFile(event);
return;
}
@@ -74,7 +72,7 @@ export default {
return;
}
- const imageUrl = `${this.imageRoot}${file.name}`;
+ const imageUrl = joinPaths(this.imageRoot, file.name);
this.$emit('addImage', { imageUrl, file, altText: altText || file.name });
},
@@ -108,7 +106,7 @@ export default {
:ok-title="$options.okTitle"
@ok="onOk"
>
- <gl-tabs v-if="glFeatures.sseImageUploads" v-model="tabIndex">
+ <gl-tabs v-model="tabIndex">
<!-- Upload file Tab -->
<gl-tab :title="$options.uploadTabTitle">
<upload-image-tab ref="uploadImageTab" @input="setFile" />
@@ -128,17 +126,6 @@ export default {
</gl-tab>
</gl-tabs>
- <gl-form-group
- v-else
- class="gl-mt-5 gl-mb-3"
- :label="$options.urlLabel"
- label-for="url-input"
- :state="!Boolean(urlError)"
- :invalid-feedback="urlError"
- >
- <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
- </gl-form-group>
-
<!-- Description Input -->
<gl-form-group :label="$options.descriptionLabel" label-for="description-input">
<gl-form-input id="description-input" ref="descriptionInput" v-model="description" />
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 c2518441506..9eacf74bba8 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
@@ -53,7 +53,6 @@ export default {
imageRoot: {
type: String,
required: true,
- validator: prop => prop.endsWith('/'),
},
},
data() {
@@ -115,10 +114,9 @@ export default {
if (file) {
this.$emit('uploadImage', { file, imageUrl });
- // TODO - ensure that the actual repo URL for the image is used in Markdown mode
}
- addImage(this.editorInstance, image);
+ addImage(this.editorInstance, image, file);
},
onOpenInsertVideoModal() {
this.$refs.insertVideoModal.show();
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
index 2bce691e793..9744e25a8e1 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
@@ -99,6 +99,10 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) =>
? `\n\n${node.innerText}\n\n`
: baseRenderer.convert(node, subContent);
},
+ IMG(node) {
+ const { originalSrc } = node.dataset;
+ return `![${node.alt}](${originalSrc || node.src})`;
+ },
};
};
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 8b3fbcabcfa..463e64b4936 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
@@ -34,6 +34,20 @@ const buildVideoIframe = src => {
return wrapper;
};
+const buildImg = (alt, originalSrc, file) => {
+ const img = document.createElement('img');
+ const src = file ? URL.createObjectURL(file) : originalSrc;
+ const attributes = { alt, src };
+
+ if (file) {
+ img.dataset.originalSrc = originalSrc;
+ }
+
+ Object.assign(img, attributes);
+
+ return img;
+};
+
export const generateToolbarItem = config => {
const { icon, classes, event, command, tooltip, isDivider } = config;
@@ -59,7 +73,14 @@ export const addCustomEventListener = (editorApi, event, handler) => {
export const removeCustomEventListener = (editorApi, event, handler) =>
editorApi.eventManager.removeEventHandler(event, handler);
-export const addImage = ({ editor }, image) => editor.exec('AddImage', image);
+export const addImage = ({ editor }, { altText, imageUrl }, file) => {
+ if (editor.isWysiwygMode()) {
+ const img = buildImg(altText, imageUrl, file);
+ editor.getSquire().insertElement(img);
+ } else {
+ editor.insertText(`![${altText}](${imageUrl})`);
+ }
+};
export const insertVideo = ({ editor }, url) => {
const videoIframe = buildVideoIframe(url);
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql
new file mode 100644
index 00000000000..ff0626167a9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql
@@ -0,0 +1,20 @@
+query getRunnerPlatforms($projectPath: ID!, $groupPath: ID!) {
+ runnerPlatforms {
+ nodes {
+ name
+ humanReadableName
+ architectures {
+ nodes {
+ name
+ downloadLocation
+ }
+ }
+ }
+ }
+ project(fullPath: $projectPath) {
+ id
+ }
+ group(fullPath: $groupPath) {
+ id
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql
new file mode 100644
index 00000000000..643c1991807
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql
@@ -0,0 +1,16 @@
+query runnerSetupInstructions(
+ $platform: String!
+ $architecture: String!
+ $projectId: ID!
+ $groupId: ID!
+) {
+ runnerSetup(
+ platform: $platform
+ architecture: $architecture
+ projectId: $projectId
+ groupId: $groupId
+ ) {
+ installInstructions
+ registerInstructions
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
new file mode 100644
index 00000000000..b70b1277155
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
@@ -0,0 +1,220 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlModal,
+ GlModalDirective,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+} from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import getRunnerPlatforms from './graphql/queries/get_runner_platforms.query.graphql';
+import getRunnerSetupInstructions from './graphql/queries/get_runner_setup.query.graphql';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ GlIcon,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ inject: {
+ projectPath: {
+ default: '',
+ },
+ groupPath: {
+ default: '',
+ },
+ },
+ apollo: {
+ runnerPlatforms: {
+ query: getRunnerPlatforms,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ groupPath: this.groupPath,
+ };
+ },
+ update(data) {
+ return data;
+ },
+ error() {
+ this.showAlert = true;
+ },
+ },
+ },
+ data() {
+ return {
+ showAlert: false,
+ selectedPlatformArchitectures: [],
+ selectedPlatform: {},
+ selectedArchitecture: {},
+ runnerPlatforms: {},
+ instructions: {},
+ };
+ },
+ computed: {
+ isPlatformSelected() {
+ return Object.keys(this.selectedPlatform).length > 0;
+ },
+ instructionsEmpty() {
+ return this.instructions && Object.keys(this.instructions).length === 0;
+ },
+ groupId() {
+ return this.runnerPlatforms?.group?.id ?? '';
+ },
+ projectId() {
+ return this.runnerPlatforms?.project?.id ?? '';
+ },
+ platforms() {
+ return this.runnerPlatforms.runnerPlatforms?.nodes;
+ },
+ },
+ methods: {
+ selectPlatform(name) {
+ this.selectedPlatform = this.platforms.find(platform => platform.name === name);
+ this.selectedPlatformArchitectures = this.selectedPlatform?.architectures?.nodes;
+ [this.selectedArchitecture] = this.selectedPlatformArchitectures;
+ this.selectArchitecture(this.selectedArchitecture);
+ },
+ selectArchitecture(architecture) {
+ this.selectedArchitecture = architecture;
+
+ this.$apollo.addSmartQuery('instructions', {
+ variables() {
+ return {
+ platform: this.selectedPlatform.name,
+ architecture: this.selectedArchitecture.name,
+ projectId: this.projectId,
+ groupId: this.groupId,
+ };
+ },
+ query: getRunnerSetupInstructions,
+ update(data) {
+ return data?.runnerSetup;
+ },
+ error() {
+ this.showAlert = true;
+ },
+ });
+ },
+ toggleAlert(state) {
+ this.showAlert = state;
+ },
+ },
+ modalId: 'installation-instructions-modal',
+ i18n: {
+ installARunner: __('Install a Runner'),
+ architecture: s__('Runners|Architecture'),
+ downloadInstallBinary: s__('Runners|Download and Install Binary'),
+ downloadLatestBinary: s__('Runners|Download Latest Binary'),
+ registerRunner: s__('Runners|Register Runner'),
+ method: __('Method'),
+ fetchError: s__('An error has occurred fetching instructions'),
+ instructions: __('Show Runner installation instructions'),
+ },
+ closeButton: {
+ text: __('Close'),
+ attributes: [{ variant: 'default' }],
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-button v-gl-modal-directive="$options.modalId" data-testid="show-modal-button">
+ {{ $options.i18n.instructions }}
+ </gl-button>
+ <gl-modal
+ :modal-id="$options.modalId"
+ :title="$options.i18n.installARunner"
+ :action-secondary="$options.closeButton"
+ >
+ <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)">
+ {{ $options.i18n.fetchError }}
+ </gl-alert>
+ <h5>{{ __('Environment') }}</h5>
+ <gl-button-group class="gl-mb-5">
+ <gl-button
+ v-for="platform in platforms"
+ :key="platform.name"
+ data-testid="platform-button"
+ @click="selectPlatform(platform.name)"
+ >
+ {{ platform.humanReadableName }}
+ </gl-button>
+ </gl-button-group>
+ <template v-if="isPlatformSelected">
+ <h5>
+ {{ $options.i18n.architecture }}
+ </h5>
+ <gl-dropdown class="gl-mb-5" :text="selectedArchitecture.name">
+ <gl-dropdown-item
+ v-for="architecture in selectedPlatformArchitectures"
+ :key="architecture.name"
+ data-testid="architecture-dropdown-item"
+ @click="selectArchitecture(architecture)"
+ >
+ {{ architecture.name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <div class="gl-display-flex gl-align-items-center gl-mb-5">
+ <h5>{{ $options.i18n.downloadInstallBinary }}</h5>
+ <gl-button
+ class="gl-ml-auto"
+ :href="selectedArchitecture.downloadLocation"
+ download
+ data-testid="binary-download-button"
+ >
+ {{ $options.i18n.downloadLatestBinary }}
+ </gl-button>
+ </div>
+ </template>
+ <template v-if="!instructionsEmpty">
+ <div class="gl-display-flex">
+ <pre
+ class="bg-light gl-flex-fill-1 gl-white-space-pre-line"
+ data-testid="binary-instructions"
+ >
+ {{ instructions.installInstructions }}
+ </pre>
+ <gl-button
+ class="gl-align-self-start gl-ml-2 gl-mt-2"
+ category="tertiary"
+ variant="link"
+ :data-clipboard-text="instructions.installationInstructions"
+ >
+ <gl-icon name="copy-to-clipboard" />
+ </gl-button>
+ </div>
+
+ <hr />
+ <h5 class="gl-mb-5">{{ $options.i18n.registerRunner }}</h5>
+ <h5 class="gl-mb-5">{{ $options.i18n.method }}</h5>
+ <div class="gl-display-flex">
+ <pre
+ class="bg-light gl-flex-fill-1 gl-white-space-pre-line"
+ data-testid="runner-instructions"
+ >
+ {{ instructions.registerInstructions }}
+ </pre>
+ <gl-button
+ class="gl-align-self-start gl-ml-2 gl-mt-2"
+ category="tertiary"
+ variant="link"
+ :data-clipboard-text="instructions.registerInstructions"
+ >
+ <gl-icon name="copy-to-clipboard" />
+ </gl-button>
+ </div>
+ </template>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
new file mode 100644
index 00000000000..1d3bd312b09
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
@@ -0,0 +1,211 @@
+<script>
+import {
+ GlIcon,
+ GlLoadingIcon,
+ GlDropdown,
+ GlDropdownForm,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlButton,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
+
+import axios from '~/lib/utils/axios_utils';
+
+export default {
+ components: {
+ GlIcon,
+ GlLoadingIcon,
+ GlDropdown,
+ GlDropdownForm,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlButton,
+ },
+ directives: {
+ GlTooltip,
+ },
+ props: {
+ projectsFetchPath: {
+ type: String,
+ required: true,
+ },
+ dropdownButtonTitle: {
+ type: String,
+ required: true,
+ },
+ dropdownHeaderTitle: {
+ type: String,
+ required: true,
+ },
+ moveInProgress: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ projectsListLoading: false,
+ projectsListLoadFailed: false,
+ searchKey: '',
+ projects: [],
+ selectedProject: null,
+ projectItemClick: false,
+ };
+ },
+ computed: {
+ hasNoSearchResults() {
+ return Boolean(
+ !this.projectsListLoading &&
+ !this.projectsListLoadFailed &&
+ this.searchKey &&
+ !this.projects.length,
+ );
+ },
+ failedToLoadResults() {
+ return !this.projectsListLoading && this.projectsListLoadFailed;
+ },
+ },
+ watch: {
+ searchKey(value = '') {
+ this.fetchProjects(value);
+ },
+ },
+ methods: {
+ fetchProjects(search = '') {
+ this.projectsListLoading = true;
+ this.projectsListLoadFailed = false;
+ return axios
+ .get(this.projectsFetchPath, {
+ params: {
+ search,
+ },
+ })
+ .then(({ data }) => {
+ this.projects = data;
+ this.$refs.searchInput.focusInput();
+ })
+ .catch(() => {
+ this.projectsListLoadFailed = true;
+ })
+ .finally(() => {
+ this.projectsListLoading = false;
+ });
+ },
+ isSelectedProject(project) {
+ if (this.selectedProject) {
+ return this.selectedProject.id === project.id;
+ }
+ return false;
+ },
+ /**
+ * This handler is to prevent dropdown
+ * from closing when an item is selected
+ * and emit an event only when dropdown closes.
+ */
+ handleDropdownHide(e) {
+ if (this.projectItemClick) {
+ e.preventDefault();
+ this.projectItemClick = false;
+ } else {
+ this.$emit('dropdown-close');
+ }
+ },
+ handleDropdownCloseClick() {
+ this.$refs.dropdown.hide();
+ },
+ handleProjectSelect(project) {
+ this.selectedProject = project.id === this.selectedProject?.id ? null : project;
+ this.projectItemClick = true;
+ },
+ handleMoveClick() {
+ this.$refs.dropdown.hide();
+ this.$emit('move-issuable', this.selectedProject);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="block js-issuable-move-block issuable-move-dropdown sidebar-move-issue-dropdown">
+ <div
+ v-gl-tooltip.left.viewport
+ data-testid="move-collapsed"
+ :title="dropdownButtonTitle"
+ class="sidebar-collapsed-icon"
+ @click="$emit('toggle-collapse')"
+ >
+ <gl-icon name="arrow-right" />
+ </div>
+ <gl-dropdown
+ ref="dropdown"
+ :block="true"
+ :disabled="moveInProgress"
+ class="hide-collapsed"
+ toggle-class="js-sidebar-dropdown-toggle"
+ @shown="fetchProjects"
+ @hide="handleDropdownHide"
+ >
+ <template #button-content
+ ><gl-loading-icon v-if="moveInProgress" class="gl-mr-3" />{{
+ dropdownButtonTitle
+ }}</template
+ >
+ <gl-dropdown-form class="gl-pt-0">
+ <div
+ data-testid="header"
+ class="gl-display-flex gl-pb-3 gl-border-1 gl-border-b-solid gl-border-gray-100"
+ >
+ <span class="gl-flex-grow-1 gl-text-center gl-font-weight-bold gl-py-1">{{
+ dropdownHeaderTitle
+ }}</span>
+ <gl-button
+ variant="link"
+ icon="close"
+ class="gl-mr-2 gl-w-auto! gl-p-2!"
+ @click.prevent="handleDropdownCloseClick"
+ />
+ </div>
+ <gl-search-box-by-type
+ ref="searchInput"
+ v-model.trim="searchKey"
+ :placeholder="__('Search project')"
+ :debounce="300"
+ />
+ <div data-testid="content" class="dropdown-content">
+ <gl-loading-icon v-if="projectsListLoading" size="md" class="gl-p-5" />
+ <ul v-else>
+ <gl-dropdown-item
+ v-for="project in projects"
+ :key="project.id"
+ :is-check-item="true"
+ :is-checked="isSelectedProject(project)"
+ @click.stop.prevent="handleProjectSelect(project)"
+ >{{ project.name_with_namespace }}</gl-dropdown-item
+ >
+ </ul>
+ <div v-if="hasNoSearchResults" class="gl-text-center gl-p-3">
+ {{ __('No matching results') }}
+ </div>
+ <div v-if="failedToLoadResults" class="gl-text-center gl-p-3">
+ {{ __('Failed to load projects') }}
+ </div>
+ </div>
+ <div
+ data-testid="footer"
+ class="gl-pt-3 gl-px-3 gl-border-1 gl-border-t-solid gl-border-gray-100"
+ >
+ <gl-button
+ category="primary"
+ variant="success"
+ :disabled="!Boolean(selectedProject)"
+ class="gl-text-center! issuable-move-button"
+ @click="handleMoveClick"
+ >{{ __('Move') }}</gl-button
+ >
+ </div>
+ </gl-dropdown-form>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
index c2ebf78d541..973cc314ee3 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
@@ -1,11 +1,10 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
@@ -45,12 +44,9 @@ export default {
<template>
<div
- v-tooltip
+ v-gl-tooltip.left.viewport
:title="labelsList"
class="sidebar-collapsed-icon"
- data-placement="left"
- data-container="body"
- data-boundary="viewport"
@click="handleClick"
>
<gl-icon name="labels" />
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 353dee862d0..a365673f7a1 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
@@ -92,6 +92,13 @@ export default {
}
}
},
+ handleComponentAppear() {
+ // We can avoid putting `catch` block here
+ // as failure is handled within actions.js already.
+ return this.fetchLabels().then(() => {
+ this.$refs.searchInput.focusInput();
+ });
+ },
/**
* We want to remove loaded labels to ensure component
* fetches fresh set of labels every time when shown.
@@ -139,7 +146,7 @@ export default {
</script>
<template>
- <gl-intersection-observer @appear="fetchLabels" @disappear="handleComponentDisappear">
+ <gl-intersection-observer @appear="handleComponentAppear" @disappear="handleComponentDisappear">
<div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
<div
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
@@ -158,8 +165,8 @@ export default {
</div>
<div class="dropdown-input" @click.stop="() => {}">
<gl-search-box-by-type
+ ref="searchInput"
v-model="searchKey"
- :autofocus="true"
:disabled="labelsFetchInProgress"
data-qa-selector="dropdown_input_field"
/>
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 e624bd1eaee..14b46c1c431 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
@@ -20,7 +20,7 @@ export const receiveLabelsFailure = ({ commit }) => {
};
export const fetchLabels = ({ state, dispatch }) => {
dispatch('requestLabels');
- axios
+ return axios
.get(state.labelsFetchPath)
.then(({ data }) => {
dispatch('receiveLabelsSuccess', data);
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
new file mode 100644
index 00000000000..c5bbe1b33fb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
@@ -0,0 +1,29 @@
+<script>
+import { GlDropdown, GlDropdownForm } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlDropdownForm,
+ GlDropdown,
+ },
+ props: {
+ headerText: {
+ type: String,
+ required: true,
+ },
+ text: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown class="show" :text="text" :header-text="headerText">
+ <slot name="search"></slot>
+ <gl-dropdown-form>
+ <slot name="items"></slot>
+ </gl-dropdown-form>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql
new file mode 100644
index 00000000000..612a0c02e82
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql
@@ -0,0 +1,13 @@
+query issueParticipants($id: IssueID!) {
+ issue(id: $id) {
+ participants {
+ nodes {
+ username
+ name
+ webUrl
+ avatarUrl
+ id
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql
new file mode 100644
index 00000000000..9ead95a3801
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql
@@ -0,0 +1,17 @@
+mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $projectPath: ID!) {
+ issueSetAssignees(
+ input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath }
+ ) {
+ issue {
+ assignees {
+ nodes {
+ username
+ id
+ name
+ webUrl
+ avatarUrl
+ }
+ }
+ }
+ }
+}
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 f2e9c4a4fbb..9b6d0a87374 100644
--- a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
+++ b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
@@ -1,7 +1,7 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
-import { roundOffFloat } from '~/lib/utils/common_utils';
+import { roundDownFloat } from '~/lib/utils/common_utils';
export default {
directives: {
@@ -89,7 +89,7 @@ export default {
return 0;
}
- const percent = roundOffFloat((count / this.totalCount) * 100, 1);
+ const percent = roundDownFloat((count / this.totalCount) * 100, 1);
if (percent > 0 && percent < 1) {
return '< 1';
}
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/constants.js b/app/assets/javascripts/vue_shared/components/upload_dropzone/constants.js
new file mode 100644
index 00000000000..85414704cb6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/constants.js
@@ -0,0 +1,9 @@
+// We may wish to make this more restrictive, as per
+// https://gitlab.com/gitlab-org/gitlab/issues/118611
+export const VALID_IMAGE_FILE_MIMETYPE = {
+ mimetype: 'image/*',
+ regex: /image\/.+/,
+};
+
+// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
+export const VALID_DATA_TRANSFER_TYPE = 'Files';
diff --git a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
index 6694b0dab8d..b645758d891 100644
--- a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
@@ -1,10 +1,8 @@
<script>
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
-import uploadDesignMutation from '../../graphql/mutations/upload_design.mutation.graphql';
-import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages';
-import { isValidDesignFile } from '../../utils/design_management_utils';
-import { VALID_DATA_TRANSFER_TYPE, VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
+import { __ } from '~/locale';
+import { isValidImage } from './utils';
+import { VALID_DATA_TRANSFER_TYPE, VALID_IMAGE_FILE_MIMETYPE } from './constants';
export default {
components: {
@@ -13,15 +11,31 @@ export default {
GlSprintf,
},
props: {
- hasDesigns: {
+ displayAsCard: {
type: Boolean,
- required: true,
+ required: false,
+ default: false,
},
- isDraggingDesign: {
+ enableDragBehavior: {
type: Boolean,
required: false,
default: false,
},
+ dropToStartMessage: {
+ type: String,
+ required: false,
+ default: __('Drop your files to start your upload.'),
+ },
+ isFileValid: {
+ type: Function,
+ required: false,
+ default: isValidImage,
+ },
+ validFileMimetypes: {
+ type: Array,
+ required: false,
+ default: () => [VALID_IMAGE_FILE_MIMETYPE.mimetype],
+ },
},
data() {
return {
@@ -35,14 +49,17 @@ export default {
},
iconStyles() {
return {
- size: this.hasDesigns ? 24 : 16,
- class: this.hasDesigns ? 'gl-mb-2' : 'gl-mr-3 gl-text-gray-500',
+ size: this.displayAsCard ? 24 : 16,
+ class: this.displayAsCard ? 'gl-mb-2' : 'gl-mr-3 gl-text-gray-500',
};
},
+ validMimeTypeString() {
+ return this.validFileMimetypes.join();
+ },
},
methods: {
isValidUpload(files) {
- return files.every(isValidDesignFile);
+ return files.every(this.isFileValid);
},
isValidDragDataType({ dataTransfer }) {
return Boolean(dataTransfer && dataTransfer.types.some(t => t === VALID_DATA_TRANSFER_TYPE));
@@ -56,7 +73,7 @@ export default {
const { files } = dataTransfer;
if (!this.isValidUpload(Array.from(files))) {
- createFlash(UPLOAD_DESIGN_INVALID_FILETYPE_ERROR);
+ this.$emit('error');
return;
}
@@ -72,12 +89,10 @@ export default {
openFileUpload() {
this.$refs.fileUpload.click();
},
- onDesignInputChange(e) {
+ onFileInputChange(e) {
this.$emit('change', e.target.files);
},
},
- uploadDesignMutation,
- VALID_DESIGN_FILE_MIMETYPE,
};
</script>
@@ -93,23 +108,25 @@ export default {
>
<slot>
<button
- 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"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
@click="openFileUpload"
>
<div
- :class="{ 'gl-flex-direction-column': hasDesigns }"
+ :class="{ 'gl-flex-direction-column': displayAsCard }"
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center"
data-testid="dropzone-area"
>
<gl-icon name="upload" :size="iconStyles.size" :class="iconStyles.class" />
<p class="gl-mb-0">
- <gl-sprintf :message="__('Drop or %{linkStart}upload%{linkEnd} designs to attach')">
- <template #link="{ content }">
- <gl-link @click.stop="openFileUpload">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
+ <slot name="upload-text" :openFileUpload="openFileUpload">
+ <gl-sprintf :message="__('Drop or %{linkStart}upload%{linkEnd} files to attach')">
+ <template #link="{ content }">
+ <gl-link @click.stop="openFileUpload">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </slot>
</p>
</div>
</button>
@@ -117,29 +134,37 @@ export default {
<input
ref="fileUpload"
type="file"
- name="design_file"
- :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
+ name="upload_file"
+ :accept="validFileMimetypes"
class="hide"
multiple
- @change="onDesignInputChange"
+ @change="onFileInputChange"
/>
</slot>
- <transition name="design-dropzone-fade">
+ <transition name="upload-dropzone-fade">
<div
- v-show="dragging && !isDraggingDesign"
- 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"
+ v-show="dragging && !enableDragBehavior"
+ class="card upload-dropzone-border upload-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 gl-text-center">
- <h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Oh no!') }}</h3>
- <span>{{
- __(
- 'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.',
- )
- }}</span>
+ <slot name="invalid-drag-data-slot">
+ <h3 :class="{ 'gl-font-base gl-display-inline': !displayAsCard }">
+ {{ __('Oh no!') }}
+ </h3>
+ <span>{{
+ __(
+ 'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.',
+ )
+ }}</span>
+ </slot>
</div>
<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>
+ <slot name="valid-drag-data-slot">
+ <h3 :class="{ 'gl-font-base gl-display-inline': !displayAsCard }">
+ {{ __('Incoming!') }}
+ </h3>
+ <span>{{ dropToStartMessage }}</span>
+ </slot>
</div>
</div>
</transition>
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/utils.js b/app/assets/javascripts/vue_shared/components/upload_dropzone/utils.js
new file mode 100644
index 00000000000..cf51a570d46
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/utils.js
@@ -0,0 +1,4 @@
+import { VALID_IMAGE_FILE_MIMETYPE } from './constants';
+
+export const isValidImage = ({ type }) =>
+ (type.match(VALID_IMAGE_FILE_MIMETYPE.regex) || []).length > 0;
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 3f5738b2b93..2ab4c55d9b0 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
@@ -6,6 +6,7 @@ import {
GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlIcon,
} from '@gitlab/ui';
+import UserAvailabilityStatus from '~/set_status_modal/components/user_availability_status.vue';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
import { glEmojiTag } from '../../../emoji';
@@ -25,6 +26,7 @@ export default {
GlPopover,
GlSkeletonLoading,
UserAvatarImage,
+ UserAvailabilityStatus,
},
props: {
target: {
@@ -63,6 +65,9 @@ export default {
websiteUrl.length
);
},
+ availabilityStatus() {
+ return this.user?.status?.availability || null;
+ },
},
};
</script>
@@ -89,6 +94,10 @@ export default {
<div class="gl-mb-3">
<h5 class="gl-m-0">
{{ user.name }}
+ <user-availability-status
+ v-if="availabilityStatus"
+ :availability="availabilityStatus"
+ />
</h5>
<span class="gl-text-gray-500">@{{ user.username }}</span>
</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 877414519f7..dbb1a075e76 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -169,7 +169,7 @@ export default {
</script>
<template>
- <div class="d-inline-block gl-ml-3">
+ <div class="gl-sm-ml-3">
<actions-button
:actions="actions"
:selected-key="selection"
diff --git a/app/assets/javascripts/vue_shared/directives/validation.js b/app/assets/javascripts/vue_shared/directives/validation.js
new file mode 100644
index 00000000000..09bec78edcc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/directives/validation.js
@@ -0,0 +1,132 @@
+import { merge } from 'lodash';
+import { s__ } from '~/locale';
+
+/**
+ * Validation messages will take priority based on the property order.
+ *
+ * For example:
+ * { valueMissing: {...}, urlTypeMismatch: {...} }
+ *
+ * `valueMissing` will be displayed the user has entered a value
+ * after that, if the input is not a valid URL then `urlTypeMismatch` will show
+ */
+const defaultFeedbackMap = {
+ valueMissing: {
+ isInvalid: el => el.validity?.valueMissing,
+ message: s__('Please fill out this field.'),
+ },
+ urlTypeMismatch: {
+ isInvalid: el => el.type === 'url' && el.validity?.typeMismatch,
+ message: s__('Please enter a valid URL format, ex: http://www.example.com/home'),
+ },
+};
+
+const getFeedbackForElement = (feedbackMap, el) =>
+ Object.values(feedbackMap).find(f => f.isInvalid(el))?.message || el.validationMessage;
+
+const focusFirstInvalidInput = e => {
+ const { target: formEl } = e;
+ const invalidInput = formEl.querySelector('input:invalid');
+
+ if (invalidInput) {
+ invalidInput.focus();
+ }
+};
+
+const isEveryFieldValid = form => Object.values(form.fields).every(({ state }) => state === true);
+
+const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = false }) => {
+ const { form } = context;
+ const { name } = el;
+
+ if (!name) {
+ if (process.env.NODE_ENV === 'development') {
+ // eslint-disable-next-line no-console
+ console.warn(
+ '[gitlab] the validation directive requires the given input to have "name" attribute',
+ );
+ }
+ return;
+ }
+
+ const formField = form.fields[name];
+ const isValid = el.checkValidity();
+
+ // This makes sure we always report valid fields - this can be useful for cases where the consuming
+ // component's logic depends on certain fields being in a valid state.
+ // Invalid input, on the other hand, should only be reported once we want to display feedback to the user.
+ // (eg.: After a field has been touched and moved away from, a submit-button has been clicked, ...)
+ formField.state = reportInvalidInput ? isValid : isValid || null;
+ formField.feedback = reportInvalidInput ? getFeedbackForElement(feedbackMap, el) : '';
+
+ form.state = isEveryFieldValid(form);
+};
+
+/**
+ * Takes an object that allows to add or change custom feedback messages.
+ *
+ * The passed in object will be merged with the built-in feedback
+ * so it is possible to override a built-in message.
+ *
+ * @example
+ * validate({
+ * tooLong: {
+ * check: el => el.validity.tooLong === true,
+ * message: 'Your custom feedback'
+ * }
+ * })
+ *
+ * @example
+ * validate({
+ * valueMissing: {
+ * message: 'Your custom feedback'
+ * }
+ * })
+ *
+ * @param {Object<string, { message: string, isValid: ?function}>} customFeedbackMap
+ * @returns {{ inserted: function, update: function }} validateDirective
+ */
+export default function(customFeedbackMap = {}) {
+ const feedbackMap = merge(defaultFeedbackMap, customFeedbackMap);
+ const elDataMap = new WeakMap();
+
+ return {
+ inserted(el, binding, { context }) {
+ const { arg: showGlobalValidation } = binding;
+ const { form: formEl } = el;
+
+ const validate = createValidator(context, feedbackMap);
+ const elData = { validate, isTouched: false, isBlurred: false };
+
+ elDataMap.set(el, elData);
+
+ el.addEventListener('input', function markAsTouched() {
+ elData.isTouched = true;
+ // once the element has been marked as touched we can stop listening on the 'input' event
+ el.removeEventListener('input', markAsTouched);
+ });
+
+ el.addEventListener('blur', function markAsBlurred({ target }) {
+ if (elData.isTouched) {
+ elData.isBlurred = true;
+ validate({ el: target, reportInvalidInput: true });
+ // this event handler can be removed, since the live-feedback in `update` takes over
+ el.removeEventListener('blur', markAsBlurred);
+ }
+ });
+
+ if (formEl) {
+ formEl.addEventListener('submit', focusFirstInvalidInput);
+ }
+
+ validate({ el, reportInvalidInput: showGlobalValidation });
+ },
+ update(el, binding) {
+ const { arg: showGlobalValidation } = binding;
+ const { validate, isTouched, isBlurred } = elDataMap.get(el);
+ const showValidationFeedback = showGlobalValidation || (isTouched && isBlurred);
+
+ validate({ el, reportInvalidInput: showValidationFeedback });
+ },
+ };
+}
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 c0fc055a01b..56da2637825 100644
--- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
@@ -1,7 +1,6 @@
import { isEmpty } from 'lodash';
import { sprintf, __ } from '~/locale';
import { formatDate } from '~/lib/utils/datetime_utility';
-import tooltip from '~/vue_shared/directives/tooltip';
import timeagoMixin from '~/vue_shared/mixins/timeago';
const mixins = {
@@ -99,9 +98,6 @@ const mixins = {
default: () => ({}),
},
},
- directives: {
- tooltip,
- },
mixins: [timeagoMixin],
computed: {
hasState() {
diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js
new file mode 100644
index 00000000000..2f87c4e7878
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/constants.js
@@ -0,0 +1,3 @@
+export const FEEDBACK_TYPE_DISMISSAL = 'dismissal';
+export const FEEDBACK_TYPE_ISSUE = 'issue';
+export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request';
diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
index d5696e3c8cf..89253cc7116 100644
--- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
@@ -3,6 +3,7 @@ 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 { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
import Flash from '~/flash';
import Api from '~/api';
@@ -52,12 +53,27 @@ export default {
});
},
methods: {
- checkHasSecurityReports(reportTypes) {
- return Api.pipelineJobs(this.projectId, this.pipelineId).then(({ data: jobs }) =>
- jobs.some(({ artifacts = [] }) =>
+ async checkHasSecurityReports(reportTypes) {
+ let page = 1;
+ while (page) {
+ // eslint-disable-next-line no-await-in-loop
+ const { data: jobs, headers } = await Api.pipelineJobs(this.projectId, this.pipelineId, {
+ per_page: 100,
+ page,
+ });
+
+ const hasSecurityReports = jobs.some(({ artifacts = [] }) =>
artifacts.some(({ file_type }) => reportTypes.includes(file_type)),
- ),
- );
+ );
+
+ if (hasSecurityReports) {
+ return true;
+ }
+
+ page = parseIntPagination(normalizeHeaders(headers)).nextPage;
+ }
+
+ return false;
},
activatePipelinesTab() {
if (window.mrTabs) {
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js
new file mode 100644
index 00000000000..22a45341c51
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js
@@ -0,0 +1,24 @@
+import * as types from './mutation_types';
+import { fetchDiffData } from '../../utils';
+
+export const setDiffEndpoint = ({ commit }, path) => commit(types.SET_DIFF_ENDPOINT, path);
+
+export const requestDiff = ({ commit }) => commit(types.REQUEST_DIFF);
+
+export const receiveDiffSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_DIFF_SUCCESS, response);
+
+export const receiveDiffError = ({ commit }, response) =>
+ commit(types.RECEIVE_DIFF_ERROR, response);
+
+export const fetchDiff = ({ state, rootState, dispatch }) => {
+ dispatch('requestDiff');
+
+ return fetchDiffData(rootState, state.paths.diffEndpoint, 'sast')
+ .then(data => {
+ dispatch('receiveDiffSuccess', data);
+ })
+ .catch(() => {
+ dispatch('receiveDiffError');
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js
new file mode 100644
index 00000000000..68c81bb4509
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import mutations from './mutations';
+import * as actions from './actions';
+
+export default {
+ namespaced: true,
+ state,
+ mutations,
+ actions,
+};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js
new file mode 100644
index 00000000000..aacec0fb679
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js
@@ -0,0 +1,4 @@
+export const RECEIVE_DIFF_SUCCESS = 'RECEIVE_DIFF_SUCCESS';
+export const RECEIVE_DIFF_ERROR = 'RECEIVE_DIFF_ERROR';
+export const REQUEST_DIFF = 'REQUEST_DIFF';
+export const SET_DIFF_ENDPOINT = 'SET_DIFF_ENDPOINT';
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js
new file mode 100644
index 00000000000..5f6153ca3b1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import * as types from './mutation_types';
+import { parseDiff } from '../../utils';
+
+export default {
+ [types.SET_DIFF_ENDPOINT](state, path) {
+ Vue.set(state.paths, 'diffEndpoint', path);
+ },
+
+ [types.REQUEST_DIFF](state) {
+ state.isLoading = true;
+ },
+
+ [types.RECEIVE_DIFF_SUCCESS](state, { diff, enrichData }) {
+ const { added, fixed, existing } = parseDiff(diff, enrichData);
+ const baseReportOutofDate = diff.base_report_out_of_date || false;
+ const hasBaseReport = Boolean(diff.base_report_created_at);
+
+ state.isLoading = false;
+ state.newIssues = added;
+ state.resolvedIssues = fixed;
+ state.allIssues = existing;
+ state.baseReportOutofDate = baseReportOutofDate;
+ state.hasBaseReport = hasBaseReport;
+ },
+
+ [types.RECEIVE_DIFF_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js
new file mode 100644
index 00000000000..e860e3af924
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js
@@ -0,0 +1,16 @@
+export default () => ({
+ paths: {
+ head: null,
+ base: null,
+ diffEndpoint: null,
+ },
+
+ isLoading: false,
+ hasError: false,
+
+ newIssues: [],
+ resolvedIssues: [],
+ allIssues: [],
+ baseReportOutofDate: false,
+ hasBaseReport: false,
+});
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js
new file mode 100644
index 00000000000..c9da824613d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js
@@ -0,0 +1,24 @@
+import { fetchDiffData } from '../../utils';
+import * as types from './mutation_types';
+
+export const setDiffEndpoint = ({ commit }, path) => commit(types.SET_DIFF_ENDPOINT, path);
+
+export const requestDiff = ({ commit }) => commit(types.REQUEST_DIFF);
+
+export const receiveDiffSuccess = ({ commit }, response) =>
+ commit(types.RECEIVE_DIFF_SUCCESS, response);
+
+export const receiveDiffError = ({ commit }, response) =>
+ commit(types.RECEIVE_DIFF_ERROR, response);
+
+export const fetchDiff = ({ state, rootState, dispatch }) => {
+ dispatch('requestDiff');
+
+ return fetchDiffData(rootState, state.paths.diffEndpoint, 'secret_detection')
+ .then(data => {
+ dispatch('receiveDiffSuccess', data);
+ })
+ .catch(() => {
+ dispatch('receiveDiffError');
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js
new file mode 100644
index 00000000000..68c81bb4509
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import mutations from './mutations';
+import * as actions from './actions';
+
+export default {
+ namespaced: true,
+ state,
+ mutations,
+ actions,
+};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js
new file mode 100644
index 00000000000..aacec0fb679
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js
@@ -0,0 +1,4 @@
+export const RECEIVE_DIFF_SUCCESS = 'RECEIVE_DIFF_SUCCESS';
+export const RECEIVE_DIFF_ERROR = 'RECEIVE_DIFF_ERROR';
+export const REQUEST_DIFF = 'REQUEST_DIFF';
+export const SET_DIFF_ENDPOINT = 'SET_DIFF_ENDPOINT';
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js
new file mode 100644
index 00000000000..ee943b0621c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js
@@ -0,0 +1,30 @@
+import { parseDiff } from '~/vue_shared/security_reports/store/utils';
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_DIFF_ENDPOINT](state, path) {
+ state.paths.diffEndpoint = path;
+ },
+
+ [types.REQUEST_DIFF](state) {
+ state.isLoading = true;
+ },
+
+ [types.RECEIVE_DIFF_SUCCESS](state, { diff, enrichData }) {
+ const { added, fixed, existing } = parseDiff(diff, enrichData);
+ const baseReportOutofDate = diff.base_report_out_of_date || false;
+ const hasBaseReport = Boolean(diff.base_report_created_at);
+
+ state.isLoading = false;
+ state.newIssues = added;
+ state.resolvedIssues = fixed;
+ state.allIssues = existing;
+ state.baseReportOutofDate = baseReportOutofDate;
+ state.hasBaseReport = hasBaseReport;
+ },
+
+ [types.RECEIVE_DIFF_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js
new file mode 100644
index 00000000000..e860e3af924
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js
@@ -0,0 +1,16 @@
+export default () => ({
+ paths: {
+ head: null,
+ base: null,
+ diffEndpoint: null,
+ },
+
+ isLoading: false,
+ hasError: false,
+
+ newIssues: [],
+ resolvedIssues: [],
+ allIssues: [],
+ baseReportOutofDate: false,
+ hasBaseReport: false,
+});
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js
new file mode 100644
index 00000000000..6e50efae741
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js
@@ -0,0 +1,75 @@
+import pollUntilComplete from '~/lib/utils/poll_until_complete';
+import axios from '~/lib/utils/axios_utils';
+import {
+ FEEDBACK_TYPE_DISMISSAL,
+ FEEDBACK_TYPE_ISSUE,
+ FEEDBACK_TYPE_MERGE_REQUEST,
+} from '../constants';
+
+export const fetchDiffData = (state, endpoint, category) => {
+ const requests = [pollUntilComplete(endpoint)];
+
+ if (state.canReadVulnerabilityFeedback) {
+ requests.push(axios.get(state.vulnerabilityFeedbackPath, { params: { category } }));
+ }
+
+ return Promise.all(requests).then(([diffResponse, enrichResponse]) => ({
+ diff: diffResponse.data,
+ enrichData: enrichResponse?.data ?? [],
+ }));
+};
+
+/**
+ * Returns given vulnerability enriched with the corresponding
+ * feedback (`dismissal` or `issue` type)
+ * @param {Object} vulnerability
+ * @param {Array} feedback
+ */
+export const enrichVulnerabilityWithFeedback = (vulnerability, feedback = []) =>
+ feedback
+ .filter(fb => fb.project_fingerprint === vulnerability.project_fingerprint)
+ .reduce((vuln, fb) => {
+ if (fb.feedback_type === FEEDBACK_TYPE_DISMISSAL) {
+ return {
+ ...vuln,
+ isDismissed: true,
+ dismissalFeedback: fb,
+ };
+ }
+ if (fb.feedback_type === FEEDBACK_TYPE_ISSUE && fb.issue_iid) {
+ return {
+ ...vuln,
+ hasIssue: true,
+ issue_feedback: fb,
+ };
+ }
+ if (fb.feedback_type === FEEDBACK_TYPE_MERGE_REQUEST && fb.merge_request_iid) {
+ return {
+ ...vuln,
+ hasMergeRequest: true,
+ merge_request_feedback: fb,
+ };
+ }
+ return vuln;
+ }, vulnerability);
+
+/**
+ * Generates the added, fixed, and existing vulnerabilities from the API report.
+ *
+ * @param {Object} diff The original reports.
+ * @param {Object} enrichData Feedback data to add to the reports.
+ * @returns {Object}
+ */
+export const parseDiff = (diff, enrichData) => {
+ const enrichVulnerability = vulnerability => ({
+ ...enrichVulnerabilityWithFeedback(vulnerability, enrichData),
+ category: vulnerability.report_type,
+ title: vulnerability.message || vulnerability.name,
+ });
+
+ return {
+ added: diff.added ? diff.added.map(enrichVulnerability) : [],
+ fixed: diff.fixed ? diff.fixed.map(enrichVulnerability) : [],
+ existing: diff.existing ? diff.existing.map(enrichVulnerability) : [],
+ };
+};
diff --git a/app/assets/javascripts/vuex_shared/modules/members/actions.js b/app/assets/javascripts/vuex_shared/modules/members/actions.js
index f7fdddfd070..4c31b3c9744 100644
--- a/app/assets/javascripts/vuex_shared/modules/members/actions.js
+++ b/app/assets/javascripts/vuex_shared/modules/members/actions.js
@@ -1,5 +1,6 @@
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
+import { formatDate } from '~/lib/utils/datetime_utility';
export const updateMemberRole = async ({ state, commit }, { memberId, accessLevel }) => {
try {
@@ -23,3 +24,21 @@ export const showRemoveGroupLinkModal = ({ commit }, groupLink) => {
export const hideRemoveGroupLinkModal = ({ commit }) => {
commit(types.HIDE_REMOVE_GROUP_LINK_MODAL);
};
+
+export const updateMemberExpiration = async ({ state, commit }, { memberId, expiresAt }) => {
+ try {
+ await axios.put(
+ state.memberPath.replace(':id', memberId),
+ state.requestFormatter({ expires_at: expiresAt ? formatDate(expiresAt, 'isoDate') : '' }),
+ );
+
+ commit(types.RECEIVE_MEMBER_EXPIRATION_SUCCESS, {
+ memberId,
+ expiresAt: expiresAt ? formatDate(expiresAt, 'isoUtcDateTime') : null,
+ });
+ } catch (error) {
+ commit(types.RECEIVE_MEMBER_EXPIRATION_ERROR);
+
+ throw error;
+ }
+};
diff --git a/app/assets/javascripts/vuex_shared/modules/members/index.js b/app/assets/javascripts/vuex_shared/modules/members/index.js
index 682e85298ad..586d52a5288 100644
--- a/app/assets/javascripts/vuex_shared/modules/members/index.js
+++ b/app/assets/javascripts/vuex_shared/modules/members/index.js
@@ -1,6 +1,6 @@
import createState from 'ee_else_ce/vuex_shared/modules/members/state';
-import * as actions from './actions';
-import mutations from './mutations';
+import mutations from 'ee_else_ce/vuex_shared/modules/members/mutations';
+import * as actions from 'ee_else_ce/vuex_shared/modules/members/actions';
export default initialState => ({
namespaced: true,
diff --git a/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js b/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js
index 00f4c910669..77307aa745b 100644
--- a/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js
+++ b/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js
@@ -1,6 +1,9 @@
export const RECEIVE_MEMBER_ROLE_SUCCESS = 'RECEIVE_MEMBER_ROLE_SUCCESS';
export const RECEIVE_MEMBER_ROLE_ERROR = 'RECEIVE_MEMBER_ROLE_ERROR';
+export const RECEIVE_MEMBER_EXPIRATION_SUCCESS = 'RECEIVE_MEMBER_EXPIRATION_SUCCESS';
+export const RECEIVE_MEMBER_EXPIRATION_ERROR = 'RECEIVE_MEMBER_EXPIRATION_ERROR';
+
export const HIDE_ERROR = 'HIDE_ERROR';
export const SHOW_REMOVE_GROUP_LINK_MODAL = 'SHOW_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
index 281c947e68f..2415e744290 100644
--- a/app/assets/javascripts/vuex_shared/modules/members/mutations.js
+++ b/app/assets/javascripts/vuex_shared/modules/members/mutations.js
@@ -19,6 +19,21 @@ export default {
);
state.showError = true;
},
+ [types.RECEIVE_MEMBER_EXPIRATION_SUCCESS](state, { memberId, expiresAt }) {
+ const member = findMember(state, memberId);
+
+ if (!member) {
+ return;
+ }
+
+ Vue.set(member, 'expiresAt', expiresAt);
+ },
+ [types.RECEIVE_MEMBER_EXPIRATION_ERROR](state) {
+ state.errorMessage = s__(
+ "Members|An error occurred while updating the member's expiration date, please try again.",
+ );
+ state.showError = true;
+ },
[types.HIDE_ERROR](state) {
state.showError = false;
state.errorMessage = '';
diff --git a/app/assets/javascripts/vuex_shared/modules/members/state.js b/app/assets/javascripts/vuex_shared/modules/members/state.js
index e4867819e17..ab3ebb34616 100644
--- a/app/assets/javascripts/vuex_shared/modules/members/state.js
+++ b/app/assets/javascripts/vuex_shared/modules/members/state.js
@@ -3,6 +3,7 @@ export default ({
sourceId,
currentUserId,
tableFields,
+ tableAttrs,
memberPath,
requestFormatter,
}) => ({
@@ -10,6 +11,7 @@ export default ({
sourceId,
currentUserId,
tableFields,
+ tableAttrs,
memberPath,
requestFormatter,
showError: false,
diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue
index 9400dacedc2..3c1de57252a 100644
--- a/app/assets/javascripts/whats_new/components/app.vue
+++ b/app/assets/javascripts/whats_new/components/app.vue
@@ -1,8 +1,16 @@
<script>
import { mapState, mapActions } from 'vuex';
-import { GlDrawer, GlBadge, GlIcon, GlLink } from '@gitlab/ui';
+import {
+ GlDrawer,
+ GlBadge,
+ GlIcon,
+ GlLink,
+ GlInfiniteScroll,
+ GlResizeObserverDirective,
+} from '@gitlab/ui';
import SkeletonLoader from './skeleton_loader.vue';
import Tracking from '~/tracking';
+import { getDrawerBodyHeight } from '../utils/get_drawer_body_height';
const trackingMixin = Tracking.mixin();
@@ -12,8 +20,12 @@ export default {
GlBadge,
GlIcon,
GlLink,
+ GlInfiniteScroll,
SkeletonLoader,
},
+ directives: {
+ GlResizeObserver: GlResizeObserverDirective,
+ },
mixins: [trackingMixin],
props: {
storageKey: {
@@ -23,7 +35,7 @@ export default {
},
},
computed: {
- ...mapState(['open', 'features']),
+ ...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight']),
},
mounted() {
this.openDrawer(this.storageKey);
@@ -35,36 +47,64 @@ export default {
this.track('click_whats_new_drawer', { label: 'namespace_id', value: namespaceId });
},
methods: {
- ...mapActions(['openDrawer', 'closeDrawer', 'fetchItems']),
+ ...mapActions(['openDrawer', 'closeDrawer', 'fetchItems', 'setDrawerBodyHeight']),
+ bottomReached() {
+ if (this.pageInfo.nextPage) {
+ this.fetchItems(this.pageInfo.nextPage);
+ }
+ },
+ handleResize() {
+ const height = getDrawerBodyHeight(this.$refs.drawer.$el);
+ this.setDrawerBodyHeight(height);
+ },
},
};
</script>
<template>
<div>
- <gl-drawer class="whats-new-drawer" :open="open" @close="closeDrawer">
+ <gl-drawer
+ ref="drawer"
+ v-gl-resize-observer="handleResize"
+ class="whats-new-drawer"
+ :open="open"
+ @close="closeDrawer"
+ >
<template #header>
- <h4 class="page-title my-2">{{ __("What's new at GitLab") }}</h4>
+ <h4 class="page-title gl-my-2">{{ __("What's new at GitLab") }}</h4>
</template>
- <div class="pb-6">
- <template v-if="features">
- <div v-for="feature in features" :key="feature.title" class="mb-6">
+ <gl-infinite-scroll
+ v-if="features.length"
+ :fetched-items="features.length"
+ :max-list-height="drawerBodyHeight"
+ class="gl-p-0"
+ @bottomReached="bottomReached"
+ >
+ <template #items>
+ <div
+ v-for="feature in features"
+ :key="feature.title"
+ class="gl-pb-7 gl-pt-5 gl-px-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+ >
<gl-link
:href="feature.url"
target="_blank"
- data-testid="whats-new-title-link"
+ class="whats-new-item-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>
+ <h5 class="gl-font-lg">{{ 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>
+ <gl-badge
+ v-for="package_name in feature.packages"
+ :key="package_name"
+ size="sm"
+ class="whats-new-item-badge gl-mr-2"
+ >
+ <gl-icon name="license" />{{ package_name }}
+ </gl-badge>
</div>
<gl-link
:href="feature.url"
@@ -76,7 +116,7 @@ export default {
<img
:alt="feature.title"
:src="feature.image_url"
- class="img-thumbnail px-6 gl-py-3 whats-new-item-image"
+ class="img-thumbnail gl-px-8 gl-py-3 whats-new-item-image"
/>
</gl-link>
<p class="gl-pt-3">{{ feature.body }}</p>
@@ -90,10 +130,10 @@ export default {
>
</div>
</template>
- <div v-else class="gl-mt-5">
- <skeleton-loader />
- <skeleton-loader />
- </div>
+ </gl-infinite-scroll>
+ <div v-else class="gl-mt-5">
+ <skeleton-loader />
+ <skeleton-loader />
</div>
</gl-drawer>
<div v-if="open" class="whats-new-modal-backdrop modal-backdrop"></div>
diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js
index a84dfb399d8..532febd61cb 100644
--- a/app/assets/javascripts/whats_new/store/actions.js
+++ b/app/assets/javascripts/whats_new/store/actions.js
@@ -1,5 +1,6 @@
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
export default {
closeDrawer({ commit }) {
@@ -12,9 +13,33 @@ export default {
localStorage.setItem(storageKey, JSON.stringify(false));
}
},
- fetchItems({ commit }) {
- return axios.get('/-/whats_new').then(({ data }) => {
- commit(types.SET_FEATURES, data);
- });
+ fetchItems({ commit, state }, page) {
+ if (state.fetching) {
+ return false;
+ }
+
+ commit(types.SET_FETCHING, true);
+
+ return axios
+ .get('/-/whats_new', {
+ params: {
+ page,
+ },
+ })
+ .then(({ data, headers }) => {
+ commit(types.ADD_FEATURES, data);
+
+ const normalizedHeaders = normalizeHeaders(headers);
+ const { nextPage } = parseIntPagination(normalizedHeaders);
+ commit(types.SET_PAGE_INFO, {
+ nextPage,
+ });
+ })
+ .finally(() => {
+ commit(types.SET_FETCHING, false);
+ });
+ },
+ setDrawerBodyHeight({ commit }, height) {
+ commit(types.SET_DRAWER_BODY_HEIGHT, height);
},
};
diff --git a/app/assets/javascripts/whats_new/store/mutation_types.js b/app/assets/javascripts/whats_new/store/mutation_types.js
index 124d33a88b1..5715c442f66 100644
--- a/app/assets/javascripts/whats_new/store/mutation_types.js
+++ b/app/assets/javascripts/whats_new/store/mutation_types.js
@@ -1,3 +1,6 @@
export const CLOSE_DRAWER = 'CLOSE_DRAWER';
export const OPEN_DRAWER = 'OPEN_DRAWER';
-export const SET_FEATURES = 'SET_FEATURES';
+export const ADD_FEATURES = 'ADD_FEATURES';
+export const SET_PAGE_INFO = 'SET_PAGE_INFO';
+export const SET_FETCHING = 'SET_FETCHING';
+export const SET_DRAWER_BODY_HEIGHT = 'SET_DRAWER_BODY_HEIGHT';
diff --git a/app/assets/javascripts/whats_new/store/mutations.js b/app/assets/javascripts/whats_new/store/mutations.js
index 4fb7b17244e..725521780dc 100644
--- a/app/assets/javascripts/whats_new/store/mutations.js
+++ b/app/assets/javascripts/whats_new/store/mutations.js
@@ -7,7 +7,16 @@ export default {
[types.OPEN_DRAWER](state) {
state.open = true;
},
- [types.SET_FEATURES](state, data) {
- state.features = data;
+ [types.ADD_FEATURES](state, data) {
+ state.features = state.features.concat(data);
+ },
+ [types.SET_PAGE_INFO](state, pageInfo) {
+ state.pageInfo = pageInfo;
+ },
+ [types.SET_FETCHING](state, fetching) {
+ state.fetching = fetching;
+ },
+ [types.SET_DRAWER_BODY_HEIGHT](state, height) {
+ state.drawerBodyHeight = height;
},
};
diff --git a/app/assets/javascripts/whats_new/store/state.js b/app/assets/javascripts/whats_new/store/state.js
index 4c76284b865..793c6aa2b98 100644
--- a/app/assets/javascripts/whats_new/store/state.js
+++ b/app/assets/javascripts/whats_new/store/state.js
@@ -1,4 +1,9 @@
export default {
open: false,
- features: null,
+ features: [],
+ fetching: false,
+ drawerBodyHeight: null,
+ pageInfo: {
+ nextPage: null,
+ },
};
diff --git a/app/assets/javascripts/whats_new/utils/get_drawer_body_height.js b/app/assets/javascripts/whats_new/utils/get_drawer_body_height.js
new file mode 100644
index 00000000000..21fc90c34a4
--- /dev/null
+++ b/app/assets/javascripts/whats_new/utils/get_drawer_body_height.js
@@ -0,0 +1,6 @@
+export const getDrawerBodyHeight = drawer => {
+ const drawerViewableHeight = drawer.clientHeight - drawer.getBoundingClientRect().top;
+ const drawerHeaderHeight = drawer.querySelector('.gl-drawer-header').clientHeight;
+
+ return drawerViewableHeight - drawerHeaderHeight;
+};
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index a31cb0b0485..52bc19fddd9 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -1,8 +1,5 @@
@import './pages/admin';
-@import './pages/alert_management/details';
-@import './pages/alert_management/severity-icons';
@import './pages/branches';
-@import './pages/builds';
@import './pages/ci_projects';
@import './pages/clusters';
@import './pages/commits';
@@ -27,7 +24,6 @@
@import './pages/notes';
@import './pages/notifications';
@import './pages/pages';
-@import './pages/pipeline_schedules';
@import './pages/pipelines';
@import './pages/profile';
@import './pages/profiles/preferences';
@@ -39,7 +35,6 @@
@import './pages/settings';
@import './pages/settings_ci_cd';
@import './pages/sherlock';
-@import './pages/status';
@import './pages/storage_quota';
@import './pages/tree';
@import './pages/trials';
diff --git a/app/assets/stylesheets/behaviors.scss b/app/assets/stylesheets/behaviors.scss
index 120a139ff3d..bcfa5bac5d5 100644
--- a/app/assets/stylesheets/behaviors.scss
+++ b/app/assets/stylesheets/behaviors.scss
@@ -1,28 +1,3 @@
-// Details
-//--------
-.js-details-container {
- .content {
- display: none;
- &.hide { display: block; }
- }
-
- &.open .content {
- display: block;
- &.hide { display: none; }
- }
-}
-
-// Toggle between two states.
-.js-toggler-container {
- .turn-on { display: block; }
- .turn-off { display: none; }
-
- &.on {
- .turn-on { display: none; }
- .turn-off { display: block; }
- }
-}
-
// Hide element if Vue is still working on rendering it fully.
[v-cloak='true'] {
display: none !important;
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index aac32e7fb2d..3d5076f485c 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -115,14 +115,10 @@ code {
background-color: $gray-50;
border-radius: $border-radius-default;
- .code > & {
- background-color: inherit;
- padding: unset;
- }
-
+ .code > &,
.build-trace & {
background-color: inherit;
- padding: inherit;
+ padding: unset;
}
}
@@ -131,12 +127,6 @@ table {
border-spacing: 0;
}
-.tooltip,
-.no-pointer-events {
- // Fix bootstrap4 bug whereby tooltips flicker when they are hovered over their borders
- pointer-events: none;
-}
-
@each $breakpoint in map-keys($grid-breakpoints) {
@include media-breakpoint-up($breakpoint) {
$infix: breakpoint-infix($breakpoint, $grid-breakpoints);
diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss
index 81f2091e915..579a86a94a4 100644
--- a/app/assets/stylesheets/components/design_management/design.scss
+++ b/app/assets/stylesheets/components/design_management/design.scss
@@ -75,10 +75,6 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
left: 0;
}
-.design-scaler {
- z-index: 1;
-}
-
.design-scaler-wrapper {
bottom: 0;
left: 50%;
@@ -185,41 +181,3 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
.design-card-header {
background: transparent;
}
-
-.design-dropzone-border {
- border: 2px dashed $gray-100;
-}
-
-.design-dropzone-card {
- transition: border $gl-transition-duration-medium $general-hover-transition-curve;
- color: $gl-text-color;
-
- &:focus,
- &:active {
- outline: none;
- border: 2px dashed $purple;
- color: $gl-text-color;
- }
-
- &:hover {
- border-color: $gray-300;
- }
-}
-
-.design-dropzone-overlay {
- border: 2px dashed $purple;
- top: 0;
- left: 0;
- pointer-events: none;
- opacity: 1;
-}
-
-.design-dropzone-fade-enter-active,
-.design-dropzone-fade-leave-active {
- transition: opacity $general-hover-transition-duration $general-hover-transition-curve;
-}
-
-.design-dropzone-fade-enter,
-.design-dropzone-fade-leave-to {
- opacity: 0;
-}
diff --git a/app/assets/stylesheets/pages/alert_management/severity-icons.scss b/app/assets/stylesheets/components/severity/icons.scss
index f58ad87a673..8ddf873196a 100644
--- a/app/assets/stylesheets/pages/alert_management/severity-icons.scss
+++ b/app/assets/stylesheets/components/severity/icons.scss
@@ -2,26 +2,26 @@
.incident-management-list,
.alert-management-details {
.icon-critical {
- color: $red-800;
+ @include gl-text-red-800;
}
.icon-high {
- color: $red-600;
+ @include gl-text-red-600;
}
.icon-medium {
- color: $orange-400;
+ @include gl-text-orange-400;
}
.icon-low {
- color: $orange-300;
+ @include gl-text-orange-300;
}
.icon-info {
- color: $blue-400;
+ @include gl-text-blue-400;
}
.icon-unknown {
- color: $gray-200;
+ @include gl-text-gray-200;
}
}
diff --git a/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss b/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss
new file mode 100644
index 00000000000..2bc6eba3342
--- /dev/null
+++ b/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss
@@ -0,0 +1,37 @@
+.upload-dropzone-border {
+ border: 2px dashed $gray-100;
+}
+
+.upload-dropzone-card {
+ transition: border $gl-transition-duration-medium $general-hover-transition-curve;
+ color: $gl-text-color;
+
+ &:focus,
+ &:active {
+ outline: none;
+ border: 2px dashed $purple;
+ color: $gl-text-color;
+ }
+
+ &:hover {
+ border-color: $gray-300;
+ }
+}
+
+.upload-dropzone-overlay {
+ border: 2px dashed $purple;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+ opacity: 1;
+}
+
+.upload-dropzone-fade-enter-active,
+.upload-dropzone-fade-leave-active {
+ transition: opacity $general-hover-transition-duration $general-hover-transition-curve;
+}
+
+.upload-dropzone-fade-enter,
+.upload-dropzone-fade-leave-to {
+ opacity: 0;
+}
diff --git a/app/assets/stylesheets/components/whats_new.scss b/app/assets/stylesheets/components/whats_new.scss
index 6c58346b750..64e82531c30 100644
--- a/app/assets/stylesheets/components/whats_new.scss
+++ b/app/assets/stylesheets/components/whats_new.scss
@@ -1,6 +1,11 @@
.whats-new-drawer {
margin-top: $header-height;
@include gl-shadow-none;
+ overflow-y: hidden;
+
+ .gl-infinite-scroll-legend {
+ @include gl-display-none;
+ }
}
.with-performance-bar .whats-new-drawer {
@@ -13,6 +18,14 @@
@include gl-font-weight-bold;
}
+.whats-new-item-title-link {
+ &:hover,
+ &:focus,
+ &:active {
+ @include gl-text-gray-900;
+ }
+}
+
.whats-new-item-image {
border-color: $gray-50;
}
diff --git a/app/assets/stylesheets/fontawesome_custom.scss b/app/assets/stylesheets/fontawesome_custom.scss
index a3338ff13b5..8a955cffc49 100644
--- a/app/assets/stylesheets/fontawesome_custom.scss
+++ b/app/assets/stylesheets/fontawesome_custom.scss
@@ -92,55 +92,23 @@
content: '\f0d7';
}
-.fa-check::before {
- content: '\f00c';
-}
-
.fa-warning::before,
.fa-exclamation-triangle::before {
content: '\f071';
}
-.fa-external-link::before {
- content: '\f08e';
-}
-
.fa-spinner::before {
content: '\f110';
}
-.fa-trash-o::before {
- content: '\f014';
-}
-
.fa-caret-right::before {
content: '\f0da';
}
-.fa-refresh::before {
- content: '\f021';
-}
-
-.fa-chevron-up::before {
- content: '\f077';
-}
-
-.fa-paperclip::before {
- content: '\f0c6';
-}
-
-.fa-bug::before {
- content: '\f188';
-}
-
.fa-exclamation-circle::before {
content: '\f06a';
}
-.fa-bell::before {
- content: '\f0f3';
-}
-
.fa-file-o::before {
content: '\f016';
}
@@ -153,10 +121,6 @@
content: '\f111';
}
-.fa-git::before {
- content: '\f1d3';
-}
-
.fa-thumb-tack::before {
content: '\f08d';
}
@@ -165,38 +129,6 @@
content: '\f06d';
}
-.fa-pause::before {
- content: '\f04c';
-}
-
-.fa-play::before {
- content: '\f04b';
-}
-
-.fa-share::before {
- content: '\f064';
-}
-
-.fa-book::before {
- content: '\f02d';
-}
-
-.fa-times-circle::before {
- content: '\f057';
-}
-
-.fa-skype::before {
- content: '\f17e';
-}
-
-.fa-linkedin-square::before {
- content: '\f08c';
-}
-
-.fa-twitter-square::before {
- content: '\f081';
-}
-
.fa-file-pdf-o::before {
content: '\f1c1';
}
@@ -229,6 +161,14 @@
content: '\f1c8';
}
+.fa-square-o::before {
+ content: '\f096';
+}
+
+.fa-check-square-o::before {
+ content: '\f046';
+}
+
.sr-only {
position: absolute;
width: 1px;
diff --git a/app/assets/stylesheets/framework/broadcast_messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss
index c1647c16c77..b8934d2797a 100644
--- a/app/assets/stylesheets/framework/broadcast_messages.scss
+++ b/app/assets/stylesheets/framework/broadcast_messages.scss
@@ -15,10 +15,6 @@
.broadcast-banner-message {
text-align: center;
-
- .broadcast-message-dismiss {
- color: inherit;
- }
}
.broadcast-notification-message {
@@ -36,10 +32,6 @@
&.preview {
position: static;
}
-
- .broadcast-message-dismiss {
- color: $gray-700;
- }
}
.toggle-colors {
diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss
index de767ac3fe0..5b7f1a3f38b 100644
--- a/app/assets/stylesheets/framework/calendar.scss
+++ b/app/assets/stylesheets/framework/calendar.scss
@@ -14,14 +14,11 @@
.str-truncated {
max-width: 70%;
}
-
- .user-calendar-activities-loading {
- font-size: 24px;
- }
}
.user-calendar {
text-align: center;
+ min-height: 172px;
.calendar {
display: inline-block;
@@ -42,12 +39,9 @@
.calendar-hint {
font-size: 12px;
-
- &.bottom-right {
- direction: ltr;
- margin-top: -23px;
- float: right;
- }
+ direction: ltr;
+ margin-top: -23px;
+ float: right;
}
.pika-single.gitlab-theme {
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 8dbed9c03f2..deb2d6c4641 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -424,7 +424,6 @@ img.emoji {
.w-15p { width: 15%; }
.w-30p { width: 30%; }
.w-60p { width: 60%; }
-.w-70p { width: 70%; }
.h-12em { height: 12em; }
.h-32-px { height: 32px;}
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index c0a2350d080..e16ab5ee72f 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -6,11 +6,18 @@
border-top: 1px solid $border-color;
}
+ &.has-body {
+ .file-title {
+ box-shadow: 0 -2px 0 0 var(--white);
+ }
+ }
+
+ table.code tr:last-of-type td:last-of-type {
+ @include gl-rounded-bottom-right-base();
+ }
+
.file-title,
.file-title-flex-parent {
- border-top-left-radius: $border-radius-default;
- border-top-right-radius: $border-radius-default;
- box-shadow: 0 -2px 0 0 var(--white);
cursor: pointer;
.dropdown-menu {
@@ -113,7 +120,6 @@
.diff-content {
background: $white;
color: $gl-text-color;
- border-radius: 0 0 3px 3px;
.unfold {
cursor: pointer;
@@ -443,6 +449,7 @@
}
}
+.diff-table.code,
table.code {
width: 100%;
font-family: $monospace-font;
@@ -453,14 +460,20 @@ table.code {
table-layout: fixed;
border-radius: 0 0 $border-radius-default $border-radius-default;
+ .diff-tr:first-of-type.line_expansion > .diff-td,
tr:first-of-type.line_expansion > td {
border-top: 0;
}
- tr:nth-last-of-type(2).line_expansion > td {
- border-bottom: 0;
+ .diff-tr:nth-last-of-type(2).line_expansion > .diff-td,
+ tr:nth-last-of-type(2).line_expansion,
+ tr:last-of-type.line_expansion {
+ > td {
+ border-bottom: 0;
+ }
}
+ .diff-tr.line_holder .diff-td,
tr.line_holder td {
line-height: $code-line-height;
font-size: $code-font-size;
@@ -556,24 +569,95 @@ table.code {
}
.line_holder:last-of-type {
+ .diff-td:first-child,
td:first-child {
border-bottom-left-radius: $border-radius-default;
}
}
&.left-side-selected {
+ .diff-td.line_content.parallel.right-side,
td.line_content.parallel.right-side {
user-select: none;
}
}
&.right-side-selected {
+ .diff-td.line_content.parallel.left-side,
td.line_content.parallel.left-side {
user-select: none;
}
}
}
+// Merge request diff grid layout
+.diff-grid {
+ .diff-grid-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .diff-grid-left,
+ .diff-grid-right {
+ display: grid;
+ grid-template-columns: 50px 8px 1fr;
+
+ .diff-td:nth-child(2) {
+ display: none;
+ }
+ }
+
+ .diff-grid-comments {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .diff-grid-drafts {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ }
+
+ &.inline {
+ .diff-grid-comments {
+ display: grid;
+ grid-template-columns: 1fr;
+ }
+
+ .diff-grid-drafts {
+ display: grid;
+ grid-template-columns: 1fr;
+ }
+
+ .diff-grid-row {
+ grid-template-columns: 1fr;
+ }
+
+ .diff-grid-left,
+ .diff-grid-right {
+ grid-template-columns: 50px 50px 8px 1fr;
+
+ .diff-td:nth-child(2) {
+ display: block;
+ }
+ }
+
+ .diff-grid-left .old:nth-child(1) [data-linenumber],
+ .diff-grid-right .new:nth-child(2) [data-linenumber] {
+ display: inline;
+ }
+
+ .diff-grid-left .old:nth-child(2) [data-linenumber],
+ .diff-grid-right .new:nth-child(1) [data-linenumber] {
+ display: none;
+ }
+ }
+}
+
+// Merge request diff grid layout overrides
+.diff-table.code .diff-tr.line_holder .diff-td.line_content.parallel {
+ width: unset;
+}
+
.diff-stats {
align-items: center;
padding: 0 1rem;
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index ca20b18f851..2094c824286 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -407,7 +407,8 @@
}
}
- &.droplab-item-selected i {
+ &.droplab-item-selected i,
+ &.droplab-item-selected svg {
visibility: visible;
}
diff --git a/app/assets/stylesheets/framework/editor-lite.scss b/app/assets/stylesheets/framework/editor-lite.scss
index 20fea7a82ca..c3b287a6c3d 100644
--- a/app/assets/stylesheets/framework/editor-lite.scss
+++ b/app/assets/stylesheets/framework/editor-lite.scss
@@ -1,3 +1,21 @@
+[data-editor-loading] {
+ @include gl-relative;
+ @include gl-display-flex;
+ @include gl-justify-content-center;
+ @include gl-align-items-center;
+
+ &::before {
+ content: '';
+ @include spinner(32px, 3px);
+ @include gl-absolute;
+ @include gl-z-index-1;
+ }
+
+ pre {
+ opacity: 0;
+ }
+}
+
[id^='editor-lite-'] {
height: 500px;
}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index f8710cc1346..fe8c27ae9b6 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -45,11 +45,6 @@
}
.file-actions {
- position: absolute;
- top: 5px;
- right: 15px;
- margin-left: auto;
-
.btn:not(.btn-icon) {
padding: 0 10px;
font-size: 13px;
@@ -342,30 +337,14 @@ span.idiff {
padding: $gl-padding-8 $gl-padding;
margin: 0;
border-radius: $border-radius-default $border-radius-default 0 0;
- }
-
- .file-header-content {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- padding-right: 30px;
- position: relative;
- width: auto;
-
- @media (max-width: map-get($grid-breakpoints, sm)-1) {
- width: 100%;
- }
- }
- .file-holder & {
- .file-actions {
- position: static;
+ @include media-breakpoint-up(md) {
+ flex-wrap: nowrap;
}
}
- .btn-clipboard {
- position: absolute;
- right: 0;
+ .file-header-content {
+ padding-right: 30px;
}
a {
@@ -384,15 +363,11 @@ span.idiff {
z-index: 2;
}
- @include media-breakpoint-down(xs) {
+ @include media-breakpoint-down(sm) {
display: block;
.file-actions {
- white-space: normal;
-
- .btn-group {
- padding-top: 5px;
- }
+ margin-top: 5px;
}
}
}
diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss
index 0fb91db0afb..d5f7ec68454 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -9,9 +9,15 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25);
&.sticky {
position: sticky;
- position: -webkit-sticky;
top: $flash-container-top;
z-index: 251;
+
+ .flash-alert,
+ .flash-notice,
+ .flash-success,
+ .flash-warning {
+ @include gl-mb-4;
+ }
}
&.flash-container-page {
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index 63be2bdef8e..20d44b71bf6 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -3,6 +3,38 @@
* Mixins with fixed values
*/
+@keyframes blinking-dot {
+ 0% {
+ opacity: 1;
+ }
+
+ 25% {
+ opacity: 0.4;
+ }
+
+ 75% {
+ opacity: 0.4;
+ }
+
+ 100% {
+ opacity: 1;
+ }
+}
+
+@keyframes blinking-scroll-button {
+ 0% {
+ opacity: 0.2;
+ }
+
+ 50% {
+ opacity: 1;
+ }
+
+ 100% {
+ opacity: 0.2;
+ }
+}
+
@mixin str-truncated($max-width: 82%) {
display: inline-block;
overflow: hidden;
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index f8c46a4495e..372e3bed6e0 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -117,12 +117,6 @@ body.modal-open {
border-bottom-right-radius: $modal-border-radius;
}
}
-
- @include media-breakpoint-up(sm) {
- .modal-dialog {
- margin: 64px auto;
- }
- }
}
.recaptcha-modal .recaptcha-form {
@@ -134,7 +128,7 @@ body.modal-open {
}
.issues-import-modal,
-.issues-export-modal {
+.issuable-export-modal {
.modal-header {
justify-content: flex-start;
@@ -166,8 +160,4 @@ body.modal-open {
min-height: $modal-body-height;
}
}
-
- .checkmark {
- color: $green-400;
- }
}
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 7ebc972ac37..3e218de6af9 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -69,7 +69,7 @@
line-height: 28px;
white-space: normal;
- /* Small devices (phones, tablets, 768px and lower) */
+ /* Small devices (phones, 768px and lower) */
@include media-breakpoint-down(xs) {
width: 100%;
}
@@ -92,7 +92,7 @@
padding: 16px 15px 11px;
}
- /* Small devices (phones, tablets, 768px and lower) */
+ /* Small devices (phones, 768px and lower) */
@include media-breakpoint-down(sm) {
width: 100%;
}
@@ -102,15 +102,6 @@
display: inline-block;
text-align: right;
- @include media-breakpoint-down(sm) {
- margin-top: $gl-padding-8;
- }
-
- @include media-breakpoint-up(md) {
- display: flex;
- align-items: center;
- }
-
> .btn,
> .btn-group,
> .btn-container,
@@ -146,6 +137,35 @@
}
}
+ @include media-breakpoint-up(md) {
+ display: flex;
+ align-items: center;
+ }
+
+ @include media-breakpoint-down(md) {
+ $controls-margin: $btn-margin-5 - 2px;
+ flex: 0 0 100%;
+ margin-top: $gl-padding-8;
+
+ .controls-item,
+ .controls-item-full,
+ .controls-item:last-child {
+ flex: 1 1 35%;
+ display: block;
+ width: 100%;
+ margin: $controls-margin;
+
+ .btn,
+ .dropdown {
+ margin: 0;
+ }
+ }
+
+ .controls-item-full {
+ flex: 1 1 100%;
+ }
+ }
+
@include media-breakpoint-down(sm) {
padding-bottom: 0;
width: 100%;
@@ -239,32 +259,6 @@
pre {
width: 100%;
}
-
- @include media-breakpoint-down(md) {
- .nav-controls {
- $controls-margin: $btn-margin-5 - 2px;
- flex: 0 0 100%;
- margin-top: $gl-padding-8;
-
- .controls-item,
- .controls-item-full,
- .controls-item:last-child {
- flex: 1 1 35%;
- display: block;
- width: 100%;
- margin: $controls-margin;
-
- .btn,
- .dropdown {
- margin: 0;
- }
- }
-
- .controls-item-full {
- flex: 1 1 100%;
- }
- }
- }
}
.scrolling-tabs-container {
diff --git a/app/assets/stylesheets/framework/spinner.scss b/app/assets/stylesheets/framework/spinner.scss
index a74aeb9f220..2aa0ab6c1eb 100644
--- a/app/assets/stylesheets/framework/spinner.scss
+++ b/app/assets/stylesheets/framework/spinner.scss
@@ -20,7 +20,7 @@
}
}
-.spinner {
+@mixin spinner($size: 16px, $border-width: 2px, $color: $orange-400) {
border-radius: 50%;
position: relative;
margin: 0 auto;
@@ -30,8 +30,12 @@
animation-iteration-count: infinite;
border-style: solid;
display: inline-flex;
- @include spinner-size(16px, 2px);
- @include spinner-color($orange-400);
+ @include spinner-size($size, $border-width);
+ @include spinner-color($color);
+}
+
+.spinner {
+ @include spinner;
&.spinner-md {
@include spinner-size(32px, 3px);
@@ -56,3 +60,7 @@
vertical-align: text-bottom;
}
}
+
+.spin {
+ animation: spinner-rotate 2s infinite linear;
+}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index c15d46d43b2..3d09edfe181 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -453,11 +453,9 @@
h4,
h5,
h6 {
- position: relative;
-
a.anchor {
- left: -16px;
- position: absolute;
+ float: left;
+ margin-left: -16px;
text-decoration: none;
outline: none;
diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss
index 31075b09b83..d9b9f3694c1 100644
--- a/app/assets/stylesheets/highlight/common.scss
+++ b/app/assets/stylesheets/highlight/common.scss
@@ -20,6 +20,7 @@
@mixin diff-expansion($background, $border, $link) {
background-color: $background;
+ .diff-td,
td {
border-top: 1px solid $border;
border-bottom: 1px solid $border;
@@ -41,3 +42,12 @@
border-left: 3px solid $no-coverage;
}
}
+
+@mixin line-number-hover($color) {
+ background-color: $color;
+ border-color: darken($color, 5%);
+
+ a {
+ color: darken($color, 15%);
+ }
+}
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 8d965ea4309..d51d5b7137d 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -125,6 +125,9 @@ $dark-il: #de935f;
@include dark-diff-match-line;
}
+ .diff-td.diff-line-num.hll:not(.empty-cell),
+ .diff-td.line-coverage.hll:not(.empty-cell),
+ .diff-td.line_content.hll:not(.empty-cell),
td.diff-line-num.hll:not(.empty-cell),
td.line-coverage.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
@@ -158,15 +161,17 @@ $dark-il: #de935f;
}
}
+ .diff-grid-left:hover,
+ .diff-grid-right:hover {
+ .diff-line-num:not(.empty-cell) {
+ @include line-number-hover($dark-over-bg);
+ }
+ }
+
.diff-line-num {
&.is-over,
&.hll:not(.empty-cell).is-over {
- background-color: $dark-over-bg;
- border-color: darken($dark-over-bg, 5%);
-
- a {
- color: darken($dark-over-bg, 15%);
- }
+ @include line-number-hover($dark-over-bg);
}
}
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index 5ef2b9dcc36..e690f9c7c74 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -125,6 +125,9 @@ $monokai-gi: #a6e22e;
@include dark-diff-match-line;
}
+ .diff-td.diff-line-num.hll:not(.empty-cell),
+ .diff-td.line-coverage.hll:not(.empty-cell),
+ .diff-td.line_content.hll:not(.empty-cell),
td.diff-line-num.hll:not(.empty-cell),
td.line-coverage.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
@@ -158,15 +161,17 @@ $monokai-gi: #a6e22e;
}
}
+ .diff-grid-left:hover,
+ .diff-grid-right:hover {
+ .diff-line-num:not(.empty-cell) {
+ @include line-number-hover($monokai-over-bg);
+ }
+ }
+
.diff-line-num {
&.is-over,
&.hll:not(.empty-cell).is-over {
- background-color: $monokai-over-bg;
- border-color: darken($monokai-over-bg, 5%);
-
- a {
- color: darken($monokai-over-bg, 15%);
- }
+ @include line-number-hover($monokai-over-bg);
}
}
diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss
index fb548a00526..4fc6e5dba39 100644
--- a/app/assets/stylesheets/highlight/themes/none.scss
+++ b/app/assets/stylesheets/highlight/themes/none.scss
@@ -59,6 +59,13 @@
}
}
+ .diff-grid-left:hover,
+ .diff-grid-right:hover {
+ .diff-line-num:not(.empty-cell) {
+ @include line-number-hover($none-over-bg);
+ }
+ }
+
.diff-line-num {
&.old {
a {
@@ -74,12 +81,7 @@
&.is-over,
&.hll:not(.empty-cell).is-over {
- background-color: $none-over-bg;
- border-color: darken($none-over-bg, 5%);
-
- a {
- color: darken($none-over-bg, 15%);
- }
+ @include line-number-hover($none-over-bg);
}
&.hll:not(.empty-cell) {
diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index 190a6e6156a..8c532f53182 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -129,6 +129,9 @@ $solarized-dark-il: #2aa198;
@include dark-diff-match-line;
}
+ .diff-td.diff-line-num.hll:not(.empty-cell),
+ .diff-td.line-coverage.hll:not(.empty-cell),
+ .diff-td.line_content.hll:not(.empty-cell),
td.diff-line-num.hll:not(.empty-cell),
td.line-coverage.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
@@ -140,6 +143,13 @@ $solarized-dark-il: #2aa198;
@include line-coverage-border-color($solarized-dark-coverage, $solarized-dark-no-coverage);
}
+ .diff-grid-left:hover,
+ .diff-grid-right:hover {
+ .diff-line-num:not(.empty-cell) {
+ @include line-number-hover($solarized-dark-over-bg);
+ }
+ }
+
.diff-line-num.new,
.line-coverage.new,
.line_content.new {
@@ -165,12 +175,7 @@ $solarized-dark-il: #2aa198;
.diff-line-num {
&.is-over,
&.hll:not(.empty-cell).is-over {
- background-color: $solarized-dark-over-bg;
- border-color: darken($solarized-dark-over-bg, 5%);
-
- a {
- color: darken($solarized-dark-over-bg, 15%);
- }
+ @include line-number-hover($solarized-dark-over-bg);
}
}
diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index 71d8dd06834..1f9042a9534 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -136,6 +136,9 @@ $solarized-light-il: #2aa198;
@include match-line;
}
+ .diff-td.diff-line-num.hll:not(.empty-cell),
+ .diff-td.line-coverage.hll:not(.empty-cell),
+ .diff-td.line_content.hll:not(.empty-cell),
td.diff-line-num.hll:not(.empty-cell),
td.line-coverage.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
@@ -159,6 +162,13 @@ $solarized-light-il: #2aa198;
}
}
+ .diff-grid-left:hover,
+ .diff-grid-right:hover {
+ .diff-line-num:not(.empty-cell) {
+ @include line-number-hover($solarized-light-over-bg);
+ }
+ }
+
.diff-line-num.old,
.line-coverage.old,
.line_content.old {
@@ -173,12 +183,7 @@ $solarized-light-il: #2aa198;
.diff-line-num {
&.is-over,
&.hll:not(.empty-cell).is-over {
- background-color: $solarized-light-over-bg;
- border-color: darken($solarized-light-over-bg, 5%);
-
- a {
- color: darken($solarized-light-over-bg, 15%);
- }
+ @include line-number-hover($solarized-light-over-bg);
}
}
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index 3e126a52c4b..bb5ca94af33 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -113,6 +113,13 @@ pre.code,
@include match-line;
}
+ .diff-grid-left:hover,
+ .diff-grid-right:hover {
+ .diff-line-num:not(.empty-cell) {
+ @include line-number-hover($white-over-bg);
+ }
+ }
+
.diff-line-num {
&.old {
background-color: $line-number-old;
@@ -134,12 +141,7 @@ pre.code,
&.is-over,
&.hll:not(.empty-cell).is-over {
- background-color: $white-over-bg;
- border-color: darken($white-over-bg, 5%);
-
- a {
- color: darken($white-over-bg, 15%);
- }
+ @include line-number-hover($white-over-bg);
}
&.hll:not(.empty-cell) {
diff --git a/app/assets/stylesheets/lazy_bundles/select2.scss b/app/assets/stylesheets/lazy_bundles/select2.scss
new file mode 100644
index 00000000000..f2c372020ef
--- /dev/null
+++ b/app/assets/stylesheets/lazy_bundles/select2.scss
@@ -0,0 +1,654 @@
+/*
+Version: 3.5.2 Timestamp: Sat Nov 1 14:43:36 EDT 2014
+Updated 2020-10-05 by TimZ
+*/
+.select2-container {
+ margin: 0;
+ position: relative;
+ display: inline-block;
+}
+
+.select2-container,
+.select2-drop,
+.select2-search,
+.select2-search input {
+ box-sizing: border-box;
+}
+
+.select2-container .select2-choice {
+ display: block;
+ height: 26px;
+ padding: 0 0 0 8px;
+ overflow: hidden;
+ position: relative;
+
+ border: 1px solid #aaa;
+ white-space: nowrap;
+ line-height: 26px;
+ color: #444;
+ text-decoration: none;
+
+ border-radius: 4px;
+
+ background-clip: padding-box;
+
+ user-select: none;
+
+ background-color: #fff;
+ background-image: linear-gradient(to top, #eee 0%, #fff 50%);
+}
+
+html[dir='rtl'] .select2-container .select2-choice {
+ padding: 0 8px 0 0;
+}
+
+.select2-container.select2-drop-above .select2-choice {
+ border-bottom-color: #aaa;
+
+ border-radius: 0 0 4px 4px;
+
+ background-image: linear-gradient(to bottom, #eee 0%, #fff 90%);
+}
+
+.select2-container.select2-allowclear .select2-choice .select2-chosen {
+ margin-right: 42px;
+}
+
+.select2-container .select2-choice > .select2-chosen {
+ margin-right: 26px;
+ display: block;
+ overflow: hidden;
+
+ white-space: nowrap;
+
+ text-overflow: ellipsis;
+ float: none;
+ width: auto;
+}
+
+html[dir='rtl'] .select2-container .select2-choice > .select2-chosen {
+ margin-left: 26px;
+ margin-right: 0;
+}
+
+.select2-container .select2-choice abbr {
+ display: none;
+ width: 12px;
+ height: 12px;
+ position: absolute;
+ right: 24px;
+ top: 8px;
+
+ font-size: 1px;
+ text-decoration: none;
+
+ border: 0;
+ /* stylelint-disable-next-line function-url-quotes */
+ background: url(image-path('select2.png')) right top no-repeat;
+ cursor: pointer;
+ outline: 0;
+}
+
+.select2-container.select2-allowclear .select2-choice abbr {
+ display: inline-block;
+}
+
+.select2-container .select2-choice abbr:hover {
+ background-position: right -11px;
+ cursor: pointer;
+}
+
+.select2-drop-mask {
+ border: 0;
+ margin: 0;
+ padding: 0;
+ position: fixed;
+ left: 0;
+ top: 0;
+ min-height: 100%;
+ min-width: 100%;
+ height: auto;
+ width: auto;
+ opacity: 0;
+ z-index: 9998;
+ /* styles required for IE to work */
+ background-color: #fff;
+ filter: alpha(opacity=0);
+}
+
+.select2-drop {
+ width: 100%;
+ margin-top: -1px;
+ position: absolute;
+ z-index: 9999;
+ top: 100%;
+
+ background: #fff;
+ color: #000;
+ border: 1px solid #aaa;
+ border-top: 0;
+
+ border-radius: 0 0 4px 4px;
+
+ box-shadow: 0 4px 5px rgba(0, 0, 0, 0.15);
+}
+
+.select2-drop.select2-drop-above {
+ margin-top: 1px;
+ border-top: 1px solid #aaa;
+ border-bottom: 0;
+
+ border-radius: 4px 4px 0 0;
+
+ box-shadow: 0 -4px 5px rgba(0, 0, 0, 0.15);
+}
+
+.select2-drop-active {
+ border: 1px solid #5897fb;
+ border-top: 0;
+}
+
+.select2-drop.select2-drop-above.select2-drop-active {
+ border-top: 1px solid #5897fb;
+}
+
+.select2-drop-auto-width {
+ border-top: 1px solid #aaa;
+ width: auto;
+}
+
+.select2-drop-auto-width .select2-search {
+ padding-top: 4px;
+}
+
+.select2-container .select2-choice .select2-arrow {
+ display: inline-block;
+ width: 18px;
+ height: 100%;
+ position: absolute;
+ right: 0;
+ top: 0;
+
+ border-left: 1px solid #aaa;
+ border-radius: 0 4px 4px 0;
+
+ background-clip: padding-box;
+
+ background: #ccc;
+ background-image: linear-gradient(to top, #ccc 0%, #eee 60%);
+}
+
+html[dir='rtl'] .select2-container .select2-choice .select2-arrow {
+ left: 0;
+ right: auto;
+
+ border-left: 0;
+ border-right: 1px solid #aaa;
+ border-radius: 4px 0 0 4px;
+}
+
+.select2-container .select2-choice .select2-arrow b {
+ display: block;
+ width: 100%;
+ height: 100%;
+ /* stylelint-disable-next-line function-url-quotes */
+ background: url(image-path("select2.png")) no-repeat 0 1px;
+}
+
+html[dir='rtl'] .select2-container .select2-choice .select2-arrow b {
+ background-position: 2px 1px;
+}
+
+.select2-search {
+ display: inline-block;
+ width: 100%;
+ min-height: 26px;
+ margin: 0;
+ padding-left: 4px;
+ padding-right: 4px;
+
+ position: relative;
+ z-index: 10000;
+
+ white-space: nowrap;
+}
+
+.select2-search input {
+ width: 100%;
+ height: auto !important;
+ min-height: 26px;
+ padding: 4px 20px 4px 5px;
+ margin: 0;
+
+ outline: 0;
+ font-family: sans-serif;
+ font-size: 1em;
+
+ border: 1px solid #aaa;
+ border-radius: 0;
+
+ box-shadow: none;
+ /* stylelint-disable-next-line function-url-quotes */
+ background: url(image-path('select2.png')) no-repeat 100% -22px, linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0;
+}
+
+html[dir='rtl'] .select2-search input {
+ padding: 4px 5px 4px 20px;
+ /* stylelint-disable-next-line function-url-quotes */
+ background: url(image-path('select2.png')) no-repeat -37px -22px, linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0;
+}
+
+.select2-drop.select2-drop-above .select2-search input {
+ margin-top: 4px;
+}
+
+.select2-search input.select2-active {
+ /* stylelint-disable-next-line function-url-quotes */
+ background: url(image-path('select2-spinner.gif')) no-repeat 100%, linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0;
+}
+
+.select2-container-active .select2-choice,
+.select2-container-active .select2-choices {
+ border: 1px solid #5897fb;
+ outline: none;
+
+ box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
+}
+
+.select2-dropdown-open .select2-choice {
+ border-bottom-color: transparent;
+ box-shadow: 0 1px 0 #fff inset;
+
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+
+ background-color: #eee;
+ background-image: linear-gradient(to top, #fff 0%, #eee 50%);
+}
+
+.select2-dropdown-open.select2-drop-above .select2-choice,
+.select2-dropdown-open.select2-drop-above .select2-choices {
+ border: 1px solid #5897fb;
+ border-top-color: transparent;
+
+ background-image: linear-gradient(to bottom, #fff 0%, #eee 50%);
+}
+
+.select2-dropdown-open .select2-choice .select2-arrow {
+ background: transparent;
+ border-left: 0;
+ filter: none;
+}
+
+html[dir='rtl'] .select2-dropdown-open .select2-choice .select2-arrow {
+ border-right: 0;
+}
+
+.select2-dropdown-open .select2-choice .select2-arrow b {
+ background-position: -18px 1px;
+}
+
+html[dir='rtl'] .select2-dropdown-open .select2-choice .select2-arrow b {
+ background-position: -16px 1px;
+}
+
+.select2-hidden-accessible {
+ border: 0;
+ clip: rect(0 0 0 0);
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ width: 1px;
+}
+
+/* results */
+.select2-results {
+ max-height: 200px;
+ padding: 0 0 0 4px;
+ margin: 4px 4px 4px 0;
+ position: relative;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+html[dir='rtl'] .select2-results {
+ padding: 0 4px 0 0;
+ margin: 4px 0 4px 4px;
+}
+
+.select2-results ul.select2-result-sub {
+ margin: 0;
+ padding-left: 0;
+}
+
+.select2-results li {
+ list-style: none;
+ display: list-item;
+ background-image: none;
+}
+
+.select2-results li.select2-result-with-children > .select2-result-label {
+ font-weight: bold;
+}
+
+.select2-results .select2-result-label {
+ padding: 3px 7px 4px;
+ margin: 0;
+ cursor: pointer;
+
+ min-height: 1em;
+
+ user-select: none;
+}
+
+.select2-results-dept-1 .select2-result-label { padding-left: 20px; }
+.select2-results-dept-2 .select2-result-label { padding-left: 40px; }
+.select2-results-dept-3 .select2-result-label { padding-left: 60px; }
+.select2-results-dept-4 .select2-result-label { padding-left: 80px; }
+.select2-results-dept-5 .select2-result-label { padding-left: 100px; }
+.select2-results-dept-6 .select2-result-label { padding-left: 110px; }
+.select2-results-dept-7 .select2-result-label { padding-left: 120px; }
+
+.select2-results .select2-highlighted {
+ background: #3875d7;
+ color: #fff;
+}
+
+.select2-results li em {
+ background: #feffde;
+ font-style: normal;
+}
+
+.select2-results .select2-highlighted em {
+ background: transparent;
+}
+
+.select2-results .select2-highlighted ul {
+ background: #fff;
+ color: #000;
+}
+
+.select2-results .select2-no-results,
+.select2-results .select2-searching,
+.select2-results .select2-ajax-error,
+.select2-results .select2-selection-limit {
+ background: #f4f4f4;
+ display: list-item;
+ padding-left: 5px;
+}
+
+/*
+disabled look for disabled choices in the results dropdown
+*/
+.select2-results .select2-disabled.select2-highlighted {
+ color: #666;
+ background: #f4f4f4;
+ display: list-item;
+ cursor: default;
+}
+
+.select2-results .select2-disabled {
+ background: #f4f4f4;
+ display: list-item;
+ cursor: default;
+}
+
+.select2-results .select2-selected {
+ display: none;
+}
+
+.select2-more-results.select2-active {
+ /* stylelint-disable-next-line function-url-quotes */
+ background: #f4f4f4 url(image-path('select2-spinner.gif')) no-repeat 100%;
+}
+
+.select2-results .select2-ajax-error {
+ background: rgba(255, 50, 50, 0.2);
+}
+
+.select2-more-results {
+ background: #f4f4f4;
+ display: list-item;
+}
+
+/* disabled styles */
+
+.select2-container.select2-container-disabled .select2-choice {
+ background-color: #f4f4f4;
+ background-image: none;
+ border: 1px solid #ddd;
+ cursor: default;
+}
+
+.select2-container.select2-container-disabled .select2-choice .select2-arrow {
+ background-color: #f4f4f4;
+ background-image: none;
+ border-left: 0;
+}
+
+.select2-container.select2-container-disabled .select2-choice abbr {
+ display: none;
+}
+
+
+/* multiselect */
+
+.select2-container-multi .select2-choices {
+ height: auto !important;
+ height: 1%;
+ margin: 0;
+ padding: 0 5px 0 0;
+ position: relative;
+
+ border: 1px solid #aaa;
+ cursor: text;
+ overflow: hidden;
+
+ background-color: #fff;
+ background-image: linear-gradient(to bottom, #eee 1%, #fff 15%);
+}
+
+html[dir='rtl'] .select2-container-multi .select2-choices {
+ padding: 0 0 0 5px;
+}
+
+.select2-locked {
+ padding: 3px 5px !important;
+}
+
+.select2-container-multi .select2-choices {
+ min-height: 26px;
+}
+
+.select2-container-multi.select2-container-active .select2-choices {
+ border: 1px solid #5897fb;
+ outline: none;
+
+ box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
+}
+
+.select2-container-multi .select2-choices li {
+ float: left;
+ list-style: none;
+}
+
+html[dir='rtl'] .select2-container-multi .select2-choices li {
+ float: right;
+}
+
+.select2-container-multi .select2-choices .select2-search-field {
+ margin: 0;
+ padding: 0;
+ white-space: nowrap;
+}
+
+.select2-container-multi .select2-choices .select2-search-field input {
+ padding: 5px;
+ margin: 1px 0;
+
+ font-family: sans-serif;
+ font-size: 100%;
+ color: #666;
+ outline: 0;
+ border: 0;
+
+ box-shadow: none;
+ background: transparent !important;
+}
+
+.select2-container-multi .select2-choices .select2-search-field input.select2-active {
+ /* stylelint-disable-next-line function-url-quotes */
+ background: #fff url(image-path('select2-spinner.gif')) no-repeat 100% !important;
+}
+
+.select2-default {
+ color: #999 !important;
+}
+
+.select2-container-multi .select2-choices .select2-search-choice {
+ padding: 3px 5px 3px 18px;
+ margin: 3px 0 3px 5px;
+ position: relative;
+
+ line-height: 13px;
+ color: #333;
+ cursor: default;
+ border: 1px solid #aaa;
+
+ border-radius: 3px;
+
+ box-shadow: 0 0 2px #fff inset, 0 1px 0 rgba(0, 0, 0, 0.05);
+
+ background-clip: padding-box;
+
+ user-select: none;
+
+ background-color: #e4e4e4;
+ background-image: linear-gradient(to bottom, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eee 100%);
+}
+
+html[dir='rtl'] .select2-container-multi .select2-choices .select2-search-choice {
+ margin: 3px 5px 3px 0;
+ padding: 3px 18px 3px 5px;
+}
+
+.select2-container-multi .select2-choices .select2-search-choice .select2-chosen {
+ cursor: default;
+}
+
+.select2-container-multi .select2-choices .select2-search-choice-focus {
+ background: #d4d4d4;
+}
+
+.select2-search-choice-close {
+ display: block;
+ width: 12px;
+ height: 13px;
+ position: absolute;
+ right: 3px;
+ top: 4px;
+
+ font-size: 1px;
+ outline: none;
+ /* stylelint-disable-next-line function-url-quotes */
+ background: url(image-path('select2.png')) right top no-repeat;
+}
+
+html[dir='rtl'] .select2-search-choice-close {
+ right: auto;
+ left: 3px;
+}
+
+.select2-container-multi .select2-search-choice-close {
+ left: 3px;
+}
+
+html[dir='rtl'] .select2-container-multi .select2-search-choice-close {
+ left: auto;
+ right: 2px;
+}
+
+.select2-container-multi .select2-choices .select2-search-choice .select2-search-choice-close:hover {
+ background-position: right -11px;
+}
+
+.select2-container-multi .select2-choices .select2-search-choice-focus .select2-search-choice-close {
+ background-position: right -11px;
+}
+
+/* disabled styles */
+.select2-container-multi.select2-container-disabled .select2-choices {
+ background-color: #f4f4f4;
+ background-image: none;
+ border: 1px solid #ddd;
+ cursor: default;
+}
+
+.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice {
+ padding: 3px 5px;
+ border: 1px solid #ddd;
+ background-image: none;
+ background-color: #f4f4f4;
+}
+
+.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice .select2-search-choice-close {
+ display: none;
+ background: none;
+}
+/* end multiselect */
+
+
+.select2-result-selectable .select2-match,
+.select2-result-unselectable .select2-match {
+ text-decoration: underline;
+}
+
+.select2-offscreen,
+.select2-offscreen:focus {
+ clip: rect(0 0 0 0) !important;
+ width: 1px !important;
+ height: 1px !important;
+ border: 0 !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ overflow: hidden !important;
+ position: absolute !important;
+ outline: 0 !important;
+ left: 0 !important;
+ top: 0 !important;
+}
+
+.select2-display-none {
+ display: none;
+}
+
+.select2-measure-scrollbar {
+ position: absolute;
+ top: -10000px;
+ left: -10000px;
+ width: 100px;
+ height: 100px;
+ overflow: scroll;
+}
+
+@media only screen and (min-resolution: 120dpi) {
+ .select2-search input,
+ .select2-search-choice-close,
+ .select2-container .select2-choice abbr,
+ .select2-container .select2-choice .select2-arrow b {
+ /* stylelint-disable-next-line function-url-quotes */
+ background-image: url(image-path("select2x2.png")) !important;
+ background-repeat: no-repeat !important;
+ background-size: 60px 40px !important;
+ }
+
+ .select2-search input {
+ background-position: 100% -21px !important;
+ }
+}
+
+/* End of select2.css */
+
+@import './select2_overrides';
diff --git a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
new file mode 100644
index 00000000000..6c51c4b0ec3
--- /dev/null
+++ b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
@@ -0,0 +1,359 @@
+@import 'page_bundles/mixins_and_variables_and_functions';
+/** Select2 selectbox style override **/
+.select2-container {
+ width: 100% !important;
+
+ &.input-md,
+ &.input-lg {
+ display: block;
+ }
+}
+
+.select2-container,
+.select2-container.select2-drop-above {
+ .select2-choice {
+ background: $white;
+ color: $gl-text-color;
+ border-color: $border-color;
+ height: 34px;
+ padding: $gl-vert-padding $gl-input-padding;
+ font-size: $gl-font-size;
+ line-height: 1.42857143;
+ border-radius: $gl-border-radius-base;
+
+ .select2-arrow {
+ background-image: none;
+ background-color: transparent;
+ border: 0;
+ padding-top: 12px;
+ padding-right: 20px;
+ font-size: 10px;
+
+ b {
+ display: none;
+ }
+
+ &::after {
+ content: '\f078';
+ position: absolute;
+ z-index: 1;
+ text-align: center;
+ pointer-events: none;
+ box-sizing: border-box;
+ color: $gray-darkest;
+ display: inline-block;
+ font: normal normal normal 14px/1 FontAwesome;
+ font-size: inherit;
+ text-rendering: auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+ }
+
+ .select2-chosen {
+ margin-right: 15px;
+ }
+
+ &:hover {
+ border-color: $gray-darkest;
+ color: $gl-text-color;
+ }
+ }
+
+ // Essentially we’re doing @include form-control-focus here (from
+ // bootstrap/scss/mixins/_forms.scss), except that the bootstrap mixin adds a
+ // `&:focus` selector and we’re never actually focusing the .select2-choice
+ // link nor the .select2-container, the Select2 library focuses an off-screen
+ // .select2-focusser element instead.
+ &.select2-container-active:not(.select2-dropdown-open) {
+ .select2-choice {
+ color: $input-focus-color;
+ background-color: $input-focus-bg;
+ border-color: $input-focus-border-color;
+ outline: 0;
+ }
+
+ // Reusable focus “glow” box-shadow
+ @mixin form-control-focus-glow {
+ @if $enable-shadows {
+ box-shadow: $input-box-shadow, $input-focus-box-shadow;
+ } @else {
+ box-shadow: $input-focus-box-shadow;
+ }
+ }
+
+ // Apply the focus “glow” shadow to the .select2-container if it also has
+ // the .block-truncated class as that applies an overflow: hidden, thereby
+ // hiding the glow of the nested .select2-choice element.
+ &.block-truncated {
+ @include form-control-focus-glow;
+ }
+
+ // Apply the glow directly to the .select2-choice link if we’re not
+ // block-truncating the container.
+ &:not(.block-truncated) .select2-choice {
+ @include form-control-focus-glow;
+ }
+ }
+
+ &.is-invalid {
+ ~ .invalid-feedback {
+ display: block;
+ }
+
+ .select2-choices,
+ .select2-choice {
+ border-color: $red-500;
+ }
+ }
+}
+
+.select2-drop,
+.select2-drop.select2-drop-above {
+ background: $white;
+ box-shadow: 0 2px 4px $dropdown-shadow-color;
+ border-radius: $gl-border-radius-base;
+ border: 1px solid $border-color;
+ min-width: 175px;
+ color: $gl-text-color;
+ z-index: 999;
+
+ .modal-open & {
+ z-index: $zindex-modal + 200;
+ }
+}
+
+.select2-drop-mask {
+ z-index: 998;
+
+ .modal-open & {
+ z-index: $zindex-modal + 100;
+ }
+}
+
+.select2-drop.select2-drop-above.select2-drop-active {
+ border-top: 1px solid $border-color;
+ margin-top: -6px;
+}
+
+.select2-container-active {
+ .select2-choice,
+ .select2-choices {
+ box-shadow: none;
+ }
+}
+
+.select2-dropdown-open,
+.select2-dropdown-open.select2-drop-above {
+ .select2-choice {
+ border-color: $gray-darkest;
+ outline: 0;
+ }
+}
+
+.select2-container-multi {
+ .select2-choices {
+ border-radius: $border-radius-default;
+ border-color: $border-color;
+ background: none;
+
+ .select2-search-field input {
+ padding: 5px $gl-input-padding;
+ height: auto;
+ font-family: inherit;
+ font-size: inherit;
+ }
+
+ .select2-search-choice {
+ margin: 5px 0 0 8px;
+ box-shadow: none;
+ border-color: $border-color;
+ color: $gl-text-color;
+ line-height: 15px;
+ background-color: $gray-light;
+ background-image: none;
+ padding: 3px 18px 3px 5px;
+
+ .select2-search-choice-close {
+ top: 5px;
+ left: initial;
+ right: 3px;
+ }
+
+ &.select2-search-choice-focus {
+ border-color: $gl-text-color;
+ }
+ }
+ }
+}
+
+.select2-drop-active {
+ margin-top: $dropdown-vertical-offset;
+ font-size: 14px;
+
+ .select2-results {
+ max-height: 350px;
+ }
+}
+
+.select2-search {
+ padding: $grid-size;
+
+ .select2-drop-auto-width & {
+ padding: $grid-size;
+ }
+
+ input {
+ padding: $grid-size;
+ background: transparent image-url('select2.png');
+ color: $gl-text-color;
+ background-clip: content-box;
+ background-origin: content-box;
+ background-repeat: no-repeat;
+ background-position: right 0 bottom 0 !important;
+ border: 1px solid $border-color;
+ border-radius: $border-radius-default;
+ line-height: 16px;
+ transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
+
+ &:focus {
+ border-color: $blue-300;
+ }
+
+ &.select2-active {
+ background-color: $white;
+ background-image: image-url('select2-spinner.gif') !important;
+ background-origin: content-box;
+ background-repeat: no-repeat;
+ background-position: right 6px center !important;
+ background-size: 16px 16px !important;
+ }
+ }
+
+ + .select2-results {
+ padding-top: 0;
+ }
+}
+
+.select2-results {
+ margin: 0;
+ padding: #{$gl-padding / 2} 0;
+
+ .select2-no-results,
+ .select2-searching,
+ .select2-ajax-error,
+ .select2-selection-limit {
+ background: transparent;
+ padding: #{$gl-padding / 2} $gl-padding;
+ }
+
+ .select2-result-label,
+ .select2-more-results {
+ padding: #{$gl-padding / 2} $gl-padding;
+ }
+
+ .select2-highlighted {
+ background: transparent;
+ color: $gl-text-color;
+
+ .select2-result-label {
+ background: $gray-darker;
+ }
+ }
+
+ .select2-result {
+ padding: 0 1px;
+ }
+
+ li.select2-result-with-children > .select2-result-label {
+ font-weight: $gl-font-weight-bold;
+ color: $gl-text-color;
+ }
+}
+
+.select2-highlighted {
+ .group-result {
+ .group-path {
+ color: $gray-700;
+ }
+ }
+}
+
+.select2-result-selectable,
+.select2-result-unselectable {
+ .select2-match {
+ font-weight: $gl-font-weight-bold;
+ text-decoration: none;
+ }
+}
+
+.input-group {
+ .select2-container {
+ display: table-cell;
+ max-width: 180px;
+ }
+}
+
+.file-editor {
+ .select2 {
+ float: right;
+ }
+}
+
+.import-namespace-select {
+ > .select2-choice {
+ border-radius: $border-radius-default 0 0 $border-radius-default;
+ position: relative;
+ left: 1px;
+ }
+}
+
+.issue-form {
+ .select2-container {
+ width: 250px !important;
+ }
+}
+
+.new_project,
+.edit-project,
+.import-project {
+ .input-group {
+ .select2-container {
+ display: unset;
+ max-width: unset;
+ flex-grow: 1;
+ }
+ }
+
+ .input-group-prepend,
+ .input-group-append {
+ + .select2 a {
+ border-radius: 0 $gl-border-radius-base $gl-border-radius-base 0;
+ }
+ }
+}
+
+.project-path {
+ .select2-choice {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+}
+
+.transfer-project .select2-container {
+ min-width: 200px;
+}
+
+.right-sidebar {
+ .block {
+ .select2-container span {
+ margin-top: 0;
+ }
+ }
+}
+
+.block-truncated {
+ > div:not(.block):not(.select2-display-none) {
+ display: inline;
+ }
+}
diff --git a/app/assets/stylesheets/mailer.scss b/app/assets/stylesheets/mailer.scss
index b2050c0e73f..27c6ef20269 100644
--- a/app/assets/stylesheets/mailer.scss
+++ b/app/assets/stylesheets/mailer.scss
@@ -143,4 +143,21 @@ tr.footer td {
color: $mailer-link-color;
text-decoration: none;
}
+
+ .gitlab-info {
+ padding: $gl-padding-24 0;
+ }
+
+ .gitlab-info-text {
+ max-width: 640px;
+ margin: 0 auto;
+ text-align: center;
+ color: $gray-400;
+ font-size: $gl-font-size-small;
+ }
+
+ .footer-logo {
+ width: 90px;
+ height: 33px;
+ }
}
diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
index d1f7c2e9865..52cc7d3449e 100644
--- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
+++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss
@@ -5,7 +5,7 @@
$bs-input-focus-border: #80bdff;
$bs-input-focus-box-shadow: rgba(0, 123, 255, 0.25);
- a:not(.btn),
+ a:not(.btn):not(.gl-tab-nav-item),
.gl-button.btn-link,
.gl-button.btn-link:hover,
.gl-button.btn-link:focus,
@@ -151,7 +151,7 @@
border-color: var(--ide-border-color-alt, $gray-100);
code {
- background-color: var(--ide-border-color, inherit);
+ background-color: var(--ide-empty-state-background, inherit);
}
}
@@ -427,7 +427,7 @@
}
.md table:not(.code) tbody {
- background-color: var(--ide-border-color, $white);
+ background-color: var(--ide-empty-state-background, $white);
}
.animation-container {
diff --git a/app/assets/stylesheets/pages/alert_management/details.scss b/app/assets/stylesheets/page_bundles/alert_management_details.scss
index 514f228e223..beb80a14c5a 100644
--- a/app/assets/stylesheets/pages/alert_management/details.scss
+++ b/app/assets/stylesheets/page_bundles/alert_management_details.scss
@@ -1,24 +1,26 @@
+@import 'mixins_and_variables_and_functions';
+
.alert-management-details {
@include media-breakpoint-down(xs) {
.alert-details-incident-button {
- width: 100%;
+ @include gl-w-full;
}
}
.toggle-sidebar-mobile-button {
- right: 0;
+ @include gl-right-0;
}
.dropdown-menu-toggle {
&:hover {
- background-color: $white;
+ @include gl-bg-white;
}
}
.assignee-dropdown-item {
.dropdown-item {
- display: flex;
- align-items: center;
+ @include gl-display-flex;
+ @include gl-align-items-center;
&::before {
top: 50% !important;
@@ -26,7 +28,9 @@
&.is-active {
&:last-child {
- border-bottom: 1px solid $gray-100;
+ @include gl-border-b-gray-100;
+ @include gl-border-b-1;
+ @include gl-border-b-solid;
}
}
}
diff --git a/app/assets/stylesheets/page_bundles/alert_management_settings.scss b/app/assets/stylesheets/page_bundles/alert_management_settings.scss
new file mode 100644
index 00000000000..fb7c1602cba
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/alert_management_settings.scss
@@ -0,0 +1,24 @@
+@import 'mixins_and_variables_and_functions';
+
+$stroke-size: 1px;
+
+.right-arrow {
+ @include gl-relative;
+ @include gl-w-full;
+ height: $stroke-size;
+ @include gl-display-inline-block;
+ background-color: var(--gray-400, $gray-400);
+ min-width: $gl-spacing-scale-5;
+
+ &-head {
+ @include gl-absolute;
+ top: -$gl-spacing-scale-2;
+ left: calc(100% - #{$gl-spacing-scale-3} - #{2 * $stroke-size});
+ border-color: var(--gray-400, $gray-400);
+ @include gl-border-solid;
+ border-width: 0 $stroke-size $stroke-size 0;
+ @include gl-display-inline-block;
+ @include gl-p-2;
+ transform: rotate(-45deg);
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index e908e3622ed..ffc15af6329 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -83,9 +83,6 @@
}
.board {
- // the next line cannot be replaced with .d-inline-block because it breaks display: none of SortableJS
- // see https://gitlab.com/gitlab-org/gitlab-foss/issues/64828
- display: inline-block;
width: calc(85vw - 15px);
@include media-breakpoint-up(sm) {
@@ -116,39 +113,10 @@
&.is-collapsed {
width: 50px;
- .board-title {
- flex-direction: column;
- }
-
.board-title-caret {
margin-top: 1px;
}
- .user-avatar-link,
- .milestone-icon {
- margin-top: $gl-padding-8;
- transform: rotate(90deg);
- }
-
- .board-title-text {
- flex-grow: 0;
- margin: $gl-padding-8 0;
-
- .board-title-main-text {
- display: block;
- }
-
- .board-title-sub-text {
- display: none;
- }
- }
-
- .issue-count-badge {
- border: 0;
- white-space: nowrap;
- padding: 0;
- }
-
.board-title-text > span,
.issue-count-badge > span {
height: 16px;
@@ -197,10 +165,7 @@
}
.board-title {
- align-items: center;
- font-size: 1em;
border-bottom: 1px solid var(--gray-100, $gray-100);
- padding: 0 $gl-spacing-scale-3;
height: 3rem;
.js-max-issue-size::before {
@@ -208,21 +173,6 @@
}
}
-.board-title-text {
- flex-grow: 1;
-}
-
-.board-delete.gl-button {
- background-color: transparent;
- outline: 0;
-
- &:hover {
- color: var(--blue-600, $blue-600);
- box-shadow: none;
- }
-}
-
-.board-blank-state,
.board-promotion-state {
background-color: var(--white, $white);
flex: 1;
@@ -230,19 +180,6 @@
overflow-x: hidden;
}
-.board-blank-state-list {
- > li:not(:last-child) {
- margin-bottom: 8px;
- }
-
- .label-color {
- top: 2px;
- width: 16px;
- height: 16px;
- margin-right: 3px;
- }
-}
-
.board-list-component {
min-height: 0; // firefox fix
}
@@ -311,10 +248,6 @@
}
}
-.board-card-header {
- text-align: initial;
-}
-
.board-card-assignee {
margin-top: -$gl-padding-4;
margin-bottom: -$gl-padding-4;
@@ -586,28 +519,6 @@
}
}
-.board-swimlanes {
- overflow-x: auto;
-}
-
.board-header-collapsed-info-icon:hover {
color: var(--gray-900, $gray-900);
}
-
-$epic-icons-spacing: 40px;
-
-.board-epic-lane {
- max-width: calc(100vw - #{$contextual-sidebar-width} - #{$epic-icons-spacing});
-
- .page-with-icon-sidebar & {
- max-width: calc(100vw - #{$contextual-sidebar-collapsed-width} - #{$epic-icons-spacing});
- }
-
- .page-with-icon-sidebar .is-compact & {
- max-width: calc(100vw - #{$contextual-sidebar-collapsed-width} - #{$gutter-width} - #{$epic-icons-spacing});
- }
-
- .is-compact & {
- max-width: calc(100vw - #{$contextual-sidebar-width} - #{$gutter-width} - #{$epic-icons-spacing});
- }
-}
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/page_bundles/build.scss
index d7b4db3840e..2f0f4a46658 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/page_bundles/build.scss
@@ -1,45 +1,4 @@
-@keyframes fade-out-status {
- 0%,
- 50% {
- opacity: 1;
- }
-
- 100% {
- opacity: 0;
- }
-}
-
-@keyframes blinking-dot {
- 0% {
- opacity: 1;
- }
-
- 25% {
- opacity: 0.4;
- }
-
- 75% {
- opacity: 0.4;
- }
-
- 100% {
- opacity: 1;
- }
-}
-
-@keyframes blinking-scroll-button {
- 0% {
- opacity: 0.2;
- }
-
- 50% {
- opacity: 1;
- }
-
- 100% {
- opacity: 0.2;
- }
-}
+@import 'mixins_and_variables_and_functions';
.build-page {
.build-trace {
@@ -325,25 +284,11 @@
}
}
-.build-light-text {
- color: $gl-text-color-secondary;
- word-wrap: break-word;
-}
-
-.build-gutter-toggle {
- position: absolute;
- top: 50%;
- right: 0;
- margin-top: -17px;
-}
-
-@include media-breakpoint-down(sm) {
- .top-bar {
- .truncated-info {
- white-space: nowrap;
- overflow: hidden;
- max-width: 220px;
- text-overflow: ellipsis;
+@include media-breakpoint-down(md) {
+ .content-list {
+ &.builds-content-list {
+ width: 100%;
+ overflow: auto;
}
}
}
diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/page_bundles/ci_status.scss
index b37c5172ad2..8522a0a8fe4 100644
--- a/app/assets/stylesheets/pages/status.scss
+++ b/app/assets/stylesheets/page_bundles/ci_status.scss
@@ -1,3 +1,5 @@
+@import 'mixins_and_variables_and_functions';
+
.ci-status {
padding: 2px 7px 4px;
border: 1px solid $gray-darker;
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 71e74297ee8..15cc10d1532 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -5,7 +5,9 @@
@import './ide_theme_overrides';
@import './ide_themes/dark';
+@import './ide_themes/solarized-light';
@import './ide_themes/solarized-dark';
+@import './ide_themes/monokai';
$search-list-icon-width: 18px;
$ide-activity-bar-width: 60px;
@@ -176,11 +178,11 @@ $ide-commit-header-height: 48px;
height: 100%;
overflow: auto;
padding: $gl-padding;
- background-color: var(--ide-border-color, transparent);
+ background-color: var(--ide-empty-state-background, transparent);
}
.file-container {
- background-color: var(--ide-border-color, $gray-darker);
+ background-color: var(--ide-empty-state-background, $gray-darker);
display: flex;
height: 100%;
align-items: center;
@@ -491,7 +493,7 @@ $ide-commit-header-height: 48px;
height: 100vh;
align-items: center;
justify-content: center;
- background-color: var(--ide-border-color, transparent);
+ background-color: var(--ide-empty-state-background, transparent);
}
.ide {
@@ -915,12 +917,6 @@ $ide-commit-header-height: 48px;
}
}
-.ide-pipeline-list {
- flex: 1;
- overflow: auto;
- padding: 0 $gl-padding;
-}
-
.ide-pipeline-header {
min-height: 55px;
padding-left: $gl-padding;
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss b/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss
index 41f9a8e6db7..c7aae77c412 100644
--- a/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss
+++ b/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss
@@ -12,6 +12,7 @@
--ide-highlight-background: #252526;
--ide-link-color: #428fdc;
--ide-footer-background: #060606;
+ --ide-empty-state-background: var(--ide-border-color);
--ide-input-border: #868686;
--ide-input-background: transparent;
@@ -35,6 +36,13 @@
--ide-btn-success-hover-border-width: 2px;
--ide-btn-success-focus-box-shadow: 0 0 0 1px #2da160;
+ // Danger styles should be the same as default styles in dark theme
+ --ide-btn-danger-secondary-background: var(--ide-btn-default-background);
+ --ide-btn-danger-secondary-border: var(--ide-btn-default-border);
+ --ide-btn-danger-secondary-hover-border: var(--ide-btn-default-hover-border);
+ --ide-btn-danger-secondary-hover-border-width: var(--ide-btn-default-hover-border-width);
+ --ide-btn-danger-secondary-focus-box-shadow: var(--ide-btn-default-focus-box-shadow);
+
--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);
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_monokai.scss b/app/assets/stylesheets/page_bundles/ide_themes/_monokai.scss
new file mode 100644
index 00000000000..f53ace0b6c2
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/ide_themes/_monokai.scss
@@ -0,0 +1,66 @@
+// -------
+// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes
+// -------
+.ide.theme-monokai {
+ --ide-border-color: #1a1a18;
+ --ide-border-color-alt: #3f4237;
+ --ide-highlight-accent: #fff;
+ --ide-text-color: #ccc;
+ --ide-text-color-secondary: #b7b7b7;
+ --ide-background: #282822;
+ --ide-background-hover: #2d2d2d;
+ --ide-highlight-background: #1f1f1d;
+ --ide-link-color: #428fdc;
+ --ide-footer-background: #404338;
+ --ide-empty-state-background: #1a1a18;
+
+ --ide-input-border: #7d8175;
+ --ide-input-background: transparent;
+ --ide-input-color: #fff;
+
+ --ide-btn-default-background: transparent;
+ --ide-btn-default-border: #7d8175;
+ --ide-btn-default-hover-border: #b5bda5;
+ --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;
+
+ // Danger styles should be the same as default styles in dark theme
+ --ide-btn-danger-secondary-background: var(--ide-btn-default-background);
+ --ide-btn-danger-secondary-border: var(--ide-btn-default-border);
+ --ide-btn-danger-secondary-hover-border: var(--ide-btn-default-hover-border);
+ --ide-btn-danger-secondary-hover-border-width: var(--ide-btn-default-hover-border-width);
+ --ide-btn-danger-secondary-focus-box-shadow: var(--ide-btn-default-focus-box-shadow);
+
+ --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-dropdown-background: #36382f;
+ --ide-dropdown-hover-background: #404338;
+
+ --ide-dropdown-btn-hover-border: #b5bda5;
+ --ide-dropdown-btn-hover-background: #3f4237;
+
+ --ide-file-row-btn-hover-background: #404338;
+
+ --ide-diff-insert: rgba(155, 185, 85, 0.2);
+ --ide-diff-remove: rgba(255, 0, 0, 0.2);
+
+ --ide-animation-gradient-1: #404338;
+ --ide-animation-gradient-2: #36382f;
+}
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 ccb6f7a333b..1906b3ca938 100644
--- a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss
+++ b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss
@@ -12,6 +12,7 @@
--ide-highlight-background: #003240;
--ide-link-color: #73b9ff;
--ide-footer-background: var(--ide-highlight-background);
+ --ide-empty-state-background: var(--ide-border-color);
--ide-input-border: #d8d8d8;
--ide-input-background: transparent;
@@ -35,6 +36,13 @@
--ide-btn-success-hover-border-width: 2px;
--ide-btn-success-focus-box-shadow: 0 0 0 1px #2da160;
+ // Danger styles should be the same as default styles in dark theme
+ --ide-btn-danger-secondary-background: var(--ide-btn-default-background);
+ --ide-btn-danger-secondary-border: var(--ide-btn-default-border);
+ --ide-btn-danger-secondary-hover-border: var(--ide-btn-default-hover-border);
+ --ide-btn-danger-secondary-hover-border-width: var(--ide-btn-default-hover-border-width);
+ --ide-btn-danger-secondary-focus-box-shadow: var(--ide-btn-default-focus-box-shadow);
+
--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);
diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss
new file mode 100644
index 00000000000..315a0ae6202
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss
@@ -0,0 +1,57 @@
+// -------
+// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes
+// -------
+.ide.theme-solarized-light {
+ --ide-border-color: #dfd7bf;
+ --ide-border-color-alt: #dfd7bf;
+ --ide-highlight-accent: #5c4e21;
+ --ide-text-color: #616161;
+ --ide-text-color-secondary: #526f76;
+ --ide-background: #efe8d3;
+ --ide-background-hover: #ded6be;
+ --ide-highlight-background: #fef6e1;
+ --ide-link-color: #955800;
+ --ide-footer-background: #efe8d3;
+ --ide-empty-state-background: #fef6e1;
+
+ --ide-input-border: #c0b9a4;
+ --ide-input-background: transparent;
+
+ --ide-btn-default-background: transparent;
+ --ide-btn-default-border: #c0b9a4;
+ --ide-btn-default-hover-border: #c0b9a4;
+
+ --ide-btn-primary-background: #b16802;
+ --ide-btn-primary-border: #a35f00;
+ --ide-btn-primary-hover-border: #955800;
+ --ide-btn-primary-hover-border-width: 2px;
+ --ide-btn-primary-focus-box-shadow: 0 0 0 1px #dd8101;
+
+ --ide-btn-danger-secondary-background: transparent;
+
+ --ide-btn-disabled-background: transparent;
+ --ide-btn-disabled-border: rgba(192, 185, 64, 0.48);
+ --ide-btn-disabled-hover-border: rgba(192, 185, 64, 0.48);
+ --ide-btn-disabled-hover-border-width: 1px;
+ --ide-btn-disabled-focus-box-shadow: transparent;
+ --ide-btn-disabled-color: rgba(82, 82, 82, 0.48);
+
+ --ide-dropdown-background: #fef6e1;
+ --ide-dropdown-hover-background: #efe8d3;
+
+ --ide-dropdown-btn-hover-border: #dfd7bf;
+ --ide-dropdown-btn-hover-background: #efe8d3;
+
+ --ide-file-row-btn-hover-background: #ded6be;
+
+ --ide-animation-gradient-1: #d3cbb3;
+ --ide-animation-gradient-2: #efe8d3;
+
+ .ide-empty-state,
+ .ide-sidebar,
+ .ide-commit-empty-state {
+ img {
+ filter: sepia(1) brightness(0.7);
+ }
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss
index b8cdd120e04..c3e49da92a6 100644
--- a/app/assets/stylesheets/page_bundles/jira_connect.scss
+++ b/app/assets/stylesheets/page_bundles/jira_connect.scss
@@ -1,4 +1,6 @@
-@import 'framework/variables';
+@import 'mixins_and_variables_and_functions';
+// We should only import styles that we actually use.
+// @import '@gitlab/ui/src/scss/gitlab_ui';
$atlaskit-border-color: #dfe1e6;
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 5553dffac05..be74503c21f 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -11,9 +11,19 @@
}
.diff-tree-list {
+ // This 11px value should match the additional value found in
+ // /assets/stylesheets/framework/diffs.scss
+ // for the $mr-file-header-top SCSS variable within the
+ // .file-title,
+ // .file-title-flex-parent {
+ // rule.
+ // If they don't match, the file tree and the diff files stick
+ // to the top at different heights, which is a bad-looking defect
+ $diff-file-header-top: 11px;
+ $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + $diff-file-header-top;
+
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;
diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss
index 8e7be629481..1de66aa73da 100644
--- a/app/assets/stylesheets/page_bundles/pipeline.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline.scss
@@ -482,3 +482,7 @@
@include build-trace();
}
}
+
+.progress-bar.bg-primary {
+ background-color: $blue-500 !important;
+}
diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/page_bundles/pipeline_schedules.scss
index 81716991a36..412971253ca 100644
--- a/app/assets/stylesheets/pages/pipeline_schedules.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline_schedules.scss
@@ -1,3 +1,5 @@
+@import 'mixins_and_variables_and_functions';
+
.pipeline-schedule-form {
.gl-field-error {
margin: 10px 0 0;
@@ -32,11 +34,11 @@
}
.next-run-cell {
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
}
a {
- color: $text-color;
+ color: var(--gl-text-color, $gl-text-color);
}
svg {
@@ -46,13 +48,9 @@
.pipeline-schedules-user-callout {
.bordered-box.content-block {
- border: 1px solid $border-color;
+ border: 1px solid var(--border-color, $border-color);
background-color: transparent;
- padding: 16px;
- }
-
- #dismiss-callout-btn {
- color: $gl-text-color;
+ padding: $gl-spacing-scale-5;
}
}
diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss
index 6ff07017d2e..e0e56893afc 100644
--- a/app/assets/stylesheets/page_bundles/pipelines.scss
+++ b/app/assets/stylesheets/page_bundles/pipelines.scss
@@ -5,6 +5,10 @@
* Pipelines Bundle: Pipeline lists and Mini Pipelines
*/
+.pipelines-container .top-area .nav-controls > .btn:last-child {
+ float: none;
+}
+
// Pipelines list
// Should affect pipelines table components rendered by:
// - app/assets/javascripts/commit/pipelines/pipelines_bundle.js
diff --git a/app/assets/stylesheets/page_bundles/reports.scss b/app/assets/stylesheets/page_bundles/reports.scss
index 5a9dd454970..18ca5f9a3a9 100644
--- a/app/assets/stylesheets/page_bundles/reports.scss
+++ b/app/assets/stylesheets/page_bundles/reports.scss
@@ -8,14 +8,14 @@
.report-block-list-issue-parent {
padding: $gl-padding-top $gl-padding;
- border-top: 1px solid $border-color;
+ border-top: 1px solid var(--border-color, $border-color);
}
}
.report-block-container {
- border-top: 1px solid $border-color;
+ border-top: 1px solid var(--border-color, $border-color);
padding: $gl-padding - 2;
- background-color: $gray-light;
+ background-color: var(--gray-50, $gray-10);
// Clean MR widget CSS
line-height: 20px;
diff --git a/app/assets/stylesheets/page_bundles/experimental_separate_sign_up.scss b/app/assets/stylesheets/page_bundles/signup.scss
index 337b5b001fe..9ed48b693b9 100644
--- a/app/assets/stylesheets/page_bundles/experimental_separate_sign_up.scss
+++ b/app/assets/stylesheets/page_bundles/signup.scss
@@ -1,27 +1,6 @@
@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;
diff --git a/app/assets/stylesheets/page_bundles/todos.scss b/app/assets/stylesheets/page_bundles/todos.scss
index 3eec5b53a30..3e20ca9c62f 100644
--- a/app/assets/stylesheets/page_bundles/todos.scss
+++ b/app/assets/stylesheets/page_bundles/todos.scss
@@ -219,7 +219,6 @@
.todos-empty-content {
align-self: center;
max-width: 480px;
- margin-right: 20px;
}
.todos-empty-hero {
diff --git a/app/assets/stylesheets/pages/admin.scss b/app/assets/stylesheets/pages/admin.scss
index f0acb78f731..af1eefd7587 100644
--- a/app/assets/stylesheets/pages/admin.scss
+++ b/app/assets/stylesheets/pages/admin.scss
@@ -8,3 +8,11 @@
.usage-data {
max-height: 400px;
}
+
+[data-page='admin:jobs:index'] {
+ .admin-builds-table {
+ td:last-child {
+ min-width: 120px;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index c55bfeb7b15..17474b95e50 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -14,12 +14,6 @@
@extend %commit-description-base;
}
-.js-details-expand {
- &:hover {
- text-decoration: none;
- }
-}
-
.commit-box {
border-top: 1px solid $border-color;
padding: $gl-padding 0;
@@ -30,17 +24,6 @@
}
}
-.commit-hash-full {
- @include media-breakpoint-down(sm) {
- width: 80px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- display: inline-block;
- vertical-align: bottom;
- }
-}
-
.pipeline-info {
.status-icon-container {
display: inline-block;
@@ -225,9 +208,9 @@
display: inline-flex;
.label,
- .btn {
+ .btn:not(.gl-button) {
padding: $gl-vert-padding $gl-btn-padding;
- border: 1px $border-color solid;
+ border: 1px $gray-200 solid;
font-size: $gl-font-size;
line-height: $line-height-base;
border-radius: 0;
diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss
index 9f9964ac447..5c845c37e90 100644
--- a/app/assets/stylesheets/pages/editor.scss
+++ b/app/assets/stylesheets/pages/editor.scss
@@ -8,18 +8,18 @@
background: $gray-normal;
}
- #editor {
- border: 0;
- border-radius: 0;
+ #editor,
+ .editor {
+ @include gl-border-0;
+ @include gl-m-0;
+ @include gl-p-0;
+ @include gl-relative;
+ @include gl-w-full;
height: 500px;
- margin: 0;
- padding: 0;
- position: relative;
- width: 100%;
.editor-loading-content {
- height: 100%;
- border: 0;
+ @include gl-h-full;
+ @include gl-border-0;
}
}
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index ee4f74882a1..e73b6b18afd 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -43,12 +43,6 @@
}
}
-.ldap-group-links {
- .form-actions {
- margin-bottom: $gl-padding;
- }
-}
-
.save-group-loader {
margin-top: $gl-padding-50;
margin-bottom: $gl-padding-50;
@@ -343,11 +337,11 @@ table.pipeline-project-metrics tr td {
}
.user-access-role {
+ @include gl-px-3;
display: inline-block;
color: $gl-text-color-secondary;
font-size: 12px;
line-height: 20px;
- padding: 0 $label-padding;
border: 1px solid $border-color;
border-radius: $label-border-radius;
font-weight: $gl-font-weight-normal;
diff --git a/app/assets/stylesheets/pages/incident_management_list.scss b/app/assets/stylesheets/pages/incident_management_list.scss
index c0a1fa72b1f..ba363e2d119 100644
--- a/app/assets/stylesheets/pages/incident_management_list.scss
+++ b/app/assets/stylesheets/pages/incident_management_list.scss
@@ -8,13 +8,12 @@
@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
+ tr:not(.b-table-busy-slot):not(.b-table-empty-row) {
&:hover {
- border-top-style: double;
+ @include gl-border-t-double;
td {
- border-bottom-style: initial;
+ @include gl-border-b-initial;
}
}
}
@@ -22,7 +21,7 @@
tr {
&:focus {
- outline: none;
+ @include gl-outline-none;
}
td,
@@ -118,26 +117,34 @@
}
.gl-tabs-nav {
- border-bottom-width: 0;
+ @include gl-border-b-0;
.gl-tab-nav-item {
- color: $gray-500;
+ @include gl-text-gray-500;
> .gl-tab-counter-badge {
- color: inherit;
+ @include gl-reset-color;
@include gl-font-sm;
- background-color: $gray-50;
+ @include gl-bg-gray-50;
}
}
}
@include media-breakpoint-down(xs) {
.list-header {
- flex-direction: column-reverse;
+ @include gl-flex-direction-column-reverse;
}
.create-incident-button {
@include gl-w-full;
}
}
+
+ .integration-list {
+ .b-table-empty-row {
+ td {
+ @include gl-px-0;
+ }
+ }
+ }
}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 7097c2b10c4..cc4827f75d4 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -1,16 +1,12 @@
.issuable-warning-icon {
background-color: $orange-50;
border-radius: $border-radius-default;
+ color: $orange-600;
width: $issuable-warning-size;
height: $issuable-warning-size;
text-align: center;
margin-right: $issuable-warning-icon-margin;
line-height: $gl-line-height-24;
-
- .icon {
- fill: $orange-600;
- vertical-align: text-bottom;
- }
}
.limit-container-width {
@@ -77,14 +73,6 @@
}
}
-.issuable-filter-count {
- span {
- display: block;
- margin-bottom: -16px;
- padding: 13px 0;
- }
-}
-
.issuable-show-labels {
.gl-label {
margin-bottom: 5px;
@@ -662,12 +650,6 @@
}
}
-.issuable-form-padding-top {
- @include media-breakpoint-up(sm) {
- padding-top: 7px;
- }
-}
-
.issuable-status-box {
align-self: stretch;
display: flex;
@@ -822,11 +804,7 @@
}
}
-.time_tracker {
- padding-bottom: 0;
- border-bottom: 0;
-
-
+.time-tracker {
.sidebar-collapsed-icon {
> .stopwatch-svg {
display: inline-block;
@@ -939,6 +917,25 @@
}
}
+/*
+ * Following overrides are done to prevent
+ * legacy dropdown styles from influencing
+ * GitLab UI components used within GlDropdown
+ */
+.issuable-move-dropdown {
+ .b-dropdown-form {
+ @include gl-p-0;
+ }
+
+ .gl-search-box-by-type button.gl-clear-icon-button:hover {
+ @include gl-bg-transparent;
+ }
+
+ .issuable-move-button:not(.disabled):hover {
+ @include gl-text-white;
+ }
+}
+
.right-sidebar-collapsed {
.sidebar-grouped-item {
.sidebar-collapsed-icon {
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index d2eb00c4a4d..08faebc8ec0 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -67,7 +67,6 @@ ul.related-merge-requests > li {
}
}
-.merge-request-ci-status,
.related-merge-requests {
.ci-status-link {
display: block;
@@ -93,11 +92,6 @@ ul.related-merge-requests > li {
}
}
-.issues-footer {
- padding-top: $gl-padding;
- padding-bottom: 37px;
-}
-
.issues-nav-controls,
.new-branch-col {
font-size: 0;
@@ -194,6 +188,12 @@ ul.related-merge-requests > li {
border-width: 1px;
line-height: $line-height-base;
width: auto;
+
+ &.disabled {
+ background-color: $gray-light;
+ border-color: $gray-100;
+ color: $gl-text-color-disabled;
+ }
}
}
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index 31606cb3ba5..4d93702f1c2 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -92,13 +92,8 @@
margin-bottom: 0;
}
- &.sortable-ghost {
- opacity: 0.3;
- }
-
.prioritized-labels:not(.is-not-draggable) & {
box-shadow: 0 1px 2px $issue-boards-card-shadow;
- cursor: move;
cursor: grab;
border: 0;
@@ -108,126 +103,20 @@
}
}
- .btn-action {
- .fa {
- font-size: 18px;
- vertical-align: middle;
- pointer-events: none;
- }
-
- &:hover {
- color: $blue-600;
-
- &.remove-row {
- color: $red-500;
- }
- }
- }
-
- .color-label {
- padding: $gl-padding-4 $grid-size;
- }
-
.prepend-description-left {
vertical-align: top;
line-height: 24px;
}
}
-.prioritized-labels {
- margin-bottom: 30px;
-
- .add-priority {
- display: none;
- color: $gray-light;
- }
-
- li:hover {
- .draggable-handler {
- display: inline-block;
- opacity: 1;
- }
- }
-}
-
-.other-labels {
- .remove-priority {
- display: none;
- }
-}
-
-.filtered-labels {
- font-size: 0;
- padding: 12px 16px;
-
- .label-row {
- margin-top: 4px;
- margin-bottom: 4px;
-
- &:not(:last-child) {
- margin-right: 8px;
- }
- }
-
- .label-remove {
- border-left: 1px solid $label-remove-border;
- z-index: 3;
- border-radius: $label-border-radius;
- padding: 6px 10px 6px 9px;
-
- &:hover {
- box-shadow: inset 0 0 0 80px $label-remove-border;
- }
- }
-
- .btn {
- color: inherit;
- }
-
- a.btn {
- padding: 0;
-
- .has-tooltip {
- top: 0;
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- line-height: 1.1;
- }
- }
-}
-
-.label-subscription {
- vertical-align: middle;
-
- .dropdown-group-label a {
- cursor: pointer;
- }
+.prioritized-labels .add-priority,
+.other-labels .remove-priority {
+ display: none;
}
.label-subscribe-button {
width: 105px;
font-weight: 200;
-
- .label-subscribe-button-icon {
- &[disabled] {
- opacity: 0.5;
- pointer-events: none;
- }
- }
-
- .label-subscribe-button-loading {
- display: none;
- }
-
- &.disabled {
- .label-subscribe-button-icon {
- display: none;
- }
-
- .label-subscribe-button-loading {
- display: block;
- }
- }
}
.labels-container {
@@ -255,11 +144,6 @@
}
.label-list-item {
- .content-list &::before,
- .content-list &::after {
- content: none;
- }
-
.label-name {
width: 200px;
@@ -268,37 +152,16 @@
}
}
- .label {
- padding: 4px $grid-size;
- font-size: $label-font-size;
- position: relative;
- top: $gl-padding-4;
- }
-
.label-action {
color: $gray-700;
cursor: pointer;
- svg {
- fill: $gray-700;
- }
-
&:hover {
color: $blue-600;
-
- svg {
- fill: $blue-600;
- }
}
- &.remove-row {
- &:hover {
- color: $red-500;
-
- svg {
- fill: $red-500;
- }
- }
+ &.remove-row:hover {
+ color: $red-500;
}
}
}
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index 922f95ff5df..a8b489f1273 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -225,9 +225,14 @@
}
.col-actions {
- width: px-to-rem(50px);
+ width: px-to-rem(65px);
}
}
+
+ .gl-datepicker-input {
+ width: px-to-rem(165px);
+ max-width: 100%;
+ }
}
.card-mobile {
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 6f71177e870..a0ac55e4c6c 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -463,8 +463,7 @@ $mr-widget-min-height: 69px;
.mr-list {
.merge-request {
- padding: 10px 0 10px 15px;
- position: relative;
+ padding: 10px $gl-padding;
display: flex;
.issuable-info-container {
@@ -737,14 +736,6 @@ $mr-widget-min-height: 69px;
border-bottom: 0;
}
- .comments-disabled-notif {
- line-height: 28px;
-
- .btn {
- margin-left: 5px;
- }
- }
-
.mr-version-dropdown,
.mr-version-compare-dropdown {
margin: 0 7px;
@@ -1048,9 +1039,3 @@ $mr-widget-min-height: 69px;
.diff-file-row.is-active {
background-color: $gray-50;
}
-
-.merge-request-container {
- .flash-container {
- @include gl-mb-4;
- }
-}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 8b3d3268a8c..0c24ea9ccc6 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -2,6 +2,7 @@
* Note Form
*/
.diff-file .diff-content {
+ .diff-tr.line_holder:hover > .diff-td .line_note_link,
tr.line_holder:hover > td .line_note_link {
opacity: 1;
filter: alpha(opacity = 100);
@@ -226,10 +227,6 @@ table {
display: none;
}
-.parallel-comment {
- padding: 6px;
-}
-
.error-alert > .alert {
margin-top: 5px;
margin-bottom: 5px;
@@ -311,31 +308,12 @@ table {
}
}
-.discussion-notes-count {
- font-size: 16px;
-}
-
-.edit_note {
- .markdown-area {
- min-height: 140px;
- max-height: 500px;
- }
-
- .note-form-actions {
- background: transparent;
- }
-}
-
.comment-toolbar {
padding-top: $gl-padding-top;
color: $gl-text-color-secondary;
border-top: 1px solid $border-color;
}
-.md-helper {
- padding-top: 10px;
-}
-
.toolbar-button {
padding: 0;
background: none;
@@ -473,31 +451,6 @@ table {
margin-right: 5px;
}
-.attach-new-file,
-.button-attach-file,
-.retry-uploading-link {
- color: $blue-600;
- padding: 0;
- background: none;
- border: 0;
- font-size: 14px;
- line-height: 16px;
- vertical-align: initial;
-
- &:hover,
- &:focus {
- text-decoration: none;
-
- .text-attach-file {
- text-decoration: underline;
- }
- }
-
- .gl-icon:not(:last-child) {
- margin-right: 0;
- }
-}
-
.markdown-selector {
color: $blue-600;
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index b510822a20a..e23ec25a2f3 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -453,6 +453,8 @@ $note-form-margin-left: 72px;
}
.diff-file {
+ .diff-grid-left:hover,
+ .diff-grid-right:hover,
.is-over {
.add-diff-note {
display: inline-flex;
@@ -490,6 +492,7 @@ $note-form-margin-left: 72px;
.notes_holder {
font-family: $regular-font;
+ .diff-td,
td {
border: 1px solid $border-color;
border-left: 0;
@@ -798,21 +801,15 @@ $note-form-margin-left: 72px;
}
.note-role {
- margin: 0 3px;
-}
-
-.note-role-special {
- position: relative;
- display: inline-block;
- color: $gl-text-color-secondary;
- font-size: 12px;
- text-shadow: 0 0 15px $gl-text-color-inverted;
+ margin: 0 8px;
}
/**
* Line note button on the side of diffs
*/
+.diff-grid-left:hover,
+.diff-grid-right:hover,
.line_holder .is-over:not(.no-comment-btn) {
.add-diff-note {
opacity: 1;
@@ -895,6 +892,15 @@ $note-form-margin-left: 72px;
outline: 0;
transition: color $general-hover-transition-duration $general-hover-transition-curve;
+ &[disabled] {
+ padding: 0 8px !important;
+ box-shadow: none !important;
+
+ .gl-button-loading-indicator {
+ margin-right: 0 !important;
+ }
+ }
+
&.is-disabled {
cursor: default;
}
@@ -902,16 +908,22 @@ $note-form-margin-left: 72px;
&:not(.is-disabled) {
&:hover,
&:focus {
- color: $green-600;
+ svg {
+ color: $green-600;
+ }
}
}
&.is-active {
- color: $green-600;
+ svg {
+ @include gl-text-green-500;
+ }
&:hover,
&:focus {
- color: $green-700;
+ svg {
+ color: $green-700;
+ }
}
}
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 2df43b861b2..b37aa6cd285 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -1,11 +1,17 @@
-@include media-breakpoint-down(md) {
- .content-list {
- &.builds-content-list {
- width: 100%;
- overflow: auto;
- }
- }
-}
+/**
+ * !! NOTE: Do not add more code in this file:
+ *
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/267602
+ *
+ * For new pipeline CSS please consider:
+ *
+ * For pipelines tables and lists:
+ * - `app/assets/stylesheets/page_bundles/pipelines.scss`
+ *
+ * For individual pipelines and mini-pipelines:
+ * - `app/assets/stylesheets/page_bundles/pipeline.scss`
+ *
+**/
.ci-table {
.avatar {
@@ -81,38 +87,13 @@
}
}
-[data-page='admin:jobs:index'] {
- .admin-builds-table {
- td:last-child {
- min-width: 120px;
+@include media-breakpoint-down(sm) {
+ .top-bar {
+ .truncated-info {
+ white-space: nowrap;
+ overflow: hidden;
+ max-width: 220px;
+ text-overflow: ellipsis;
}
}
}
-
-.pipelines-container .top-area .nav-controls > .btn:last-child {
- float: none;
-}
-
-.progress-bar.bg-primary {
- background-color: $blue-500 !important;
-}
-
-.pipeline-stage-pill {
- width: 10rem;
-}
-
-.pipeline-job-pill {
- width: 8rem;
-}
-
-.stage-rounded {
- border-radius: 2rem;
-}
-
-.stage-left-rounded {
- border-radius: 2rem 0 0 2rem;
-}
-
-.stage-right-rounded {
- border-radius: 0 2rem 2rem 0;
-}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 938d8d34717..09501d3713d 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -252,15 +252,6 @@
}
}
-.split-one {
- display: inline-table;
- margin-right: 12px;
-
- > a {
- margin: -1px;
- }
-}
-
.save-project-loader {
margin-top: 50px;
margin-bottom: 50px;
@@ -505,92 +496,6 @@
}
}
-.create-project-options {
- display: flex;
-
- @include media-breakpoint-down(xs) {
- display: block;
- }
-
- .first-column {
- @include media-breakpoint-up(xs) {
- max-width: 50%;
- padding-right: 30px;
- }
-
- @include media-breakpoint-down(xs) {
- max-width: 100%;
- width: 100%;
- }
- }
-
- .second-column {
- @include media-breakpoint-up(xs) {
- width: 50%;
- flex: 1;
- padding-left: 30px;
- position: relative;
- }
-
- @include media-breakpoint-down(xs) {
- max-width: 100%;
- width: 100%;
- padding-left: 0;
- position: relative;
- }
-
- // Mobile
- @include media-breakpoint-down(xs) {
- padding-top: 30px;
- }
-
- &::before {
- content: 'OR';
- position: absolute;
- left: -10px;
- top: 50%;
- z-index: 10;
- padding: $gl-padding-8 0;
- text-align: center;
- background-color: $white;
- color: $gl-text-color-tertiary;
- transform: translateY(-50%);
- font-size: 12px;
- font-weight: $gl-font-weight-bold;
- line-height: 20px;
-
- // Mobile
- @include media-breakpoint-down(xs) {
- left: 50%;
- top: 0;
- transform: translateX(-50%);
- padding: 0 $gl-padding-8;
- }
- }
-
- &::after {
- content: '';
- position: absolute;
- background-color: $border-color;
- bottom: 0;
- left: 0;
- right: auto;
- height: 100%;
- width: 1px;
- top: 0;
-
- // Mobile
- @include media-breakpoint-down(xs) {
- top: 10px;
- left: 10px;
- right: 10px;
- height: 1px;
- width: auto;
- }
- }
- }
-}
-
.project-stats,
.project-buttons {
.scrolling-tabs-container {
@@ -754,17 +659,6 @@ pre.light-well {
}
}
-.project-footer {
- margin-top: 20px;
-
- .btn-remove {
- @include btn-middle;
- @include btn-red;
-
- float: left !important;
- }
-}
-
/*
* Projects list rendered on dashboard and user page
*/
@@ -1059,24 +953,6 @@ pre.light-well {
}
}
-.cannot-be-merged,
-.cannot-be-merged:hover {
- color: $red-500;
- margin-top: 2px;
- position: relative;
- z-index: 2;
-}
-
-.private-forks-notice .private-fork-icon {
- i:nth-child(1) {
- color: $green-600;
- }
-
- i:nth-child(2) {
- color: $white;
- }
-}
-
.new-protected-branch,
.new-protected-tag {
label {
@@ -1117,23 +993,6 @@ pre.light-well {
}
}
-.custom-notifications-form {
- .is-loading {
- .custom-notification-event-loading {
- display: inline-block;
- }
- }
-}
-
-.custom-notification-event-loading {
- display: none;
- margin-left: 5px;
-
- &.is-done {
- color: $green-600;
- }
-}
-
.project-refs-form .dropdown-menu,
.dropdown-menu-projects {
width: 300px;
@@ -1233,34 +1092,6 @@ pre.light-well {
}
}
-.variables-table {
- table-layout: fixed;
-
- &.table-responsive {
- border: 0;
- }
-
- .variable-key {
- max-width: 120px;
- overflow: hidden;
- word-wrap: break-word;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
-
- .variable-value {
- max-width: 150px;
- overflow: hidden;
- word-wrap: break-word;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
-
- .variable-menu {
- text-align: right;
- }
-}
-
.services-installation-info .row {
margin-bottom: 10px;
}
@@ -1286,18 +1117,6 @@ pre.light-well {
padding-bottom: 37px;
}
-.project-ci-body {
- .incorrect-syntax {
- font-size: 18px;
- color: $red-500;
- }
-
- .correct-syntax {
- font-size: 18px;
- color: $green-500;
- }
-}
-
.project-ci-linter {
.ci-editor {
height: 400px;
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index a62e28a9b8a..502a1881fd2 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -270,7 +270,8 @@ input[type='checkbox']:hover {
width: 100%;
}
- .dropdown-menu-toggle {
+ .dropdown-menu-toggle,
+ .gl-new-dropdown {
@include media-breakpoint-up(lg) {
width: 240px;
}
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index 73fe76f139f..429181c2ad4 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -62,7 +62,7 @@
.tree-controls {
margin-bottom: 10px;
- .btn,
+ .btn:not(.dropdown-toggle-split),
.dropdown,
.btn-group {
width: 100%;
diff --git a/app/assets/stylesheets/pages/users.scss b/app/assets/stylesheets/pages/users.scss
index 0863b573f75..917d16a9c53 100644
--- a/app/assets/stylesheets/pages/users.scss
+++ b/app/assets/stylesheets/pages/users.scss
@@ -51,43 +51,6 @@
outline: 0;
}
-.flex-users-panel {
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: center;
-
- @include media-breakpoint-down(sm) {
- display: block;
-
- .flex-project-title {
- vertical-align: top;
- display: inline-block;
- max-width: 90%;
- }
- }
-
- .flex-project-title {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
-
- .badge.badge-pill {
- height: 17px;
- line-height: 16px;
- margin-right: 5px;
- padding-top: 1px;
- padding-bottom: 1px;
- }
-
- .flex-users-form {
- flex-wrap: nowrap;
- white-space: nowrap;
- margin-left: auto;
- }
-}
-
.content-list.members-list li {
display: flex;
justify-content: space-between;
diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss
index dc127cd2554..c6c9f3b7365 100644
--- a/app/assets/stylesheets/performance_bar.scss
+++ b/app/assets/stylesheets/performance_bar.scss
@@ -6,7 +6,7 @@
left: 0;
top: 0;
width: 100%;
- z-index: #{$zindex-modal-backdrop + 1};
+ z-index: #{$zindex-modal-backdrop - 1};
height: $performance-bar-height;
background: $black;
diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss
index 9ed1600419d..7b66b61ff36 100644
--- a/app/assets/stylesheets/print.scss
+++ b/app/assets/stylesheets/print.scss
@@ -31,7 +31,7 @@ nav.navbar-collapse.collapse,
.nav,
.btn,
ul.notes-form,
-.merge-request-ci-status .ci-status-link::after,
+.ci-status-link::after,
.issuable-gutter-toggle,
.gutter-toggle,
.issuable-details .content-block-small,
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index 66cc4452858..6ab02bd5e27 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -201,6 +201,15 @@ $line-removed-dark: $red-200;
// Misc component overrides that should live elsewhere
.gl-label {
filter: brightness(0.9) contrast(1.1);
+
+ // This applies to the gl-label markups
+ // rendered and cached in the backend (labels_helper.rb)
+ &.gl-label-scoped {
+ .gl-label-text-scoped,
+ .gl-label-close {
+ color: $gray-900;
+ }
+ }
}
// white-ish text for light labels
@@ -210,6 +219,15 @@ $line-removed-dark: $red-200;
color: $gray-900;
}
+// This applies to "gl-labels" from "gitlab-ui"
+.gl-label.gl-label-scoped.gl-label-text-dark,
+.gl-label.gl-label-scoped.gl-label-text-light {
+ .gl-label-text-scoped,
+ .gl-label-close {
+ color: $gray-900;
+ }
+}
+
// duplicated class as the original .atwho-view style is added later
.atwho-view.atwho-view {
background-color: $white;
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index e236c264f5c..a3bb7c868df 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -122,3 +122,10 @@
margin-left: $gl-spacing-scale-3;
}
}
+
+// This is used to help prevent issues with margin collapsing.
+// See https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Box_Model/Mastering_margin_collapsing.
+.gl-force-block-formatting-context::after {
+ content: '';
+ display: flex;
+}
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 786ba73a96f..56ec10fa43a 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -53,7 +53,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
def integrations
return not_found unless instance_level_integrations?
- @integrations = Service.find_or_initialize_all(Service.for_instance).sort_by(&:title)
+ @integrations = Service.find_or_initialize_all_non_project_specific(Service.for_instance).sort_by(&:title)
end
def update
@@ -216,10 +216,10 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
remove_blank_params_for!(:elasticsearch_aws_secret_access_key, :eks_secret_access_key)
- # TODO Remove domain_blacklist_raw in APIv5 (See https://gitlab.com/gitlab-org/gitlab-foss/issues/67204)
- params.delete(:domain_blacklist_raw) if params[:domain_blacklist_file]
- params.delete(:domain_blacklist_raw) if params[:domain_blacklist]
- params.delete(:domain_whitelist_raw) if params[:domain_whitelist]
+ # TODO Remove domain_denylist_raw in APIv5 (See https://gitlab.com/gitlab-org/gitlab-foss/issues/67204)
+ params.delete(:domain_denylist_raw) if params[:domain_denylist_file]
+ params.delete(:domain_denylist_raw) if params[:domain_denylist]
+ params.delete(:domain_allowlist_raw) if params[:domain_allowlist]
params.require(:application_setting).permit(
visible_application_setting_attributes
@@ -240,7 +240,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
*ApplicationSetting.repository_storages_weighted_attributes,
:lets_encrypt_notification_email,
:lets_encrypt_terms_of_service_accepted,
- :domain_blacklist_file,
+ :domain_denylist_file,
:raw_blob_request_limit,
:issues_create_limit,
:default_branch_name,
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index 7d981d67840..33a8cc4ae42 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -16,6 +16,7 @@ class Admin::DashboardController < Admin::ApplicationController
@groups = Group.order_id_desc.with_route.limit(10)
@notices = Gitlab::ConfigChecker::PumaRuggedChecker.check
@notices += Gitlab::ConfigChecker::ExternalDatabaseChecker.check
+ @redis_versions = [Gitlab::Redis::Queues, Gitlab::Redis::SharedState, Gitlab::Redis::Cache].map(&:version).uniq
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/admin/instance_statistics_controller.rb b/app/controllers/admin/instance_statistics_controller.rb
index dfbd704cb0c..05a0a1ce314 100644
--- a/app/controllers/admin/instance_statistics_controller.rb
+++ b/app/controllers/admin/instance_statistics_controller.rb
@@ -13,6 +13,6 @@ class Admin::InstanceStatisticsController < Admin::ApplicationController
end
def check_feature_flag
- render_404 unless Feature.enabled?(:instance_statistics)
+ render_404 unless Feature.enabled?(:instance_statistics, default_enabled: true)
end
end
diff --git a/app/controllers/admin/integrations_controller.rb b/app/controllers/admin/integrations_controller.rb
index 9a1d5a11f7f..aab8705f5cb 100644
--- a/app/controllers/admin/integrations_controller.rb
+++ b/app/controllers/admin/integrations_controller.rb
@@ -8,8 +8,8 @@ class Admin::IntegrationsController < Admin::ApplicationController
private
- def find_or_initialize_integration(name)
- Service.find_or_initialize_integration(name, instance: true)
+ def find_or_initialize_non_project_specific_integration(name)
+ Service.find_or_initialize_non_project_specific_integration(name, instance: true)
end
def integrations_enabled?
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index bd7b69384b2..2d0bb0bfebc 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -6,7 +6,6 @@ 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
@@ -298,10 +297,6 @@ 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 05f496c3b99..c38c6abddc1 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -22,7 +22,7 @@ class ApplicationController < ActionController::Base
include Impersonation
include Gitlab::Logging::CloudflareHelper
include Gitlab::Utils::StrongMemoize
- include ControllerWithFeatureCategory
+ include ::Gitlab::WithFeatureCategory
before_action :authenticate_user!, except: [:route_not_found]
before_action :enforce_terms!, if: :should_enforce_terms?
@@ -121,7 +121,7 @@ class ApplicationController < ActionController::Base
end
def route_not_found
- if current_user
+ if current_user || browser.bot.search_engine?
not_found
else
store_location_for(:user, request.fullpath) unless request.xhr?
@@ -266,6 +266,12 @@ class ApplicationController < ActionController::Base
end
end
+ def stream_headers
+ headers['Content-Length'] = nil
+ headers['X-Accel-Buffering'] = 'no' # Disable buffering on Nginx
+ headers['Last-Modified'] = '0' # Prevent buffering via Rack::ETag middleware
+ end
+
def default_headers
headers['X-Frame-Options'] = 'DENY'
headers['X-XSS-Protection'] = '1; mode=block'
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index ac4ee14c6a9..9ee69c7c07f 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -53,7 +53,7 @@ class AutocompleteController < ApplicationController
end
def deploy_keys_with_owners
- deploy_keys = DeployKeys::CollectKeysService.new(project, current_user).execute
+ deploy_keys = DeployKey.with_write_access_for_project(project)
render json: DeployKeySerializer.new.represent(deploy_keys, { with_owner: true, user: current_user })
end
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 52719e90e04..9800d94964d 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -272,7 +272,7 @@ class Clusters::ClustersController < Clusters::BaseController
end
def aws_role_params
- params.require(:cluster).permit(:role_arn)
+ params.require(:cluster).permit(:role_arn, :region)
end
def generate_gcp_authorize_url
diff --git a/app/controllers/concerns/controller_with_feature_category.rb b/app/controllers/concerns/controller_with_feature_category.rb
deleted file mode 100644
index c1ff9ef2e69..00000000000
--- a/app/controllers/concerns/controller_with_feature_category.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-# frozen_string_literal: true
-
-module ControllerWithFeatureCategory
- extend ActiveSupport::Concern
- include Gitlab::ClassAttributes
-
- class_methods do
- def feature_category(category, actions = [])
- feature_category_configuration[category] ||= []
- feature_category_configuration[category] += actions.map(&:to_s)
-
- validate_config!(feature_category_configuration)
- end
-
- def feature_category_for_action(action)
- category_config = feature_category_configuration.find do |_, actions|
- actions.empty? || actions.include?(action)
- end
-
- category_config&.first || superclass_feature_category_for_action(action)
- end
-
- private
-
- def validate_config!(config)
- 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 duplicate_actions.any?
- raise ArgumentError, "Actions have multiple feature categories: #{duplicate_actions.join(', ')}"
- end
- end
-
- def feature_category_configuration
- class_attributes[:feature_category_config] ||= {}
- end
-
- def superclass_feature_category_for_action(action)
- return unless superclass.respond_to?(:feature_category_for_action)
-
- superclass.feature_category_for_action(action)
- end
- end
-end
diff --git a/app/controllers/concerns/dependency_proxy_access.rb b/app/controllers/concerns/dependency_proxy_access.rb
new file mode 100644
index 00000000000..5036d0cfce4
--- /dev/null
+++ b/app/controllers/concerns/dependency_proxy_access.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module DependencyProxyAccess
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :verify_dependency_proxy_enabled!
+ before_action :authorize_read_dependency_proxy!
+ end
+
+ private
+
+ def verify_dependency_proxy_enabled!
+ render_404 unless group.dependency_proxy_feature_available?
+ end
+
+ def authorize_read_dependency_proxy!
+ access_denied! unless can?(current_user, :read_dependency_proxy, group)
+ end
+
+ def authorize_admin_dependency_proxy!
+ access_denied! unless can?(current_user, :admin_dependency_proxy, group)
+ end
+end
diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb
index 39f63bbaaec..8e9b038437d 100644
--- a/app/controllers/concerns/integrations_actions.rb
+++ b/app/controllers/concerns/integrations_actions.rb
@@ -52,7 +52,7 @@ module IntegrationsActions
def integration
# Using instance variable `@service` still required as it's used in ServiceParams.
# Should be removed once that is refactored to use `@integration`.
- @integration = @service ||= find_or_initialize_integration(params[:id]) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ @integration = @service ||= find_or_initialize_non_project_specific_integration(params[:id]) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
def success_message
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index a1a2740cde2..3b46a547d47 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -8,9 +8,6 @@ module IssuableActions
before_action :authorize_destroy_issuable!, only: :destroy
before_action :check_destroy_confirmation!, only: :destroy
before_action :authorize_admin_issuable!, only: :bulk_update
- before_action only: :show do
- push_frontend_feature_flag(:scoped_labels, type: :licensed, default_enabled: true)
- end
before_action do
push_frontend_feature_flag(:not_issuable_queries, @project, default_enabled: true)
end
diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb
index 2844acea271..bc3fd32759f 100644
--- a/app/controllers/concerns/lfs_request.rb
+++ b/app/controllers/concerns/lfs_request.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
# This concern assumes:
+# - a `#container` accessor
# - a `#project` accessor
# - a `#user` accessor
# - a `#authentication_result` accessor
@@ -11,6 +12,7 @@
# - a `#has_authentication_ability?(ability)` method
module LfsRequest
extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
CONTENT_TYPE = 'application/vnd.git-lfs+json'
@@ -29,16 +31,19 @@ module LfsRequest
message: _('Git LFS is not enabled on this GitLab server, contact your admin.'),
documentation_url: help_url
},
+ content_type: CONTENT_TYPE,
status: :not_implemented
)
end
def lfs_check_access!
- return render_lfs_not_found unless project
+ return render_lfs_not_found unless container&.lfs_enabled?
return if download_request? && lfs_download_access?
return if upload_request? && lfs_upload_access?
- if project.public? || can?(user, :read_project, project)
+ # Only return a 403 response if the user has download access permission,
+ # otherwise return a 404 to avoid exposing the existence of the container.
+ if lfs_download_access?
lfs_forbidden!
else
render_lfs_not_found
@@ -72,9 +77,9 @@ module LfsRequest
end
def lfs_download_access?
- return false unless project.lfs_enabled?
-
- ci? || lfs_deploy_token? || user_can_download_code? || build_can_download_code? || deploy_token_can_download_code?
+ strong_memoize(:lfs_download_access) do
+ ci? || lfs_deploy_token? || user_can_download_code? || build_can_download_code? || deploy_token_can_download_code?
+ end
end
def deploy_token_can_download_code?
@@ -93,11 +98,12 @@ module LfsRequest
end
def lfs_upload_access?
- return false unless project.lfs_enabled?
- return false unless has_authentication_ability?(:push_code)
- return false if limit_exceeded?
+ strong_memoize(:lfs_upload_access) do
+ next false unless has_authentication_ability?(:push_code)
+ next false if limit_exceeded?
- lfs_deploy_token? || can?(user, :push_code, project)
+ lfs_deploy_token? || can?(user, :push_code, project)
+ end
end
def lfs_deploy_token?
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index 7a5b470f366..bfa7a30bc65 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -31,6 +31,10 @@ module NotesActions
# We know there's more data, so tell the frontend to poll again after 1ms
set_polling_interval_header(interval: 1) if meta[:more]
+ # Only present an ETag for the empty response to ensure pagination works
+ # as expected
+ ::Gitlab::EtagCaching::Middleware.skip!(response) if notes.present?
+
render json: meta.merge(notes: notes)
end
@@ -115,7 +119,7 @@ module NotesActions
end
def gather_some_notes
- paginator = Gitlab::UpdatedNotesPaginator.new(
+ paginator = ::Gitlab::UpdatedNotesPaginator.new(
notes_finder.execute.inc_relations_for_view,
last_fetched_at: last_fetched_at
)
diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb
index 1b2e6461dee..bc2e7fba288 100644
--- a/app/controllers/concerns/routable_actions.rb
+++ b/app/controllers/concerns/routable_actions.rb
@@ -51,7 +51,7 @@ module RoutableActions
flash[:notice] = "#{routable.class.to_s.titleize} '#{requested_full_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path."
end
- redirect_to build_canonical_path(routable)
+ redirect_to build_canonical_path(routable), status: :moved_permanently
end
end
end
diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb
index 2f06cd84ee5..8b053ef7c59 100644
--- a/app/controllers/concerns/send_file_upload.rb
+++ b/app/controllers/concerns/send_file_upload.rb
@@ -70,16 +70,7 @@ module SendFileUpload
Avatarable::ALLOWED_IMAGE_SCALER_WIDTHS.include?(params[:width]&.to_i)
end
- # We use two separate feature gates to allow image resizing.
- # The first, `:dynamic_image_resizing_requester`, based on the content requester.
- # Enabling it for the user would allow that user to send resizing requests for any avatar.
- # The second, `:dynamic_image_resizing_owner`, based on the content owner.
- # Enabling it for the user would allow anyone to send resizing requests against the mentioned user avatar only.
- # This flag allows us to operate on trusted data only, more in https://gitlab.com/gitlab-org/gitlab/-/issues/241533.
- # Because of this, you need to enable BOTH to serve resized image,
- # as you would need at least one allowed requester and at least one allowed avatar.
def scaling_allowed_by_feature_flags?(file_upload)
- Feature.enabled?(:dynamic_image_resizing_requester, current_user) &&
- Feature.enabled?(:dynamic_image_resizing_owner, file_upload.model)
+ Feature.enabled?(:dynamic_image_resizing, default_enabled: true, type: :ops)
end
end
diff --git a/app/controllers/concerns/sends_blob.rb b/app/controllers/concerns/sends_blob.rb
index 9bba61fda84..381f2eba352 100644
--- a/app/controllers/concerns/sends_blob.rb
+++ b/app/controllers/concerns/sends_blob.rb
@@ -44,7 +44,6 @@ module SendsBlob
Blob::CACHE_TIME
end
- response.etag = blob.id
!stale
end
diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb
index e4c3df6ccc3..0153ede2821 100644
--- a/app/controllers/concerns/snippets_actions.rb
+++ b/app/controllers/concerns/snippets_actions.rb
@@ -57,11 +57,6 @@ module SnippetsActions
render 'show'
end
- format.json do
- conditionally_expand_blob(blob)
- render_blob_json(blob)
- end
-
format.js do
if @snippet.embeddable?
conditionally_expand_blobs(blobs)
diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb
index aed109309e3..6abb2e16226 100644
--- a/app/controllers/concerns/wiki_actions.rb
+++ b/app/controllers/concerns/wiki_actions.rb
@@ -103,9 +103,10 @@ module WikiActions
@page = response.payload[:page]
if response.success?
+ flash[:toast] = _('Wiki page was successfully updated.')
+
redirect_to(
- wiki_page_path(wiki, page),
- notice: _('Wiki was successfully updated.')
+ wiki_page_path(wiki, page)
)
else
render 'shared/wikis/edit'
@@ -122,9 +123,10 @@ module WikiActions
@page = response.payload[:page]
if response.success?
+ flash[:toast] = _('Wiki page was successfully created.')
+
redirect_to(
- wiki_page_path(wiki, page),
- notice: _('Wiki was successfully updated.')
+ wiki_page_path(wiki, page)
)
else
render 'shared/wikis/edit'
@@ -169,9 +171,10 @@ module WikiActions
response = WikiPages::DestroyService.new(container: container, current_user: current_user).execute(page)
if response.success?
+ flash[:toast] = _("Wiki page was successfully deleted.")
+
redirect_to wiki_path(wiki),
- status: :found,
- notice: _("Page was successfully deleted")
+ status: :found
else
@error = response
render 'shared/wikis/edit'
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index b971c5783a8..c2d72610c66 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -7,9 +7,8 @@ class Groups::BoardsController < Groups::ApplicationController
before_action :authorize_read_board!, only: [:index, :show]
before_action :assign_endpoint_vars
before_action do
- push_frontend_feature_flag(:multi_select_board, default_enabled: true)
push_frontend_feature_flag(:graphql_board_lists, group, default_enabled: false)
- push_frontend_feature_flag(:boards_with_swimlanes, group, default_enabled: false)
+ push_frontend_feature_flag(:boards_with_swimlanes, group, default_enabled: true)
end
feature_category :boards
diff --git a/app/controllers/groups/dependency_proxies_controller.rb b/app/controllers/groups/dependency_proxies_controller.rb
new file mode 100644
index 00000000000..367dbafdd59
--- /dev/null
+++ b/app/controllers/groups/dependency_proxies_controller.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Groups
+ class DependencyProxiesController < Groups::ApplicationController
+ include DependencyProxyAccess
+
+ before_action :authorize_admin_dependency_proxy!, only: :update
+ before_action :dependency_proxy
+
+ feature_category :package_registry
+
+ def show
+ @blobs_count = group.dependency_proxy_blobs.count
+ @blobs_total_size = group.dependency_proxy_blobs.total_size
+ end
+
+ def update
+ dependency_proxy.update(dependency_proxy_params)
+
+ redirect_to group_dependency_proxy_path(group)
+ end
+
+ private
+
+ def dependency_proxy
+ @dependency_proxy ||=
+ group.dependency_proxy_setting || group.create_dependency_proxy_setting
+ end
+
+ def dependency_proxy_params
+ params.require(:dependency_proxy_group_setting).permit(:enabled)
+ end
+ end
+end
diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
new file mode 100644
index 00000000000..f46902ef90f
--- /dev/null
+++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+class Groups::DependencyProxyForContainersController < Groups::ApplicationController
+ include DependencyProxyAccess
+ include SendFileUpload
+
+ before_action :ensure_token_granted!
+ before_action :ensure_feature_enabled!
+
+ attr_reader :token
+
+ feature_category :package_registry
+
+ def manifest
+ result = DependencyProxy::PullManifestService.new(image, tag, token).execute
+
+ if result[:status] == :success
+ render json: result[:manifest]
+ else
+ render status: result[:http_status], json: result[:message]
+ end
+ end
+
+ def blob
+ result = DependencyProxy::FindOrCreateBlobService
+ .new(group, image, token, params[:sha]).execute
+
+ if result[:status] == :success
+ send_upload(result[:blob].file)
+ else
+ head result[:http_status]
+ end
+ end
+
+ private
+
+ def image
+ params[:image]
+ end
+
+ def tag
+ params[:tag]
+ end
+
+ def dependency_proxy
+ @dependency_proxy ||=
+ group.dependency_proxy_setting || group.create_dependency_proxy_setting
+ end
+
+ def ensure_feature_enabled!
+ render_404 unless dependency_proxy.enabled
+ end
+
+ def ensure_token_granted!
+ result = DependencyProxy::RequestTokenService.new(image).execute
+
+ if result[:status] == :success
+ @token = result[:token]
+ else
+ render status: result[:http_status], json: result[:message]
+ end
+ end
+end
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index 173a24ceb74..03d41f1dd6d 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -6,7 +6,7 @@ class Groups::MilestonesController < Groups::ApplicationController
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)
+ push_frontend_feature_flag(:burnup_charts, @group, default_enabled: true)
end
feature_category :issue_tracking
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index 0c72c8a037b..723edc4b7e9 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -8,9 +8,6 @@ module Groups
skip_cross_project_access_check :show
before_action :authorize_admin_group!
before_action :authorize_update_max_artifacts_size!, only: [:update]
- before_action do
- push_frontend_feature_flag(:new_variables_ui, @group, default_enabled: true)
- end
before_action :define_variables, only: [:show]
feature_category :continuous_integration
diff --git a/app/controllers/groups/settings/integrations_controller.rb b/app/controllers/groups/settings/integrations_controller.rb
index b089cfdf341..a66372b3571 100644
--- a/app/controllers/groups/settings/integrations_controller.rb
+++ b/app/controllers/groups/settings/integrations_controller.rb
@@ -10,7 +10,7 @@ module Groups
feature_category :integrations
def index
- @integrations = Service.find_or_initialize_all(Service.for_group(group)).sort_by(&:title)
+ @integrations = Service.find_or_initialize_all_non_project_specific(Service.for_group(group)).sort_by(&:title)
end
def edit
@@ -21,12 +21,12 @@ module Groups
private
- def find_or_initialize_integration(name)
- Service.find_or_initialize_integration(name, group_id: group.id)
+ def find_or_initialize_non_project_specific_integration(name)
+ Service.find_or_initialize_non_project_specific_integration(name, group_id: group.id)
end
def integrations_enabled?
- Feature.enabled?(:group_level_integrations, group)
+ Feature.enabled?(:group_level_integrations, group, default_enabled: true)
end
def scoped_edit_integration_path(integration)
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 6f8dc75f6bd..8d528e123e1 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -30,7 +30,6 @@ 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
@@ -133,13 +132,23 @@ class GroupsController < Groups::ApplicationController
def update
if Groups::UpdateService.new(@group, current_user, group_params).execute
- redirect_to edit_group_path(@group, anchor: params[:update_section]), notice: "Group '#{@group.name}' was successfully updated."
+ notice = "Group '#{@group.name}' was successfully updated."
+
+ redirect_to edit_group_origin_location, notice: notice
else
@group.reset
render action: "edit"
end
end
+ def edit_group_origin_location
+ if params.dig(:group, :redirect_target) == 'repository_settings'
+ group_settings_repository_path(@group, anchor: 'js-default-branch-name')
+ else
+ edit_group_path(@group, anchor: params[:update_section])
+ end
+ end
+
def destroy
Groups::DestroyService.new(@group, current_user).async_execute
@@ -181,8 +190,6 @@ class GroupsController < Groups::ApplicationController
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
@@ -193,6 +200,8 @@ class GroupsController < Groups::ApplicationController
protected
def render_show_html
+ record_experiment_user(:invite_members_empty_group_version_a) if ::Gitlab.com?
+
render 'groups/show', locals: { trial: params[:trial] }
end
diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb
index 151ba46e629..87cda723895 100644
--- a/app/controllers/import/base_controller.rb
+++ b/app/controllers/import/base_controller.rb
@@ -48,18 +48,14 @@ class Import::BaseController < ApplicationController
private
- def filter_attribute
- :name
- end
-
def sanitized_filter_param
- @filter ||= sanitize(params[:filter])
+ @filter ||= sanitize(params[:filter])&.downcase
end
def filtered(collection)
return collection unless sanitized_filter_param
- collection.select { |item| item[filter_attribute].include?(sanitized_filter_param) }
+ collection.select { |item| item[:name].to_s.downcase.include?(sanitized_filter_param) }
end
def serialized_provider_repos
diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb
index 0ffd9ef8bdd..57bd39bbe06 100644
--- a/app/controllers/import/bitbucket_controller.rb
+++ b/app/controllers/import/bitbucket_controller.rb
@@ -132,8 +132,4 @@ class Import::BitbucketController < Import::BaseController
refresh_token: session[:bitbucket_refresh_token]
}
end
-
- def sanitized_filter_param
- @filter ||= sanitize(params[:filter])
- end
end
diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb
index bee78cb3283..1846b1e0cec 100644
--- a/app/controllers/import/bitbucket_server_controller.rb
+++ b/app/controllers/import/bitbucket_server_controller.rb
@@ -170,10 +170,6 @@ class Import::BitbucketServerController < Import::BaseController
BitbucketServer::Paginator::PAGE_LENGTH
end
- def sanitized_filter_param
- sanitize(params[:filter])
- end
-
def bitbucket_connection_error(error)
flash[:alert] = _("Unable to connect to server: %{error}") % { error: error }
clear_session_data
diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb
index cb2922c2d47..78f4a0cffca 100644
--- a/app/controllers/import/bulk_imports_controller.rb
+++ b/app/controllers/import/bulk_imports_controller.rb
@@ -6,13 +6,13 @@ class Import::BulkImportsController < ApplicationController
feature_category :importers
- rescue_from Gitlab::BulkImport::Client::ConnectionError, with: :bulk_import_connection_error
+ rescue_from BulkImports::Clients::Http::ConnectionError, with: :bulk_import_connection_error
def configure
- session[access_token_key] = params[access_token_key]&.strip
- session[url_key] = params[url_key]
+ session[access_token_key] = configure_params[access_token_key]&.strip
+ session[url_key] = configure_params[url_key]
- redirect_to status_import_bulk_import_url
+ redirect_to status_import_bulk_imports_url
end
def status
@@ -25,6 +25,12 @@ class Import::BulkImportsController < ApplicationController
end
end
+ def create
+ BulkImportService.new(current_user, create_params, credentials).execute
+
+ render json: :ok
+ end
+
private
def serialized_importable_data
@@ -36,20 +42,33 @@ class Import::BulkImportsController < ApplicationController
end
def importable_data
- client.get('groups', top_level_only: true)
+ client.get('groups', top_level_only: true).parsed_response
end
def client
- @client ||= Gitlab::BulkImport::Client.new(
+ @client ||= BulkImports::Clients::Http.new(
uri: session[url_key],
token: session[access_token_key]
)
end
- def import_params
+ def configure_params
params.permit(access_token_key, url_key)
end
+ def create_params
+ params.permit(:bulk_import, [*bulk_import_params])
+ end
+
+ def bulk_import_params
+ %i[
+ source_type
+ source_full_path
+ destination_name
+ destination_namespace
+ ]
+ end
+
def ensure_group_import_enabled
render_404 unless Feature.enabled?(:bulk_import)
end
@@ -106,4 +125,11 @@ class Import::BulkImportsController < ApplicationController
session[url_key] = nil
session[access_token_key] = nil
end
+
+ def credentials
+ {
+ url: session[url_key],
+ access_token: [access_token_key]
+ }
+ end
end
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index a1adc6e062a..8ac93aeb9c0 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -15,6 +15,7 @@ class Import::GithubController < Import::BaseController
rescue_from OAuthConfigMissingError, with: :missing_oauth_config
rescue_from Octokit::Unauthorized, with: :provider_unauthorized
rescue_from Octokit::TooManyRequests, with: :provider_rate_limit
+ rescue_from Gitlab::GithubImport::RateLimitError, with: :rate_limit_threshold_exceeded
def new
if !ci_cd_only? && github_import_configured? && logged_in_with_provider?
@@ -114,7 +115,7 @@ class Import::GithubController < Import::BaseController
def client_repos
@client_repos ||= if Feature.enabled?(:remove_legacy_github_client)
- filtered(concatenated_repos)
+ concatenated_repos
else
filtered(client.repos)
end
@@ -122,8 +123,15 @@ class Import::GithubController < Import::BaseController
def concatenated_repos
return [] unless client.respond_to?(:each_page)
+ return client.each_page(:repos).flat_map(&:objects) unless sanitized_filter_param
- client.each_page(:repos).flat_map(&:objects)
+ client.search_repos_by_name(sanitized_filter_param).flat_map(&:objects).flat_map(&:items)
+ end
+
+ def sanitized_filter_param
+ super
+
+ @filter = @filter&.tr(' ', '')&.tr(':', '')
end
def oauth_client
@@ -246,12 +254,8 @@ class Import::GithubController < Import::BaseController
{}
end
- def sanitized_filter_param
- @filter ||= sanitize(params[:filter])
- end
-
- def filter_attribute
- :name
+ def rate_limit_threshold_exceeded
+ head :too_many_requests
end
end
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index c7b8486d1c9..26fc1c11f6d 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -15,13 +15,11 @@ class InvitesController < ApplicationController
feature_category :authentication_and_authorization
def show
- track_new_user_invite_experiment('opened')
accept if skip_invitation_prompt?
end
def accept
if member.accept_invite!(current_user)
- 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] }
@@ -110,25 +108,13 @@ class InvitesController < ApplicationController
end
end
- 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.experiment(experiment_key).tracking_category,
+ Gitlab::Experimentation.experiment(:invitation_reminders).tracking_category,
action,
property: property,
label: Digest::MD5.hexdigest(member.to_global_id.to_s)
diff --git a/app/controllers/jwks_controller.rb b/app/controllers/jwks_controller.rb
new file mode 100644
index 00000000000..e7b839f5590
--- /dev/null
+++ b/app/controllers/jwks_controller.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class JwksController < ActionController::Base # rubocop:disable Rails/ApplicationController
+ def index
+ render json: { keys: keys }
+ end
+
+ private
+
+ def keys
+ [
+ # We keep openid_connect_signing_key so that we can seamlessly
+ # replace it with ci_jwt_signing_key and remove it on the next release.
+ # TODO: Remove openid_connect_signing_key in 13.7
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/221031
+ Rails.application.secrets.openid_connect_signing_key,
+ Gitlab::CurrentSettings.ci_jwt_signing_key
+ ].compact.map do |key_data|
+ OpenSSL::PKey::RSA.new(key_data)
+ .public_key
+ .to_jwk
+ .slice(:kty, :kid, :e, :n)
+ .merge(use: 'sig', alg: 'RS256')
+ end
+ end
+end
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index b005347c43a..a45205c5da7 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -9,9 +9,13 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
end
def create
- @personal_access_token = finder.build(personal_access_token_params)
+ result = ::PersonalAccessTokens::CreateService.new(
+ current_user: current_user, target_user: current_user, params: personal_access_token_params
+ ).execute
- if @personal_access_token.save
+ @personal_access_token = result.payload[:personal_access_token]
+
+ if result.success?
PersonalAccessToken.redis_store!(current_user.id, @personal_access_token.token)
redirect_to profile_personal_access_tokens_path, notice: _("Your new personal access token has been created.")
else
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index c85c83688a4..afebeafff7c 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -127,7 +127,7 @@ class ProfilesController < Profiles::ApplicationController
:include_private_contributions,
:timezone,
:job_title,
- status: [:emoji, :message]
+ status: [:emoji, :message, :availability]
)
end
end
diff --git a/app/controllers/projects/alert_management_controller.rb b/app/controllers/projects/alert_management_controller.rb
index 0d0ef9b05cb..8ecf8fadefd 100644
--- a/app/controllers/projects/alert_management_controller.rb
+++ b/app/controllers/projects/alert_management_controller.rb
@@ -10,6 +10,5 @@ class Projects::AlertManagementController < Projects::ApplicationController
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 2241ded2db8..a3f4d784f25 100644
--- a/app/controllers/projects/alerting/notifications_controller.rb
+++ b/app/controllers/projects/alerting/notifications_controller.rb
@@ -14,7 +14,7 @@ module Projects
def create
token = extract_alert_manager_token(request)
- result = notify_service.execute(token)
+ result = notify_service.execute(token, integration)
head result.http_status
end
@@ -45,6 +45,18 @@ module Projects
end
end
+ def integration
+ AlertManagement::HttpIntegrationsFinder.new(
+ project,
+ endpoint_identifier: endpoint_identifier,
+ active: true
+ ).execute.first
+ end
+
+ def endpoint_identifier
+ params[:endpoint_identifier] || AlertManagement::HttpIntegration::LEGACY_IDENTIFIER
+ end
+
def notification_payload
@notification_payload ||= params.permit![:notification]
end
diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb
index e9c533daa80..001967b8bb4 100644
--- a/app/controllers/projects/autocomplete_sources_controller.rb
+++ b/app/controllers/projects/autocomplete_sources_controller.rb
@@ -39,7 +39,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
private
def autocomplete_service
- @autocomplete_service ||= ::Projects::AutocompleteService.new(@project, current_user)
+ @autocomplete_service ||= ::Projects::AutocompleteService.new(@project, current_user, params)
end
def target
diff --git a/app/controllers/projects/avatars_controller.rb b/app/controllers/projects/avatars_controller.rb
index f228206032d..fb113df137f 100644
--- a/app/controllers/projects/avatars_controller.rb
+++ b/app/controllers/projects/avatars_controller.rb
@@ -3,6 +3,8 @@
class Projects::AvatarsController < Projects::ApplicationController
include SendsBlob
+ skip_before_action :default_cache_headers, only: :show
+
before_action :authorize_admin_project!, only: [:destroy]
feature_category :projects
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index c6251d27b05..02e941db636 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -33,7 +33,7 @@ class Projects::BlobController < Projects::ApplicationController
before_action :set_last_commit_sha, only: [:edit, :update]
before_action only: :show do
- push_frontend_experiment(:suggest_pipeline)
+ push_frontend_feature_flag(:suggest_pipeline, default_enabled: true)
push_frontend_feature_flag(:gitlab_ci_yml_preview, @project, default_enabled: false)
end
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 193352ffa70..fe4502a0e06 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -8,8 +8,7 @@ class Projects::BoardsController < Projects::ApplicationController
before_action :authorize_read_board!, only: [:index, :show]
before_action :assign_endpoint_vars
before_action do
- push_frontend_feature_flag(:multi_select_board, default_enabled: true)
- push_frontend_feature_flag(:boards_with_swimlanes, project, default_enabled: false)
+ push_frontend_feature_flag(:boards_with_swimlanes, project, default_enabled: true)
end
feature_category :boards
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index 9124728ee25..cf1efda5d13 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -27,7 +27,7 @@ class Projects::BranchesController < Projects::ApplicationController
@refs_pipelines = @project.ci_pipelines.latest_successful_for_refs(@branches.map(&:name))
@merged_branch_names = repository.merged_branch_names(@branches.map(&:name))
- @branch_pipeline_statuses = branch_pipeline_statuses
+ @branch_pipeline_statuses = Ci::CommitStatusesFinder.new(@project, repository, current_user, @branches).execute
# https://gitlab.com/gitlab-org/gitlab/-/issues/22851
Gitlab::GitalyClient.allow_n_plus_1_calls do
@@ -197,15 +197,4 @@ class Projects::BranchesController < Projects::ApplicationController
confidential_issue_project
end
-
- def branch_pipeline_statuses
- latest_commits = @branches.map do |branch|
- [branch.name, repository.commit(branch.dereferenced_target).sha]
- end.to_h
-
- latest_pipelines = project.ci_pipelines.latest_pipeline_per_commit(latest_commits.values)
- latest_commits.transform_values do |commit_sha|
- latest_pipelines[commit_sha]&.detailed_status(current_user)
- end.compact
- end
end
diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb
index 7e900fc6051..9dc3194df85 100644
--- a/app/controllers/projects/ci/lints_controller.rb
+++ b/app/controllers/projects/ci/lints_controller.rb
@@ -2,28 +2,22 @@
class Projects::Ci::LintsController < Projects::ApplicationController
before_action :authorize_create_pipeline!
- before_action do
- push_frontend_feature_flag(:ci_lint_vue, project)
- end
feature_category :pipeline_authoring
+ respond_to :json, only: [:create]
+
def show
end
def create
- @content = params[:content]
- @dry_run = params[:dry_run]
+ content = params[:content]
+ dry_run = params[:dry_run]
- @result = Gitlab::Ci::Lint
+ result = Gitlab::Ci::Lint
.new(project: @project, current_user: current_user)
- .validate(@content, dry_run: @dry_run)
+ .validate(content, dry_run: dry_run)
- respond_to do |format|
- format.html { render :show }
- format.json do
- render json: ::Ci::Lint::ResultSerializer.new.represent(@result)
- end
- end
+ render json: ::Ci::Lint::ResultSerializer.new.represent(result)
end
end
diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb
new file mode 100644
index 00000000000..c2428270fa6
--- /dev/null
+++ b/app/controllers/projects/ci/pipeline_editor_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Projects::Ci::PipelineEditorController < Projects::ApplicationController
+ before_action :check_can_collaborate!
+
+ feature_category :pipeline_authoring
+
+ def show
+ render_404 unless ::Gitlab::Ci::Features.ci_pipeline_editor_page_enabled?(@project)
+ end
+
+ private
+
+ def check_can_collaborate!
+ render_404 unless can_collaborate_with_project?(@project)
+ end
+end
diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb
index 6cdd1c0bc8c..c8528ad6d28 100644
--- a/app/controllers/projects/imports_controller.rb
+++ b/app/controllers/projects/imports_controller.rb
@@ -55,7 +55,7 @@ class Projects::ImportsController < Projects::ApplicationController
end
def require_namespace_project_creation_permission
- render_404 unless current_user.can?(:admin_project, @project) || current_user.can?(:create_projects, @project.namespace)
+ render_404 unless can?(current_user, :admin_project, @project) || can?(current_user, :create_projects, @project.namespace)
end
def redirect_if_progress
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 9a8965dbeb6..3a1b4f380a2 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -44,22 +44,19 @@ 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(:vue_issue_header, @project, default_enabled: true)
end
before_action only: :show do
real_time_feature_flag = :real_time_issue_sidebar
real_time_enabled = Gitlab::ActionCable::Config.in_app? || Feature.enabled?(real_time_feature_flag, @project)
- gon.push({ features: { real_time_feature_flag.to_s.camelize(:lower) => real_time_enabled } }, true)
+ push_to_gon_features(real_time_feature_flag, real_time_enabled)
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, type: :licensed)
- end
-
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
respond_to :html
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 3ceb60a6aef..07e38c80291 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -14,6 +14,9 @@ class Projects::JobsController < Projects::ApplicationController
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize
before_action :verify_proxy_request!, only: :proxy_websocket_authorize
+ before_action do
+ push_frontend_feature_flag(:ci_job_line_links, @project)
+ end
layout 'project'
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 07c38431f0f..7fbeac12644 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -20,7 +20,10 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
end
def diffs_batch
- diffs = @compare.diffs_in_batch(params[:page], params[:per_page], diff_options: diff_options)
+ diff_options_hash = diff_options
+ diff_options_hash[:paths] = params[:paths] if params[:paths]
+
+ diffs = @compare.diffs_in_batch(params[:page], params[:per_page], diff_options: diff_options_hash)
positions = @merge_request.note_positions_for_paths(diffs.diff_file_paths, current_user)
environment = @merge_request.environments_for(current_user, latest: true).last
@@ -31,6 +34,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
environment: environment,
merge_request: @merge_request,
diff_view: diff_view,
+ merge_ref_head_diff: render_merge_ref_head_diff?,
pagination_data: diffs.pagination_data
}
@@ -64,7 +68,10 @@ 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, default_enabled: true) ? "inline" : diff_view)
+ options = additional_attributes.merge(
+ diff_view: unified_diff_lines_view_type(@merge_request.project),
+ merge_ref_head_diff: render_merge_ref_head_diff?
+ )
if @merge_request.project.context_commits_enabled?
options[:context_commits] = @merge_request.recent_context_commits
@@ -113,7 +120,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
end
end
- if Gitlab::Utils.to_boolean(params[:diff_head]) && @merge_request.diffable_merge_ref?
+ if render_merge_ref_head_diff?
return CompareService.new(@project, @merge_request.merge_ref_head.sha)
.execute(@project, @merge_request.target_branch)
end
@@ -155,6 +162,10 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
@notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes), @merge_request)
end
+ def render_merge_ref_head_diff?
+ Gitlab::Utils.to_boolean(params[:diff_head]) && @merge_request.diffable_merge_ref?
+ end
+
def note_positions
@note_positions ||= Gitlab::Diff::PositionCollection.new(renderable_notes.map(&:position))
end
@@ -173,7 +184,6 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
end
def update_diff_discussion_positions!
- 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?
Discussions::CaptureDiffNotePositionsService.new(@merge_request).execute
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 91a041bb35b..f2b41294a85 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -12,7 +12,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
include SourcegraphDecorator
include DiffHelper
- skip_before_action :merge_request, only: [:index, :bulk_update]
+ skip_before_action :merge_request, only: [:index, :bulk_update, :export_csv]
before_action :apply_diff_view_cookie!, only: [:show]
before_action :whitelist_query_limiting, only: [:assign_related_issues, :update]
before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort]
@@ -27,7 +27,7 @@ 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_experiment(:suggest_pipeline)
+ push_frontend_feature_flag(:suggest_pipeline, default_enabled: true)
push_frontend_feature_flag(:widget_visibility_polling, @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)
@@ -37,9 +37,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
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, default_enabled: true)
+ push_frontend_feature_flag(:unified_diff_components, @project)
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)
+ push_frontend_feature_flag(:remove_resolve_note, @project, default_enabled: true)
+ push_frontend_feature_flag(:test_failure_history, @project)
record_experiment_user(:invite_members_version_a)
record_experiment_user(:invite_members_version_b)
@@ -47,7 +50,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
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]
@@ -317,6 +319,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
super
end
+ def export_csv
+ IssuableExportCsvWorker.perform_async(:merge_request, current_user.id, project.id, finder_options.to_h) # rubocop:disable CodeReuse/Worker
+
+ index_path = project_merge_requests_path(project)
+ message = _('Your CSV export has started. It will be emailed to %{email} when complete.') % { email: current_user.notification_email }
+ redirect_to(index_path, notice: message)
+ end
+
protected
alias_method :subscribable_resource, :merge_request
@@ -471,7 +481,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def endpoint_metadata_url(project, merge_request)
params = request.query_parameters
- params[:view] = cookies[:diff_view] if params[:view].blank? && cookies[:diff_view].present?
+ params[:view] = unified_diff_lines_view_type(project)
if Feature.enabled?(:default_merge_ref_for_diffs, project)
params = params.merge(diff_head: true)
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index e6c4af00b29..31189c888b7 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -7,7 +7,7 @@ class Projects::MilestonesController < Projects::ApplicationController
before_action :check_issuables_available!
before_action :milestone, only: [:edit, :update, :destroy, :show, :issues, :merge_requests, :participants, :labels, :promote]
before_action do
- push_frontend_feature_flag(:burnup_charts, @project)
+ push_frontend_feature_flag(:burnup_charts, @project, default_enabled: true)
end
# Allow read any milestone
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index e50e293a103..77fd7688caf 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -60,7 +60,7 @@ class Projects::NotesController < Projects::ApplicationController
def render_json_with_notes_serializer
prepare_notes_for_rendering([note])
- render json: note_serializer.represent(note)
+ render json: note_serializer.represent(note, render_truncated_diff_lines: true)
end
def note
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 953dce4d63c..f71a92ee874 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -12,11 +12,11 @@ class Projects::PipelinesController < Projects::ApplicationController
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, project)
+ push_frontend_feature_flag(:new_pipeline_form, project, default_enabled: true)
push_frontend_feature_flag(:graphql_pipeline_header, project, type: :development, default_enabled: false)
+ push_frontend_feature_flag(:graphql_pipeline_details, 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]
@@ -194,6 +194,7 @@ class Projects::PipelinesController < Projects::ApplicationController
@counts[:total] = @project.all_pipelines.count(:all)
@counts[:success] = @project.all_pipelines.success.count(:all)
@counts[:failed] = @project.all_pipelines.failed.count(:all)
+ @counts[:total_duration] = @project.all_pipelines.total_duration
end
def test_report
@@ -213,7 +214,7 @@ class Projects::PipelinesController < Projects::ApplicationController
def config_variables
respond_to do |format|
format.json do
- render json: Ci::ListConfigVariablesService.new(@project).execute(params[:sha])
+ render json: Ci::ListConfigVariablesService.new(@project, current_user).execute(params[:sha])
end
end
end
diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb
index a9490c106d4..d8ba7e4f235 100644
--- a/app/controllers/projects/raw_controller.rb
+++ b/app/controllers/projects/raw_controller.rb
@@ -6,13 +6,14 @@ class Projects::RawController < Projects::ApplicationController
include SendsBlob
include StaticObjectExternalStorage
+ skip_before_action :default_cache_headers, only: :show
+
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:blob) }
before_action :require_non_empty_project
before_action :authorize_download_code!
before_action :show_rate_limit, only: [:show], unless: :external_storage_request?
before_action :assign_ref_vars
- 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
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index 4e8260d9e53..a6e795a2b91 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -54,7 +54,7 @@ class Projects::ReleasesController < Projects::ApplicationController
end
def sanitized_filepath
- CGI.unescape(params[:filepath])
+ "/#{CGI.unescape(params[:filepath])}"
end
def sanitized_tag_name
diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb
index ba3ab52e3af..fb6a09cff65 100644
--- a/app/controllers/projects/repositories_controller.rb
+++ b/app/controllers/projects/repositories_controller.rb
@@ -8,6 +8,8 @@ class Projects::RepositoriesController < Projects::ApplicationController
prepend_before_action(only: [:archive]) { authenticate_sessionless_user!(:archive) }
+ skip_before_action :default_cache_headers, only: :archive
+
# Authorize
before_action :require_non_empty_project, except: :create
before_action :archive_rate_limit!, only: :archive
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index 544074f9840..24fa0894a9c 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -52,7 +52,7 @@ 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'
+ if !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
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index 93ad549bc50..6ed9f74297d 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -13,6 +13,8 @@ class Projects::ServicesController < Projects::ApplicationController
before_action :redirect_deprecated_prometheus_service, only: [:update]
before_action only: :edit do
push_frontend_feature_flag(:jira_issues_integration, @project, type: :licensed, default_enabled: true)
+ push_frontend_feature_flag(:jira_vulnerabilities_integration, @project, type: :licensed, default_enabled: true)
+ push_frontend_feature_flag(:jira_for_vulnerabilities, @project, type: :development, default_enabled: false)
end
respond_to :html
@@ -70,7 +72,7 @@ class Projects::ServicesController < Projects::ApplicationController
return { error: true, message: s_('Integrations|Connection failed. Please check your settings.'), service_response: result[:message].to_s, test_failed: true }
end
- {}
+ result[:data].presence || {}
rescue Gitlab::HTTP::BlockedUrlError => e
{ error: true, message: s_('Integrations|Connection failed. Please check your settings.'), service_response: e.message, test_failed: true }
end
diff --git a/app/controllers/projects/settings/access_tokens_controller.rb b/app/controllers/projects/settings/access_tokens_controller.rb
index cbd6716fdf7..74350147825 100644
--- a/app/controllers/projects/settings/access_tokens_controller.rb
+++ b/app/controllers/projects/settings/access_tokens_controller.rb
@@ -23,7 +23,7 @@ module Projects
redirect_to namespace_project_settings_access_tokens_path, notice: _("Your new project access token has been created.")
else
- render :index
+ redirect_to namespace_project_settings_access_tokens_path, alert: _("Failed to create new project access token: %{token_response_message}") % { token_response_message: token_response.message }
end
end
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index 2963321f803..f76278a12a4 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -5,10 +5,11 @@ module Projects
class CiCdController < Projects::ApplicationController
include RunnerSetupScripts
+ NUMBER_OF_RUNNERS_PER_PAGE = 20
+
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)
end
@@ -76,7 +77,7 @@ module Projects
[
:runners_token, :builds_enabled, :build_allow_git_fetch,
:build_timeout_human_readable, :build_coverage_regex, :public_builds,
- :auto_cancel_pending_pipelines, :ci_config_path,
+ :auto_cancel_pending_pipelines, :ci_config_path, :auto_rollback_enabled,
auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy],
ci_cd_settings_attributes: [:default_git_depth, :forward_deployment_enabled]
].tap do |list|
@@ -109,13 +110,13 @@ module Projects
end
def define_runners_variables
- @project_runners = @project.runners.ordered
+ @project_runners = @project.runners.ordered.page(params[:project_page]).per(NUMBER_OF_RUNNERS_PER_PAGE).with_tags
@assignable_runners = current_user
.ci_owned_runners
.assignable_for(project)
.ordered
- .page(params[:page]).per(20)
+ .page(params[:specific_page]).per(NUMBER_OF_RUNNERS_PER_PAGE)
@shared_runners = ::Ci::Runner.instance_type.active
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index c407b15e29f..c9386a2edec 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -6,6 +6,11 @@ module Projects
before_action :authorize_admin_operations!
before_action :authorize_read_prometheus_alerts!, only: [:reset_alerting_token]
+ before_action do
+ push_frontend_feature_flag(:http_integrations_list, @project)
+ push_frontend_feature_flag(:multiple_http_integrations_custom_mapping, @project)
+ end
+
respond_to :json, only: [:reset_alerting_token, :reset_pagerduty_token]
helper_method :error_tracking_setting
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index 0994bebb1d0..dd50ab1bc7a 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -18,14 +18,13 @@ module Projects
end
def cleanup
- cleanup_params = params.require(:project).permit(:bfg_object_map)
- result = Projects::UpdateService.new(project, current_user, cleanup_params).execute
+ bfg_object_map = params.require(:project).require(:bfg_object_map)
+ result = Projects::CleanupService.enqueue(project, current_user, bfg_object_map)
if result[:status] == :success
- RepositoryCleanupWorker.perform_async(project.id, current_user.id) # rubocop:disable CodeReuse/Worker
flash[:notice] = _('Repository cleanup has started. You will receive an email once the cleanup operation is complete.')
else
- flash[:alert] = _('Failed to upload object map file')
+ flash[:alert] = status.fetch(:message, _('Failed to upload object map file'))
end
redirect_to project_settings_repository_path(project)
diff --git a/app/controllers/projects/static_site_editor_controller.rb b/app/controllers/projects/static_site_editor_controller.rb
index 7e2e32a843f..5c3d9b60877 100644
--- a/app/controllers/projects/static_site_editor_controller.rb
+++ b/app/controllers/projects/static_site_editor_controller.rb
@@ -6,12 +6,16 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController
layout 'fullscreen'
+ content_security_policy do |policy|
+ next if policy.directives.blank?
+
+ frame_src_values = Array.wrap(policy.directives['frame-src']) | ['https://www.youtube.com']
+ policy.frame_src(*frame_src_values)
+ end
+
prepend_before_action :authenticate_user!, only: [:show]
before_action :assign_ref_and_path, only: [:show]
before_action :authorize_edit_tree!, only: [:show]
- before_action do
- push_frontend_feature_flag(:sse_image_uploads)
- end
feature_category :static_site_editor
@@ -47,6 +51,8 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController
payload.transform_values do |value|
if value.is_a?(String) || value.is_a?(Integer)
value
+ elsif value.nil?
+ ''
else
value.to_json
end
diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb
index 1d783241196..94b0473e1f3 100644
--- a/app/controllers/projects/tags_controller.rb
+++ b/app/controllers/projects/tags_controller.rb
@@ -24,6 +24,7 @@ class Projects::TagsController < Projects::ApplicationController
tag_names = @tags.map(&:name)
@tags_pipelines = @project.ci_pipelines.latest_successful_for_refs(tag_names)
@releases = project.releases.where(tag: tag_names)
+ @tag_pipeline_statuses = Ci::CommitStatusesFinder.new(@project, @repository, current_user, @tags).execute
respond_to do |format|
format.html
diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb
index 7ab23e39cf0..f4726638777 100644
--- a/app/controllers/projects/templates_controller.rb
+++ b/app/controllers/projects/templates_controller.rb
@@ -7,6 +7,14 @@ class Projects::TemplatesController < Projects::ApplicationController
feature_category :templates
+ def index
+ templates = @template_type.template_subsets(project)
+
+ respond_to do |format|
+ format.json { render json: templates.to_json }
+ end
+ end
+
def show
template = @template_type.find(params[:key], project)
diff --git a/app/controllers/projects/terraform_controller.rb b/app/controllers/projects/terraform_controller.rb
new file mode 100644
index 00000000000..aef163c98c5
--- /dev/null
+++ b/app/controllers/projects/terraform_controller.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class Projects::TerraformController < Projects::ApplicationController
+ before_action :authorize_can_read_terraform_state!
+
+ feature_category :infrastructure_as_code
+
+ def index
+ end
+
+ private
+
+ def authorize_can_read_terraform_state!
+ access_denied! unless can?(current_user, :read_terraform_state, project)
+ end
+end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 09e7563cefd..c03a820b384 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -15,7 +15,7 @@ class ProjectsController < Projects::ApplicationController
around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
before_action :whitelist_query_limiting, only: [:create]
- before_action :authenticate_user!, except: [:index, :show, :activity, :refs, :resolve]
+ before_action :authenticate_user!, except: [:index, :show, :activity, :refs, :resolve, :unfoldered_environment_names]
before_action :redirect_git_extension, only: [:show]
before_action :project, except: [:index, :new, :create, :resolve]
before_action :repository, except: [:index, :new, :create, :resolve]
@@ -317,8 +317,6 @@ class ProjectsController < Projects::ApplicationController
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
@@ -383,6 +381,20 @@ class ProjectsController < Projects::ApplicationController
.merge(import_url_params)
end
+ def project_feature_attributes
+ %i[
+ builds_access_level
+ issues_access_level
+ forking_access_level
+ merge_requests_access_level
+ repository_access_level
+ snippets_access_level
+ wiki_access_level
+ pages_access_level
+ metrics_dashboard_access_level
+ ]
+ end
+
def project_params_attributes
[
:allow_merge_on_skipped_pipeline,
@@ -420,23 +432,11 @@ class ProjectsController < Projects::ApplicationController
:suggestion_commit_message,
:packages_enabled,
:service_desk_enabled,
-
- project_feature_attributes: %i[
- builds_access_level
- issues_access_level
- forking_access_level
- merge_requests_access_level
- repository_access_level
- snippets_access_level
- wiki_access_level
- pages_access_level
- metrics_dashboard_access_level
- ],
project_setting_attributes: %i[
show_default_award_emojis
squash_option
]
- ]
+ ] + [project_feature_attributes: project_feature_attributes]
end
def project_params_create_attributes
diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb
new file mode 100644
index 00000000000..5b3f78a92ad
--- /dev/null
+++ b/app/controllers/registrations/welcome_controller.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+module Registrations
+ class WelcomeController < ApplicationController
+ layout 'welcome'
+ skip_before_action :authenticate_user!, :required_signup_info, :check_two_factor_requirement, only: [:show, :update]
+ before_action :require_current_user
+
+ feature_category :authentication_and_authorization
+
+ def show
+ return redirect_to path_for_signed_in_user(current_user) if completed_welcome_step?
+ end
+
+ def update
+ result = ::Users::SignupService.new(current_user, update_params).execute
+
+ if result[:status] == :success
+ process_gitlab_com_tracking
+
+ return redirect_to new_users_sign_up_group_path if experiment_enabled?(:onboarding_issues) && show_onboarding_issues_experiment?
+
+ redirect_to path_for_signed_in_user(current_user)
+ else
+ render :show
+ end
+ end
+
+ private
+
+ def require_current_user
+ return redirect_to new_user_registration_path unless current_user
+ end
+
+ def completed_welcome_step?
+ current_user.role.present? && !current_user.setup_for_company.nil?
+ end
+
+ def process_gitlab_com_tracking
+ return false unless ::Gitlab.com?
+ return false unless show_onboarding_issues_experiment?
+
+ track_experiment_event(:onboarding_issues, 'signed_up')
+ record_experiment_user(:onboarding_issues)
+ end
+
+ def update_params
+ params.require(:user).permit(:role, :setup_for_company)
+ end
+
+ def requires_confirmation?(user)
+ return false if user.confirmed?
+ return false if Feature.enabled?(:soft_email_confirmation)
+
+ true
+ end
+
+ def path_for_signed_in_user(user)
+ return users_almost_there_path if requires_confirmation?(user)
+
+ stored_location_for(user) || dashboard_projects_path
+ end
+
+ def show_onboarding_issues_experiment?
+ !helpers.in_subscription_flow? &&
+ !helpers.in_invitation_flow? &&
+ !helpers.in_oauth_flow? &&
+ !helpers.in_trial_flow?
+ end
+ end
+end
+
+Registrations::WelcomeController.prepend_if_ee('EE::Registrations::WelcomeController')
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index b3dc0e986f4..04cb9616cf6 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -3,26 +3,22 @@
class RegistrationsController < Devise::RegistrationsController
include Recaptcha::Verify
include AcceptsPendingInvitations
- include RecaptchaExperimentHelper
+ include RecaptchaHelper
include InvisibleCaptchaOnSignup
BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval'.freeze
- layout :choose_layout
+ layout 'devise'
- 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 :load_recaptcha, only: :new
+ before_action :set_invite_params, only: :new
feature_category :authentication_and_authorization
def new
- if experiment_enabled?(:signup_flow)
- @resource = build_resource
- else
- redirect_to new_user_session_path(anchor: 'register-pane')
- end
+ @resource = build_resource
end
def create
@@ -32,6 +28,11 @@ class RegistrationsController < Devise::RegistrationsController
super do |new_user|
persist_accepted_terms_if_required(new_user)
set_role_required(new_user)
+
+ if pending_approval?
+ NotificationService.new.new_instance_access_request(new_user)
+ end
+
yield new_user if block_given?
end
@@ -52,31 +53,6 @@ class RegistrationsController < Devise::RegistrationsController
end
end
- def welcome
- return redirect_to new_user_registration_path unless current_user
- return redirect_to path_for_signed_in_user(current_user) if current_user.role.present? && !current_user.setup_for_company.nil?
- 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
-
- if result[:status] == :success
- if ::Gitlab.com? && show_onboarding_issues_experiment?
- track_experiment_event(:onboarding_issues, 'signed_up')
- record_experiment_user(:onboarding_issues)
- end
-
- return redirect_to new_users_sign_up_group_path if experiment_enabled?(:onboarding_issues) && show_onboarding_issues_experiment?
-
- redirect_to path_for_signed_in_user(current_user)
- else
- render :welcome
- end
- end
-
protected
def persist_accepted_terms_if_required(new_user)
@@ -160,6 +136,12 @@ class RegistrationsController < Devise::RegistrationsController
render action: 'new'
end
+ def pending_approval?
+ return false unless Gitlab::CurrentSettings.require_admin_approval_after_user_signup
+
+ resource.persisted? && resource.blocked_pending_approval?
+ end
+
def sign_up_params
params.require(:user).permit(:username, :email, :name, :first_name, :last_name, :password)
end
@@ -180,49 +162,17 @@ class RegistrationsController < Devise::RegistrationsController
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42380')
end
- def path_for_signed_in_user(user)
- if requires_confirmation?(user)
- users_almost_there_path
- else
- stored_location_for(user) || dashboard_projects_path
- end
- end
-
- def requires_confirmation?(user)
- return false if user.confirmed?
- return false if Feature.enabled?(:soft_email_confirmation)
- return false if experiment_enabled?(:signup_flow)
-
- true
- end
-
def load_recaptcha
Gitlab::Recaptcha.load_configurations!
end
- # Part of an experiment to build a new sign up flow. Will be resolved
- # with https://gitlab.com/gitlab-org/growth/engineering/issues/64
- def choose_layout
- if %w(welcome update_registration).include?(action_name) || experiment_enabled?(:signup_flow)
- 'devise_experimental_separate_sign_up_flow'
- else
- 'devise'
- end
- end
-
- def show_onboarding_issues_experiment?
- !helpers.in_subscription_flow? &&
- !helpers.in_invitation_flow? &&
- !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')
+ def set_invite_params
+ @invite_email = ActionController::Base.helpers.sanitize(params[:invite_email])
+ end
+end
diff --git a/app/controllers/repositories/git_http_client_controller.rb b/app/controllers/repositories/git_http_client_controller.rb
index de452aa69b7..ec854bd0dde 100644
--- a/app/controllers/repositories/git_http_client_controller.rb
+++ b/app/controllers/repositories/git_http_client_controller.rb
@@ -6,7 +6,7 @@ module Repositories
include KerberosSpnegoHelper
include Gitlab::Utils::StrongMemoize
- attr_reader :authentication_result, :redirected_path, :container
+ attr_reader :authentication_result, :redirected_path
delegate :actor, :authentication_abilities, to: :authentication_result, allow_nil: true
delegate :type, to: :authentication_result, allow_nil: true, prefix: :auth_result
@@ -75,6 +75,12 @@ module Repositories
headers['Www-Authenticate'] = challenges.join("\n") if challenges.any?
end
+ def container
+ parse_repo_path unless defined?(@container)
+
+ @container
+ end
+
def project
parse_repo_path unless defined?(@project)
diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb
index 35751a2578f..96185608c09 100644
--- a/app/controllers/repositories/lfs_api_controller.rb
+++ b/app/controllers/repositories/lfs_api_controller.rb
@@ -17,9 +17,9 @@ module Repositories
end
if download_request?
- render json: { objects: download_objects! }
+ render json: { objects: download_objects! }, content_type: LfsRequest::CONTENT_TYPE
elsif upload_request?
- render json: { objects: upload_objects! }
+ render json: { objects: upload_objects! }, content_type: LfsRequest::CONTENT_TYPE
else
raise "Never reached"
end
@@ -31,6 +31,7 @@ module Repositories
message: _('Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.'),
documentation_url: "#{Gitlab.config.gitlab.url}/help"
},
+ content_type: LfsRequest::CONTENT_TYPE,
status: :not_implemented
)
end
diff --git a/app/controllers/repositories/lfs_storage_controller.rb b/app/controllers/repositories/lfs_storage_controller.rb
index 0436b740979..48784842d48 100644
--- a/app/controllers/repositories/lfs_storage_controller.rb
+++ b/app/controllers/repositories/lfs_storage_controller.rb
@@ -29,7 +29,7 @@ module Repositories
def upload_finalize
if store_file!(oid, size)
- head 200
+ head 200, content_type: LfsRequest::CONTENT_TYPE
else
render plain: 'Unprocessable entity', status: :unprocessable_entity
end
@@ -59,10 +59,17 @@ module Repositories
params[:size].to_i
end
+ def uploaded_file
+ params[:file]
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def store_file!(oid, size)
object = LfsObject.find_by(oid: oid, size: size)
- unless object&.file&.exists?
+
+ if object
+ replace_file!(object) unless object.file&.exists?
+ else
object = create_file!(oid, size)
end
@@ -73,12 +80,19 @@ module Repositories
# rubocop: enable CodeReuse/ActiveRecord
def create_file!(oid, size)
- uploaded_file = params[:file]
return unless uploaded_file.is_a?(UploadedFile)
LfsObject.create!(oid: oid, size: size, file: uploaded_file)
end
+ def replace_file!(lfs_object)
+ raise UploadedFile::InvalidPathError unless uploaded_file.is_a?(UploadedFile)
+
+ Gitlab::AppJsonLogger.info(message: "LFS file replaced because it did not exist", oid: oid, size: size)
+ lfs_object.file = uploaded_file
+ lfs_object.save!
+ end
+
def link_to_project!(object)
return unless object
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 0380bc1c548..4b21edc98d5 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -9,6 +9,7 @@ class SearchController < ApplicationController
SCOPE_PRELOAD_METHOD = {
projects: :with_web_entity_associations,
issues: :with_web_entity_associations,
+ merge_requests: :with_web_entity_associations,
epics: :with_web_entity_associations
}.freeze
@@ -35,7 +36,10 @@ class SearchController < ApplicationController
return unless search_term_valid?
+ return if check_single_commit_result?
+
@search_term = params[:search]
+ @sort = params[:sort] || default_sort
@scope = search_service.scope
@show_snippets = search_service.show_snippets?
@@ -47,8 +51,6 @@ class SearchController < ApplicationController
eager_load_user_status if @scope == 'users'
increment_search_counters
-
- check_single_commit_result
end
def count
@@ -81,6 +83,11 @@ class SearchController < ApplicationController
SCOPE_PRELOAD_METHOD[@scope.to_sym]
end
+ # overridden in EE
+ def default_sort
+ 'created_desc'
+ end
+
def search_term_valid?
unless search_service.valid_query_length?
flash[:alert] = t('errors.messages.search_chars_too_long', count: SearchService::SEARCH_CHAR_LIMIT)
@@ -103,14 +110,23 @@ class SearchController < ApplicationController
@search_objects = @search_objects.eager_load(:status) # rubocop:disable CodeReuse/ActiveRecord
end
- def check_single_commit_result
- if @search_results.single_commit_result?
- only_commit = @search_results.objects('commits').first
- query = params[:search].strip.downcase
- found_by_commit_sha = Commit.valid_hash?(query) && only_commit.sha.start_with?(query)
+ def check_single_commit_result?
+ return false if params[:force_search_results]
+ return false unless @project.present?
+ # download_code project policy grants user the read_commit ability
+ return false unless Ability.allowed?(current_user, :download_code, @project)
- redirect_to project_commit_path(@project, only_commit) if found_by_commit_sha
- end
+ query = params[:search].strip.downcase
+ return false unless Commit.valid_hash?(query)
+
+ commit = @project.commit_by(oid: query)
+ return false unless commit.present?
+
+ link = search_path(safe_params.merge(force_search_results: true))
+ flash[:notice] = html_escape(_("You have been redirected to the only result; see the %{a_start}search results%{a_end} instead.")) % { a_start: "<a href=\"#{link}\"><u>".html_safe, a_end: '</u></a>'.html_safe }
+ redirect_to project_commit_path(@project, commit)
+
+ true
end
def increment_search_counters
@@ -130,6 +146,9 @@ class SearchController < ApplicationController
payload[:metadata]['meta.search.project_id'] = params[:project_id]
payload[:metadata]['meta.search.search'] = params[:search]
payload[:metadata]['meta.search.scope'] = params[:scope]
+ payload[:metadata]['meta.search.filters.confidential'] = params[:confidential]
+ payload[:metadata]['meta.search.filters.state'] = params[:state]
+ payload[:metadata]['meta.search.force_search_results'] = params[:force_search_results]
end
def block_anonymous_global_searches
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 61120c5b7d1..b8842b2efdb 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -300,13 +300,13 @@ class SessionsController < Devise::SessionsController
def authentication_method
if user_params[:otp_attempt]
- "two-factor"
+ AuthenticationEvent::TWO_FACTOR
elsif user_params[:device_response] && Feature.enabled?(:webauthn)
- "two-factor-via-webauthn-device"
+ AuthenticationEvent::TWO_FACTOR_WEBAUTHN
elsif user_params[:device_response] && !Feature.enabled?(:webauthn)
- "two-factor-via-u2f-device"
+ AuthenticationEvent::TWO_FACTOR_U2F
else
- "standard"
+ AuthenticationEvent::STANDARD
end
end
diff --git a/app/controllers/whats_new_controller.rb b/app/controllers/whats_new_controller.rb
index 7156faa4e49..384c984089a 100644
--- a/app/controllers/whats_new_controller.rb
+++ b/app/controllers/whats_new_controller.rb
@@ -5,14 +5,14 @@ class WhatsNewController < ApplicationController
skip_before_action :authenticate_user!
- before_action :check_feature_flag
+ before_action :check_feature_flag, :check_valid_page_param, :set_pagination_headers
feature_category :navigation
def index
respond_to do |format|
format.js do
- render json: whats_new_most_recent_release_items
+ render json: whats_new_release_items(page: current_page)
end
end
end
@@ -22,4 +22,23 @@ class WhatsNewController < ApplicationController
def check_feature_flag
render_404 unless Feature.enabled?(:whats_new_drawer, current_user)
end
+
+ def check_valid_page_param
+ render_404 if current_page < 1
+ end
+
+ def set_pagination_headers
+ response.set_header('X-Next-Page', next_page)
+ end
+
+ def current_page
+ params[:page]&.to_i || 1
+ end
+
+ def next_page
+ next_page = current_page + 1
+ next_index = next_page - 1
+
+ next_page if whats_new_file_paths[next_index]
+ end
end
diff --git a/app/finders/alert_management/http_integrations_finder.rb b/app/finders/alert_management/http_integrations_finder.rb
new file mode 100644
index 00000000000..9f511be0ace
--- /dev/null
+++ b/app/finders/alert_management/http_integrations_finder.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class HttpIntegrationsFinder
+ def initialize(project, params)
+ @project = project
+ @params = params
+ end
+
+ def execute
+ @collection = project.alert_management_http_integrations
+
+ filter_by_availability
+ filter_by_endpoint_identifier
+ filter_by_active
+
+ collection
+ end
+
+ private
+
+ attr_reader :project, :params, :collection
+
+ def filter_by_availability
+ return if multiple_alert_http_integrations?
+
+ first_id = project.alert_management_http_integrations
+ .ordered_by_id
+ .select(:id)
+ .at_most(1)
+
+ @collection = collection.id_in(first_id)
+ end
+
+ def filter_by_endpoint_identifier
+ return unless params[:endpoint_identifier]
+
+ @collection = collection.for_endpoint_identifier(params[:endpoint_identifier])
+ end
+
+ def filter_by_active
+ return unless params[:active]
+
+ @collection = collection.active
+ end
+
+ # Overridden in EE
+ def multiple_alert_http_integrations?
+ false
+ end
+ end
+end
+
+::AlertManagement::HttpIntegrationsFinder.prepend_if_ee('EE::AlertManagement::HttpIntegrationsFinder')
diff --git a/app/finders/ci/commit_statuses_finder.rb b/app/finders/ci/commit_statuses_finder.rb
new file mode 100644
index 00000000000..3c465eb88f3
--- /dev/null
+++ b/app/finders/ci/commit_statuses_finder.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Ci
+ class CommitStatusesFinder
+ include ::Gitlab::Utils::StrongMemoize
+
+ def initialize(project, repository, current_user, refs)
+ @project = project
+ @repository = repository
+ @current_user = current_user
+ @refs = refs
+ end
+
+ def execute
+ return [] unless Ability.allowed?(@current_user, :read_pipeline, @project)
+
+ commit_statuses
+ end
+
+ private
+
+ def latest_commits
+ strong_memoize(:latest_commits) do
+ refs.map do |ref|
+ [ref.name, @repository.commit(ref.dereferenced_target).sha]
+ end.to_h
+ end
+ end
+
+ def commit_statuses
+ latest_pipelines = project.ci_pipelines.latest_pipeline_per_commit(latest_commits.values)
+
+ latest_commits.transform_values do |commit_sha|
+ latest_pipelines[commit_sha]&.detailed_status(current_user)
+ end.compact
+ end
+
+ attr_reader :project, :repository, :current_user, :refs
+ end
+end
diff --git a/app/finders/ci/jobs_finder.rb b/app/finders/ci/jobs_finder.rb
index 40c610f8209..78791d737da 100644
--- a/app/finders/ci/jobs_finder.rb
+++ b/app/finders/ci/jobs_finder.rb
@@ -25,11 +25,7 @@ module Ci
attr_reader :current_user, :pipeline, :project, :params, :type
def init_collection
- if Feature.enabled?(:ci_jobs_finder_refactor, default_enabled: true)
- pipeline_jobs || project_jobs || all_jobs
- else
- project ? project_builds : all_jobs
- end
+ pipeline_jobs || project_jobs || all_jobs
end
def all_jobs
@@ -38,12 +34,6 @@ module Ci
type.all
end
- def project_builds
- raise Gitlab::Access::AccessDeniedError unless can?(current_user, :read_build, project)
-
- project.builds.relevant
- end
-
def project_jobs
return unless project
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :read_build, project)
@@ -59,9 +49,7 @@ module Ci
end
def filter_by_scope(builds)
- if Feature.enabled?(:ci_jobs_finder_refactor, default_enabled: true)
- return filter_by_statuses!(params[:scope], builds) if params[:scope].is_a?(Array)
- end
+ return filter_by_statuses!(params[:scope], builds) if params[:scope].is_a?(Array)
case params[:scope]
when 'pending'
diff --git a/app/finders/concerns/finder_with_cross_project_access.rb b/app/finders/concerns/finder_with_cross_project_access.rb
index a55fb58a1bc..e63b868e470 100644
--- a/app/finders/concerns/finder_with_cross_project_access.rb
+++ b/app/finders/concerns/finder_with_cross_project_access.rb
@@ -32,7 +32,7 @@ module FinderWithCrossProjectAccess
end
override :execute
- def execute(*args)
+ def execute(*args, **kwargs)
check = Gitlab::CrossProjectAccess.find_check(self)
original = -> { super }
diff --git a/app/finders/environment_names_finder.rb b/app/finders/environment_names_finder.rb
index a92998921c7..e9063ef4c90 100644
--- a/app/finders/environment_names_finder.rb
+++ b/app/finders/environment_names_finder.rb
@@ -13,7 +13,7 @@
class EnvironmentNamesFinder
attr_reader :project_or_group, :current_user
- def initialize(project_or_group, current_user)
+ def initialize(project_or_group, current_user = nil)
@project_or_group = project_or_group
@current_user = current_user
end
@@ -31,14 +31,24 @@ class EnvironmentNamesFinder
end
def namespace_environments
- projects =
- project_or_group.all_projects.public_or_visible_to_user(current_user)
+ # We assume reporter access is needed for the :read_environment permission
+ # here. This expection is also present in
+ # IssuableFinder::Params#min_access_level, which is used for filtering out
+ # merge requests that don't have the right permissions.
+ #
+ # We use this approach so we don't need to load every project into memory
+ # just to verify if we can see their environments. Doing so would not be
+ # efficient, and possibly mess up pagination if certain projects are not
+ # meant to be visible.
+ projects = project_or_group
+ .all_projects
+ .public_or_visible_to_user(current_user, Gitlab::Access::REPORTER)
Environment.for_project(projects)
end
def project_environments
- if current_user.can?(:read_environment, project_or_group)
+ if Ability.allowed?(current_user, :read_environment, project_or_group)
project_or_group.environments
else
Environment.none
diff --git a/app/finders/feature_flags_user_lists_finder.rb b/app/finders/feature_flags_user_lists_finder.rb
new file mode 100644
index 00000000000..ebe60acd711
--- /dev/null
+++ b/app/finders/feature_flags_user_lists_finder.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class FeatureFlagsUserListsFinder
+ attr_reader :project, :current_user, :params
+
+ def initialize(project, current_user, params = {})
+ @project = project
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ unless Ability.allowed?(current_user, :read_feature_flag, project)
+ return Operations::FeatureFlagsUserList.none
+ end
+
+ items = feature_flags_user_lists
+ by_search(items)
+ end
+
+ private
+
+ def feature_flags_user_lists
+ project.operations_feature_flags_user_lists
+ end
+
+ def by_search(items)
+ if params[:search].present?
+ items.for_name_like(params[:search])
+ else
+ items
+ end
+ end
+end
diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb
index 5f24b15156c..8362e782ad1 100644
--- a/app/finders/group_projects_finder.rb
+++ b/app/finders/group_projects_finder.rb
@@ -12,6 +12,7 @@
# only_owned: boolean
# only_shared: boolean
# limit: integer
+# include_subgroups: boolean
# params:
# sort: string
# visibility_level: int
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 9c4aecedd93..d431c3e3699 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -153,10 +153,8 @@ class IssuableFinder
end
def row_count
- fast_fail = Feature.enabled?(:soft_fail_count_by_state, params.group || params.project)
-
Gitlab::IssuablesCountForState
- .new(self, nil, fast_fail: fast_fail)
+ .new(self, nil, fast_fail: true)
.for_state_or_opened(params[:state])
end
@@ -341,6 +339,15 @@ class IssuableFinder
cte << items
items = klass.with(cte.to_arel).from(klass.table_name)
+ elsif Feature.enabled?(:pg_hint_plan_for_issuables, params.project)
+ items = items.optimizer_hints(<<~HINTS)
+ BitmapScan(
+ issues idx_issues_on_project_id_and_created_at_and_id_and_state_id
+ idx_issues_on_project_id_and_due_date_and_id_and_state_id
+ idx_issues_on_project_id_and_updated_at_and_id_and_state_id
+ index_issues_on_project_id_and_iid
+ )
+ HINTS
end
items.full_search(search, matched_columns: params[:in], use_minimum_char_limit: !use_cte_for_search?)
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 32be5bee0db..5c9010ee3e0 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -97,6 +97,8 @@ class IssuesFinder < IssuableFinder
items.due_between(Date.today.beginning_of_month, Date.today.end_of_month)
elsif params.filter_by_due_next_month_and_previous_two_weeks?
items.due_between(Date.today - 2.weeks, (Date.today + 1.month).end_of_month)
+ else
+ items.none
end
end
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index c998de75ab2..1f847b09752 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -66,6 +66,11 @@ class MergeRequestsFinder < IssuableFinder
by_source_project_id(items)
end
+ def filter_negated_items(items)
+ items = super(items)
+ by_negated_target_branch(items)
+ end
+
private
def by_commit(items)
@@ -98,6 +103,14 @@ class MergeRequestsFinder < IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
+ # rubocop: disable CodeReuse/ActiveRecord
+ def by_negated_target_branch(items)
+ return items unless not_params[:target_branch]
+
+ items.where.not(target_branch: not_params[:target_branch])
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
def source_project_id
@source_project_id ||= params[:source_project_id].presence
end
@@ -142,19 +155,6 @@ 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)
@@ -165,10 +165,6 @@ class MergeRequestsFinder < IssuableFinder
# 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]
diff --git a/app/finders/packages/group_packages_finder.rb b/app/finders/packages/group_packages_finder.rb
index 8b948bb056d..a51057571f1 100644
--- a/app/finders/packages/group_packages_finder.rb
+++ b/app/finders/packages/group_packages_finder.rb
@@ -25,7 +25,7 @@ module Packages
.including_build_info
.including_project_route
.including_tags
- .for_projects(group_projects_visible_to_current_user)
+ .for_projects(group_projects_visible_to_current_user.select(:id))
.processed
.has_version
.sort_by_attribute("#{params[:order_by]}_#{params[:sort]}")
@@ -36,11 +36,14 @@ module Packages
end
def group_projects_visible_to_current_user
+ # according to project_policy.rb
+ # access to packages is ruled by:
+ # - project is public or the current user has access to it with at least the reporter level
+ # - the repository feature is available to the current_user
::Project
.in_namespace(groups)
.public_or_visible_to_user(current_user, Gitlab::Access::REPORTER)
- .with_project_feature
- .select { |project| Ability.allowed?(current_user, :read_package, project) }
+ .with_feature_available_for_user(:repository, current_user)
end
def package_type
diff --git a/app/finders/packages/npm/package_finder.rb b/app/finders/packages/npm/package_finder.rb
index 8599fd07e7f..2854226e178 100644
--- a/app/finders/packages/npm/package_finder.rb
+++ b/app/finders/packages/npm/package_finder.rb
@@ -12,6 +12,8 @@ module Packages
end
def execute
+ return Packages::Package.none unless project
+
packages
end
diff --git a/app/finders/personal_access_tokens_finder.rb b/app/finders/personal_access_tokens_finder.rb
index 93f8c520b63..4a6eed8f5ee 100644
--- a/app/finders/personal_access_tokens_finder.rb
+++ b/app/finders/personal_access_tokens_finder.rb
@@ -14,6 +14,7 @@ class PersonalAccessTokensFinder
tokens = PersonalAccessToken.all
tokens = by_current_user(tokens)
tokens = by_user(tokens)
+ tokens = by_users(tokens)
tokens = by_impersonation(tokens)
tokens = by_state(tokens)
@@ -37,6 +38,12 @@ class PersonalAccessTokensFinder
tokens.for_user(@params[:user])
end
+ def by_users(tokens)
+ return tokens unless @params[:users]
+
+ tokens.for_users(@params[:users])
+ end
+
def sort(tokens)
available_sort_orders = PersonalAccessToken.simple_sorts.keys
diff --git a/app/finders/security/jobs_finder.rb b/app/finders/security/jobs_finder.rb
new file mode 100644
index 00000000000..e2efb2e18c9
--- /dev/null
+++ b/app/finders/security/jobs_finder.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+# Security::JobsFinder
+#
+# Abstract class encapsulating common logic for finding jobs (builds) that are related to the Secure products
+# SAST, DAST, Dependency Scanning, Container Scanning and License Management, Coverage Fuzzing
+#
+# Arguments:
+# params:
+# pipeline: required, only jobs for the specified pipeline will be found
+# job_types: required, array of job types that should be returned, defaults to all job types
+
+module Security
+ class JobsFinder
+ attr_reader :pipeline
+
+ def self.allowed_job_types
+ # Example return: [:sast, :dast, :dependency_scanning, :container_scanning, :license_management, :coverage_fuzzing]
+ raise NotImplementedError, 'allowed_job_types must be overwritten to return an array of job types'
+ end
+
+ def initialize(pipeline:, job_types: [])
+ if self.class == Security::JobsFinder
+ raise NotImplementedError, 'This is an abstract class, please instantiate its descendants'
+ end
+
+ if job_types.empty?
+ @job_types = self.class.allowed_job_types
+ elsif valid_job_types?(job_types)
+ @job_types = job_types
+ else
+ raise ArgumentError, "job_types must be from the following: #{self.class.allowed_job_types}"
+ end
+
+ @pipeline = pipeline
+ end
+
+ def execute
+ return [] if @job_types.empty?
+
+ if Feature.enabled?(:ci_build_metadata_config)
+ find_jobs
+ else
+ find_jobs_legacy
+ end
+ end
+
+ private
+
+ def find_jobs
+ @pipeline.builds.with_secure_reports_from_config_options(@job_types)
+ end
+
+ def find_jobs_legacy
+ # the query doesn't guarantee accuracy, so we verify it here
+ legacy_jobs_query.select do |job|
+ @job_types.find { |job_type| job.options.dig(:artifacts, :reports, job_type) }
+ end
+ end
+
+ def legacy_jobs_query
+ @job_types.map do |job_type|
+ @pipeline.builds.with_secure_reports_from_options(job_type)
+ end.reduce(&:or)
+ end
+
+ def valid_job_types?(job_types)
+ (job_types - self.class.allowed_job_types).empty?
+ end
+ end
+end
diff --git a/app/finders/security/license_compliance_jobs_finder.rb b/app/finders/security/license_compliance_jobs_finder.rb
new file mode 100644
index 00000000000..100f94b2cc7
--- /dev/null
+++ b/app/finders/security/license_compliance_jobs_finder.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+# Security::LicenseScanningJobsFinder
+#
+# Used to find jobs (builds) that are related to the License Management.
+#
+# Arguments:
+# params:
+# pipeline: required, only jobs for the specified pipeline will be found
+# job_types: required, array of job types that should be returned, defaults to all job types
+
+module Security
+ class LicenseComplianceJobsFinder < JobsFinder
+ def self.allowed_job_types
+ [:license_management, :license_scanning]
+ end
+ end
+end
diff --git a/app/finders/security/security_jobs_finder.rb b/app/finders/security/security_jobs_finder.rb
new file mode 100644
index 00000000000..2352e19c7da
--- /dev/null
+++ b/app/finders/security/security_jobs_finder.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# Security::SecurityJobsFinder
+#
+# Used to find jobs (builds) that are related to the Secure products:
+# SAST, DAST, Dependency Scanning and Container Scanning
+#
+# Arguments:
+# params:
+# pipeline: required, only jobs for the specified pipeline will be found
+# job_types: required, array of job types that should be returned, defaults to all job types
+
+module Security
+ class SecurityJobsFinder < JobsFinder
+ def self.allowed_job_types
+ [:sast, :dast, :dependency_scanning, :container_scanning, :secret_detection, :coverage_fuzzing, :api_fuzzing]
+ end
+ end
+end
diff --git a/app/finders/user_groups_counter.rb b/app/finders/user_groups_counter.rb
new file mode 100644
index 00000000000..7dbc8502be2
--- /dev/null
+++ b/app/finders/user_groups_counter.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class UserGroupsCounter
+ def initialize(user_ids)
+ @user_ids = user_ids
+ end
+
+ def execute
+ Namespace.unscoped do
+ Namespace.from_union([
+ groups,
+ project_groups
+ ]).group(:user_id).count # rubocop: disable CodeReuse/ActiveRecord
+ end
+ end
+
+ private
+
+ attr_reader :user_ids
+
+ def groups
+ Group.for_authorized_group_members(user_ids)
+ .select('namespaces.*, members.user_id as user_id')
+ end
+
+ def project_groups
+ Group.for_authorized_project_members(user_ids)
+ .select('namespaces.*, project_authorizations.user_id as user_id')
+ end
+end
diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb
index 2f5043f9ffa..d66a2333d11 100644
--- a/app/graphql/gitlab_schema.rb
+++ b/app/graphql/gitlab_schema.rb
@@ -30,6 +30,8 @@ class GitlabSchema < GraphQL::Schema
default_max_page_size 100
+ lazy_resolve ::Gitlab::Graphql::Lazy, :force
+
class << self
def multiplex(queries, **kwargs)
kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context])
@@ -76,6 +78,13 @@ class GitlabSchema < GraphQL::Schema
find_by_gid(gid)
end
+ def resolve_type(type, object, ctx = :__undefined__)
+ tc = type.metadata[:type_class]
+ return if tc.respond_to?(:assignable?) && !tc.assignable?(object)
+
+ super
+ end
+
# Find an object by looking it up from its 'GlobalID'.
#
# * For `ApplicationRecord`s, this is equivalent to
diff --git a/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb b/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb
index a3a421f8938..17f9b5b5637 100644
--- a/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb
+++ b/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb
@@ -33,9 +33,9 @@ module Mutations
super
end
- def resolve(args)
+ def resolve(queue_name:, **args)
{
- result: Gitlab::SidekiqQueue.new(args[:queue_name]).drop_jobs!(args, timeout: 30),
+ result: Gitlab::SidekiqQueue.new(queue_name).drop_jobs!(args, timeout: 30),
errors: []
}
rescue Gitlab::SidekiqQueue::NoMetadataError
@@ -44,7 +44,7 @@ module Mutations
errors: ['No metadata provided']
}
rescue Gitlab::SidekiqQueue::InvalidQueueError
- raise Gitlab::Graphql::Errors::ResourceNotAvailable, "Queue #{args[:queue_name]} not found"
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, "Queue #{queue_name} not found"
end
end
end
diff --git a/app/graphql/mutations/alert_management/base.rb b/app/graphql/mutations/alert_management/base.rb
index 0ccfcf34180..81d5ee95f06 100644
--- a/app/graphql/mutations/alert_management/base.rb
+++ b/app/graphql/mutations/alert_management/base.rb
@@ -4,7 +4,6 @@ module Mutations
module AlertManagement
class Base < BaseMutation
include Gitlab::Utils::UsageData
- include ResolvesProject
argument :project_path, GraphQL::ID_TYPE,
required: true,
@@ -33,13 +32,12 @@ module Mutations
private
- def find_object(project_path:, iid:)
- project = resolve_project(full_path: project_path)
+ def find_object(project_path:, **args)
+ project = Project.find_by_full_path(project_path)
return unless project
- resolver = Resolvers::AlertManagement::AlertResolver.single.new(object: project, context: context, field: nil)
- resolver.resolve(iid: iid)
+ ::AlertManagement::AlertsFinder.new(current_user, project, args).execute.first
end
end
end
diff --git a/app/graphql/mutations/alert_management/http_integration/create.rb b/app/graphql/mutations/alert_management/http_integration/create.rb
new file mode 100644
index 00000000000..ddb75e66bb4
--- /dev/null
+++ b/app/graphql/mutations/alert_management/http_integration/create.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AlertManagement
+ module HttpIntegration
+ class Create < HttpIntegrationBase
+ include ResolvesProject
+
+ graphql_name 'HttpIntegrationCreate'
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'The project to create the integration in'
+
+ argument :name, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'The name of the integration'
+
+ argument :active, GraphQL::BOOLEAN_TYPE,
+ required: true,
+ description: 'Whether the integration is receiving alerts'
+
+ def resolve(args)
+ project = authorized_find!(full_path: args[:project_path])
+
+ response ::AlertManagement::HttpIntegrations::CreateService.new(
+ project,
+ current_user,
+ args.slice(:name, :active)
+ ).execute
+ end
+
+ private
+
+ def find_object(full_path:)
+ resolve_project(full_path: full_path)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/alert_management/http_integration/destroy.rb b/app/graphql/mutations/alert_management/http_integration/destroy.rb
new file mode 100644
index 00000000000..0f478760aab
--- /dev/null
+++ b/app/graphql/mutations/alert_management/http_integration/destroy.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AlertManagement
+ module HttpIntegration
+ class Destroy < HttpIntegrationBase
+ graphql_name 'HttpIntegrationDestroy'
+
+ argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration],
+ required: true,
+ description: "The id of the integration to remove"
+
+ def resolve(id:)
+ integration = authorized_find!(id: id)
+
+ response ::AlertManagement::HttpIntegrations::DestroyService.new(
+ integration,
+ current_user
+ ).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb b/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb
new file mode 100644
index 00000000000..d328eabf244
--- /dev/null
+++ b/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AlertManagement
+ module HttpIntegration
+ class HttpIntegrationBase < BaseMutation
+ field :integration,
+ Types::AlertManagement::HttpIntegrationType,
+ null: true,
+ description: "The HTTP integration"
+
+ authorize :admin_operations
+
+ private
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id, expected_class: ::AlertManagement::HttpIntegration)
+ end
+
+ def response(result)
+ {
+ integration: result.payload[:integration],
+ errors: result.errors
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/alert_management/http_integration/reset_token.rb b/app/graphql/mutations/alert_management/http_integration/reset_token.rb
new file mode 100644
index 00000000000..eefab156825
--- /dev/null
+++ b/app/graphql/mutations/alert_management/http_integration/reset_token.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AlertManagement
+ module HttpIntegration
+ class ResetToken < HttpIntegrationBase
+ graphql_name 'HttpIntegrationResetToken'
+
+ argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration],
+ required: true,
+ description: "The id of the integration to mutate"
+
+ def resolve(id:)
+ integration = authorized_find!(id: id)
+
+ response ::AlertManagement::HttpIntegrations::UpdateService.new(
+ integration,
+ current_user,
+ regenerate_token: true
+ ).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/alert_management/http_integration/update.rb b/app/graphql/mutations/alert_management/http_integration/update.rb
new file mode 100644
index 00000000000..309c45b04ac
--- /dev/null
+++ b/app/graphql/mutations/alert_management/http_integration/update.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AlertManagement
+ module HttpIntegration
+ class Update < HttpIntegrationBase
+ graphql_name 'HttpIntegrationUpdate'
+
+ argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration],
+ required: true,
+ description: "The id of the integration to mutate"
+
+ argument :name, GraphQL::STRING_TYPE,
+ required: false,
+ description: "The name of the integration"
+
+ argument :active, GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: "Whether the integration is receiving alerts"
+
+ def resolve(args)
+ integration = authorized_find!(id: args[:id])
+
+ response ::AlertManagement::HttpIntegrations::UpdateService.new(
+ integration,
+ current_user,
+ args.slice(:name, :active)
+ ).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/create.rb b/app/graphql/mutations/alert_management/prometheus_integration/create.rb
new file mode 100644
index 00000000000..935ec53795c
--- /dev/null
+++ b/app/graphql/mutations/alert_management/prometheus_integration/create.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AlertManagement
+ module PrometheusIntegration
+ class Create < PrometheusIntegrationBase
+ include ResolvesProject
+
+ graphql_name 'PrometheusIntegrationCreate'
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'The project to create the integration in'
+
+ argument :active, GraphQL::BOOLEAN_TYPE,
+ required: true,
+ description: 'Whether the integration is receiving alerts'
+
+ argument :api_url, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Endpoint at which prometheus can be queried'
+
+ def resolve(args)
+ project = authorized_find!(full_path: args[:project_path])
+
+ return integration_exists if project.prometheus_service
+
+ result = ::Projects::Operations::UpdateService.new(
+ project,
+ current_user,
+ **integration_attributes(args),
+ **token_attributes
+ ).execute
+
+ response(project.prometheus_service, result)
+ end
+
+ private
+
+ def find_object(full_path:)
+ resolve_project(full_path: full_path)
+ end
+
+ def integration_exists
+ response(nil, message: _('Multiple Prometheus integrations are not supported'))
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb b/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb
new file mode 100644
index 00000000000..6b690ac239a
--- /dev/null
+++ b/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AlertManagement
+ module PrometheusIntegration
+ class PrometheusIntegrationBase < BaseMutation
+ field :integration,
+ Types::AlertManagement::PrometheusIntegrationType,
+ null: true,
+ description: "The newly created integration"
+
+ authorize :admin_project
+
+ private
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id, expected_class: ::PrometheusService)
+ end
+
+ def response(integration, result)
+ {
+ integration: integration,
+ errors: Array(result[:message])
+ }
+ end
+
+ def integration_attributes(args)
+ {
+ prometheus_integration_attributes: {
+ manual_configuration: args[:active],
+ api_url: args[:api_url]
+ }.compact
+ }
+ end
+
+ def token_attributes
+ { alerting_setting_attributes: { regenerate_token: true } }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb b/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb
new file mode 100644
index 00000000000..745ac51f6e3
--- /dev/null
+++ b/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AlertManagement
+ module PrometheusIntegration
+ class ResetToken < PrometheusIntegrationBase
+ graphql_name 'PrometheusIntegrationResetToken'
+
+ argument :id, Types::GlobalIDType[::PrometheusService],
+ required: true,
+ description: "The id of the integration to mutate"
+
+ def resolve(id:)
+ integration = authorized_find!(id: id)
+
+ result = ::Projects::Operations::UpdateService.new(
+ integration.project,
+ current_user,
+ token_attributes
+ ).execute
+
+ response integration, result
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/update.rb b/app/graphql/mutations/alert_management/prometheus_integration/update.rb
new file mode 100644
index 00000000000..1f0dea119c5
--- /dev/null
+++ b/app/graphql/mutations/alert_management/prometheus_integration/update.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AlertManagement
+ module PrometheusIntegration
+ class Update < PrometheusIntegrationBase
+ graphql_name 'PrometheusIntegrationUpdate'
+
+ argument :id, Types::GlobalIDType[::PrometheusService],
+ required: true,
+ description: "The id of the integration to mutate"
+
+ argument :active, GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: "Whether the integration is receiving alerts"
+
+ argument :api_url, GraphQL::STRING_TYPE,
+ required: false,
+ description: "Endpoint at which prometheus can be queried"
+
+ def resolve(args)
+ integration = authorized_find!(id: args[:id])
+
+ result = ::Projects::Operations::UpdateService.new(
+ integration.project,
+ current_user,
+ integration_attributes(args)
+ ).execute
+
+ response integration.reset, result
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/alert_management/update_alert_status.rb b/app/graphql/mutations/alert_management/update_alert_status.rb
index 1e14bae048a..74185dca529 100644
--- a/app/graphql/mutations/alert_management/update_alert_status.rb
+++ b/app/graphql/mutations/alert_management/update_alert_status.rb
@@ -9,9 +9,9 @@ module Mutations
required: true,
description: 'The status to set the alert'
- def resolve(args)
- alert = authorized_find!(project_path: args[:project_path], iid: args[:iid])
- result = update_status(alert, args[:status])
+ def resolve(project_path:, iid:, status:)
+ alert = authorized_find!(project_path: project_path, iid: iid)
+ result = update_status(alert, status)
track_usage_event(:incident_management_alert_status_changed, current_user.id)
diff --git a/app/graphql/mutations/boards/create.rb b/app/graphql/mutations/boards/create.rb
index e381205242e..ebbd19930ec 100644
--- a/app/graphql/mutations/boards/create.rb
+++ b/app/graphql/mutations/boards/create.rb
@@ -3,8 +3,7 @@
module Mutations
module Boards
class Create < ::Mutations::BaseMutation
- include Mutations::ResolvesGroup
- include ResolvesProject
+ include Mutations::ResolvesResourceParent
graphql_name 'CreateBoard'
@@ -13,12 +12,6 @@ module Mutations
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,
@@ -28,7 +21,7 @@ module Mutations
required: false,
description: 'The ID of the user to be assigned to the board.'
argument :milestone_id,
- GraphQL::ID_TYPE,
+ Types::GlobalIDType[Milestone],
required: false,
description: 'The ID of the milestone to be assigned to the board.'
argument :weight,
@@ -36,17 +29,14 @@ module Mutations
required: false,
description: 'The weight of the board.'
argument :label_ids,
- [GraphQL::ID_TYPE],
+ [Types::GlobalIDType[Label]],
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)
+ board_parent = authorized_resource_parent_find!(args)
response = ::Boards::CreateService.new(board_parent, current_user, args).execute
{
@@ -54,25 +44,6 @@ module Mutations
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/update.rb b/app/graphql/mutations/boards/lists/update.rb
index 7efed3058b3..14502b5174f 100644
--- a/app/graphql/mutations/boards/lists/update.rb
+++ b/app/graphql/mutations/boards/lists/update.rb
@@ -6,7 +6,7 @@ module Mutations
class Update < BaseMutation
graphql_name 'UpdateBoardList'
- argument :list_id, GraphQL::ID_TYPE,
+ argument :list_id, Types::GlobalIDType[List],
required: true,
loads: Types::BoardListType,
description: 'Global ID of the list.'
diff --git a/app/graphql/mutations/commits/create.rb b/app/graphql/mutations/commits/create.rb
index 9ed1bb819c8..2b9107350fd 100644
--- a/app/graphql/mutations/commits/create.rb
+++ b/app/graphql/mutations/commits/create.rb
@@ -13,7 +13,11 @@ module Mutations
argument :branch, GraphQL::STRING_TYPE,
required: true,
- description: 'Name of the branch'
+ description: 'Name of the branch to commit into, it can be a new branch'
+
+ argument :start_branch, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'If on a new branch, name of the original branch'
argument :message,
GraphQL::STRING_TYPE,
@@ -32,13 +36,13 @@ module Mutations
authorize :push_code
- def resolve(project_path:, branch:, message:, actions:)
+ def resolve(project_path:, branch:, message:, actions:, **args)
project = authorized_find!(full_path: project_path)
attributes = {
commit_message: message,
branch_name: branch,
- start_branch: branch,
+ start_branch: args[:start_branch] || branch,
actions: actions.map { |action| action.to_h }
}
diff --git a/app/graphql/mutations/concerns/mutations/package_eventable.rb b/app/graphql/mutations/concerns/mutations/package_eventable.rb
new file mode 100644
index 00000000000..86fd7b9a88a
--- /dev/null
+++ b/app/graphql/mutations/concerns/mutations/package_eventable.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Mutations
+ module PackageEventable
+ extend ActiveSupport::Concern
+
+ private
+
+ def track_event(event, scope)
+ ::Packages::CreateEventService.new(nil, current_user, event_name: event, scope: scope).execute
+ ::Gitlab::Tracking.event(event.to_s, scope.to_s)
+ end
+ end
+end
diff --git a/app/graphql/mutations/concerns/mutations/resolves_resource_parent.rb b/app/graphql/mutations/concerns/mutations/resolves_resource_parent.rb
new file mode 100644
index 00000000000..04a9abf9529
--- /dev/null
+++ b/app/graphql/mutations/concerns/mutations/resolves_resource_parent.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Mutations
+ module ResolvesResourceParent
+ extend ActiveSupport::Concern
+ include Mutations::ResolvesGroup
+ include ResolvesProject
+
+ included do
+ argument :project_path, GraphQL::ID_TYPE,
+ required: false,
+ description: 'The project full path the resource is associated with'
+
+ argument :group_path, GraphQL::ID_TYPE,
+ required: false,
+ description: 'The group full path the resource is associated with'
+ end
+
+ def ready?(**args)
+ unless args[:project_path].present? ^ args[:group_path].present?
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ 'Exactly one of group_path or project_path arguments is required'
+ end
+
+ super
+ end
+
+ private
+
+ def authorized_resource_parent_find!(args)
+ authorized_find!(project_path: args.delete(:project_path),
+ group_path: args.delete(:group_path))
+ end
+
+ def find_object(project_path: nil, group_path: nil)
+ if group_path.present?
+ resolve_group(full_path: group_path)
+ else
+ resolve_project(full_path: project_path)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/container_repositories/destroy.rb b/app/graphql/mutations/container_repositories/destroy.rb
new file mode 100644
index 00000000000..8312193147f
--- /dev/null
+++ b/app/graphql/mutations/container_repositories/destroy.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Mutations
+ module ContainerRepositories
+ class Destroy < Mutations::BaseMutation
+ include ::Mutations::PackageEventable
+
+ graphql_name 'DestroyContainerRepository'
+
+ authorize :destroy_container_image
+
+ argument :id,
+ ::Types::GlobalIDType[::ContainerRepository],
+ required: true,
+ description: 'ID of the container repository.'
+
+ field :container_repository,
+ Types::ContainerRepositoryType,
+ null: false,
+ description: 'The container repository policy after scheduling the deletion.'
+
+ def resolve(id:)
+ container_repository = authorized_find!(id: id)
+
+ container_repository.delete_scheduled!
+ DeleteContainerRepositoryWorker.perform_async(current_user.id, container_repository.id)
+ track_event(:delete_repository, :container)
+
+ {
+ container_repository: container_repository,
+ errors: []
+ }
+ end
+
+ private
+
+ def find_object(id:)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::ContainerRepository].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/custom_emoji/create.rb b/app/graphql/mutations/custom_emoji/create.rb
new file mode 100644
index 00000000000..d912a29d12e
--- /dev/null
+++ b/app/graphql/mutations/custom_emoji/create.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Mutations
+ module CustomEmoji
+ class Create < BaseMutation
+ include Mutations::ResolvesGroup
+
+ graphql_name 'CreateCustomEmoji'
+
+ authorize :create_custom_emoji
+
+ field :custom_emoji,
+ Types::CustomEmojiType,
+ null: true,
+ description: 'The new custom emoji'
+
+ argument :group_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'Namespace full path the emoji is associated with'
+
+ argument :name, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Name of the emoji'
+
+ argument :url, GraphQL::STRING_TYPE,
+ required: true,
+ as: :file,
+ description: 'Location of the emoji file'
+
+ def resolve(group_path:, **args)
+ group = authorized_find!(group_path: group_path)
+ # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37911#note_444682238
+ args[:external] = true
+
+ custom_emoji = group.custom_emoji.create(args)
+
+ {
+ custom_emoji: custom_emoji.valid? ? custom_emoji : nil,
+ errors: errors_on_object(custom_emoji)
+ }
+ end
+
+ private
+
+ def find_object(group_path:)
+ resolve_group(full_path: group_path)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/labels/create.rb b/app/graphql/mutations/labels/create.rb
new file mode 100644
index 00000000000..cb03651618e
--- /dev/null
+++ b/app/graphql/mutations/labels/create.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Labels
+ class Create < BaseMutation
+ include Mutations::ResolvesResourceParent
+
+ graphql_name 'LabelCreate'
+
+ field :label,
+ Types::LabelType,
+ null: true,
+ description: 'The label after mutation'
+
+ argument :title, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Title of the label'
+
+ argument :description, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Description of the label'
+
+ argument :color, GraphQL::STRING_TYPE,
+ required: false,
+ default_value: Label::DEFAULT_COLOR,
+ description: "The color of the label given in 6-digit hex notation with leading '#' sign (e.g. #FFAABB) or one of the CSS color names in https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#Color_keywords"
+
+ authorize :admin_label
+
+ def resolve(args)
+ parent = authorized_resource_parent_find!(args)
+ parent_key = parent.is_a?(Project) ? :project : :group
+
+ label = ::Labels::CreateService.new(args).execute(parent_key => parent)
+
+ {
+ label: label.persisted? ? label : nil,
+ errors: errors_on_object(label)
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/merge_requests/set_labels.rb b/app/graphql/mutations/merge_requests/set_labels.rb
index c1e45808593..712c68c9425 100644
--- a/app/graphql/mutations/merge_requests/set_labels.rb
+++ b/app/graphql/mutations/merge_requests/set_labels.rb
@@ -6,7 +6,7 @@ module Mutations
graphql_name 'MergeRequestSetLabels'
argument :label_ids,
- [GraphQL::ID_TYPE],
+ [::Types::GlobalIDType[Label]],
required: true,
description: <<~DESC
The Label IDs to set. Replaces existing labels by default.
@@ -23,10 +23,11 @@ module Mutations
merge_request = authorized_find!(project_path: project_path, iid: iid)
project = merge_request.project
- label_ids = label_ids
- .map { |gid| GlobalID.parse(gid) }
- .select(&method(:label_descendant?))
- .map(&:model_id) # MergeRequests::UpdateService expects integers
+ # TODO: remove this line when the compatibility layer is removed:
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ label_ids = label_ids.map { |id| ::Types::GlobalIDType[::Label].coerce_isolated_input(id) }
+ # MergeRequests::UpdateService expects integers
+ label_ids = label_ids.compact.map(&:model_id)
attribute_name = case operation_mode
when Types::MutationOperationModeEnum.enum[:append]
@@ -45,10 +46,6 @@ module Mutations
errors: errors_on_object(merge_request)
}
end
-
- def label_descendant?(gid)
- gid&.model_class&.ancestors&.include?(Label)
- end
end
end
end
diff --git a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb
index 6e183e78d9b..d6731dfcafd 100644
--- a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb
+++ b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb
@@ -9,8 +9,7 @@ module Mutations
authorize :delete_metrics_dashboard_annotation
- argument :id,
- GraphQL::ID_TYPE,
+ argument :id, ::Types::GlobalIDType[::Metrics::Dashboard::Annotation],
required: true,
description: 'The global ID of the annotation to delete'
diff --git a/app/graphql/mutations/notes/reposition_image_diff_note.rb b/app/graphql/mutations/notes/reposition_image_diff_note.rb
new file mode 100644
index 00000000000..0d88bcd9a30
--- /dev/null
+++ b/app/graphql/mutations/notes/reposition_image_diff_note.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Notes
+ # This mutation differs from the update note mutations as it checks the
+ # `reposition_note` permission, and doesn't allow updating a note's `body`.
+ class RepositionImageDiffNote < Mutations::Notes::Base
+ graphql_name 'RepositionImageDiffNote'
+
+ description 'Repositions a DiffNote on an image (a `Note` where the `position.positionType` is `"image"`)'
+
+ authorize :reposition_note
+
+ argument :id,
+ Types::GlobalIDType[DiffNote],
+ loads: Types::Notes::NoteType,
+ as: :note,
+ required: true,
+ description: 'The global id of the DiffNote to update'
+
+ argument :position,
+ Types::Notes::UpdateDiffImagePositionInputType,
+ required: true,
+ description: copy_field_description(Types::Notes::NoteType, :position)
+
+ def resolve(note:, position:)
+ authorize!(note)
+
+ pre_update_checks!(note, position)
+
+ updated_note = ::Notes::UpdateService.new(
+ note.project,
+ current_user,
+ note_params(note.position, position)
+ ).execute(note)
+
+ {
+ note: updated_note.reset,
+ errors: errors_on_object(updated_note)
+ }
+ end
+
+ private
+
+ # An ImageDiffNote does not exist as a class itself, but is instead
+ # just a `DiffNote` with a particular kind of `Gitlab::Diff::Position`.
+ # In addition to accepting a `DiffNote` Global ID we also need to
+ # perform this check.
+ def pre_update_checks!(note, position)
+ unless note.position&.on_image?
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable,
+ 'Resource is not an ImageDiffNote'
+ end
+ end
+
+ def note_params(old_position, new_position)
+ position = old_position.to_h.merge(new_position)
+
+ {
+ position: Gitlab::Diff::Position.new(position)
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/notes/update/image_diff_note.rb b/app/graphql/mutations/notes/update/image_diff_note.rb
index ef70a8d2bf4..f4533cd9edb 100644
--- a/app/graphql/mutations/notes/update/image_diff_note.rb
+++ b/app/graphql/mutations/notes/update/image_diff_note.rb
@@ -47,12 +47,11 @@ module Mutations
end
def position_params(note, args)
- new_position = args[:position]&.to_h&.compact
- return unless new_position
+ return unless args[:position]
original_position = note.position.to_h
- Gitlab::Diff::Position.new(original_position.merge(new_position))
+ Gitlab::Diff::Position.new(original_position.merge(args[:position]))
end
end
end
diff --git a/app/graphql/mutations/releases/base.rb b/app/graphql/mutations/releases/base.rb
new file mode 100644
index 00000000000..d53cfbe6a11
--- /dev/null
+++ b/app/graphql/mutations/releases/base.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Releases
+ class Base < BaseMutation
+ include ResolvesProject
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'Full path of the project the release is associated with'
+
+ private
+
+ def find_object(full_path:)
+ resolve_project(full_path: full_path)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/releases/create.rb b/app/graphql/mutations/releases/create.rb
new file mode 100644
index 00000000000..57c1541c368
--- /dev/null
+++ b/app/graphql/mutations/releases/create.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Releases
+ class Create < Base
+ graphql_name 'ReleaseCreate'
+
+ field :release,
+ Types::ReleaseType,
+ null: true,
+ description: 'The release after mutation'
+
+ argument :tag_name, GraphQL::STRING_TYPE,
+ required: true, as: :tag,
+ description: 'Name of the tag to associate with the release'
+
+ argument :ref, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'The commit SHA or branch name to use if creating a new tag'
+
+ argument :name, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Name of the release'
+
+ argument :description, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Description (also known as "release notes") of the release'
+
+ argument :released_at, Types::TimeType,
+ required: false,
+ description: 'The date when the release will be/was ready. Defaults to the current time.'
+
+ argument :milestones, [GraphQL::STRING_TYPE],
+ required: false,
+ description: 'The title of each milestone the release is associated with. GitLab Premium customers can specify group milestones.'
+
+ argument :assets, Types::ReleaseAssetsInputType,
+ required: false,
+ description: 'Assets associated to the release'
+
+ authorize :create_release
+
+ def resolve(project_path:, milestones: nil, assets: nil, **scalars)
+ project = authorized_find!(full_path: project_path)
+
+ params = {
+ **scalars,
+ milestones: milestones.presence || [],
+ assets: assets.to_h
+ }.with_indifferent_access
+
+ result = ::Releases::CreateService.new(project, current_user, params).execute
+
+ if result[:status] == :success
+ {
+ release: result[:release],
+ errors: []
+ }
+ else
+ {
+ release: nil,
+ errors: [result[:message]]
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/snippets/destroy.rb b/app/graphql/mutations/snippets/destroy.rb
index dc9a1e82575..4915d7dd77a 100644
--- a/app/graphql/mutations/snippets/destroy.rb
+++ b/app/graphql/mutations/snippets/destroy.rb
@@ -7,8 +7,7 @@ module Mutations
ERROR_MSG = 'Error deleting the snippet'
- argument :id,
- GraphQL::ID_TYPE,
+ argument :id, ::Types::GlobalIDType[::Snippet],
required: true,
description: 'The global id of the snippet to destroy'
diff --git a/app/graphql/mutations/snippets/mark_as_spam.rb b/app/graphql/mutations/snippets/mark_as_spam.rb
index 8cfbbae7c08..d6b96c699c0 100644
--- a/app/graphql/mutations/snippets/mark_as_spam.rb
+++ b/app/graphql/mutations/snippets/mark_as_spam.rb
@@ -5,8 +5,7 @@ module Mutations
class MarkAsSpam < Base
graphql_name 'MarkAsSpamSnippet'
- argument :id,
- GraphQL::ID_TYPE,
+ argument :id, ::Types::GlobalIDType[::Snippet],
required: true,
description: 'The global id of the snippet to update'
diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb
index 74266880806..bcaa807e4c1 100644
--- a/app/graphql/mutations/snippets/update.rb
+++ b/app/graphql/mutations/snippets/update.rb
@@ -7,8 +7,7 @@ module Mutations
graphql_name 'UpdateSnippet'
- argument :id,
- GraphQL::ID_TYPE,
+ argument :id, ::Types::GlobalIDType[::Snippet],
required: true,
description: 'The global id of the snippet to update'
diff --git a/app/graphql/mutations/terraform/state/base.rb b/app/graphql/mutations/terraform/state/base.rb
new file mode 100644
index 00000000000..b1721c784b1
--- /dev/null
+++ b/app/graphql/mutations/terraform/state/base.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Terraform
+ module State
+ class Base < BaseMutation
+ authorize :admin_terraform_state
+
+ argument :id,
+ Types::GlobalIDType[::Terraform::State],
+ required: true,
+ description: 'Global ID of the Terraform state'
+
+ private
+
+ def find_object(id:)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/terraform/state/delete.rb b/app/graphql/mutations/terraform/state/delete.rb
new file mode 100644
index 00000000000..f08219cb395
--- /dev/null
+++ b/app/graphql/mutations/terraform/state/delete.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Terraform
+ module State
+ class Delete < Base
+ graphql_name 'TerraformStateDelete'
+
+ def resolve(id:)
+ state = authorized_find!(id: id)
+ state.destroy
+
+ { errors: errors_on_object(state) }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/terraform/state/lock.rb b/app/graphql/mutations/terraform/state/lock.rb
new file mode 100644
index 00000000000..d22c8de2560
--- /dev/null
+++ b/app/graphql/mutations/terraform/state/lock.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Terraform
+ module State
+ class Lock < Base
+ graphql_name 'TerraformStateLock'
+
+ def resolve(id:)
+ state = authorized_find!(id: id)
+
+ if state.locked?
+ state.errors.add(:base, 'state is already locked')
+ else
+ state.update(lock_xid: lock_xid, locked_by_user: current_user, locked_at: Time.current)
+ end
+
+ { errors: errors_on_object(state) }
+ end
+
+ private
+
+ def lock_xid
+ SecureRandom.uuid
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/terraform/state/unlock.rb b/app/graphql/mutations/terraform/state/unlock.rb
new file mode 100644
index 00000000000..0818dbd7fb3
--- /dev/null
+++ b/app/graphql/mutations/terraform/state/unlock.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Terraform
+ module State
+ class Unlock < Base
+ graphql_name 'TerraformStateUnlock'
+
+ def resolve(id:)
+ state = authorized_find!(id: id)
+ state.update(lock_xid: nil, locked_by_user: nil, locked_at: nil)
+
+ { errors: errors_on_object(state) }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/todos/base.rb b/app/graphql/mutations/todos/base.rb
index 6db863796bc..4dab3bbc3f4 100644
--- a/app/graphql/mutations/todos/base.rb
+++ b/app/graphql/mutations/todos/base.rb
@@ -11,16 +11,6 @@ module Mutations
id = ::Types::GlobalIDType[::Todo].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
-
- def map_to_global_ids(ids)
- return [] if ids.blank?
-
- ids.map { |id| to_global_id(id) }
- end
-
- def to_global_id(id)
- Gitlab::GlobalId.as_global_id(id, model_name: Todo.name).to_s
- end
end
end
end
diff --git a/app/graphql/mutations/todos/create.rb b/app/graphql/mutations/todos/create.rb
new file mode 100644
index 00000000000..53c88696fdd
--- /dev/null
+++ b/app/graphql/mutations/todos/create.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Todos
+ class Create < ::Mutations::Todos::Base
+ graphql_name 'TodoCreate'
+
+ authorize :create_todo
+
+ argument :target_id,
+ Types::GlobalIDType[Todoable],
+ required: true,
+ description: "The global ID of the to-do item's parent. Issues, merge requests, designs and epics are supported"
+
+ field :todo, Types::TodoType,
+ null: true,
+ description: 'The to-do created'
+
+ def resolve(target_id:)
+ id = ::Types::GlobalIDType[Todoable].coerce_isolated_input(target_id)
+ target = authorized_find!(id)
+
+ todo = TodoService.new.mark_todo(target, current_user)&.first
+ errors = errors_on_object(todo) if todo
+
+ {
+ todo: todo,
+ errors: errors
+ }
+ end
+
+ private
+
+ def find_object(id)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/todos/mark_all_done.rb b/app/graphql/mutations/todos/mark_all_done.rb
index 8b53658ddd5..97bbbeeaa2f 100644
--- a/app/graphql/mutations/todos/mark_all_done.rb
+++ b/app/graphql/mutations/todos/mark_all_done.rb
@@ -8,7 +8,7 @@ module Mutations
authorize :update_user
field :updated_ids,
- [GraphQL::ID_TYPE],
+ [::Types::GlobalIDType[::Todo]],
null: false,
deprecated: { reason: 'Use todos', milestone: '13.2' },
description: 'Ids of the updated todos'
@@ -23,7 +23,7 @@ module Mutations
updated_ids = mark_all_todos_done
{
- updated_ids: map_to_global_ids(updated_ids),
+ updated_ids: updated_ids,
todos: Todo.id_in(updated_ids),
errors: []
}
diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb
index ea5f5414134..9e0a95c48ec 100644
--- a/app/graphql/mutations/todos/restore_many.rb
+++ b/app/graphql/mutations/todos/restore_many.rb
@@ -12,7 +12,7 @@ module Mutations
required: true,
description: 'The global ids of the todos to restore (a maximum of 50 is supported at once)'
- field :updated_ids, [GraphQL::ID_TYPE],
+ field :updated_ids, [::Types::GlobalIDType[Todo]],
null: false,
description: 'The ids of the updated todo items',
deprecated: { reason: 'Use todos', milestone: '13.2' }
@@ -28,7 +28,7 @@ module Mutations
updated_ids = restore(todos)
{
- updated_ids: gids_of(updated_ids),
+ updated_ids: updated_ids,
todos: Todo.id_in(updated_ids),
errors: errors_on_objects(todos)
}
@@ -36,10 +36,6 @@ module Mutations
private
- def gids_of(ids)
- 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|
# TODO: remove this line when the compatibility layer is removed
diff --git a/app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql b/app/graphql/queries/design_management/design_permissions.query.graphql
index a87b256dc95..55dfa35129c 100644
--- a/app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql
+++ b/app/graphql/queries/design_management/design_permissions.query.graphql
@@ -1,8 +1,11 @@
query permissions($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
+ __typename
id
issue(iid: $iid) {
+ __typename
userPermissions {
+ __typename
createDesign
}
}
diff --git a/app/graphql/queries/design_management/get_design_list.query.graphql b/app/graphql/queries/design_management/get_design_list.query.graphql
new file mode 100644
index 00000000000..ade03d99797
--- /dev/null
+++ b/app/graphql/queries/design_management/get_design_list.query.graphql
@@ -0,0 +1,40 @@
+query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) {
+ project(fullPath: $fullPath) {
+ __typename
+ id
+ issue(iid: $iid) {
+ __typename
+ designCollection {
+ __typename
+ copyState
+ designs(atVersion: $atVersion) {
+ __typename
+ nodes {
+ __typename
+ id
+ event
+ filename
+ notesCount
+ image
+ imageV432x230
+ currentUserTodos(state: pending) {
+ __typename
+ nodes {
+ __typename
+ id
+ }
+ }
+ }
+ }
+ versions {
+ __typename
+ nodes {
+ __typename
+ id
+ sha
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/repository/queries/files.query.graphql b/app/graphql/queries/repository/files.query.graphql
index 9e9f5303dd4..232d98a932c 100644
--- a/app/assets/javascripts/repository/queries/files.query.graphql
+++ b/app/graphql/queries/repository/files.query.graphql
@@ -1,6 +1,13 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+fragment PageInfo on PageInfo {
+ __typename
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+}
fragment TreeEntry on Entry {
+ __typename
id
sha
name
@@ -16,10 +23,15 @@ query getFiles(
$nextPageCursor: String
) {
project(fullPath: $projectPath) {
+ __typename
repository {
+ __typename
tree(path: $path, ref: $ref) {
+ __typename
trees(first: $pageSize, after: $nextPageCursor) {
+ __typename
edges {
+ __typename
node {
...TreeEntry
webPath
@@ -30,7 +42,9 @@ query getFiles(
}
}
submodules(first: $pageSize, after: $nextPageCursor) {
+ __typename
edges {
+ __typename
node {
...TreeEntry
webUrl
@@ -42,7 +56,9 @@ query getFiles(
}
}
blobs(first: $pageSize, after: $nextPageCursor) {
+ __typename
edges {
+ __typename
node {
...TreeEntry
mode
diff --git a/app/assets/javascripts/repository/queries/permissions.query.graphql b/app/graphql/queries/repository/permissions.query.graphql
index 092fa44e2d0..c0262a882cd 100644
--- a/app/assets/javascripts/repository/queries/permissions.query.graphql
+++ b/app/graphql/queries/repository/permissions.query.graphql
@@ -1,6 +1,8 @@
query getPermissions($projectPath: ID!) {
project(fullPath: $projectPath) {
+ __typename
userPermissions {
+ __typename
pushCode
forkProject
createMergeRequestIn
diff --git a/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql b/app/graphql/queries/snippet/project_permissions.query.graphql
index 03c81460fb5..0c38e4f8a07 100644
--- a/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql
+++ b/app/graphql/queries/snippet/project_permissions.query.graphql
@@ -1,6 +1,8 @@
query CanCreateProjectSnippet($fullPath: ID!) {
project(fullPath: $fullPath) {
+ __typename
userPermissions {
+ __typename
createSnippet
}
}
diff --git a/app/graphql/queries/snippet/snippet.query.graphql b/app/graphql/queries/snippet/snippet.query.graphql
new file mode 100644
index 00000000000..2205dc26642
--- /dev/null
+++ b/app/graphql/queries/snippet/snippet.query.graphql
@@ -0,0 +1,65 @@
+query GetSnippetQuery($ids: [SnippetID!]) {
+ snippets(ids: $ids) {
+ __typename
+ nodes {
+ __typename
+ id
+ title
+ description
+ descriptionHtml
+ createdAt
+ updatedAt
+ visibilityLevel
+ webUrl
+ httpUrlToRepo
+ sshUrlToRepo
+ blobs {
+ __typename
+ nodes {
+ __typename
+ binary
+ name
+ path
+ rawPath
+ size
+ externalStorage
+ renderedAsText
+ simpleViewer {
+ __typename
+ collapsed
+ renderError
+ tooLarge
+ type
+ fileType
+ }
+ richViewer {
+ __typename
+ collapsed
+ renderError
+ tooLarge
+ type
+ fileType
+ }
+ }
+ }
+ userPermissions {
+ __typename
+ adminSnippet
+ updateSnippet
+ }
+ project {
+ __typename
+ fullPath
+ webUrl
+ }
+ author {
+ __typename
+ id
+ avatarUrl
+ name
+ username
+ webUrl
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql b/app/graphql/queries/snippet/snippet_blob_content.query.graphql
index 0e04ee9b7b8..005f42ff726 100644
--- a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql
+++ b/app/graphql/queries/snippet/snippet_blob_content.query.graphql
@@ -1,9 +1,13 @@
query SnippetBlobContent($ids: [ID!], $rich: Boolean!, $paths: [String!]) {
snippets(ids: $ids) {
+ __typename
nodes {
+ __typename
id
blobs(paths: $paths) {
+ __typename
nodes {
+ __typename
path
richData @include(if: $rich)
plainData @skip(if: $rich)
diff --git a/app/assets/javascripts/snippets/queries/userPermissions.query.graphql b/app/graphql/queries/snippet/user_permissions.query.graphql
index c3e5519e266..a4914189807 100644
--- a/app/assets/javascripts/snippets/queries/userPermissions.query.graphql
+++ b/app/graphql/queries/snippet/user_permissions.query.graphql
@@ -1,6 +1,8 @@
query CanCreatePersonalSnippet {
currentUser {
+ __typename
userPermissions {
+ __typename
createSnippet
}
}
diff --git a/app/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver.rb b/app/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver.rb
index aea3afa8ec5..9bac9f222ab 100644
--- a/app/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver.rb
+++ b/app/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver.rb
@@ -13,10 +13,20 @@ module Resolvers
required: true,
description: 'The type of measurement/statistics to retrieve'
- def resolve(identifier:)
+ argument :recorded_after, Types::TimeType,
+ required: false,
+ description: 'Measurement recorded after this date'
+
+ argument :recorded_before, Types::TimeType,
+ required: false,
+ description: 'Measurement recorded before this date'
+
+ def resolve(identifier:, recorded_before: nil, recorded_after: nil)
authorize!
::Analytics::InstanceStatistics::Measurement
+ .recorded_after(recorded_after)
+ .recorded_before(recorded_before)
.with_identifier(identifier)
.order_by_latest
end
diff --git a/app/graphql/resolvers/alert_management/alert_resolver.rb b/app/graphql/resolvers/alert_management/alert_resolver.rb
index dc9b1dbb5f4..c3219d9cdc3 100644
--- a/app/graphql/resolvers/alert_management/alert_resolver.rb
+++ b/app/graphql/resolvers/alert_management/alert_resolver.rb
@@ -19,7 +19,7 @@ module Resolvers
required: false
argument :search, GraphQL::STRING_TYPE,
- description: 'Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.',
+ description: 'Search query for title, description, service, or monitoring_tool.',
required: false
argument :assignee_username, GraphQL::STRING_TYPE,
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 96ea4610aff..8fc0f9fd1ff 100644
--- a/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb
+++ b/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb
@@ -6,7 +6,7 @@ module Resolvers
type Types::AlertManagement::AlertStatusCountsType, null: true
argument :search, GraphQL::STRING_TYPE,
- description: 'Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.',
+ description: 'Search query for title, description, service, or monitoring_tool.',
required: false
argument :assignee_username, GraphQL::STRING_TYPE,
diff --git a/app/graphql/resolvers/alert_management/integrations_resolver.rb b/app/graphql/resolvers/alert_management/integrations_resolver.rb
new file mode 100644
index 00000000000..4d1fe367277
--- /dev/null
+++ b/app/graphql/resolvers/alert_management/integrations_resolver.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module AlertManagement
+ class IntegrationsResolver < BaseResolver
+ alias_method :project, :synchronized_object
+
+ type Types::AlertManagement::IntegrationType.connection_type, null: true
+
+ def resolve(**args)
+ http_integrations + prometheus_integrations
+ end
+
+ private
+
+ def prometheus_integrations
+ return [] unless Ability.allowed?(current_user, :admin_project, project)
+
+ Array(project.prometheus_service)
+ end
+
+ def http_integrations
+ return [] unless Ability.allowed?(current_user, :admin_operations, project)
+
+ ::AlertManagement::HttpIntegrationsFinder.new(project, {}).execute
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/assigned_merge_requests_resolver.rb b/app/graphql/resolvers/assigned_merge_requests_resolver.rb
index 172a8e298ad..30415ef5d2d 100644
--- a/app/graphql/resolvers/assigned_merge_requests_resolver.rb
+++ b/app/graphql/resolvers/assigned_merge_requests_resolver.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
module Resolvers
- class AssignedMergeRequestsResolver < UserMergeRequestsResolver
+ class AssignedMergeRequestsResolver < UserMergeRequestsResolverBase
+ type ::Types::MergeRequestType.connection_type, null: true
accept_author
def user_role
diff --git a/app/graphql/resolvers/authored_merge_requests_resolver.rb b/app/graphql/resolvers/authored_merge_requests_resolver.rb
index bc796f8685a..1426ca83c06 100644
--- a/app/graphql/resolvers/authored_merge_requests_resolver.rb
+++ b/app/graphql/resolvers/authored_merge_requests_resolver.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
module Resolvers
- class AuthoredMergeRequestsResolver < UserMergeRequestsResolver
+ class AuthoredMergeRequestsResolver < UserMergeRequestsResolverBase
+ type ::Types::MergeRequestType.connection_type, null: true
accept_assignee
def user_role
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index 2b8854fb4d0..87a63231b22 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -8,32 +8,81 @@ module Resolvers
argument_class ::Types::BaseArgument
- def self.single
- @single ||= Class.new(self) do
- def ready?(**args)
- ready, early_return = super
- [ready, select_result(early_return)]
- end
+ def self.singular_type
+ return unless type
- def resolve(**args)
- select_result(super)
- end
+ unwrapped = type.unwrap
+
+ %i[node_type relay_node_type of_type itself].reduce(nil) do |t, m|
+ t || unwrapped.try(m)
+ end
+ end
- def single?
- true
+ def self.when_single(&block)
+ as_single << block
+
+ # Have we been called after defining the single version of this resolver?
+ if @single.present?
+ @single.instance_exec(&block)
+ end
+ end
+
+ def self.as_single
+ @as_single ||= []
+ end
+
+ def self.single_definition_blocks
+ ancestors.flat_map { |klass| klass.try(:as_single) || [] }
+ end
+
+ def self.single
+ @single ||= begin
+ parent = self
+ klass = Class.new(self) do
+ type parent.singular_type, null: true
+
+ def ready?(**args)
+ ready, early_return = super
+ [ready, select_result(early_return)]
+ end
+
+ def resolve(**args)
+ select_result(super)
+ end
+
+ def single?
+ true
+ end
+
+ def select_result(results)
+ results&.first
+ end
+
+ define_singleton_method :to_s do
+ "#{parent}.single"
+ end
end
- def select_result(results)
- results&.first
+ single_definition_blocks.each do |definition|
+ klass.instance_exec(&definition)
end
+
+ klass
end
end
def self.last
+ parent = self
@last ||= Class.new(self.single) do
+ type parent.singular_type, null: true
+
def select_result(results)
results&.last
end
+
+ define_singleton_method :to_s do
+ "#{parent}.last"
+ end
end
end
@@ -68,14 +117,13 @@ module Resolvers
end
end
+ # TODO: remove! This should never be necessary
+ # Remove as part of https://gitlab.com/gitlab-org/gitlab/-/issues/13984,
+ # since once we use that authorization approach, the object is guaranteed to
+ # be synchronized before any field.
def synchronized_object
strong_memoize(:synchronized_object) do
- case object
- when BatchLoader::GraphQL
- object.sync
- else
- object
- end
+ ::Gitlab::Graphql::Lazy.force(object)
end
end
diff --git a/app/graphql/resolvers/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb
index 3384b37e2ce..ef12dfa19ff 100644
--- a/app/graphql/resolvers/board_lists_resolver.rb
+++ b/app/graphql/resolvers/board_lists_resolver.rb
@@ -7,7 +7,7 @@ module Resolvers
type Types::BoardListType, null: true
- argument :id, GraphQL::ID_TYPE,
+ argument :id, Types::GlobalIDType[List],
required: false,
description: 'Find a list by its global ID'
diff --git a/app/graphql/resolvers/boards_resolver.rb b/app/graphql/resolvers/boards_resolver.rb
index 82efd92d33f..42b6ce03118 100644
--- a/app/graphql/resolvers/boards_resolver.rb
+++ b/app/graphql/resolvers/boards_resolver.rb
@@ -4,7 +4,7 @@ module Resolvers
class BoardsResolver < BaseResolver
type Types::BoardType, null: true
- argument :id, GraphQL::ID_TYPE,
+ argument :id, ::Types::GlobalIDType[::Board],
required: false,
description: 'Find a board by its ID'
@@ -23,10 +23,13 @@ module Resolvers
private
- def extract_board_id(gid)
- return unless gid.present?
+ def extract_board_id(id)
+ return unless id.present?
- GitlabSchema.parse_gid(gid, expected_type: ::Board).model_id
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = Types::GlobalIDType[Board].coerce_isolated_input(id)
+ id.model_id
end
end
end
diff --git a/app/graphql/resolvers/ci/jobs_resolver.rb b/app/graphql/resolvers/ci/jobs_resolver.rb
new file mode 100644
index 00000000000..8a9ae42b375
--- /dev/null
+++ b/app/graphql/resolvers/ci/jobs_resolver.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ci
+ class JobsResolver < BaseResolver
+ alias_method :pipeline, :object
+
+ argument :security_report_types, [Types::Security::ReportTypeEnum],
+ required: false,
+ description: 'Filter jobs by the type of security report they produce'
+
+ def resolve(security_report_types: [])
+ if security_report_types.present?
+ ::Security::SecurityJobsFinder.new(
+ pipeline: pipeline,
+ job_types: security_report_types
+ ).execute
+ else
+ pipeline.statuses
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/ci/runner_setup_resolver.rb b/app/graphql/resolvers/ci/runner_setup_resolver.rb
new file mode 100644
index 00000000000..241cd57f74b
--- /dev/null
+++ b/app/graphql/resolvers/ci/runner_setup_resolver.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ci
+ class RunnerSetupResolver < BaseResolver
+ type Types::Ci::RunnerSetupType, null: true
+
+ argument :platform, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Platform to generate the instructions for'
+
+ argument :architecture, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Architecture to generate the instructions for'
+
+ argument :project_id, ::Types::GlobalIDType[::Project],
+ required: false,
+ description: 'Project to register the runner for'
+
+ argument :group_id, ::Types::GlobalIDType[::Group],
+ required: false,
+ description: 'Group to register the runner for'
+
+ def resolve(platform:, architecture:, **args)
+ instructions = Gitlab::Ci::RunnerInstructions.new(
+ { current_user: current_user, os: platform, arch: architecture }.merge(target_param(args))
+ )
+
+ {
+ install_instructions: instructions.install_script || other_install_instructions(platform),
+ register_instructions: instructions.register_command
+ }
+ ensure
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'User is not authorized to register a runner for the specified resource!' if instructions.errors.include?('Gitlab::Access::AccessDeniedError')
+ end
+
+ private
+
+ def other_install_instructions(platform)
+ Gitlab::Ci::RunnerInstructions::OTHER_ENVIRONMENTS[platform.to_sym][:installation_instructions_url]
+ end
+
+ def target_param(args)
+ project_param(args[:project_id]) || group_param(args[:group_id]) || {}
+ end
+
+ def project_param(project_id)
+ return unless project_id
+
+ { project: find_object(project_id) }
+ end
+
+ def group_param(group_id)
+ return unless group_id
+
+ { group: find_object(group_id) }
+ end
+
+ def find_object(gid)
+ GlobalID::Locator.locate(gid)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/commit_pipelines_resolver.rb b/app/graphql/resolvers/commit_pipelines_resolver.rb
index 92a83523593..40af392200c 100644
--- a/app/graphql/resolvers/commit_pipelines_resolver.rb
+++ b/app/graphql/resolvers/commit_pipelines_resolver.rb
@@ -1,7 +1,9 @@
# frozen_string_literal: true
+# rubocop: disable Graphql/ResolverType
module Resolvers
class CommitPipelinesResolver < BaseResolver
+ # The GraphQL type here gets defined in this include
include ::ResolvesPipelines
alias_method :commit, :object
@@ -11,3 +13,4 @@ module Resolvers
end
end
end
+# rubocop: enable Graphql/ResolverType
diff --git a/app/graphql/resolvers/concerns/caching_array_resolver.rb b/app/graphql/resolvers/concerns/caching_array_resolver.rb
new file mode 100644
index 00000000000..4f2c8b98928
--- /dev/null
+++ b/app/graphql/resolvers/concerns/caching_array_resolver.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+# Concern that will eliminate N+1 queries for size-constrained
+# collections of items.
+#
+# **note**: The resolver will never load more items than
+# `@field.max_page_size` if defined, falling back to
+# `context.schema.default_max_page_size`.
+#
+# provided that:
+#
+# - the query can be uniquely determined by the object and the arguments
+# - the model class includes FromUnion
+# - the model class defines a scalar primary key
+#
+# This comes at the cost of returning arrays, not relations, so we don't get
+# any keyset pagination goodness. Consequently, this is only suitable for small-ish
+# result sets, as the full result set will be loaded into memory.
+#
+# To enforce this, the resolver limits the size of result sets to
+# `@field.max_page_size || context.schema.default_max_page_size`.
+#
+# **important**: If the cardinality of your collection is likely to be greater than 100,
+# then you will want to pass `max_page_size:` as part of the field definition
+# or (ideally) as part of the resolver `field_options`.
+#
+# How to implement:
+# --------------------
+#
+# Each including class operates on two generic parameters, A and R:
+# - A is any Object that can be used as a Hash key. Instances of A
+# are returned by `query_input` and then passed to `query_for`.
+# - R is any subclass of ApplicationRecord that includes FromUnion.
+# R must have a single scalar primary_key
+#
+# Classes must implement:
+# - #model_class -> Class[R]. (Must respond to :primary_key, and :from_union)
+# - #query_input(**kwargs) -> A (Must be hashable)
+# - #query_for(A) -> ActiveRecord::Relation[R]
+#
+# Note the relationship between query_input and query_for, one of which
+# consumes the input of the other
+# (i.e. `resolve(**args).sync == query_for(query_input(**args)).to_a`).
+#
+# Classes may implement:
+# - #item_found(A, R) (return value is ignored)
+# - max_union_size Integer (the maximum number of queries to run in any one union)
+module CachingArrayResolver
+ MAX_UNION_SIZE = 50
+
+ def resolve(**args)
+ key = query_input(**args)
+
+ BatchLoader::GraphQL.for(key).batch(**batch) do |keys, loader|
+ if keys.size == 1
+ # We can avoid the union entirely.
+ k = keys.first
+ limit(query_for(k)).each { |item| found(loader, k, item) }
+ else
+ queries = keys.map { |key| query_for(key) }
+
+ queries.in_groups_of(max_union_size, false).each do |group|
+ by_id = model_class
+ .from_union(tag(group), remove_duplicates: false)
+ .group_by { |r| r[primary_key] }
+
+ by_id.values.each do |item_group|
+ item = item_group.first
+ item_group.map(&:union_member_idx).each do |i|
+ found(loader, keys[i], item)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ # Override this to intercept the items once they are found
+ def item_found(query_input, item)
+ end
+
+ def max_union_size
+ MAX_UNION_SIZE
+ end
+
+ private
+
+ def primary_key
+ @primary_key ||= (model_class.primary_key || raise("No primary key for #{model_class}"))
+ end
+
+ def batch
+ { key: self.class, default_value: [] }
+ end
+
+ def found(loader, key, value)
+ loader.call(key) do |vs|
+ item_found(key, value)
+ vs << value
+ end
+ end
+
+ # Tag each row returned from each query with a the index of which query in
+ # the union it comes from. This lets us map the results back to the cache key.
+ def tag(queries)
+ queries.each_with_index.map do |q, i|
+ limit(q.select(all_fields, member_idx(i)))
+ end
+ end
+
+ def limit(query)
+ query.limit(query_limit) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def all_fields
+ model_class.arel_table[Arel.star]
+ end
+
+ # rubocop: disable Graphql/Descriptions (false positive!)
+ def query_limit
+ field&.max_page_size.presence || context.schema.default_max_page_size
+ end
+ # rubocop: enable Graphql/Descriptions
+
+ def member_idx(idx)
+ ::Arel::Nodes::SqlLiteral.new(idx.to_s).as('union_member_idx')
+ end
+end
diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
index fe6fa0bb262..4715b867ecb 100644
--- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
+++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
@@ -29,7 +29,7 @@ module IssueResolverArguments
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'
+ description: 'ID of a user assigned to the issues, "none" and "any" values are supported'
argument :created_before, Types::TimeType,
required: false,
description: 'Issues created before this date'
diff --git a/app/graphql/resolvers/concerns/looks_ahead.rb b/app/graphql/resolvers/concerns/looks_ahead.rb
index 61f23920ebb..d468047b539 100644
--- a/app/graphql/resolvers/concerns/looks_ahead.rb
+++ b/app/graphql/resolvers/concerns/looks_ahead.rb
@@ -4,6 +4,7 @@ module LooksAhead
extend ActiveSupport::Concern
included do
+ extras [:lookahead]
attr_accessor :lookahead
end
diff --git a/app/graphql/resolvers/concerns/resolves_pipelines.rb b/app/graphql/resolvers/concerns/resolves_pipelines.rb
index 46d9e174deb..f061f5f1606 100644
--- a/app/graphql/resolvers/concerns/resolves_pipelines.rb
+++ b/app/graphql/resolvers/concerns/resolves_pipelines.rb
@@ -4,7 +4,7 @@ module ResolvesPipelines
extend ActiveSupport::Concern
included do
- type [Types::Ci::PipelineType], null: false
+ type Types::Ci::PipelineType.connection_type, null: false
argument :status,
Types::Ci::PipelineStatusEnum,
required: false,
diff --git a/app/graphql/resolvers/concerns/resolves_project.rb b/app/graphql/resolvers/concerns/resolves_project.rb
index 3c5ce3dab01..b2ee7d7e850 100644
--- a/app/graphql/resolvers/concerns/resolves_project.rb
+++ b/app/graphql/resolvers/concerns/resolves_project.rb
@@ -1,6 +1,9 @@
# frozen_string_literal: true
module ResolvesProject
+ # Accepts EITHER one of
+ # - full_path: String (see Project#full_path)
+ # - project_id: GlobalID. Arguments should be typed as: `::Types::GlobalIDType[Project]`
def resolve_project(full_path: nil, project_id: nil)
unless full_path.present? ^ project_id.present?
raise ::Gitlab::Graphql::Errors::ArgumentError, 'Incompatible arguments: projectId, projectPath.'
diff --git a/app/graphql/resolvers/concerns/resolves_snippets.rb b/app/graphql/resolvers/concerns/resolves_snippets.rb
index 483372bbf63..790ff4f774f 100644
--- a/app/graphql/resolvers/concerns/resolves_snippets.rb
+++ b/app/graphql/resolvers/concerns/resolves_snippets.rb
@@ -4,9 +4,9 @@ module ResolvesSnippets
extend ActiveSupport::Concern
included do
- type Types::SnippetType, null: false
+ type Types::SnippetType.connection_type, null: false
- argument :ids, [GraphQL::ID_TYPE],
+ argument :ids, [::Types::GlobalIDType[::Snippet]],
required: false,
description: 'Array of global snippet ids, e.g., "gid://gitlab/ProjectSnippet/1"'
@@ -32,16 +32,15 @@ module ResolvesSnippets
}.merge(options_by_type(args[:type]))
end
- def resolve_ids(ids)
- Array.wrap(ids).map { |id| resolve_gid(id, :id) }
- end
-
- def resolve_gid(gid, argument)
- return unless gid.present?
+ def resolve_ids(ids, type = ::Types::GlobalIDType[::Snippet])
+ Array.wrap(ids).map do |id|
+ next unless id.present?
- GlobalID.parse(gid)&.model_id.tap do |id|
- raise Gitlab::Graphql::Errors::ArgumentError, "Invalid global id format for param #{argument}" if id.nil?
- end
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = type.coerce_isolated_input(id)
+ id.model_id
+ end.compact
end
def options_by_type(type)
diff --git a/app/graphql/resolvers/container_repositories_resolver.rb b/app/graphql/resolvers/container_repositories_resolver.rb
new file mode 100644
index 00000000000..b4b2893a3b8
--- /dev/null
+++ b/app/graphql/resolvers/container_repositories_resolver.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class ContainerRepositoriesResolver < BaseResolver
+ include ::Mutations::PackageEventable
+
+ type Types::ContainerRepositoryType, null: true
+
+ argument :name, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Filter the container repositories by their name'
+
+ def resolve(name: nil)
+ ContainerRepositoriesFinder.new(user: current_user, subject: object, params: { name: name })
+ .execute
+ .tap { track_event(:list_repositories, :container) }
+ end
+ end
+end
diff --git a/app/graphql/resolvers/design_management/design_at_version_resolver.rb b/app/graphql/resolvers/design_management/design_at_version_resolver.rb
index fd9b349f974..1b69efebe4e 100644
--- a/app/graphql/resolvers/design_management/design_at_version_resolver.rb
+++ b/app/graphql/resolvers/design_management/design_at_version_resolver.rb
@@ -9,7 +9,7 @@ module Resolvers
authorize :read_design
- argument :id, GraphQL::ID_TYPE,
+ argument :id, ::Types::GlobalIDType[::DesignManagement::DesignAtVersion],
required: true,
description: 'The Global ID of the design at this version'
@@ -18,7 +18,10 @@ module Resolvers
end
def find_object(id:)
- dav = GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::DesignAtVersion)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::DesignManagement::DesignAtVersion].coerce_isolated_input(id)
+ dav = GitlabSchema.find_by_gid(id)
return unless consistent?(dav)
dav
@@ -35,7 +38,7 @@ module Resolvers
# that the DesignAtVersion as found by its ID does in fact belong
# to this issue.
def consistent?(dav)
- issue.nil? || (dav&.design&.issue_id == issue.id)
+ issue.nil? || (dav.present? && dav.design&.issue_id == issue.id)
end
def issue
diff --git a/app/graphql/resolvers/design_management/design_resolver.rb b/app/graphql/resolvers/design_management/design_resolver.rb
index 05bdbbbe407..e0a68bae397 100644
--- a/app/graphql/resolvers/design_management/design_resolver.rb
+++ b/app/graphql/resolvers/design_management/design_resolver.rb
@@ -3,7 +3,9 @@
module Resolvers
module DesignManagement
class DesignResolver < BaseResolver
- argument :id, GraphQL::ID_TYPE,
+ type ::Types::DesignManagement::DesignType, null: true
+
+ argument :id, ::Types::GlobalIDType[::DesignManagement::Design],
required: false,
description: 'Find a design by its ID'
@@ -50,7 +52,11 @@ module Resolvers
end
def parse_gid(gid)
- GitlabSchema.parse_gid(gid, expected_type: ::DesignManagement::Design).model_id
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ gid = ::Types::GlobalIDType[::DesignManagement::Design].coerce_isolated_input(gid)
+
+ gid.model_id
end
end
end
diff --git a/app/graphql/resolvers/design_management/designs_resolver.rb b/app/graphql/resolvers/design_management/designs_resolver.rb
index 955ea6304e0..c588142ea6b 100644
--- a/app/graphql/resolvers/design_management/designs_resolver.rb
+++ b/app/graphql/resolvers/design_management/designs_resolver.rb
@@ -3,16 +3,18 @@
module Resolvers
module DesignManagement
class DesignsResolver < BaseResolver
- argument :ids,
- [GraphQL::ID_TYPE],
+ DesignID = ::Types::GlobalIDType[::DesignManagement::Design]
+ VersionID = ::Types::GlobalIDType[::DesignManagement::Version]
+
+ type ::Types::DesignManagement::DesignType.connection_type, null: true
+
+ argument :ids, [DesignID],
required: false,
description: 'Filters designs by their ID'
- argument :filenames,
- [GraphQL::STRING_TYPE],
+ argument :filenames, [GraphQL::STRING_TYPE],
required: false,
description: 'Filters designs by their filename'
- argument :at_version,
- GraphQL::ID_TYPE,
+ argument :at_version, VersionID,
required: false,
description: 'Filters designs to only those that existed at the version. ' \
'If argument is omitted or nil then all designs will reflect the latest version'
@@ -36,11 +38,20 @@ module Resolvers
def version(at_version)
return unless at_version
- GitlabSchema.object_from_id(at_version, expected_type: ::DesignManagement::Version)&.sync
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ at_version = VersionID.coerce_isolated_input(at_version)
+ # TODO: when we get promises use this to make resolve lazy
+ Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(at_version))
end
- def design_ids(ids)
- ids&.map { |id| GlobalID.parse(id, expected_type: ::DesignManagement::Design).model_id }
+ def design_ids(gids)
+ return if gids.nil?
+
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ gids = gids.map { |id| DesignID.coerce_isolated_input(id) }
+ gids.map(&:model_id)
end
def issue
diff --git a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb
index 03f7908780c..70021057f71 100644
--- a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb
+++ b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb
@@ -5,17 +5,20 @@ module Resolvers
module Version
# Resolver for a DesignAtVersion object given an implicit version context
class DesignAtVersionResolver < BaseResolver
+ DesignAtVersionID = ::Types::GlobalIDType[::DesignManagement::DesignAtVersion]
+ DesignID = ::Types::GlobalIDType[::DesignManagement::Design]
+
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::DesignManagement::DesignAtVersionType, null: true
authorize :read_design
- argument :id, GraphQL::ID_TYPE,
+ argument :id, DesignAtVersionID,
required: false,
as: :design_at_version_id,
description: 'The ID of the DesignAtVersion'
- argument :design_id, GraphQL::ID_TYPE,
+ argument :design_id, DesignID,
required: false,
description: 'The ID of a specific design'
argument :filename, GraphQL::STRING_TYPE,
@@ -29,6 +32,11 @@ module Resolvers
def resolve(design_id: nil, filename: nil, design_at_version_id: nil)
validate_arguments(design_id, filename, design_at_version_id)
+ # TODO: remove this when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ design_id &&= DesignID.coerce_isolated_input(design_id)
+ design_at_version_id &&= DesignAtVersionID.coerce_isolated_input(design_at_version_id)
+
return unless Ability.allowed?(current_user, :read_design, issue)
return specific_design_at_version(design_at_version_id) if design_at_version_id
@@ -49,7 +57,7 @@ module Resolvers
end
def specific_design_at_version(id)
- dav = GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::DesignAtVersion)
+ dav = GitlabSchema.find_by_gid(id)
return unless consistent?(dav)
dav
@@ -65,8 +73,8 @@ module Resolvers
dav.design.visible_in?(version)
end
- def find(id, filename)
- ids = [parse_design_id(id).model_id] if id
+ def find(gid, filename)
+ ids = [gid.model_id] if gid
filenames = [filename] if filename
::DesignManagement::DesignsFinder
@@ -74,10 +82,6 @@ module Resolvers
.execute
end
- def parse_design_id(id)
- GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Design)
- end
-
def issue
version.issue
end
diff --git a/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb b/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb
index 5ccb2f3e311..a129d8620d4 100644
--- a/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb
+++ b/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb
@@ -11,8 +11,9 @@ module Resolvers
authorize :read_design
- argument :ids,
- [GraphQL::ID_TYPE],
+ DesignID = ::Types::GlobalIDType[::DesignManagement::Design]
+
+ argument :ids, [DesignID],
required: false,
description: 'Filters designs by their ID'
argument :filenames,
@@ -31,16 +32,19 @@ module Resolvers
private
def find(ids, filenames)
- ids = ids&.map { |id| parse_design_id(id).model_id }
-
::DesignManagement::DesignsFinder.new(issue, current_user,
- ids: ids,
+ ids: design_ids(ids),
filenames: filenames,
visible_at_version: version)
end
- def parse_design_id(id)
- GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Design)
+ def design_ids(gids)
+ return if gids.nil?
+
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ gids = gids.map { |id| DesignID.coerce_isolated_input(id) }
+ gids.map(&:model_id)
end
def issue
diff --git a/app/graphql/resolvers/design_management/version_in_collection_resolver.rb b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb
index 9e729172881..ecd7ab3ee45 100644
--- a/app/graphql/resolvers/design_management/version_in_collection_resolver.rb
+++ b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb
@@ -11,20 +11,25 @@ module Resolvers
alias_method :collection, :object
+ VersionID = ::Types::GlobalIDType[::DesignManagement::Version]
+
argument :sha, GraphQL::STRING_TYPE,
required: false,
description: "The SHA256 of a specific version"
- argument :id, GraphQL::ID_TYPE,
+ argument :id, VersionID,
+ as: :version_id,
required: false,
description: 'The Global ID of the version'
- def resolve(id: nil, sha: nil)
- check_args(id, sha)
+ def resolve(version_id: nil, sha: nil)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ version_id &&= VersionID.coerce_isolated_input(version_id)
- gid = GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Version) if id
+ check_args(version_id, sha)
::DesignManagement::VersionsFinder
- .new(collection, current_user, sha: sha, version_id: gid&.model_id)
+ .new(collection, current_user, sha: sha, version_id: version_id&.model_id)
.execute
.first
end
diff --git a/app/graphql/resolvers/design_management/version_resolver.rb b/app/graphql/resolvers/design_management/version_resolver.rb
index b0e0843e6c8..1bc9c1a7cd6 100644
--- a/app/graphql/resolvers/design_management/version_resolver.rb
+++ b/app/graphql/resolvers/design_management/version_resolver.rb
@@ -9,7 +9,7 @@ module Resolvers
authorize :read_design
- argument :id, GraphQL::ID_TYPE,
+ argument :id, ::Types::GlobalIDType[::DesignManagement::Version],
required: true,
description: 'The Global ID of the version'
@@ -18,7 +18,11 @@ module Resolvers
end
def find_object(id:)
- GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::Version)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::DesignManagement::Version].coerce_isolated_input(id)
+
+ GitlabSchema.find_by_gid(id)
end
end
end
diff --git a/app/graphql/resolvers/design_management/versions_resolver.rb b/app/graphql/resolvers/design_management/versions_resolver.rb
index a62258dad5c..23858c8e991 100644
--- a/app/graphql/resolvers/design_management/versions_resolver.rb
+++ b/app/graphql/resolvers/design_management/versions_resolver.rb
@@ -7,12 +7,14 @@ module Resolvers
alias_method :design_or_collection, :object
+ VersionID = ::Types::GlobalIDType[::DesignManagement::Version]
+
argument :earlier_or_equal_to_sha, GraphQL::STRING_TYPE,
as: :sha,
required: false,
description: 'The SHA256 of the most recent acceptable version'
- argument :earlier_or_equal_to_id, GraphQL::ID_TYPE,
+ argument :earlier_or_equal_to_id, VersionID,
as: :id,
required: false,
description: 'The Global ID of the most recent acceptable version'
@@ -23,6 +25,9 @@ module Resolvers
end
def resolve(parent: nil, id: nil, sha: nil)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id &&= VersionID.coerce_isolated_input(id)
version = cutoff(parent, id, sha)
raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, 'cutoff not found' unless version.present?
@@ -47,8 +52,7 @@ module Resolvers
end
end
- def specific_version(id, sha)
- gid = GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Version) if id
+ def specific_version(gid, sha)
find(sha: sha, version_id: gid&.model_id).first
end
@@ -58,8 +62,8 @@ module Resolvers
.execute
end
- def by_id(id)
- GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::Version).sync
+ def by_id(gid)
+ ::Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(gid))
end
# Find an `at_version` argument passed to a parent node.
@@ -69,7 +73,11 @@ module Resolvers
# for consistency we should only present versions up to the given
# version here.
def at_version_arg(parent)
- ::Gitlab::Graphql::FindArgumentInParent.find(parent, :at_version, limit_depth: 4)
+ # TODO: remove coercion when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ version_id = ::Gitlab::Graphql::FindArgumentInParent.find(parent, :at_version, limit_depth: 4)
+ version_id &&= VersionID.coerce_isolated_input(version_id)
+ version_id
end
end
end
diff --git a/app/graphql/resolvers/echo_resolver.rb b/app/graphql/resolvers/echo_resolver.rb
index fe0b1893a23..6b85b700712 100644
--- a/app/graphql/resolvers/echo_resolver.rb
+++ b/app/graphql/resolvers/echo_resolver.rb
@@ -2,15 +2,16 @@
module Resolvers
class EchoResolver < BaseResolver
+ type ::GraphQL::STRING_TYPE, null: false
description 'Testing endpoint to validate the API with'
argument :text, GraphQL::STRING_TYPE, required: true,
description: 'Text to echo back'
- def resolve(**args)
- username = context[:current_user]&.username
+ def resolve(text:)
+ username = current_user&.username
- "#{username.inspect} says: #{args[:text]}"
+ "#{username.inspect} says: #{text}"
end
end
end
diff --git a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb
index 5027403e95c..09e76dba645 100644
--- a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb
+++ b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb
@@ -3,19 +3,22 @@
module Resolvers
module ErrorTracking
class SentryDetailedErrorResolver < BaseResolver
- argument :id, GraphQL::ID_TYPE,
+ type Types::ErrorTracking::SentryDetailedErrorType, null: true
+
+ argument :id, ::Types::GlobalIDType[::Gitlab::ErrorTracking::DetailedError],
required: true,
description: 'ID of the Sentry issue'
- def resolve(**args)
- current_user = context[:current_user]
- issue_id = GlobalID.parse(args[:id])&.model_id
+ def resolve(id:)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::Gitlab::ErrorTracking::DetailedError].coerce_isolated_input(id)
# Get data from Sentry
response = ::ErrorTracking::IssueDetailsService.new(
project,
current_user,
- { issue_id: issue_id }
+ { issue_id: id.model_id }
).execute
issue = response[:issue]
issue.gitlab_project = project if issue
diff --git a/app/graphql/resolvers/error_tracking/sentry_error_collection_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_error_collection_resolver.rb
index e4b4854c273..d47cc2bae56 100644
--- a/app/graphql/resolvers/error_tracking/sentry_error_collection_resolver.rb
+++ b/app/graphql/resolvers/error_tracking/sentry_error_collection_resolver.rb
@@ -3,6 +3,8 @@
module Resolvers
module ErrorTracking
class SentryErrorCollectionResolver < BaseResolver
+ type Types::ErrorTracking::SentryErrorCollectionType, null: true
+
def resolve(**args)
project = object
diff --git a/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb
index c365baaf475..669b487db10 100644
--- a/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb
+++ b/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb
@@ -3,18 +3,20 @@
module Resolvers
module ErrorTracking
class SentryErrorStackTraceResolver < BaseResolver
- argument :id, GraphQL::ID_TYPE,
+ argument :id, ::Types::GlobalIDType[::Gitlab::ErrorTracking::DetailedError],
required: true,
description: 'ID of the Sentry issue'
- def resolve(**args)
- issue_id = GlobalID.parse(args[:id])&.model_id
+ def resolve(id:)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::Gitlab::ErrorTracking::DetailedError].coerce_isolated_input(id)
# Get data from Sentry
response = ::ErrorTracking::IssueLatestEventService.new(
project,
current_user,
- { issue_id: issue_id }
+ { issue_id: id.model_id }
).execute
event = response[:latest_event]
diff --git a/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb
index 79f99709505..c5cf924ce7f 100644
--- a/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb
+++ b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb
@@ -3,6 +3,8 @@
module Resolvers
module ErrorTracking
class SentryErrorsResolver < BaseResolver
+ type Types::ErrorTracking::SentryErrorType.connection_type, null: true
+
def resolve(**args)
args[:cursor] = args.delete(:after)
project = object.project
diff --git a/app/graphql/resolvers/group_issues_resolver.rb b/app/graphql/resolvers/group_issues_resolver.rb
index 1fa6c78e730..1db0ab08e31 100644
--- a/app/graphql/resolvers/group_issues_resolver.rb
+++ b/app/graphql/resolvers/group_issues_resolver.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+# rubocop:disable Graphql/ResolverType (inherited from IssuesResolver)
module Resolvers
class GroupIssuesResolver < IssuesResolver
diff --git a/app/graphql/resolvers/group_members_resolver.rb b/app/graphql/resolvers/group_members_resolver.rb
index f34c873a8a9..d3aa376c29c 100644
--- a/app/graphql/resolvers/group_members_resolver.rb
+++ b/app/graphql/resolvers/group_members_resolver.rb
@@ -2,6 +2,8 @@
module Resolvers
class GroupMembersResolver < MembersResolver
+ type Types::GroupMemberType.connection_type, null: true
+
authorize :read_group_member
private
diff --git a/app/graphql/resolvers/group_merge_requests_resolver.rb b/app/graphql/resolvers/group_merge_requests_resolver.rb
index 5ee72e3f781..2bad974daf7 100644
--- a/app/graphql/resolvers/group_merge_requests_resolver.rb
+++ b/app/graphql/resolvers/group_merge_requests_resolver.rb
@@ -6,6 +6,8 @@ module Resolvers
alias_method :group, :synchronized_object
+ type Types::MergeRequestType.connection_type, null: true
+
include_subgroups 'merge requests'
accept_assignee
accept_author
diff --git a/app/graphql/resolvers/group_milestones_resolver.rb b/app/graphql/resolvers/group_milestones_resolver.rb
index 8d34cea4fa1..83b82e2720b 100644
--- a/app/graphql/resolvers/group_milestones_resolver.rb
+++ b/app/graphql/resolvers/group_milestones_resolver.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+# rubocop:disable Graphql/ResolverType (inherited from MilestonesResolver)
module Resolvers
class GroupMilestonesResolver < MilestonesResolver
@@ -6,6 +7,8 @@ module Resolvers
required: false,
description: 'Also return milestones in all subgroups and subprojects'
+ type Types::MilestoneType.connection_type, null: true
+
private
def parent_id_parameters(args)
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index 396ae02ae13..dd35219454f 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -12,7 +12,7 @@ module Resolvers
required: false,
default_value: 'created_desc'
- type Types::IssueType, null: true
+ type Types::IssueType.connection_type, null: true
NON_STABLE_CURSOR_SORTS = %i[priority_asc priority_desc
label_priority_asc label_priority_desc
diff --git a/app/graphql/resolvers/members_resolver.rb b/app/graphql/resolvers/members_resolver.rb
index 88a1ab71c45..523642e912f 100644
--- a/app/graphql/resolvers/members_resolver.rb
+++ b/app/graphql/resolvers/members_resolver.rb
@@ -5,6 +5,8 @@ module Resolvers
include Gitlab::Graphql::Authorize::AuthorizeResource
include LooksAhead
+ type Types::MemberInterface.connection_type, null: true
+
argument :search, GraphQL::STRING_TYPE,
required: false,
description: 'Search query'
diff --git a/app/graphql/resolvers/merge_request_pipelines_resolver.rb b/app/graphql/resolvers/merge_request_pipelines_resolver.rb
index b95e46d9cff..6590dfdc78c 100644
--- a/app/graphql/resolvers/merge_request_pipelines_resolver.rb
+++ b/app/graphql/resolvers/merge_request_pipelines_resolver.rb
@@ -1,7 +1,9 @@
# frozen_string_literal: true
+# rubocop: disable Graphql/ResolverType
module Resolvers
class MergeRequestPipelinesResolver < BaseResolver
+ # The GraphQL type here gets defined in this include
include ::ResolvesPipelines
alias_method :merge_request, :object
@@ -18,3 +20,4 @@ module Resolvers
end
end
end
+# rubocop: enable Graphql/ResolverType
diff --git a/app/graphql/resolvers/merge_request_resolver.rb b/app/graphql/resolvers/merge_request_resolver.rb
index a47a128ea32..4cad65fa697 100644
--- a/app/graphql/resolvers/merge_request_resolver.rb
+++ b/app/graphql/resolvers/merge_request_resolver.rb
@@ -6,6 +6,8 @@ module Resolvers
alias_method :project, :synchronized_object
+ type ::Types::MergeRequestType, null: true
+
argument :iid, GraphQL::STRING_TYPE,
required: true,
as: :iids,
diff --git a/app/graphql/resolvers/metadata_resolver.rb b/app/graphql/resolvers/metadata_resolver.rb
index 3a79e6434fb..26bfa81038c 100644
--- a/app/graphql/resolvers/metadata_resolver.rb
+++ b/app/graphql/resolvers/metadata_resolver.rb
@@ -5,7 +5,7 @@ module Resolvers
type Types::MetadataType, null: false
def resolve(**args)
- { version: Gitlab::VERSION, revision: Gitlab.revision }
+ ::InstanceMetadata.new
end
end
end
diff --git a/app/graphql/resolvers/milestones_resolver.rb b/app/graphql/resolvers/milestones_resolver.rb
index 84712b674db..564e388d571 100644
--- a/app/graphql/resolvers/milestones_resolver.rb
+++ b/app/graphql/resolvers/milestones_resolver.rb
@@ -25,7 +25,7 @@ module Resolvers
required: false,
description: 'A date that the milestone contains'
- type Types::MilestoneType, null: true
+ type Types::MilestoneType.connection_type, null: true
def resolve(**args)
validate_timeframe_params!(args)
diff --git a/app/graphql/resolvers/namespace_projects_resolver.rb b/app/graphql/resolvers/namespace_projects_resolver.rb
index c221cb9aed6..9f57c8f3405 100644
--- a/app/graphql/resolvers/namespace_projects_resolver.rb
+++ b/app/graphql/resolvers/namespace_projects_resolver.rb
@@ -23,7 +23,6 @@ module Resolvers
# The namespace could have been loaded in batch by `BatchLoader`.
# At this point we need the `id` or the `full_path` of the namespace
# to query for projects, so make sure it's loaded and not `nil` before continuing.
- namespace = object.respond_to?(:sync) ? object.sync : object
return Project.none if namespace.nil?
query = include_subgroups ? namespace.all_projects.with_route : namespace.projects.with_route
@@ -41,6 +40,14 @@ module Resolvers
complexity = super
complexity + 10
end
+
+ private
+
+ def namespace
+ strong_memoize(:namespace) do
+ object.respond_to?(:sync) ? object.sync : object
+ end
+ end
end
end
diff --git a/app/graphql/resolvers/project_members_resolver.rb b/app/graphql/resolvers/project_members_resolver.rb
index 1ca4e81f397..e64e8b845a5 100644
--- a/app/graphql/resolvers/project_members_resolver.rb
+++ b/app/graphql/resolvers/project_members_resolver.rb
@@ -1,9 +1,8 @@
# frozen_string_literal: true
+# rubocop:disable Graphql/ResolverType (inherited from MembersResolver)
module Resolvers
class ProjectMembersResolver < MembersResolver
- type Types::MemberInterface, null: true
-
authorize :read_project_member
private
diff --git a/app/graphql/resolvers/project_merge_requests_resolver.rb b/app/graphql/resolvers/project_merge_requests_resolver.rb
index ba13cb6e52c..bf082c0b182 100644
--- a/app/graphql/resolvers/project_merge_requests_resolver.rb
+++ b/app/graphql/resolvers/project_merge_requests_resolver.rb
@@ -2,6 +2,7 @@
module Resolvers
class ProjectMergeRequestsResolver < MergeRequestsResolver
+ type ::Types::MergeRequestType.connection_type, null: true
accept_assignee
accept_author
end
diff --git a/app/graphql/resolvers/project_milestones_resolver.rb b/app/graphql/resolvers/project_milestones_resolver.rb
index 976fc300b87..c88c9ce7219 100644
--- a/app/graphql/resolvers/project_milestones_resolver.rb
+++ b/app/graphql/resolvers/project_milestones_resolver.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+# rubocop:disable Graphql/ResolverType (inherited from MilestonesResolver)
module Resolvers
class ProjectMilestonesResolver < MilestonesResolver
@@ -6,6 +7,8 @@ module Resolvers
required: false,
description: "Also return milestones in the project's parent group and its ancestors"
+ type Types::MilestoneType.connection_type, null: true
+
private
def parent_id_parameters(args)
diff --git a/app/graphql/resolvers/project_pipeline_resolver.rb b/app/graphql/resolvers/project_pipeline_resolver.rb
index 181c1e77109..4cf47dbdc60 100644
--- a/app/graphql/resolvers/project_pipeline_resolver.rb
+++ b/app/graphql/resolvers/project_pipeline_resolver.rb
@@ -2,6 +2,8 @@
module Resolvers
class ProjectPipelineResolver < BaseResolver
+ type ::Types::Ci::PipelineType, null: true
+
alias_method :project, :object
argument :iid, GraphQL::ID_TYPE,
diff --git a/app/graphql/resolvers/project_pipelines_resolver.rb b/app/graphql/resolvers/project_pipelines_resolver.rb
index 86094c46c2a..0171473a77f 100644
--- a/app/graphql/resolvers/project_pipelines_resolver.rb
+++ b/app/graphql/resolvers/project_pipelines_resolver.rb
@@ -1,13 +1,28 @@
# frozen_string_literal: true
+# The GraphQL type here gets defined in
+# https://gitlab.com/gitlab-org/gitlab/blob/master/app/graphql/resolvers/concerns/resolves_pipelines.rb#L7
+# rubocop: disable Graphql/ResolverType
module Resolvers
class ProjectPipelinesResolver < BaseResolver
+ include LooksAhead
include ResolvesPipelines
alias_method :project, :object
- def resolve(**args)
- resolve_pipelines(project, args)
+ def resolve_with_lookahead(**args)
+ apply_lookahead(resolve_pipelines(project, args))
+ end
+
+ private
+
+ def preloads
+ {
+ jobs: [:statuses],
+ upstream: [:triggered_by_pipeline],
+ downstream: [:triggered_pipelines]
+ }
end
end
end
+# rubocop: enable Graphql/ResolverType
diff --git a/app/graphql/resolvers/projects/jira_imports_resolver.rb b/app/graphql/resolvers/projects/jira_imports_resolver.rb
index aa9b7139f38..efd45c2c465 100644
--- a/app/graphql/resolvers/projects/jira_imports_resolver.rb
+++ b/app/graphql/resolvers/projects/jira_imports_resolver.rb
@@ -3,6 +3,8 @@
module Resolvers
module Projects
class JiraImportsResolver < BaseResolver
+ type Types::JiraImportType.connection_type, null: true
+
include Gitlab::Graphql::Authorize::AuthorizeResource
alias_method :project, :object
diff --git a/app/graphql/resolvers/projects/jira_projects_resolver.rb b/app/graphql/resolvers/projects/jira_projects_resolver.rb
index d017f973e17..31f42d305b0 100644
--- a/app/graphql/resolvers/projects/jira_projects_resolver.rb
+++ b/app/graphql/resolvers/projects/jira_projects_resolver.rb
@@ -5,6 +5,8 @@ module Resolvers
class JiraProjectsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
+ type Types::Projects::Services::JiraProjectType.connection_type, null: true
+
argument :name,
GraphQL::STRING_TYPE,
required: false,
diff --git a/app/graphql/resolvers/projects/services_resolver.rb b/app/graphql/resolvers/projects/services_resolver.rb
index 40c64c24513..17d81e21c28 100644
--- a/app/graphql/resolvers/projects/services_resolver.rb
+++ b/app/graphql/resolvers/projects/services_resolver.rb
@@ -5,6 +5,8 @@ module Resolvers
class ServicesResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
+ type Types::Projects::ServiceType.connection_type, null: true
+
argument :active,
GraphQL::BOOLEAN_TYPE,
required: false,
diff --git a/app/graphql/resolvers/projects/snippets_resolver.rb b/app/graphql/resolvers/projects/snippets_resolver.rb
index 22895a24054..448918be2f5 100644
--- a/app/graphql/resolvers/projects/snippets_resolver.rb
+++ b/app/graphql/resolvers/projects/snippets_resolver.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+# rubocop:disable Graphql/ResolverType (inherited from ResolvesSnippets)
module Resolvers
module Projects
diff --git a/app/graphql/resolvers/releases_resolver.rb b/app/graphql/resolvers/releases_resolver.rb
index 85892c2abeb..8e8127cf279 100644
--- a/app/graphql/resolvers/releases_resolver.rb
+++ b/app/graphql/resolvers/releases_resolver.rb
@@ -4,6 +4,10 @@ module Resolvers
class ReleasesResolver < BaseResolver
type Types::ReleaseType.connection_type, null: true
+ argument :sort, Types::ReleaseSortEnum,
+ required: false, default_value: :released_at_desc,
+ description: 'Sort releases by this criteria'
+
alias_method :project, :object
# This resolver has a custom singular resolver
@@ -11,12 +15,20 @@ module Resolvers
Resolvers::ReleaseResolver
end
- def resolve(**args)
+ SORT_TO_PARAMS_MAP = {
+ released_at_desc: { order_by: 'released_at', sort: 'desc' },
+ released_at_asc: { order_by: 'released_at', sort: 'asc' },
+ created_desc: { order_by: 'created_at', sort: 'desc' },
+ created_asc: { order_by: 'created_at', sort: 'asc' }
+ }.freeze
+
+ def resolve(sort:)
return unless Feature.enabled?(:graphql_release_data, project, default_enabled: true)
ReleasesFinder.new(
project,
- current_user
+ current_user,
+ SORT_TO_PARAMS_MAP[sort]
).execute
end
end
diff --git a/app/graphql/resolvers/snippets/blobs_resolver.rb b/app/graphql/resolvers/snippets/blobs_resolver.rb
index dc28358cab6..3a0dcb50faf 100644
--- a/app/graphql/resolvers/snippets/blobs_resolver.rb
+++ b/app/graphql/resolvers/snippets/blobs_resolver.rb
@@ -5,6 +5,8 @@ module Resolvers
class BlobsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
+ type Types::Snippets::BlobType.connection_type, null: true
+
alias_method :snippet, :object
argument :paths, [GraphQL::STRING_TYPE],
diff --git a/app/graphql/resolvers/snippets_resolver.rb b/app/graphql/resolvers/snippets_resolver.rb
index 530a288a25b..77099565df0 100644
--- a/app/graphql/resolvers/snippets_resolver.rb
+++ b/app/graphql/resolvers/snippets_resolver.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+# rubocop:disable Graphql/ResolverType (inherited from ResolvesSnippets)
module Resolvers
class SnippetsResolver < BaseResolver
@@ -8,11 +9,11 @@ module Resolvers
alias_method :user, :object
- argument :author_id, GraphQL::ID_TYPE,
+ argument :author_id, ::Types::GlobalIDType[::User],
required: false,
description: 'The ID of an author'
- argument :project_id, GraphQL::ID_TYPE,
+ argument :project_id, ::Types::GlobalIDType[::Project],
required: false,
description: 'The ID of a project'
@@ -36,9 +37,11 @@ module Resolvers
private
def snippet_finder_params(args)
+ # TODO: remove the type arguments when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
super
- .merge(author: resolve_gid(args[:author_id], :author),
- project: resolve_gid(args[:project_id], :project),
+ .merge(author: resolve_ids(args[:author_id], ::Types::GlobalIDType[::User]),
+ project: resolve_ids(args[:project_id], ::Types::GlobalIDType[::Project]),
explore: args[:explore])
end
end
diff --git a/app/graphql/resolvers/todo_resolver.rb b/app/graphql/resolvers/todo_resolver.rb
index bd5f8f274cd..9a8f7a71154 100644
--- a/app/graphql/resolvers/todo_resolver.rb
+++ b/app/graphql/resolvers/todo_resolver.rb
@@ -2,7 +2,7 @@
module Resolvers
class TodoResolver < BaseResolver
- type Types::TodoType, null: true
+ type Types::TodoType.connection_type, null: true
alias_method :target, :object
diff --git a/app/graphql/resolvers/tree_resolver.rb b/app/graphql/resolvers/tree_resolver.rb
index 5aad1c71b40..075a1929c47 100644
--- a/app/graphql/resolvers/tree_resolver.rb
+++ b/app/graphql/resolvers/tree_resolver.rb
@@ -2,6 +2,8 @@
module Resolvers
class TreeResolver < BaseResolver
+ type Types::Tree::TreeType, null: true
+
argument :path, GraphQL::STRING_TYPE,
required: false,
default_value: '',
diff --git a/app/graphql/resolvers/user_merge_requests_resolver.rb b/app/graphql/resolvers/user_merge_requests_resolver_base.rb
index b0d6e159f73..47967fe69f9 100644
--- a/app/graphql/resolvers/user_merge_requests_resolver.rb
+++ b/app/graphql/resolvers/user_merge_requests_resolver_base.rb
@@ -1,14 +1,14 @@
# frozen_string_literal: true
module Resolvers
- class UserMergeRequestsResolver < MergeRequestsResolver
+ class UserMergeRequestsResolverBase < MergeRequestsResolver
include ResolvesProject
argument :project_path, GraphQL::STRING_TYPE,
required: false,
description: 'The full-path of the project the authored merge requests should be in. Incompatible with projectId.'
- argument :project_id, GraphQL::ID_TYPE,
+ argument :project_id, ::Types::GlobalIDType[::Project],
required: false,
description: 'The global ID of the project the authored merge requests should be in. Incompatible with projectPath.'
@@ -50,8 +50,10 @@ module Resolvers
end
def load_project(project_path, project_id)
- @project = resolve_project(full_path: project_path, project_id: project_id)
- @project = @project.sync if @project.respond_to?(:sync)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ project_id &&= ::Types::GlobalIDType[::Project].coerce_isolated_input(project_id)
+ @project = ::Gitlab::Graphql::Lazy.force(resolve_project(full_path: project_path, project_id: project_id))
end
def no_results_possible?(args)
diff --git a/app/graphql/resolvers/user_resolver.rb b/app/graphql/resolvers/user_resolver.rb
index a34cecba491..06c1f0cb42d 100644
--- a/app/graphql/resolvers/user_resolver.rb
+++ b/app/graphql/resolvers/user_resolver.rb
@@ -6,7 +6,7 @@ module Resolvers
type Types::UserType, null: true
- argument :id, GraphQL::ID_TYPE,
+ argument :id, Types::GlobalIDType[User],
required: false,
description: 'ID of the User'
diff --git a/app/graphql/resolvers/users/group_count_resolver.rb b/app/graphql/resolvers/users/group_count_resolver.rb
new file mode 100644
index 00000000000..5033c26554a
--- /dev/null
+++ b/app/graphql/resolvers/users/group_count_resolver.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Users
+ class GroupCountResolver < BaseResolver
+ alias_method :user, :object
+
+ def resolve(**args)
+ return unless can_read_group_count?
+
+ BatchLoader::GraphQL.for(user.id).batch do |user_ids, loader|
+ results = UserGroupsCounter.new(user_ids).execute
+
+ results.each do |user_id, count|
+ loader.call(user_id, count)
+ end
+ end
+ end
+
+ def can_read_group_count?
+ current_user&.can?(:read_group_count, user)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/users/snippets_resolver.rb b/app/graphql/resolvers/users/snippets_resolver.rb
index d757640b5ff..c2d42437ffd 100644
--- a/app/graphql/resolvers/users/snippets_resolver.rb
+++ b/app/graphql/resolvers/users/snippets_resolver.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+# rubocop:disable Graphql/ResolverType (inherited from ResolvesSnippets)
module Resolvers
module Users
diff --git a/app/graphql/resolvers/users_resolver.rb b/app/graphql/resolvers/users_resolver.rb
index 110a283b42e..f5838642141 100644
--- a/app/graphql/resolvers/users_resolver.rb
+++ b/app/graphql/resolvers/users_resolver.rb
@@ -4,6 +4,7 @@ module Resolvers
class UsersResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
+ type Types::UserType.connection_type, null: true
description 'Find Users'
argument :ids, [GraphQL::ID_TYPE],
@@ -18,10 +19,14 @@ module Resolvers
required: false,
default_value: 'created_desc'
- def resolve(ids: nil, usernames: nil, sort: nil)
+ argument :search, GraphQL::STRING_TYPE,
+ required: false,
+ description: "Query to search users by name, username, or primary email."
+
+ def resolve(ids: nil, usernames: nil, sort: nil, search: nil)
authorize!
- ::UsersFinder.new(context[:current_user], finder_params(ids, usernames, sort)).execute
+ ::UsersFinder.new(context[:current_user], finder_params(ids, usernames, sort, search)).execute
end
def ready?(**args)
@@ -42,11 +47,12 @@ module Resolvers
private
- def finder_params(ids, usernames, sort)
+ def finder_params(ids, usernames, sort, search)
params = {}
params[:sort] = sort if sort
params[:username] = usernames if usernames
params[:id] = parse_gids(ids) if ids
+ params[:search] = search if search
params
end
diff --git a/app/graphql/types/alert_management/http_integration_type.rb b/app/graphql/types/alert_management/http_integration_type.rb
new file mode 100644
index 00000000000..88782050b94
--- /dev/null
+++ b/app/graphql/types/alert_management/http_integration_type.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ module AlertManagement
+ class HttpIntegrationType < BaseObject
+ graphql_name 'AlertManagementHttpIntegration'
+ description 'An endpoint and credentials used to accept alerts for a project'
+
+ implements(Types::AlertManagement::IntegrationType)
+
+ authorize :admin_operations
+
+ def type
+ :http
+ end
+
+ def api_url
+ nil
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/alert_management/integration_type.rb b/app/graphql/types/alert_management/integration_type.rb
new file mode 100644
index 00000000000..bf599885584
--- /dev/null
+++ b/app/graphql/types/alert_management/integration_type.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Types
+ module AlertManagement
+ module IntegrationType
+ include Types::BaseInterface
+ graphql_name 'AlertManagementIntegration'
+
+ field :id,
+ GraphQL::ID_TYPE,
+ null: false,
+ description: 'ID of the integration'
+
+ field :type,
+ AlertManagement::IntegrationTypeEnum,
+ null: false,
+ description: 'Type of integration'
+
+ field :name,
+ GraphQL::STRING_TYPE,
+ null: true,
+ description: 'Name of the integration'
+
+ field :active,
+ GraphQL::BOOLEAN_TYPE,
+ null: true,
+ description: 'Whether the endpoint is currently accepting alerts'
+
+ field :token,
+ GraphQL::STRING_TYPE,
+ null: true,
+ description: 'Token used to authenticate alert notification requests'
+
+ field :url,
+ GraphQL::STRING_TYPE,
+ null: true,
+ description: 'Endpoint which accepts alert notifications'
+
+ field :api_url,
+ GraphQL::STRING_TYPE,
+ null: true,
+ description: 'URL at which Prometheus metrics can be queried to populate the metrics dashboard'
+
+ definition_methods do
+ def resolve_type(object, context)
+ if object.is_a?(::PrometheusService)
+ Types::AlertManagement::PrometheusIntegrationType
+ else
+ Types::AlertManagement::HttpIntegrationType
+ end
+ end
+ end
+
+ orphan_types Types::AlertManagement::PrometheusIntegrationType,
+ Types::AlertManagement::HttpIntegrationType
+ end
+ end
+end
diff --git a/app/graphql/types/alert_management/integration_type_enum.rb b/app/graphql/types/alert_management/integration_type_enum.rb
new file mode 100644
index 00000000000..2f9be549e58
--- /dev/null
+++ b/app/graphql/types/alert_management/integration_type_enum.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ module AlertManagement
+ class IntegrationTypeEnum < BaseEnum
+ graphql_name 'AlertManagementIntegrationType'
+ description 'Values of types of integrations'
+
+ value 'PROMETHEUS', 'Prometheus integration', value: :prometheus
+ value 'HTTP', 'Integration with any monitoring tool', value: :http
+ end
+ end
+end
diff --git a/app/graphql/types/alert_management/prometheus_integration_type.rb b/app/graphql/types/alert_management/prometheus_integration_type.rb
new file mode 100644
index 00000000000..f605e325b8b
--- /dev/null
+++ b/app/graphql/types/alert_management/prometheus_integration_type.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Types
+ module AlertManagement
+ class PrometheusIntegrationType < BaseObject
+ include ::Gitlab::Routing
+
+ graphql_name 'AlertManagementPrometheusIntegration'
+ description 'An endpoint and credentials used to accept Prometheus alerts for a project'
+
+ implements(Types::AlertManagement::IntegrationType)
+
+ authorize :admin_project
+
+ alias_method :prometheus_service, :object
+
+ def name
+ prometheus_service.title
+ end
+
+ def type
+ :prometheus
+ end
+
+ def token
+ prometheus_service.project&.alerting_setting&.token
+ end
+
+ def url
+ prometheus_service.project && notify_project_prometheus_alerts_url(prometheus_service.project, format: :json)
+ end
+
+ def active
+ prometheus_service.manual_configuration?
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/availability_enum.rb b/app/graphql/types/availability_enum.rb
new file mode 100644
index 00000000000..61686b9359f
--- /dev/null
+++ b/app/graphql/types/availability_enum.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class AvailabilityEnum < BaseEnum
+ graphql_name 'AvailabilityEnum'
+ description 'User availability status'
+
+ ::UserStatus.availabilities.keys.each do |availability_value|
+ value availability_value.upcase, value: availability_value, description: availability_value.titleize
+ end
+ end
+end
diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb
index 70e665f8fc3..9c36c83d4a3 100644
--- a/app/graphql/types/base_object.rb
+++ b/app/graphql/types/base_object.rb
@@ -8,6 +8,12 @@ module Types
field_class Types::BaseField
+ def self.accepts(*types)
+ @accepts ||= []
+ @accepts += types
+ @accepts
+ end
+
# All graphql fields exposing an id, should expose a global id.
def id
GitlabSchema.id_from_object(object)
@@ -16,5 +22,13 @@ module Types
def current_user
context[:current_user]
end
+
+ def self.assignable?(object)
+ assignable = accepts
+
+ return true if assignable.blank?
+
+ assignable.any? { |cls| object.is_a?(cls) }
+ end
end
end
diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb
index f5dc9e08427..2a7b318e283 100644
--- a/app/graphql/types/board_type.rb
+++ b/app/graphql/types/board_type.rb
@@ -4,7 +4,7 @@ module Types
class BoardType < BaseObject
graphql_name 'Board'
description 'Represents a project or group board'
-
+ accepts ::Board
authorize :read_board
field :id, type: GraphQL::ID_TYPE, null: false,
diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb
index f4a50115ee6..6d8af400ac4 100644
--- a/app/graphql/types/ci/detailed_status_type.rb
+++ b/app/graphql/types/ci/detailed_status_type.rb
@@ -30,7 +30,7 @@ module Types
if obj.has_action?
{
button_title: obj.action_button_title,
- icon: obj.icon,
+ icon: obj.action_icon,
method: obj.action_method,
path: obj.action_path,
title: obj.action_title
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index 0ee1ad47b62..feaff4e81d8 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -6,6 +6,9 @@ module Types
class JobType < BaseObject
graphql_name 'CiJob'
+ field :pipeline, Types::Ci::PipelineType, null: false,
+ description: 'Pipeline the job belongs to',
+ resolve: -> (build, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Pipeline, build.pipeline_id).find }
field :name, GraphQL::STRING_TYPE, null: true,
description: 'Name of the job'
field :needs, JobType.connection_type, null: true,
diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb
index c508b746317..c25db39f600 100644
--- a/app/graphql/types/ci/pipeline_type.rb
+++ b/app/graphql/types/ci/pipeline_type.rb
@@ -13,49 +13,89 @@ module Types
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the pipeline'
+
field :iid, GraphQL::STRING_TYPE, null: false,
description: 'Internal ID of the pipeline'
field :sha, GraphQL::STRING_TYPE, null: false,
description: "SHA of the pipeline's commit"
+
field :before_sha, GraphQL::STRING_TYPE, null: true,
description: 'Base SHA of the source branch'
+
field :status, PipelineStatusEnum, null: false,
description: "Status of the pipeline (#{::Ci::Pipeline.all_state_names.compact.join(', ').upcase})"
+
field :detailed_status, Types::Ci::DetailedStatusType, null: false,
description: 'Detailed status of the pipeline',
resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) }
+
field :config_source, PipelineConfigSourceEnum, null: true,
description: "Config source of the pipeline (#{::Enums::Ci::Pipeline.config_sources.keys.join(', ').upcase})"
+
field :duration, GraphQL::INT_TYPE, null: true,
description: 'Duration of the pipeline in seconds'
+
field :coverage, GraphQL::FLOAT_TYPE, null: true,
description: 'Coverage percentage'
+
field :created_at, Types::TimeType, null: false,
description: "Timestamp of the pipeline's creation"
+
field :updated_at, Types::TimeType, null: false,
description: "Timestamp of the pipeline's last activity"
+
field :started_at, Types::TimeType, null: true,
description: 'Timestamp when the pipeline was started'
+
field :finished_at, Types::TimeType, null: true,
description: "Timestamp of the pipeline's completion"
+
field :committed_at, Types::TimeType, null: true,
description: "Timestamp of the pipeline's commit"
+
field :stages, Types::Ci::StageType.connection_type, null: true,
description: 'Stages of the pipeline',
extras: [:lookahead],
resolver: Resolvers::Ci::PipelineStagesResolver
+
field :user, Types::UserType, null: true,
description: 'Pipeline user',
resolve: -> (pipeline, _args, _context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, pipeline.user_id).find }
+
field :retryable, GraphQL::BOOLEAN_TYPE,
description: 'Specifies if a pipeline can be retried',
method: :retryable?,
null: false
+
field :cancelable, GraphQL::BOOLEAN_TYPE,
description: 'Specifies if a pipeline can be canceled',
method: :cancelable?,
null: false
+
+ field :jobs,
+ ::Types::Ci::JobType.connection_type,
+ null: true,
+ description: 'Jobs belonging to the pipeline',
+ resolver: ::Resolvers::Ci::JobsResolver
+
+ field :source_job, Types::Ci::JobType, null: true,
+ description: 'Job where pipeline was triggered from'
+
+ field :downstream, Types::Ci::PipelineType.connection_type, null: true,
+ description: 'Pipelines this pipeline will trigger',
+ method: :triggered_pipelines_with_preloads
+
+ field :upstream, Types::Ci::PipelineType, null: true,
+ description: 'Pipeline that triggered the pipeline',
+ method: :triggered_by_pipeline
+
+ field :path, GraphQL::STRING_TYPE, null: true,
+ description: "Relative path to the pipeline's page",
+ resolve: -> (obj, _args, _ctx) { ::Gitlab::Routing.url_helpers.project_pipeline_path(obj.project, obj) }
+
+ field :project, Types::ProjectType, null: true,
+ description: 'Project the pipeline belongs to'
end
end
end
diff --git a/app/graphql/types/ci/runner_setup_type.rb b/app/graphql/types/ci/runner_setup_type.rb
new file mode 100644
index 00000000000..66abcf65adf
--- /dev/null
+++ b/app/graphql/types/ci/runner_setup_type.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class RunnerSetupType < BaseObject
+ graphql_name 'RunnerSetup'
+
+ field :install_instructions, GraphQL::STRING_TYPE, null: false,
+ description: 'Instructions for installing the runner on the specified architecture'
+ field :register_instructions, GraphQL::STRING_TYPE, null: true,
+ description: 'Instructions for registering the runner'
+ end
+ end
+end
diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb
index dd4b4c3b114..c24b47f08ef 100644
--- a/app/graphql/types/commit_type.rb
+++ b/app/graphql/types/commit_type.rb
@@ -40,16 +40,9 @@ module Types
field :author, type: Types::UserType, null: true,
description: 'Author of the commit'
- field :pipelines, Types::Ci::PipelineType.connection_type,
+ field :pipelines,
null: true,
description: 'Pipelines of the commit ordered latest first',
resolver: Resolvers::CommitPipelinesResolver
-
- field :latest_pipeline,
- type: Types::Ci::PipelineType,
- null: true,
- deprecated: { reason: 'Use `pipelines`', milestone: '12.5' },
- description: 'Latest pipeline of the commit',
- resolver: Resolvers::CommitPipelinesResolver.last
end
end
diff --git a/app/graphql/types/container_repository_cleanup_status_enum.rb b/app/graphql/types/container_repository_cleanup_status_enum.rb
new file mode 100644
index 00000000000..6e654e65360
--- /dev/null
+++ b/app/graphql/types/container_repository_cleanup_status_enum.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ class ContainerRepositoryCleanupStatusEnum < BaseEnum
+ graphql_name 'ContainerRepositoryCleanupStatus'
+ description 'Status of the tags cleanup of a container repository'
+
+ value 'UNSCHEDULED', value: 'cleanup_unscheduled', description: 'The tags cleanup is not scheduled. This is the default state.'
+ value 'SCHEDULED', value: 'cleanup_scheduled', description: 'The tags cleanup is scheduled and is going to be executed shortly.'
+ value 'UNFINISHED', value: 'cleanup_unfinished', description: 'The tags cleanup has been partially executed. There are still remaining tags to delete.'
+ value 'ONGOING', value: 'cleanup_ongoing', description: 'The tags cleanup is ongoing.'
+ end
+end
diff --git a/app/graphql/types/container_repository_details_type.rb b/app/graphql/types/container_repository_details_type.rb
new file mode 100644
index 00000000000..34523f3ea4a
--- /dev/null
+++ b/app/graphql/types/container_repository_details_type.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ class ContainerRepositoryDetailsType < Types::ContainerRepositoryType
+ graphql_name 'ContainerRepositoryDetails'
+
+ description 'Details of a container repository'
+
+ authorize :read_container_image
+
+ field :tags,
+ Types::ContainerRepositoryTagType.connection_type,
+ null: true,
+ description: 'Tags of the container repository',
+ max_page_size: 20
+
+ def can_delete
+ Ability.allowed?(current_user, :destroy_container_image, object)
+ end
+ end
+end
diff --git a/app/graphql/types/container_repository_status_enum.rb b/app/graphql/types/container_repository_status_enum.rb
new file mode 100644
index 00000000000..8f3ba8f1083
--- /dev/null
+++ b/app/graphql/types/container_repository_status_enum.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class ContainerRepositoryStatusEnum < BaseEnum
+ graphql_name 'ContainerRepositoryStatus'
+ description 'Status of a container repository'
+
+ ::ContainerRepository.statuses.keys.each do |status|
+ value status.upcase, value: status, description: "#{status.titleize} status."
+ end
+ end
+end
diff --git a/app/graphql/types/container_repository_tag_type.rb b/app/graphql/types/container_repository_tag_type.rb
new file mode 100644
index 00000000000..25e605b689d
--- /dev/null
+++ b/app/graphql/types/container_repository_tag_type.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Types
+ class ContainerRepositoryTagType < BaseObject
+ graphql_name 'ContainerRepositoryTag'
+
+ description 'A tag from a container repository'
+
+ authorize :read_container_image
+
+ field :name, GraphQL::STRING_TYPE, null: false, description: 'Name of the tag.'
+ field :path, GraphQL::STRING_TYPE, null: false, description: 'Path of the tag.'
+ field :location, GraphQL::STRING_TYPE, null: false, description: 'URL of the tag.'
+ field :digest, GraphQL::STRING_TYPE, null: false, description: 'Digest of the tag.'
+ field :revision, GraphQL::STRING_TYPE, null: false, description: 'Revision of the tag.'
+ field :short_revision, GraphQL::STRING_TYPE, null: false, description: 'Short revision of the tag.'
+ field :total_size, GraphQL::INT_TYPE, null: false, description: 'The size of the tag.'
+ field :created_at, Types::TimeType, null: false, description: 'Timestamp when the tag was created.'
+ field :can_delete, GraphQL::BOOLEAN_TYPE, null: false, description: 'Can the current user delete this tag.'
+
+ def can_delete
+ Ability.allowed?(current_user, :destroy_container_image, object)
+ end
+ end
+end
diff --git a/app/graphql/types/container_repository_type.rb b/app/graphql/types/container_repository_type.rb
new file mode 100644
index 00000000000..45d19fdbc50
--- /dev/null
+++ b/app/graphql/types/container_repository_type.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Types
+ class ContainerRepositoryType < BaseObject
+ graphql_name 'ContainerRepository'
+
+ description 'A container repository'
+
+ authorize :read_container_image
+
+ field :id, GraphQL::ID_TYPE, null: false, description: 'ID of the container repository.'
+ field :name, GraphQL::STRING_TYPE, null: false, description: 'Name of the container repository.'
+ field :path, GraphQL::STRING_TYPE, null: false, description: 'Path of the container repository.'
+ field :location, GraphQL::STRING_TYPE, null: false, description: 'URL of the container repository.'
+ field :created_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was created.'
+ field :updated_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was updated.'
+ field :expiration_policy_started_at, Types::TimeType, null: true, description: 'Timestamp when the cleanup done by the expiration policy was started on the container repository.'
+ field :expiration_policy_cleanup_status, Types::ContainerRepositoryCleanupStatusEnum, null: true, description: 'The tags cleanup status for the container repository.'
+ field :status, Types::ContainerRepositoryStatusEnum, null: true, description: 'Status of the container repository.'
+ field :tags_count, GraphQL::INT_TYPE, null: false, description: 'Number of tags associated with this image.'
+ field :can_delete, GraphQL::BOOLEAN_TYPE, null: false, description: 'Can the current user delete the container repository.'
+
+ def can_delete
+ Ability.allowed?(current_user, :update_container_image, object)
+ end
+ end
+end
diff --git a/app/graphql/types/countable_connection_type.rb b/app/graphql/types/countable_connection_type.rb
index 2538366b786..f67194d99b3 100644
--- a/app/graphql/types/countable_connection_type.rb
+++ b/app/graphql/types/countable_connection_type.rb
@@ -3,7 +3,7 @@
module Types
# rubocop: disable Graphql/AuthorizeTypes
class CountableConnectionType < GraphQL::Types::Relay::BaseConnection
- field :count, Integer, null: false,
+ field :count, GraphQL::INT_TYPE, null: false,
description: 'Total count of collection'
def count
diff --git a/app/graphql/types/custom_emoji_type.rb b/app/graphql/types/custom_emoji_type.rb
new file mode 100644
index 00000000000..f7d1a7800bc
--- /dev/null
+++ b/app/graphql/types/custom_emoji_type.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Types
+ class CustomEmojiType < BaseObject
+ graphql_name 'CustomEmoji'
+ description 'A custom emoji uploaded by user'
+
+ authorize :read_custom_emoji
+
+ field :id, ::Types::GlobalIDType[::CustomEmoji],
+ null: false,
+ description: 'The ID of the emoji'
+
+ field :name, GraphQL::STRING_TYPE,
+ null: false,
+ description: 'The name of the emoji'
+
+ field :url, GraphQL::STRING_TYPE,
+ null: false,
+ method: :file,
+ description: 'The link to file of the emoji'
+
+ field :external, GraphQL::BOOLEAN_TYPE,
+ null: false,
+ description: 'Whether the emoji is an external link'
+ end
+end
diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb
index e4631a4a903..e3885668643 100644
--- a/app/graphql/types/environment_type.rb
+++ b/app/graphql/types/environment_type.rb
@@ -18,9 +18,8 @@ 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 :path, GraphQL::STRING_TYPE, null: false,
+ description: 'The path to the environment.'
field :metrics_dashboard, Types::Metrics::DashboardType, null: true,
description: 'Metrics dashboard schema for the environment',
diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb
index 9ae9ba32c13..4c51d4248dd 100644
--- a/app/graphql/types/global_id_type.rb
+++ b/app/graphql/types/global_id_type.rb
@@ -30,6 +30,8 @@ module Types
# @param value [String]
# @return [GID]
def self.coerce_input(value, _ctx)
+ return if value.nil?
+
gid = GlobalID.parse(value)
raise GraphQL::CoercionError, "#{value.inspect} is not a valid Global ID" if gid.nil?
raise GraphQL::CoercionError, "#{value.inspect} is not a Gitlab Global ID" unless gid.app == GlobalID.app
diff --git a/app/graphql/types/grafana_integration_type.rb b/app/graphql/types/grafana_integration_type.rb
index 7db733fc62a..6625af36f82 100644
--- a/app/graphql/types/grafana_integration_type.rb
+++ b/app/graphql/types/grafana_integration_type.rb
@@ -16,13 +16,5 @@ module Types
description: 'Timestamp of the issue\'s creation'
field :updated_at, Types::TimeType, null: false,
description: 'Timestamp of the issue\'s last activity'
-
- field :token, GraphQL::STRING_TYPE, null: false,
- deprecated: { reason: 'Plain text token has been masked for security reasons', milestone: '12.7' },
- description: 'API token for the Grafana integration'
-
- def token
- object.masked_token
- end
end
end
diff --git a/app/graphql/types/group_invitation_type.rb b/app/graphql/types/group_invitation_type.rb
new file mode 100644
index 00000000000..0372ce178ff
--- /dev/null
+++ b/app/graphql/types/group_invitation_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ class GroupInvitationType < BaseObject
+ expose_permissions Types::PermissionTypes::Group
+ authorize :read_group
+
+ implements InvitationInterface
+
+ graphql_name 'GroupInvitation'
+ description 'Represents a Group Invitation'
+
+ field :group, Types::GroupType, null: true,
+ description: 'Group that a User is invited to',
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.source_id).find }
+ end
+end
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 199cc0308c5..fb028184488 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -17,6 +17,10 @@ module Types
group.avatar_url(only_path: false)
end
+ field :custom_emoji, Types::CustomEmojiType.connection_type, null: true,
+ description: 'Custom emoji within this namespace',
+ feature_flag: :custom_emoji
+
field :share_with_group_lock, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if sharing a project with another group within this group is prevented'
@@ -82,11 +86,16 @@ module Types
end
field :group_members,
- Types::GroupMemberType.connection_type,
description: 'A membership of a user within this group',
- extras: [:lookahead],
resolver: Resolvers::GroupMembersResolver
+ field :container_repositories,
+ Types::ContainerRepositoryType.connection_type,
+ null: true,
+ description: 'Container repositories of the project',
+ resolver: Resolvers::ContainerRepositoriesResolver,
+ authorize: :read_container_image
+
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args|
LabelsFinder
diff --git a/app/graphql/types/invitation_interface.rb b/app/graphql/types/invitation_interface.rb
new file mode 100644
index 00000000000..a29716c292e
--- /dev/null
+++ b/app/graphql/types/invitation_interface.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Types
+ module InvitationInterface
+ include BaseInterface
+
+ field :email, GraphQL::STRING_TYPE, null: false,
+ description: 'Email of the member to invite'
+
+ field :access_level, Types::AccessLevelType, null: true,
+ description: 'GitLab::Access level'
+
+ field :created_by, Types::UserType, null: true,
+ description: 'User that authorized membership'
+
+ field :created_at, Types::TimeType, null: true,
+ description: 'Date and time the membership was created'
+
+ field :updated_at, Types::TimeType, null: true,
+ description: 'Date and time the membership was last updated'
+
+ field :expires_at, Types::TimeType, null: true,
+ description: 'Date and time the membership expires'
+
+ field :user, Types::UserType, null: true,
+ description: 'User that is associated with the member object'
+
+ definition_methods do
+ def resolve_type(object, context)
+ case object
+ when GroupMember
+ Types::GroupInvitationType
+ when ProjectMember
+ Types::ProjectInvitationType
+ else
+ raise ::Gitlab::Graphql::Errors::BaseError, "Unknown member type #{object.class.name}"
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/issue_connection_type.rb b/app/graphql/types/issue_connection_type.rb
new file mode 100644
index 00000000000..2e0f05f741e
--- /dev/null
+++ b/app/graphql/types/issue_connection_type.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class IssueConnectionType < CountableConnectionType
+ end
+end
+
+Types::IssueConnectionType.prepend_if_ee('::EE::Types::IssueConnectionType')
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 487508f448f..49c84f75e1a 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -4,7 +4,7 @@ module Types
class IssueType < BaseObject
graphql_name 'Issue'
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class(Types::IssueConnectionType)
implements(Types::Notes::NoteableType)
implements(Types::CurrentUserTodos)
@@ -41,6 +41,9 @@ module Types
field :assignees, Types::UserType.connection_type, null: true,
description: 'Assignees of the issue'
+ field :updated_by, Types::UserType, null: true,
+ description: 'User that last updated the issue'
+
field :labels, Types::LabelType.connection_type, null: true,
description: 'Labels of the issue'
field :milestone, Types::MilestoneType, null: true,
@@ -59,6 +62,8 @@ module Types
description: 'Number of downvotes the issue has received'
field :user_notes_count, GraphQL::INT_TYPE, null: false,
description: 'Number of user notes of the issue'
+ field :user_discussions_count, GraphQL::INT_TYPE, null: false,
+ description: 'Number of user discussions in the issue'
field :web_path, GraphQL::STRING_TYPE, null: false, method: :issue_path,
description: 'Web path of the issue'
field :web_url, GraphQL::STRING_TYPE, null: false,
@@ -68,12 +73,19 @@ module Types
field :participants, Types::UserType.connection_type, null: true, complexity: 5,
description: 'List of participants in the issue'
+ field :emails_disabled, GraphQL::BOOLEAN_TYPE, null: false,
+ method: :project_emails_disabled?,
+ description: 'Indicates if a project has email notifications disabled: `true` if email notifications are disabled'
field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false, complexity: 5,
description: 'Indicates the currently logged in user is subscribed to the issue'
field :time_estimate, GraphQL::INT_TYPE, null: false,
description: 'Time estimate of the issue'
field :total_time_spent, GraphQL::INT_TYPE, null: false,
description: 'Total time reported as spent on the issue'
+ field :human_time_estimate, GraphQL::STRING_TYPE, null: true,
+ description: 'Human-readable time estimate of the issue'
+ field :human_total_time_spent, GraphQL::STRING_TYPE, null: true,
+ description: 'Human-readable total time reported as spent on the issue'
field :closed_at, Types::TimeType, null: true,
description: 'Timestamp of when the issue was closed'
@@ -86,11 +98,6 @@ module Types
field :task_completion_status, Types::TaskCompletionStatus, null: false,
description: 'Task completion status of the issue'
- field :designs, Types::DesignManagement::DesignCollectionType, null: true,
- method: :design_collection,
- deprecated: { reason: 'Use `designCollection`', milestone: '12.2' },
- description: 'The designs associated with this issue'
-
field :design_collection, Types::DesignManagement::DesignCollectionType, null: true,
description: 'Collection of design images associated with this issue'
@@ -106,14 +113,48 @@ module Types
field :severity, Types::IssuableSeverityEnum, null: true,
description: 'Severity level of the incident'
+ field :moved, GraphQL::BOOLEAN_TYPE, method: :moved?, null: true,
+ description: 'Indicates if issue got moved from other project'
+
+ field :moved_to, Types::IssueType, null: true,
+ description: 'Updated Issue after it got moved to another project'
+
+ def user_notes_count
+ BatchLoader::GraphQL.for(object.id).batch(key: :issue_user_notes_count) do |ids, loader, args|
+ counts = Note.count_for_collection(ids, 'Issue').index_by(&:noteable_id)
+
+ ids.each do |id|
+ loader.call(id, counts[id]&.count || 0)
+ end
+ end
+ end
+
+ def user_discussions_count
+ BatchLoader::GraphQL.for(object.id).batch(key: :issue_user_discussions_count) do |ids, loader, args|
+ counts = Note.count_for_collection(ids, 'Issue', 'COUNT(DISTINCT discussion_id) as count').index_by(&:noteable_id)
+
+ ids.each do |id|
+ loader.call(id, counts[id]&.count || 0)
+ end
+ end
+ end
+
def author
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
end
+ def updated_by
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.updated_by_id).find
+ end
+
def milestone
Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, object.milestone_id).find
end
+ def moved_to
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Issue, object.moved_to_id).find
+ end
+
def discussion_locked
!!object.discussion_locked
end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 372aeac055b..e68d6706c43 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -68,6 +68,8 @@ module Types
description: 'SHA of the merge request commit (set once merged)'
field :user_notes_count, GraphQL::INT_TYPE, null: true,
description: 'User notes count of the merge request'
+ field :user_discussions_count, GraphQL::INT_TYPE, null: true,
+ description: 'Number of user discussions in the merge request'
field :should_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :should_remove_source_branch?, null: true,
description: 'Indicates if the source branch of the merge request will be deleted after merge'
field :force_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :force_remove_source_branch?, null: true,
@@ -86,9 +88,6 @@ module Types
description: 'Rebase commit SHA of the merge request'
field :rebase_in_progress, GraphQL::BOOLEAN_TYPE, method: :rebase_in_progress?, null: false, calls_gitaly: true,
description: 'Indicates if there is a rebase currently in progress for the merge request'
- field :merge_commit_message, GraphQL::STRING_TYPE, method: :default_merge_commit_message, null: true,
- deprecated: { reason: 'Use `defaultMergeCommitMessage`', milestone: '11.8' },
- description: 'Default merge commit message of the merge request'
field :default_merge_commit_message, GraphQL::STRING_TYPE, null: true,
description: 'Default merge commit message of the merge request'
field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false,
@@ -112,14 +111,13 @@ module Types
field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline,
description: 'The pipeline running on the branch HEAD of the merge request'
- field :pipelines, Types::Ci::PipelineType.connection_type,
+ field :pipelines,
null: true,
description: 'Pipelines for the merge request',
resolver: Resolvers::MergeRequestPipelinesResolver
field :milestone, Types::MilestoneType, null: true,
- description: 'The milestone of the merge request',
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find }
+ description: 'The milestone of the merge request'
field :assignees, Types::UserType.connection_type, null: true, complexity: 5,
description: 'Assignees of the merge request'
field :author, Types::UserType, null: true,
@@ -159,17 +157,25 @@ module Types
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
+ counts = Note.count_for_collection(ids, 'MergeRequest').index_by(&:noteable_id)
+
+ ids.each do |id|
+ loader.call(id, counts[id]&.count || 0)
+ end
+ end
+ end
+
+ def user_discussions_count
+ BatchLoader::GraphQL.for(object.id).batch(key: :merge_request_user_discussions_count) do |ids, loader, args|
+ counts = Note.count_for_collection(ids, 'MergeRequest', 'COUNT(DISTINCT discussion_id) as count').index_by(&:noteable_id)
ids.each do |id|
- loader.call(id, counts[id] || 0)
+ loader.call(id, counts[id]&.count || 0)
end
end
end
- # rubocop: enable CodeReuse/ActiveRecord
def diff_stats(path: nil)
stats = Array.wrap(object.diff_stats&.to_a)
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 3f48e7b4a16..75ccac6d590 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -11,6 +11,13 @@ module Types
mount_mutation Mutations::AlertManagement::UpdateAlertStatus
mount_mutation Mutations::AlertManagement::Alerts::SetAssignees
mount_mutation Mutations::AlertManagement::Alerts::Todo::Create
+ mount_mutation Mutations::AlertManagement::HttpIntegration::Create
+ mount_mutation Mutations::AlertManagement::HttpIntegration::Update
+ mount_mutation Mutations::AlertManagement::HttpIntegration::ResetToken
+ mount_mutation Mutations::AlertManagement::HttpIntegration::Destroy
+ mount_mutation Mutations::AlertManagement::PrometheusIntegration::Create
+ mount_mutation Mutations::AlertManagement::PrometheusIntegration::Update
+ mount_mutation Mutations::AlertManagement::PrometheusIntegration::ResetToken
mount_mutation Mutations::AwardEmojis::Add
mount_mutation Mutations::AwardEmojis::Remove
mount_mutation Mutations::AwardEmojis::Toggle
@@ -22,6 +29,7 @@ module Types
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::CustomEmoji::Create, feature_flag: :custom_emoji
mount_mutation Mutations::Discussions::ToggleResolve
mount_mutation Mutations::Issues::Create
mount_mutation Mutations::Issues::SetAssignees
@@ -32,6 +40,7 @@ module Types
mount_mutation Mutations::Issues::SetSubscription
mount_mutation Mutations::Issues::Update
mount_mutation Mutations::Issues::Move
+ mount_mutation Mutations::Labels::Create
mount_mutation Mutations::MergeRequests::Create
mount_mutation Mutations::MergeRequests::Update
mount_mutation Mutations::MergeRequests::SetLabels
@@ -53,7 +62,13 @@ module Types
description: 'Updates a DiffNote on an image (a `Note` where the `position.positionType` is `"image"`). ' \
'If the body of the Note contains only quick actions, the Note will be ' \
'destroyed during the update, and no Note will be returned'
+ mount_mutation Mutations::Notes::RepositionImageDiffNote
mount_mutation Mutations::Notes::Destroy
+ mount_mutation Mutations::Releases::Create
+ mount_mutation Mutations::Terraform::State::Delete
+ mount_mutation Mutations::Terraform::State::Lock
+ mount_mutation Mutations::Terraform::State::Unlock
+ mount_mutation Mutations::Todos::Create
mount_mutation Mutations::Todos::MarkDone
mount_mutation Mutations::Todos::Restore
mount_mutation Mutations::Todos::MarkAllDone
@@ -68,6 +83,7 @@ module Types
mount_mutation Mutations::DesignManagement::Delete, calls_gitaly: true
mount_mutation Mutations::DesignManagement::Move
mount_mutation Mutations::ContainerExpirationPolicies::Update
+ mount_mutation Mutations::ContainerRepositories::Destroy
mount_mutation Mutations::Ci::PipelineCancel
mount_mutation Mutations::Ci::PipelineDestroy
mount_mutation Mutations::Ci::PipelineRetry
diff --git a/app/graphql/types/notes/update_diff_image_position_input_type.rb b/app/graphql/types/notes/update_diff_image_position_input_type.rb
index af99764f9f2..1b915b65ae9 100644
--- a/app/graphql/types/notes/update_diff_image_position_input_type.rb
+++ b/app/graphql/types/notes/update_diff_image_position_input_type.rb
@@ -23,6 +23,14 @@ module Types
argument :height, GraphQL::INT_TYPE,
required: false,
description: copy_field_description(Types::Notes::DiffPositionType, :height)
+
+ def prepare
+ to_h.compact.tap do |properties|
+ if properties.empty?
+ raise GraphQL::ExecutionError, "At least one property of `#{self.class.graphql_name}` must be set"
+ end
+ end
+ end
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/permission_types/custom_emoji.rb b/app/graphql/types/permission_types/custom_emoji.rb
new file mode 100644
index 00000000000..0b2e7da44f5
--- /dev/null
+++ b/app/graphql/types/permission_types/custom_emoji.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ module PermissionTypes
+ class CustomEmoji < BasePermissionType
+ graphql_name 'CustomEmojiPermissions'
+
+ abilities :create_custom_emoji, :read_custom_emoji
+ end
+ end
+end
diff --git a/app/graphql/types/permission_types/note.rb b/app/graphql/types/permission_types/note.rb
index a585d3daaa8..923f2b660fe 100644
--- a/app/graphql/types/permission_types/note.rb
+++ b/app/graphql/types/permission_types/note.rb
@@ -5,7 +5,7 @@ module Types
class Note < BasePermissionType
graphql_name 'NotePermissions'
- abilities :read_note, :create_note, :admin_note, :resolve_note, :award_emoji
+ abilities :read_note, :create_note, :admin_note, :resolve_note, :reposition_note, :award_emoji
end
end
end
diff --git a/app/graphql/types/project_invitation_type.rb b/app/graphql/types/project_invitation_type.rb
new file mode 100644
index 00000000000..a5367a4f204
--- /dev/null
+++ b/app/graphql/types/project_invitation_type.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ class ProjectInvitationType < BaseObject
+ graphql_name 'ProjectInvitation'
+ description 'Represents a Project Membership Invitation'
+
+ expose_permissions Types::PermissionTypes::Project
+
+ implements InvitationInterface
+
+ authorize :read_project
+
+ field :project, Types::ProjectType, null: true,
+ description: 'Project ID for the project of the invitation'
+
+ def project
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.source_id).find
+ end
+ end
+end
diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb
index b3916e42e92..26cb5ab59b5 100644
--- a/app/graphql/types/project_statistics_type.rb
+++ b/app/graphql/types/project_statistics_type.rb
@@ -10,18 +10,20 @@ module Types
description: 'Commit count of the project'
field :storage_size, GraphQL::FLOAT_TYPE, null: false,
- description: 'Storage size of the project'
+ description: 'Storage size of the project in bytes'
field :repository_size, GraphQL::FLOAT_TYPE, null: false,
- description: 'Repository size of the project'
+ description: 'Repository size of the project in bytes'
field :lfs_objects_size, GraphQL::FLOAT_TYPE, null: false,
- description: 'Large File Storage (LFS) object size of the project'
+ description: 'Large File Storage (LFS) object size of the project in bytes'
field :build_artifacts_size, GraphQL::FLOAT_TYPE, null: false,
- description: 'Build artifacts size of the project'
+ description: 'Build artifacts size of the project in bytes'
field :packages_size, GraphQL::FLOAT_TYPE, null: false,
- description: 'Packages size of the project'
+ description: 'Packages size of the project in bytes'
field :wiki_size, GraphQL::FLOAT_TYPE, null: true,
- description: 'Wiki size of the project'
+ description: 'Wiki size of the project in bytes'
field :snippets_size, GraphQL::FLOAT_TYPE, null: true,
- description: 'Snippets size of the project'
+ description: 'Snippets size of the project in bytes'
+ field :uploads_size, GraphQL::FLOAT_TYPE, null: true,
+ description: 'Uploads size of the project in bytes'
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index c7fc193abe8..5a436886117 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -161,7 +161,6 @@ module Types
resolver: Resolvers::ProjectMilestonesResolver
field :project_members,
- Types::MemberInterface.connection_type,
description: 'Members of the project',
resolver: Resolvers::ProjectMembersResolver
@@ -188,9 +187,9 @@ module Types
resolver: Resolvers::PackagesResolver
field :pipelines,
- Types::Ci::PipelineType.connection_type,
null: true,
description: 'Build pipelines of the project',
+ extras: [:lookahead],
resolver: Resolvers::ProjectPipelinesResolver
field :pipeline,
@@ -267,6 +266,12 @@ module Types
description: 'Counts of alerts by status for the project',
resolver: Resolvers::AlertManagement::AlertStatusCountsResolver
+ field :alert_management_integrations,
+ Types::AlertManagement::IntegrationType.connection_type,
+ null: true,
+ description: 'Integrations which can receive alerts for the project',
+ resolver: Resolvers::AlertManagement::IntegrationsResolver
+
field :releases,
Types::ReleaseType.connection_type,
null: true,
@@ -285,6 +290,12 @@ module Types
null: true,
description: 'The container expiration policy of the project'
+ field :container_repositories,
+ Types::ContainerRepositoryType.connection_type,
+ null: true,
+ description: 'Container repositories of the project',
+ resolver: Resolvers::ContainerRepositoriesResolver
+
field :label,
Types::LabelType,
null: true,
diff --git a/app/graphql/types/projects/namespace_project_sort_enum.rb b/app/graphql/types/projects/namespace_project_sort_enum.rb
index 1e13deb6508..ede29748beb 100644
--- a/app/graphql/types/projects/namespace_project_sort_enum.rb
+++ b/app/graphql/types/projects/namespace_project_sort_enum.rb
@@ -7,6 +7,7 @@ module Types
description 'Values for sorting projects'
value 'SIMILARITY', 'Most similar to the search query', value: :similarity
+ value 'STORAGE', 'Sort by storage size', value: :storage
end
end
end
diff --git a/app/graphql/types/projects/service_type_enum.rb b/app/graphql/types/projects/service_type_enum.rb
index 340fdff6b86..34e06c67be6 100644
--- a/app/graphql/types/projects/service_type_enum.rb
+++ b/app/graphql/types/projects/service_type_enum.rb
@@ -5,7 +5,7 @@ module Types
class ServiceTypeEnum < BaseEnum
graphql_name 'ServiceType'
- ::Service.services_types.each do |service_type|
+ ::Service.available_services_types(include_dev: false).each do |service_type|
value service_type.underscore.upcase, value: service_type
end
end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index bd4b53bdaa7..d194b0979b3 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -50,10 +50,14 @@ module Types
field :milestone, ::Types::MilestoneType,
null: true,
description: 'Find a milestone' do
- argument :id, ::Types::GlobalIDType[Milestone],
- required: true,
- description: 'Find a milestone by its ID'
- end
+ argument :id, ::Types::GlobalIDType[Milestone], required: true, description: 'Find a milestone by its ID'
+ end
+
+ field :container_repository, Types::ContainerRepositoryDetailsType,
+ null: true,
+ description: 'Find a container repository' do
+ argument :id, ::Types::GlobalIDType[::ContainerRepository], required: true, description: 'The global ID of the container repository'
+ end
field :user, Types::UserType,
null: true,
@@ -84,6 +88,10 @@ module Types
null: true, description: 'Supported runner platforms',
resolver: Resolvers::Ci::RunnerPlatformsResolver
+ field :runner_setup, Types::Ci::RunnerSetupType, null: true,
+ description: 'Get runner setup instructions',
+ resolver: Resolvers::Ci::RunnerSetupResolver
+
def design_management
DesignManagementObject.new(nil)
end
@@ -101,6 +109,13 @@ module Types
id = ::Types::GlobalIDType[Milestone].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
+
+ def container_repository(id:)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::ContainerRepository].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
end
end
diff --git a/app/graphql/types/release_asset_link_input_type.rb b/app/graphql/types/release_asset_link_input_type.rb
new file mode 100644
index 00000000000..d13861fad8f
--- /dev/null
+++ b/app/graphql/types/release_asset_link_input_type.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class ReleaseAssetLinkInputType < BaseInputObject
+ graphql_name 'ReleaseAssetLinkInput'
+ description 'Fields that are available when modifying a release asset link'
+
+ argument :name, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Name of the asset link'
+
+ argument :url, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'URL of the asset link'
+
+ argument :direct_asset_path, GraphQL::STRING_TYPE,
+ required: false, as: :filepath,
+ description: 'Relative path for a direct asset link'
+
+ argument :link_type, Types::ReleaseAssetLinkTypeEnum,
+ required: false, default_value: 'other',
+ description: 'The type of the asset link'
+ end
+end
diff --git a/app/graphql/types/release_asset_link_type.rb b/app/graphql/types/release_asset_link_type.rb
index 0e519ece791..8fb051f5627 100644
--- a/app/graphql/types/release_asset_link_type.rb
+++ b/app/graphql/types/release_asset_link_type.rb
@@ -24,10 +24,8 @@ module Types
def direct_asset_url
return object.url unless object.filepath
- release = object.release
- project = release.project
-
- Gitlab::Routing.url_helpers.project_release_url(project, release) << object.filepath
+ release = object.release.present
+ release.download_url(object.filepath)
end
end
end
diff --git a/app/graphql/types/release_asset_link_type_enum.rb b/app/graphql/types/release_asset_link_type_enum.rb
index 01862ada56d..70601b9f8da 100644
--- a/app/graphql/types/release_asset_link_type_enum.rb
+++ b/app/graphql/types/release_asset_link_type_enum.rb
@@ -3,7 +3,7 @@
module Types
class ReleaseAssetLinkTypeEnum < BaseEnum
graphql_name 'ReleaseAssetLinkType'
- description 'Type of the link: `other`, `runbook`, `image`, `package`; defaults to `other`'
+ description 'Type of the link: `other`, `runbook`, `image`, `package`'
::Releases::Link.link_types.keys.each do |link_type|
value link_type.upcase, value: link_type, description: "#{link_type.titleize} link type"
diff --git a/app/graphql/types/release_assets_input_type.rb b/app/graphql/types/release_assets_input_type.rb
new file mode 100644
index 00000000000..3fcb517e044
--- /dev/null
+++ b/app/graphql/types/release_assets_input_type.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class ReleaseAssetsInputType < BaseInputObject
+ graphql_name 'ReleaseAssetsInput'
+ description 'Fields that are available when modifying release assets'
+
+ argument :links, [Types::ReleaseAssetLinkInputType],
+ required: false,
+ description: 'A list of asset links to associate to the release'
+ end
+end
diff --git a/app/graphql/types/release_links_type.rb b/app/graphql/types/release_links_type.rb
index f61a16f5b67..619bb1e6c3a 100644
--- a/app/graphql/types/release_links_type.rb
+++ b/app/graphql/types/release_links_type.rb
@@ -12,12 +12,18 @@ module Types
field :self_url, GraphQL::STRING_TYPE, null: true,
description: 'HTTP URL of the release'
- field :merge_requests_url, GraphQL::STRING_TYPE, null: true,
- description: 'HTTP URL of the merge request page filtered by this release'
- field :issues_url, GraphQL::STRING_TYPE, null: true,
- description: 'HTTP URL of the issues page filtered by this release'
field :edit_url, GraphQL::STRING_TYPE, null: true,
description: "HTTP URL of the release's edit page",
authorize: :update_release
+ field :opened_merge_requests_url, GraphQL::STRING_TYPE, null: true,
+ description: 'HTTP URL of the merge request page, filtered by this release and `state=open`'
+ field :merged_merge_requests_url, GraphQL::STRING_TYPE, null: true,
+ description: 'HTTP URL of the merge request page , filtered by this release and `state=merged`'
+ field :closed_merge_requests_url, GraphQL::STRING_TYPE, null: true,
+ description: 'HTTP URL of the merge request page , filtered by this release and `state=closed`'
+ field :opened_issues_url, GraphQL::STRING_TYPE, null: true,
+ description: 'HTTP URL of the issues page, filtered by this release and `state=open`'
+ field :closed_issues_url, GraphQL::STRING_TYPE, null: true,
+ description: 'HTTP URL of the issues page, filtered by this release and `state=closed`'
end
end
diff --git a/app/graphql/types/release_sort_enum.rb b/app/graphql/types/release_sort_enum.rb
new file mode 100644
index 00000000000..2f9af1bced9
--- /dev/null
+++ b/app/graphql/types/release_sort_enum.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Types
+ # Not inheriting from Types::SortEnum since we only want
+ # to implement a subset of the sort values it defines.
+ class ReleaseSortEnum < BaseEnum
+ graphql_name 'ReleaseSort'
+ description 'Values for sorting releases'
+
+ # Borrowed from Types::SortEnum
+ # These values/descriptions should stay in-sync as much as possible.
+ value 'CREATED_DESC', 'Created at descending order', value: :created_desc
+ value 'CREATED_ASC', 'Created at ascending order', value: :created_asc
+
+ value 'RELEASED_AT_DESC', 'Released at by descending order', value: :released_at_desc
+ value 'RELEASED_AT_ASC', 'Released at by ascending order', value: :released_at_asc
+ end
+end
diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb
index 224e8c7ee03..21448b33bb5 100644
--- a/app/graphql/types/root_storage_statistics_type.rb
+++ b/app/graphql/types/root_storage_statistics_type.rb
@@ -14,5 +14,6 @@ module Types
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'
+ field :uploads_size, GraphQL::FLOAT_TYPE, null: false, description: 'The uploads size in bytes'
end
end
diff --git a/app/graphql/types/security/report_type_enum.rb b/app/graphql/types/security/report_type_enum.rb
new file mode 100644
index 00000000000..ee67f68b27b
--- /dev/null
+++ b/app/graphql/types/security/report_type_enum.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ module Security
+ class ReportTypeEnum < BaseEnum
+ graphql_name 'SecurityReportTypeEnum'
+
+ ::Security::SecurityJobsFinder.allowed_job_types.each do |report_type|
+ value report_type.upcase,
+ value: report_type,
+ description: "#{report_type.upcase.to_s.tr('_', ' ')} scan report"
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb
index 495c25c1776..f587adf207f 100644
--- a/app/graphql/types/snippet_type.rb
+++ b/app/graphql/types/snippet_type.rb
@@ -13,7 +13,7 @@ module Types
expose_permissions Types::PermissionTypes::Snippet
- field :id, GraphQL::ID_TYPE,
+ field :id, Types::GlobalIDType[::Snippet],
description: 'ID of the snippet',
null: false
diff --git a/app/graphql/types/terraform/state_type.rb b/app/graphql/types/terraform/state_type.rb
index f25f3a7789b..05b6d130f19 100644
--- a/app/graphql/types/terraform/state_type.rb
+++ b/app/graphql/types/terraform/state_type.rb
@@ -7,6 +7,8 @@ module Types
authorize :read_terraform_state
+ connection_type_class(Types::CountableConnectionType)
+
field :id, GraphQL::ID_TYPE,
null: false,
description: 'ID of the Terraform state'
@@ -25,6 +27,11 @@ module Types
null: true,
description: 'Timestamp the Terraform state was locked'
+ field :latest_version, Types::Terraform::StateVersionType,
+ complexity: 3,
+ null: true,
+ description: 'The latest version of the Terraform state'
+
field :created_at, Types::TimeType,
null: false,
description: 'Timestamp the Terraform state was created'
diff --git a/app/graphql/types/terraform/state_version_type.rb b/app/graphql/types/terraform/state_version_type.rb
new file mode 100644
index 00000000000..b1fbe42ecaf
--- /dev/null
+++ b/app/graphql/types/terraform/state_version_type.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Types
+ module Terraform
+ class StateVersionType < BaseObject
+ graphql_name 'TerraformStateVersion'
+
+ authorize :read_terraform_state
+
+ field :id, GraphQL::ID_TYPE,
+ null: false,
+ description: 'ID of the Terraform state version'
+
+ field :created_by_user, Types::UserType,
+ null: true,
+ authorize: :read_user,
+ description: 'The user that created this version',
+ resolve: -> (version, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, version.created_by_user_id).find }
+
+ field :job, Types::Ci::JobType,
+ null: true,
+ authorize: :read_build,
+ description: 'The job that created this version',
+ resolve: -> (version, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Build, version.ci_build_id).find }
+
+ field :created_at, Types::TimeType,
+ null: false,
+ description: 'Timestamp the version was created'
+
+ field :updated_at, Types::TimeType,
+ null: false,
+ description: 'Timestamp the version was updated'
+ end
+ end
+end
diff --git a/app/graphql/types/user_status_type.rb b/app/graphql/types/user_status_type.rb
index ff277c1f8e8..9cf6c862d3d 100644
--- a/app/graphql/types/user_status_type.rb
+++ b/app/graphql/types/user_status_type.rb
@@ -11,5 +11,7 @@ module Types
description: 'User status message'
field :emoji, GraphQL::STRING_TYPE, null: true,
description: 'String representation of emoji'
+ field :availability, Types::AvailabilityEnum, null: false,
+ description: 'User availability status'
end
end
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
index 8047708776d..11c5369f726 100644
--- a/app/graphql/types/user_type.rb
+++ b/app/graphql/types/user_type.rb
@@ -32,6 +32,10 @@ module Types
field :group_memberships, Types::GroupMemberType.connection_type, null: true,
description: 'Group memberships of the user',
method: :group_members
+ field :group_count, GraphQL::INT_TYPE, null: true,
+ resolver: Resolvers::Users::GroupCountResolver,
+ description: 'Group count for the user',
+ feature_flag: :user_group_counts
field :status, Types::UserStatusType, null: true,
description: 'User status'
field :project_memberships, Types::ProjectMemberType.connection_type, null: true,
@@ -42,10 +46,10 @@ module Types
resolver: Resolvers::UserStarredProjectsResolver
# Merge request field: MRs can be either authored or assigned:
- field :authored_merge_requests, Types::MergeRequestType.connection_type, null: true,
+ field :authored_merge_requests,
resolver: Resolvers::AuthoredMergeRequestsResolver,
description: 'Merge Requests authored by the user'
- field :assigned_merge_requests, Types::MergeRequestType.connection_type, null: true,
+ field :assigned_merge_requests,
resolver: Resolvers::AssignedMergeRequestsResolver,
description: 'Merge Requests assigned to the user'
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 9c408efe8cd..512649b3008 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -199,14 +199,14 @@ module ApplicationSettingsHelper
:default_projects_limit,
:default_snippet_visibility,
:disabled_oauth_sign_in_sources,
- :domain_blacklist,
- :domain_blacklist_enabled,
- # TODO Remove domain_blacklist_raw in APIv5 (See https://gitlab.com/gitlab-org/gitlab-foss/issues/67204)
- :domain_blacklist_raw,
- :domain_whitelist,
- # TODO Remove domain_whitelist_raw in APIv5 (See https://gitlab.com/gitlab-org/gitlab-foss/issues/67204)
- :domain_whitelist_raw,
- :outbound_local_requests_whitelist_raw,
+ :domain_denylist,
+ :domain_denylist_enabled,
+ # TODO Remove domain_denylist_raw in APIv5 (See https://gitlab.com/gitlab-org/gitlab-foss/issues/67204)
+ :domain_denylist_raw,
+ :domain_allowlist,
+ # TODO Remove domain_allowlist_raw in APIv5 (See https://gitlab.com/gitlab-org/gitlab-foss/issues/67204)
+ :domain_allowlist_raw,
+ :outbound_local_requests_allowlist_raw,
:dsa_key_restriction,
:ecdsa_key_restriction,
:ed25519_key_restriction,
@@ -394,6 +394,10 @@ module ApplicationSettingsHelper
def show_documentation_base_url_field?
Feature.enabled?(:help_page_documentation_redirect)
end
+
+ def signup_enabled?
+ !!Gitlab::CurrentSettings.signup_enabled
+ end
end
ApplicationSettingsHelper.prepend_if_ee('EE::ApplicationSettingsHelper')
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 7f8cb66a84f..cc43ea85a11 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -151,6 +151,13 @@ module AuthHelper
current_user.allow_password_authentication_for_web? && !current_user.password_automatically_set?
end
+ def google_tag_manager_enabled?
+ Gitlab.com? &&
+ extra_config.has_key?('google_tag_manager_id') &&
+ extra_config.google_tag_manager_id.present? &&
+ !current_user
+ end
+
extend self
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 806fea3ab44..981b5e4d92b 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -26,6 +26,25 @@ module BlobHelper
File.join(segments)
end
+ def ide_merge_request_path(merge_request, path = '')
+ target_project = merge_request.target_project
+ source_project = merge_request.source_project
+
+ if merge_request.merged?
+ branch = merge_request.target_branch_exists? ? merge_request.target_branch : target_project.default_branch
+
+ return ide_edit_path(target_project, branch, path)
+ end
+
+ if target_project != source_project
+ params = { target_project: target_project.full_path }
+ end
+
+ result = File.join(ide_path, 'project', source_project.full_path, 'merge_requests', merge_request.to_param)
+ result += "?#{params.to_query}" unless params.nil?
+ result
+ end
+
def ide_fork_and_edit_path(project = @project, ref = @ref, path = @path, options = {})
fork_path_for_current_user(project, ide_edit_path(project, ref, path))
end
@@ -49,7 +68,7 @@ module BlobHelper
def edit_blob_button(project = @project, ref = @ref, path = @path, options = {})
return unless blob = readable_blob(options, path, project, ref)
- common_classes = "btn btn-primary js-edit-blob ml-2 #{options[:extra_class]}"
+ common_classes = "btn btn-primary js-edit-blob gl-mr-3 #{options[:extra_class]}"
data = { track_event: 'click_edit', track_label: 'Edit' }
if Feature.enabled?(:web_ide_primary_edit, project.group)
@@ -69,7 +88,7 @@ module BlobHelper
def ide_edit_button(project = @project, ref = @ref, path = @path, blob:)
return unless blob
- common_classes = 'btn btn-primary ide-edit-button ml-2'
+ common_classes = 'btn btn-primary ide-edit-button gl-mr-3'
data = { track_event: 'click_edit_ide', track_label: 'Web IDE' }
unless Feature.enabled?(:web_ide_primary_edit, project.group)
@@ -363,7 +382,7 @@ module BlobHelper
end
def show_suggest_pipeline_creation_celebration?
- experiment_enabled?(:suggest_pipeline) &&
+ Feature.enabled?(:suggest_pipeline, default_enabled: true) &&
@blob.path == Gitlab::FileDetector::PATTERNS[:gitlab_ci] &&
@blob.auxiliary_viewer&.valid?(project: @project, sha: @commit.sha, user: current_user) &&
@project.uses_default_ci_config? &&
diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb
index 2a0242fe2fa..8f87cd5bfe0 100644
--- a/app/helpers/branches_helper.rb
+++ b/app/helpers/branches_helper.rb
@@ -13,7 +13,11 @@ module BranchesHelper
return [] unless access_levels
access_levels.map do |level|
- { id: level.id, type: :role, access_level: level.access_level }
+ if level.type == :deploy_key
+ { id: level.id, type: level.type, deploy_key_id: level.deploy_key_id }
+ else
+ { id: level.id, type: :role, access_level: level.access_level }
+ end
end
end
end
diff --git a/app/helpers/breadcrumbs_helper.rb b/app/helpers/breadcrumbs_helper.rb
index b067376cea0..ade7c48b03f 100644
--- a/app/helpers/breadcrumbs_helper.rb
+++ b/app/helpers/breadcrumbs_helper.rb
@@ -32,4 +32,53 @@ module BreadcrumbsHelper
@breadcrumb_dropdown_links[location] ||= []
@breadcrumb_dropdown_links[location] << link
end
+
+ def push_to_schema_breadcrumb(text, link)
+ list_item = schema_list_item(text, link, schema_breadcrumb_list.size + 1)
+
+ schema_breadcrumb_list.push(list_item)
+ end
+
+ def schema_breadcrumb_json
+ {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ 'itemListElement': build_item_list_elements
+ }.to_json
+ end
+
+ private
+
+ def schema_breadcrumb_list
+ @schema_breadcrumb_list ||= []
+ end
+
+ def build_item_list_elements
+ return @schema_breadcrumb_list unless @breadcrumbs_extra_links&.any?
+
+ last_element = schema_breadcrumb_list.pop
+
+ @breadcrumbs_extra_links.each do |el|
+ push_to_schema_breadcrumb(el[:text], el[:link])
+ end
+
+ last_element['position'] = schema_breadcrumb_list.last['position'] + 1
+ schema_breadcrumb_list.push(last_element)
+ end
+
+ def schema_list_item(text, link, position)
+ {
+ '@type' => 'ListItem',
+ 'position' => position,
+ 'name' => text,
+ 'item' => ensure_absolute_link(link)
+ }
+ end
+
+ def ensure_absolute_link(link)
+ url = URI.parse(link)
+ url.absolute? ? link : URI.join(request.base_url, link).to_s
+ rescue URI::InvalidURIError
+ "#{request.base_url}#{request.path}"
+ end
end
diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb
index e876eb64029..d47f6195c61 100644
--- a/app/helpers/ci/jobs_helper.rb
+++ b/app/helpers/ci/jobs_helper.rb
@@ -15,7 +15,8 @@ module Ci
"build_status" => @build.status,
"build_stage" => @build.stage,
"log_state" => '',
- "build_options" => javascript_build_options
+ "build_options" => javascript_build_options,
+ "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs')
}
end
end
diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb
new file mode 100644
index 00000000000..3f48b2687b9
--- /dev/null
+++ b/app/helpers/ci/pipeline_editor_helper.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Ci
+ module PipelineEditorHelper
+ include ChecksCollaboration
+
+ def can_view_pipeline_editor?(project)
+ can_collaborate_with_project?(project) &&
+ Gitlab::Ci::Features.ci_pipeline_editor_page_enabled?(project)
+ end
+ end
+end
diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb
index 552acf61f47..432aad663e4 100644
--- a/app/helpers/ci/runners_helper.rb
+++ b/app/helpers/ci/runners_helper.rb
@@ -2,13 +2,15 @@
module Ci
module RunnersHelper
+ include IconsHelper
+
def runner_status_icon(runner)
status = runner.status
case status
when :not_connected
- content_tag :i, nil,
- class: "fa fa-warning",
- title: "New runner. Has not connected yet"
+ content_tag(:span, title: "New runner. Has not connected yet") do
+ sprite_icon("warning-solid", size: 24, css_class: "gl-vertical-align-bottom!")
+ end
when :online, :offline, :paused
content_tag :i, nil,
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index f8490d79427..02d48386e31 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -196,7 +196,7 @@ module CommitsHelper
return unless external_url
link_to(external_url, class: 'btn btn-file-option has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: "View on #{environment.formatted_external_url}", data: { container: 'body' }) do
- icon('external-link')
+ sprite_icon('external-link')
end
end
diff --git a/app/helpers/defer_script_tag_helper.rb b/app/helpers/defer_script_tag_helper.rb
index d91c6d52683..be927c67aaa 100644
--- a/app/helpers/defer_script_tag_helper.rb
+++ b/app/helpers/defer_script_tag_helper.rb
@@ -1,7 +1,9 @@
# frozen_string_literal: true
module DeferScriptTagHelper
- # Override the default ActionView `javascript_include_tag` helper to support page specific deferred loading
+ # Override the default ActionView `javascript_include_tag` helper to support page specific deferred loading.
+ # PLEASE NOTE: `defer` is also critical so that we don't run JavaScript entrypoints before the DOM is ready.
+ # Please see https://gitlab.com/groups/gitlab-org/-/epics/4538#note_432159769.
def javascript_include_tag(*sources)
super(*sources, defer: true)
end
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 7c254e069f6..d6d06434590 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -64,15 +64,17 @@ module DiffHelper
else
# `sub` and substring-ing would destroy HTML-safeness of `line`
if line.start_with?('+', '-', ' ')
- line.dup.tap do |line|
- line[0] = ''
- end
+ line[1, line.length]
else
line
end
end
end
+ def diff_link_number(line_type, match, text)
+ line_type == match ? " " : text
+ end
+
def parallel_diff_discussions(left, right, diff_file)
return unless @grouped_diff_discussions
@@ -201,6 +203,14 @@ module DiffHelper
set_secure_cookie(:diff_view, params.delete(:view), type: CookiesHelper::COOKIE_TYPE_PERMANENT) if params[:view].present?
end
+ def unified_diff_lines_view_type(project)
+ if Feature.enabled?(:unified_diff_lines, project, default_enabled: true)
+ 'inline'
+ else
+ diff_view
+ end
+ end
+
private
def diff_btn(title, name, selected)
@@ -250,4 +260,18 @@ module DiffHelper
"...#{path[-(max - 3)..-1]}"
end
+
+ def code_navigation_path(diffs)
+ Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs&.head_sha)
+ end
+
+ def conflicts
+ return unless options[:merge_ref_head_diff]
+
+ conflicts_service = MergeRequests::Conflicts::ListService.new(merge_request) # rubocop:disable CodeReuse/ServiceClass
+
+ return unless conflicts_service.can_be_resolved_in_ui?
+
+ conflicts_service.conflicts.files.index_by(&:our_path)
+ end
end
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index e1378e485e4..e10e9a83b05 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -129,8 +129,7 @@ module DropdownsHelper
end
def dropdown_loading
- content_tag :div, class: "dropdown-loading" do
- icon('spinner spin')
- end
+ spinner = loading_icon(container: true, size: "md", css_class: "gl-mt-7")
+ content_tag(:div, spinner, class: "dropdown-loading")
end
end
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index 0a0dc77e5e2..017981c8c8e 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -214,6 +214,24 @@ module EmailsHelper
end
end
+ def instance_access_request_text(user, format: nil)
+ gitlab_host = Gitlab.config.gitlab.host
+
+ _('%{username} has asked for a GitLab account on your instance %{host}:') % { username: sanitize_name(user.name), host: gitlab_host }
+ end
+
+ def instance_access_request_link(user, format: nil)
+ url = admin_user_url(user)
+
+ case format
+ when :html
+ user_page = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: url }
+ _("Click %{link_start}here%{link_end} to view the request.").html_safe % { link_start: user_page, link_end: '</a>'.html_safe }
+ else
+ _('Click %{link_to} to view the request.') % { link_to: url }
+ end
+ end
+
def contact_your_administrator_text
_('Please contact your administrator with any questions.')
end
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index 7df6bef7914..7ae00a70671 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -70,6 +70,10 @@ module GitlabRoutingHelper
project_commit_url(entity.project, entity.sha, *args)
end
+ def release_url(entity, *args)
+ project_release_url(entity.project, entity, *args)
+ end
+
def preview_markdown_path(parent, *args)
return group_preview_markdown_path(parent, *args) if parent.is_a?(Group)
@@ -343,18 +347,6 @@ 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
index 7edf7dc218d..875a44c51bb 100644
--- a/app/helpers/gitpod_helper.rb
+++ b/app/helpers/gitpod_helper.rb
@@ -2,9 +2,6 @@
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 }
+ s_('Enable %{linkStart}Gitpod%{linkEnd} integration to launch a development environment in your browser directly from GitLab.')
end
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 06a52457fd6..29ead76a607 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -94,12 +94,19 @@ module GroupsHelper
else
full_title << breadcrumb_list_item(group_title_link(parent, hidable: false))
end
+
+ push_to_schema_breadcrumb(simple_sanitize(parent.name), group_path(parent))
end
full_title << render("layouts/nav/breadcrumbs/collapsed_dropdown", location: :before, title: _("Show parent subgroups"))
full_title << breadcrumb_list_item(group_title_link(group))
- full_title << ' &middot; '.html_safe + link_to(simple_sanitize(name), url, class: 'group-path breadcrumb-item-text js-breadcrumb-item-text') if name
+ push_to_schema_breadcrumb(simple_sanitize(group.name), group_path(group))
+
+ if name
+ full_title << ' &middot; '.html_safe + link_to(simple_sanitize(name), url, class: 'group-path breadcrumb-item-text js-breadcrumb-item-text')
+ push_to_schema_breadcrumb(simple_sanitize(name), url)
+ end
full_title.join.html_safe
end
@@ -163,6 +170,10 @@ module GroupsHelper
group_container_registry_nav?
end
+ def group_dependency_proxy_nav?
+ @group.dependency_proxy_feature_available?
+ end
+
def group_packages_list_nav?
@group.packages_feature_enabled?
end
@@ -174,6 +185,10 @@ module GroupsHelper
!multiple_members?(group)
end
+ def show_thanks_for_purchase_banner?
+ params.key?(:purchased_quantity) && params[:purchased_quantity].to_i > 0
+ end
+
private
def just_created?
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index 1d0001fde72..dc6164ee898 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -24,9 +24,11 @@ module IconsHelper
end
def custom_icon(icon_name, size: DEFAULT_ICON_SIZE)
- # We can't simply do the below, because there are some .erb SVGs.
- # File.read(Rails.root.join("app/views/shared/icons/_#{icon_name}.svg")).html_safe
- render "shared/icons/#{icon_name}.svg", size: size
+ memoized_icon("#{icon_name}_#{size}") do
+ # We can't simply do the below, because there are some .erb SVGs.
+ # File.read(Rails.root.join("app/views/shared/icons/_#{icon_name}.svg")).html_safe
+ render "shared/icons/#{icon_name}.svg", size: size
+ end
end
def sprite_icon_path
@@ -46,21 +48,23 @@ module IconsHelper
end
def sprite_icon(icon_name, size: DEFAULT_ICON_SIZE, css_class: nil)
- if known_sprites&.exclude?(icon_name)
- exception = ArgumentError.new("#{icon_name} is not a known icon in @gitlab-org/gitlab-svg")
- Gitlab::ErrorTracking.track_and_raise_for_dev_exception(exception)
- end
+ memoized_icon("#{icon_name}_#{size}_#{css_class}") do
+ if known_sprites&.exclude?(icon_name)
+ exception = ArgumentError.new("#{icon_name} is not a known icon in @gitlab-org/gitlab-svg")
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(exception)
+ end
- css_classes = []
- css_classes << "s#{size}" if size
- css_classes << "#{css_class}" unless css_class.blank?
+ css_classes = []
+ css_classes << "s#{size}" if size
+ css_classes << "#{css_class}" unless css_class.blank?
- content_tag(
- :svg,
- content_tag(:use, '', { 'xlink:href' => "#{sprite_icon_path}##{icon_name}" } ),
- class: css_classes.empty? ? nil : css_classes.join(' '),
- data: { testid: "#{icon_name}-icon" }
- )
+ content_tag(
+ :svg,
+ content_tag(:use, '', { 'xlink:href' => "#{sprite_icon_path}##{icon_name}" } ),
+ class: css_classes.empty? ? nil : css_classes.join(' '),
+ data: { testid: "#{icon_name}-icon" }
+ )
+ end
end
def loading_icon(container: false, color: 'orange', size: 'sm', css_class: nil)
@@ -76,26 +80,17 @@ module IconsHelper
content_tag(:span, "", class: "gl-snippet-icon gl-snippet-icon-#{name}")
end
- def audit_icon(names, options = {})
- case names
+ def audit_icon(name, css_class: nil)
+ case name
when "standard"
- names = "key"
+ name = "key"
when "two-factor"
- names = "key"
+ name = "key"
when "google_oauth2"
- names = "google"
+ name = "google"
end
- options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options)
- end
-
- def spinner(text = nil, visible = false)
- css_class = ['loading']
- css_class << 'hide' unless visible
-
- content_tag :div, class: css_class.join(' ') do
- icon('spinner spin') + text
- end
+ sprite_icon(name, css_class: css_class)
end
def boolean_to_icon(value)
@@ -178,4 +173,12 @@ module IconsHelper
@known_sprites ||= Gitlab::Json.parse(File.read(Rails.root.join('node_modules/@gitlab/svgs/dist/icons.json')))['icons']
end
+
+ def memoized_icon(key)
+ @rendered_icons ||= {}
+
+ @rendered_icons[key] || (
+ @rendered_icons[key] = yield
+ )
+ end
end
diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb
index ac6ac9979b3..cea28fd4611 100644
--- a/app/helpers/invite_members_helper.rb
+++ b/app/helpers/invite_members_helper.rb
@@ -18,4 +18,8 @@ module InviteMembersHelper
experiment_enabled?(:invite_members_version_b) && !can_import_members?
end
end
+
+ def invite_group_members?(group)
+ experiment_enabled?(:invite_members_empty_group_version_a) && Ability.allowed?(current_user, :admin_group_member, group)
+ end
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index f8e7711959a..77ced17bc22 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -76,7 +76,6 @@ module IssuablesHelper
when Issue
IssueSerializer
when MergeRequest
- opts[:experiment_enabled] = :suggest_pipeline if experiment_enabled?(:suggest_pipeline) && opts[:serializer] == 'widget'
MergeRequestSerializer
end
@@ -211,7 +210,7 @@ module IssuablesHelper
output << content_tag(:span, (sprite_icon('first-contribution', css_class: 'gl-icon gl-vertical-align-middle') if issuable.first_contribution?), class: 'has-tooltip gl-ml-2', title: _('1st contribution!'))
- output << content_tag(:span, (issuable.task_status if issuable.tasks?), id: "task_status", class: "d-none d-sm-none d-md-inline-block gl-ml-3")
+ output << content_tag(:span, (issuable.task_status if issuable.tasks?), id: "task_status", class: "d-none d-md-inline-block gl-ml-3")
output << content_tag(:span, (issuable.task_status_short if issuable.tasks?), id: "task_status_short", class: "d-md-none")
output.join.html_safe
@@ -275,7 +274,6 @@ module IssuablesHelper
canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable),
canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable),
issuableRef: issuable.to_reference,
- issuableStatus: issuable.state,
markdownPreviewPath: preview_markdown_path(parent),
markdownDocsPath: help_page_path('user/markdown'),
lockVersion: issuable.lock_version,
@@ -379,7 +377,12 @@ module IssuablesHelper
end
def issuable_display_type(issuable)
- issuable.model_name.human.downcase
+ case issuable
+ when Issue
+ issuable.issue_type.downcase
+ when MergeRequest
+ issuable.model_name.human.downcase
+ end
end
def has_filter_bar_param?
@@ -489,6 +492,21 @@ module IssuablesHelper
}
end
+ def sidebar_labels_data(issuable_sidebar, project)
+ {
+ 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: issuable_sidebar.dig(:current_user, :can_edit).to_s,
+ iid: issuable_sidebar[:iid],
+ issuable_type: issuable_sidebar[:type],
+ labels_fetch_path: issuable_sidebar[:project_labels_path],
+ labels_manage_path: project_labels_path(project),
+ project_issues_path: issuable_sidebar[:project_issuables_path],
+ project_path: project.full_path,
+ selected_labels: issuable_sidebar[:labels].to_json
+ }
+ end
+
def parent
@project || @group
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index dbf284e70e4..dee009cd3ab 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -152,6 +152,29 @@ module IssuesHelper
sort: 'desc'
}
end
+
+ def issue_header_actions_data(project, issuable, current_user)
+ new_issuable_params = ({ issuable_template: 'incident', issue: { issue_type: 'incident' } } if issuable.incident?)
+
+ {
+ can_create_issue: show_new_issue_link?(project).to_s,
+ can_reopen_issue: can?(current_user, :reopen_issue, issuable).to_s,
+ can_report_spam: issuable.submittable_as_spam_by?(current_user).to_s,
+ can_update_issue: can?(current_user, :update_issue, issuable).to_s,
+ iid: issuable.iid,
+ is_issue_author: (issuable.author == current_user).to_s,
+ issue_type: issuable_display_type(issuable),
+ new_issue_path: new_project_issue_path(project, new_issuable_params),
+ project_path: project.full_path,
+ report_abuse_path: new_abuse_report_path(user_id: issuable.author.id, ref_url: issue_url(issuable)),
+ submit_as_spam_path: mark_as_spam_project_issue_path(project, issuable)
+ }
+ end
+
+ # Overridden in EE
+ def scoped_labels_available?(parent)
+ false
+ end
end
IssuesHelper.prepend_if_ee('EE::IssuesHelper')
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index c02adfcf4c6..871d19c6a8c 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -159,6 +159,7 @@ module NotesHelper
members: autocomplete,
issues: autocomplete,
mergeRequests: autocomplete,
+ vulnerabilities: autocomplete,
epics: autocomplete,
milestones: autocomplete,
labels: autocomplete
diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb
index 542a9ad2a70..61fcda6a504 100644
--- a/app/helpers/notifications_helper.rb
+++ b/app/helpers/notifications_helper.rb
@@ -67,6 +67,7 @@ module NotificationsHelper
when :custom
_('You will only receive notifications for the events you choose')
when :owner_disabled
+ # Any change must be reflected in board_sidebar_subscription.vue
_('Notifications have been disabled by the project or group owner')
end
end
diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb
index 9965a705a01..8105fce10cf 100644
--- a/app/helpers/operations_helper.rb
+++ b/app/helpers/operations_helper.rb
@@ -29,7 +29,9 @@ module OperationsHelper
'url' => alerts_service.url,
'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
+ 'disabled' => disabled.to_s,
+ 'project_path' => @project.full_path,
+ 'multi_integrations' => 'false'
}
end
diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb
index 6808ffc3e27..e3d82e7a091 100644
--- a/app/helpers/page_layout_helper.rb
+++ b/app/helpers/page_layout_helper.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module PageLayoutHelper
+ include Gitlab::Utils::StrongMemoize
+
def page_title(*titles)
@page_title ||= []
@@ -44,7 +46,7 @@ module PageLayoutHelper
if link
@page_canonical_link = link
else
- @page_canonical_link
+ @page_canonical_link ||= generic_canonical_url
end
end
@@ -57,7 +59,7 @@ module PageLayoutHelper
subject = @project || @user || @group
- image = subject.avatar_url if subject.present?
+ image = subject.avatar_url(only_path: false) if subject.present?
image || default
end
@@ -147,4 +149,49 @@ module PageLayoutHelper
css_class.join(' ')
end
+
+ def page_itemtype(itemtype = nil)
+ if itemtype
+ @page_itemtype = { itemscope: true, itemtype: itemtype }
+ else
+ @page_itemtype || {}
+ end
+ end
+
+ def user_status_properties(user)
+ default_properties = { current_emoji: '', current_message: '', can_set_user_availability: Feature.enabled?(:set_user_availability_status, user), default_emoji: UserStatus::DEFAULT_EMOJI }
+ return default_properties unless user&.status
+
+ default_properties.merge({
+ current_emoji: user.status.emoji.to_s,
+ current_message: user.status.message.to_s,
+ current_availability: user.status.availability.to_s
+ })
+ end
+
+ private
+
+ def generic_canonical_url
+ strong_memoize(:generic_canonical_url) do
+ next unless request.get? || request.head?
+ next unless generate_generic_canonical_url?
+
+ # Request#url builds the url without the trailing slash
+ request.url
+ end
+ end
+
+ def generate_generic_canonical_url?
+ # For the main domain it doesn't matter whether there is
+ # a trailing slash or not, they're not considered different
+ # pages
+ return false if request.path == '/'
+
+ # We only need to generate the canonical url when the request has a trailing
+ # slash. In the request object, only the `original_fullpath` and
+ # `original_url` keep the slash if it's present. Both `path` and
+ # `fullpath` would return the path without the slash.
+ # Therefore, we need to process `original_fullpath`
+ request.original_fullpath.sub(request.path, '')[0] == '/'
+ end
end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 9bf819febb0..5310aef5bad 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -82,8 +82,8 @@ module PreferencesHelper
def integration_views
[].tap do |views|
- views << 'gitpod' if Gitlab::Gitpod.feature_and_settings_enabled?
- views << 'sourcegraph' if Gitlab::Sourcegraph.feature_available? && Gitlab::CurrentSettings.sourcegraph_enabled
+ views << { name: 'gitpod', message: gitpod_enable_description, message_url: 'https://gitpod.io/', help_link: help_page_path('integration/gitpod.md') } if Gitlab::Gitpod.feature_and_settings_enabled?
+ views << { name: 'sourcegraph', message: sourcegraph_url_message, message_url: Gitlab::CurrentSettings.sourcegraph_url, help_link: help_page_path('user/profile/preferences.md', anchor: 'sourcegraph') } if Gitlab::Sourcegraph.feature_available? && Gitlab::CurrentSettings.sourcegraph_enabled
end
end
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index 5a42e581867..04a3b915493 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -29,4 +29,18 @@ module ProfilesHelper
def user_profile?
params[:controller] == 'users'
end
+
+ def availability_values
+ Types::AvailabilityEnum.enum
+ end
+
+ def user_status_set_to_busy?(status)
+ status&.availability == availability_values[:busy]
+ end
+
+ def show_status_emoji?(status)
+ return false unless status
+
+ status.message.present? || status.emoji != UserStatus::DEFAULT_EMOJI
+ end
end
diff --git a/app/helpers/projects/terraform_helper.rb b/app/helpers/projects/terraform_helper.rb
new file mode 100644
index 00000000000..b286bc4d7a5
--- /dev/null
+++ b/app/helpers/projects/terraform_helper.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Projects::TerraformHelper
+ def js_terraform_list_data(project)
+ {
+ empty_state_image: image_path('illustrations/empty-state/empty-serverless-lg.svg'),
+ project_path: project.full_path
+ }
+ end
+end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index ae46135e890..f25b229d198 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -84,18 +84,8 @@ module ProjectsHelper
end
def project_title(project)
- namespace_link =
- if project.group
- group_title(project.group, nil, nil)
- else
- owner = project.namespace.owner
- link_to(simple_sanitize(owner.name), user_path(owner))
- end
-
- project_link = link_to project_path(project) do
- icon = project_icon(project, alt: project.name, class: 'avatar-tile', width: 15, height: 15) if project.avatar_url && !Rails.env.test?
- [icon, content_tag("span", simple_sanitize(project.name), class: "breadcrumb-item-text js-breadcrumb-item-text")].join.html_safe
- end
+ namespace_link = build_namespace_breadcrumb_link(project)
+ project_link = build_project_breadcrumb_link(project)
namespace_link = breadcrumb_list_item(namespace_link) unless project.group
project_link = breadcrumb_list_item project_link
@@ -302,7 +292,7 @@ module ProjectsHelper
end
def settings_operations_available?
- can?(current_user, :read_environment, @project)
+ !@project.archived? && can?(current_user, :admin_operations, @project)
end
def error_tracking_setting_project_json
@@ -465,6 +455,7 @@ module ProjectsHelper
builds: :read_build,
clusters: :read_cluster,
serverless: :read_cluster,
+ terraform: :read_terraform_state,
error_tracking: :read_sentry_issue,
alert_management: :read_alert_management_alert,
incidents: :read_issue,
@@ -484,7 +475,8 @@ module ProjectsHelper
:read_issue,
:read_sentry_issue,
:read_cluster,
- :read_feature_flag
+ :read_feature_flag,
+ :read_terraform_state
].any? do |ability|
can?(current_user, ability, project)
end
@@ -762,6 +754,7 @@ module ProjectsHelper
metrics_dashboard
feature_flags
tracings
+ terraform
]
end
@@ -784,6 +777,30 @@ module ProjectsHelper
def project_access_token_available?(project)
can?(current_user, :admin_resource_access_tokens, project)
end
+
+ def build_project_breadcrumb_link(project)
+ project_name = simple_sanitize(project.name)
+
+ push_to_schema_breadcrumb(project_name, project_path(project))
+
+ link_to project_path(project) do
+ icon = project_icon(project, alt: project_name, class: 'avatar-tile', width: 15, height: 15) if project.avatar_url && !Rails.env.test?
+ [icon, content_tag("span", project_name, class: "breadcrumb-item-text js-breadcrumb-item-text")].join.html_safe
+ end
+ end
+
+ def build_namespace_breadcrumb_link(project)
+ if project.group
+ group_title(project.group, nil, nil)
+ else
+ owner = project.namespace.owner
+ name = simple_sanitize(owner.name)
+ url = user_path(owner)
+
+ push_to_schema_breadcrumb(name, url)
+ link_to(name, url)
+ end
+ end
end
ProjectsHelper.prepend_if_ee('EE::ProjectsHelper')
diff --git a/app/helpers/recaptcha_experiment_helper.rb b/app/helpers/recaptcha_experiment_helper.rb
deleted file mode 100644
index f15e92c0e99..00000000000
--- a/app/helpers/recaptcha_experiment_helper.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-module RecaptchaExperimentHelper
- def show_recaptcha_sign_up?
- !!Gitlab::Recaptcha.enabled?
- end
-end
-
-RecaptchaExperimentHelper.prepend_if_ee('EE::RecaptchaExperimentHelper')
diff --git a/app/helpers/recaptcha_helper.rb b/app/helpers/recaptcha_helper.rb
new file mode 100644
index 00000000000..4ebac1d5b7f
--- /dev/null
+++ b/app/helpers/recaptcha_helper.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module RecaptchaHelper
+ def show_recaptcha_sign_up?
+ !!Gitlab::Recaptcha.enabled?
+ end
+end
diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb
index 050b27840a0..d9851564585 100644
--- a/app/helpers/releases_helper.rb
+++ b/app/helpers/releases_helper.rb
@@ -51,18 +51,25 @@ module ReleasesHelper
)
end
+ def group_milestone_project_releases_available?(project)
+ false
+ end
+
private
def new_edit_pages_shared_data
{
project_id: @project.id,
+ group_id: @project.group&.id,
+ group_milestones_available: group_milestone_project_releases_available?(@project),
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'),
release_assets_docs_path: help_page(anchor: 'release-assets'),
manage_milestones_path: project_milestones_path(@project),
new_milestone_path: new_project_milestone_path(@project)
}
end
end
+
+ReleasesHelper.prepend_if_ee('EE::ReleasesHelper')
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 3467f6e9a44..de1e0e4e05e 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -1,7 +1,16 @@
# frozen_string_literal: true
module SearchHelper
- SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets, :sort, :state, :confidential].freeze
+ SEARCH_GENERIC_PARAMS = [
+ :search,
+ :scope,
+ :project_id,
+ :group_id,
+ :repository_ref,
+ :snippets,
+ :sort,
+ :force_search_results
+ ].freeze
def search_autocomplete_opts(term)
return unless current_user
@@ -9,7 +18,8 @@ module SearchHelper
resources_results = [
recent_items_autocomplete(term),
groups_autocomplete(term),
- projects_autocomplete(term)
+ projects_autocomplete(term),
+ issue_autocomplete(term)
].flatten
search_pattern = Regexp.new(Regexp.escape(term), "i")
@@ -82,11 +92,27 @@ module SearchHelper
end
end
- def search_entries_empty_message(scope, term)
- (s_("SearchResults|We couldn't find any %{scope} matching %{term}") % {
+ def search_entries_empty_message(scope, term, group, project)
+ options = {
scope: search_entries_scope_label(scope, 0),
- term: "<code>#{h(term)}</code>"
- }).html_safe
+ term: "<code>#{h(term)}</code>".html_safe
+ }
+
+ # We check project first because we have 3 possible combinations here:
+ # - group && project
+ # - group
+ # - group: nil, project: nil
+ if project
+ html_escape(_("We couldn't find any %{scope} matching %{term} in project %{project}")) % options.merge(
+ project: link_to(project.full_name, project_path(project), target: '_blank', rel: 'noopener noreferrer').html_safe
+ )
+ elsif group
+ html_escape(_("We couldn't find any %{scope} matching %{term} in group %{group}")) % options.merge(
+ group: link_to(group.full_name, group_path(group), target: '_blank', rel: 'noopener noreferrer').html_safe
+ )
+ else
+ html_escape(_("We couldn't find any %{scope} matching %{term}")) % options
+ end
end
def repository_ref(project)
@@ -140,7 +166,7 @@ module SearchHelper
if @project && @project.repository.root_ref
ref = @ref || @project.repository.root_ref
- [
+ result = [
{ category: "In this project", label: _("Files"), url: project_tree_path(@project, ref) },
{ category: "In this project", label: _("Commits"), url: project_commits_path(@project, ref) },
{ category: "In this project", label: _("Network"), url: project_network_path(@project, ref) },
@@ -152,6 +178,12 @@ module SearchHelper
{ category: "In this project", label: _("Members"), url: project_project_members_path(@project) },
{ category: "In this project", label: _("Wiki"), url: project_wikis_path(@project) }
]
+
+ if can?(current_user, :read_feature_flag, @project)
+ result << { category: "In this project", label: _("Feature Flags"), url: project_feature_flags_path(@project) }
+ end
+
+ result
else
[]
end
@@ -172,6 +204,24 @@ module SearchHelper
end
# rubocop: enable CodeReuse/ActiveRecord
+ def issue_autocomplete(term)
+ return [] unless @project.present? && current_user && term =~ /\A#{Issue.reference_prefix}\d+\z/
+
+ iid = term.sub(Issue.reference_prefix, '').to_i
+ issue = @project.issues.find_by_iid(iid)
+ return [] unless issue && Ability.allowed?(current_user, :read_issue, issue)
+
+ [
+ {
+ category: 'In this project',
+ id: issue.id,
+ label: search_result_sanitize("#{issue.title} (#{issue.to_reference})"),
+ url: issue_path(issue),
+ avatar_url: issue.project.avatar_url || ''
+ }
+ ]
+ end
+
# Autocomplete results for the current user's projects
# rubocop: disable CodeReuse/ActiveRecord
def projects_autocomplete(term, limit = 5)
@@ -225,7 +275,7 @@ module SearchHelper
search_params = params
.merge(search)
.merge({ scope: scope })
- .permit(SEARCH_PERMITTED_PARAMS)
+ .permit(SEARCH_GENERIC_PARAMS)
if @scope == scope
li_class = 'active'
@@ -317,10 +367,10 @@ module SearchHelper
end
# _search_highlight is used in EE override
- def highlight_and_truncate_issue(issue, search_term, _search_highlight)
- return unless issue.description.present?
+ def highlight_and_truncate_issuable(issuable, search_term, _search_highlight)
+ return unless issuable.description.present?
- simple_search_highlight_and_truncate(issue.description, search_term, highlighter: '<span class="gl-text-black-normal gl-font-weight-bold">\1</span>')
+ simple_search_highlight_and_truncate(issuable.description, search_term, highlighter: '<span class="gl-text-black-normal gl-font-weight-bold">\1</span>')
end
def show_user_search_tab?
@@ -330,6 +380,36 @@ module SearchHelper
can?(current_user, :read_users_list)
end
end
+
+ def issuable_state_to_badge_class(issuable)
+ # Closed is considered "danger" for MR so we need to handle separately
+ if issuable.is_a?(::MergeRequest)
+ if issuable.merged?
+ :primary
+ elsif issuable.closed?
+ :danger
+ else
+ :success
+ end
+ else
+ if issuable.closed?
+ :info
+ else
+ :success
+ end
+ end
+ end
+
+ def issuable_state_text(issuable)
+ case issuable.state
+ when 'merged'
+ _("Merged")
+ when 'closed'
+ _("Closed")
+ else
+ _("Open")
+ end
+ end
end
SearchHelper.prepend_if_ee('EE::SearchHelper')
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index 114bbf59ae1..96eb14be4b4 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -35,13 +35,6 @@ module ServicesHelper
"#{event}_events"
end
- def service_save_button(disabled: false)
- button_tag(class: 'btn btn-success', type: 'submit', disabled: disabled, data: { qa_selector: 'save_changes_button' }) do
- icon('spinner spin', class: 'hidden js-btn-spinner') +
- content_tag(:span, 'Save changes', class: 'js-btn-label')
- end
- end
-
def scoped_integrations_path
if @project.present?
project_settings_integrations_path(@project)
@@ -100,7 +93,8 @@ module ServicesHelper
editable: integration.editable?.to_s,
cancel_path: scoped_integrations_path,
can_test: integration.can_test?.to_s,
- test_path: scoped_test_integration_path(integration)
+ test_path: scoped_test_integration_path(integration),
+ reset_path: ''
}
end
@@ -121,7 +115,7 @@ module ServicesHelper
end
def group_level_integrations?
- @group.present? && Feature.enabled?(:group_level_integrations, @group)
+ @group.present? && Feature.enabled?(:group_level_integrations, @group, default_enabled: true)
end
def instance_level_integrations?
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index de6990041a6..10174e5d719 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -28,7 +28,8 @@ module SortingHelper
sort_value_contacted_date => sort_title_contacted_date,
sort_value_relative_position => sort_title_relative_position,
sort_value_size => sort_title_size,
- sort_value_expire_date => sort_title_expire_date
+ sort_value_expire_date => sort_title_expire_date,
+ sort_value_relevant => sort_title_relevant
}
end
@@ -81,6 +82,13 @@ module SortingHelper
}
end
+ def search_reverse_sort_options_hash
+ {
+ sort_value_recently_created => sort_value_oldest_created,
+ sort_value_oldest_created => sort_value_recently_created
+ }
+ end
+
def groups_sort_options_hash
{
sort_value_name => sort_title_name,
@@ -218,6 +226,10 @@ module SortingHelper
sort_options_hash[sort_value]
end
+ def search_sort_option_title(sort_value)
+ sort_options_hash[sort_value]
+ end
+
def sort_direction_icon(sort_value)
case sort_value
when sort_value_milestone, sort_value_due_date, /_asc\z/
@@ -256,6 +268,13 @@ module SortingHelper
sort_direction_button(url, reverse_sort, sort_value)
end
+ def search_sort_direction_button(sort_value)
+ reverse_sort = search_reverse_sort_options_hash[sort_value]
+ url = page_filter_path(sort: reverse_sort)
+
+ sort_direction_button(url, reverse_sort, sort_value)
+ end
+
# Titles.
def sort_title_access_level_asc
s_('SortOptions|Access level, ascending')
@@ -421,6 +440,10 @@ module SortingHelper
s_('SortOptions|Expired date')
end
+ def sort_title_relevant
+ s_('SortOptions|Relevant')
+ end
+
# Values.
def sort_value_access_level_asc
'access_level_asc'
@@ -582,6 +605,10 @@ module SortingHelper
'expired_asc'
end
+ def sort_value_relevant
+ 'relevant'
+ end
+
def packages_sort_options_hash
{
sort_value_recently_created => sort_title_created_date,
diff --git a/app/helpers/sourcegraph_helper.rb b/app/helpers/sourcegraph_helper.rb
index cc5a5c77e9a..25d7b209b45 100644
--- a/app/helpers/sourcegraph_helper.rb
+++ b/app/helpers/sourcegraph_helper.rb
@@ -2,26 +2,22 @@
module SourcegraphHelper
def sourcegraph_url_message
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: Gitlab::CurrentSettings.sourcegraph_url }
- link_end = "#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}</a>".html_safe
-
message =
if Gitlab::CurrentSettings.sourcegraph_url_is_com?
- s_('SourcegraphPreferences|Uses %{link_start}Sourcegraph.com%{link_end}.').html_safe
+ s_('SourcegraphPreferences|Uses %{linkStart}Sourcegraph.com%{linkEnd}.').html_safe
else
- s_('SourcegraphPreferences|Uses a custom %{link_start}Sourcegraph instance%{link_end}.').html_safe
+ s_('SourcegraphPreferences|Uses a custom %{linkStart}Sourcegraph instance%{linkEnd}.').html_safe
end
- message % { link_start: link_start, link_end: link_end }
- end
+ experimental_message =
+ if Gitlab::Sourcegraph.feature_conditional?
+ s_("SourcegraphPreferences|This feature is experimental and currently limited to certain projects.")
+ elsif Gitlab::CurrentSettings.sourcegraph_public_only
+ s_("SourcegraphPreferences|This feature is experimental and limited to public projects.")
+ else
+ s_("SourcegraphPreferences|This feature is experimental.")
+ end
- def sourcegraph_experimental_message
- if Gitlab::Sourcegraph.feature_conditional?
- s_("SourcegraphPreferences|This feature is experimental and currently limited to certain projects.")
- elsif Gitlab::CurrentSettings.sourcegraph_public_only
- s_("SourcegraphPreferences|This feature is experimental and limited to public projects.")
- else
- s_("SourcegraphPreferences|This feature is experimental.")
- end
+ "#{message} #{experimental_message}"
end
end
diff --git a/app/helpers/stat_anchors_helper.rb b/app/helpers/stat_anchors_helper.rb
new file mode 100644
index 00000000000..76e58b45912
--- /dev/null
+++ b/app/helpers/stat_anchors_helper.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module StatAnchorsHelper
+ def stat_anchor_attrs(anchor)
+ {}.tap do |attrs|
+ attrs[:class] = %w(nav-link gl-display-flex gl-align-items-center) << extra_classes(anchor)
+ attrs[:itemprop] = anchor.itemprop if anchor.itemprop
+ end
+ end
+
+ private
+
+ def button_attribute(anchor)
+ "btn-#{anchor.class_modifier || 'missing'}"
+ end
+
+ def extra_classes(anchor)
+ if anchor.is_link
+ 'stat-link'
+ else
+ "btn #{button_attribute(anchor)}"
+ end
+ end
+end
diff --git a/app/helpers/suggest_pipeline_helper.rb b/app/helpers/suggest_pipeline_helper.rb
index d64e8d6f2cd..3151b792344 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?
- experiment_enabled?(:suggest_pipeline) &&
+ Feature.enabled?(:suggest_pipeline, default_enabled: true) &&
current_user &&
params[:suggest_gitlab_ci_yml] == 'true'
end
diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb
index 719c351242c..ecedbfb2a4f 100644
--- a/app/helpers/time_helper.rb
+++ b/app/helpers/time_helper.rb
@@ -32,4 +32,8 @@ module TimeHelper
"%02d:%02d:%02d" % [hours, minutes, seconds]
end
end
+
+ def time_in_milliseconds
+ (Time.now.to_f * 1000).to_i
+ end
end
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index 563450159b5..692971f4627 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -75,11 +75,27 @@ module TreeHelper
if user_access(project).can_push_to_branch?(ref)
ref
else
- project = tree_edit_project(project)
- project.repository.next_branch('patch')
+ patch_branch_name(ref)
end
end
+ # Generate a patch branch name that should look like:
+ # `username-branchname-patch-epoch`
+ # where `epoch` is the last 5 digits of the time since epoch (in
+ # milliseconds)
+ #
+ # Note: this correlates with how the WebIDE formats the branch name
+ # and if this implementation changes, so should the `placeholderBranchName`
+ # definition in app/assets/javascripts/ide/stores/modules/commit/getters.js
+ def patch_branch_name(ref)
+ return unless current_user
+
+ username = current_user.username
+ epoch = time_in_milliseconds.to_s.last(5)
+
+ "#{username}-#{ref}-patch-#{epoch}"
+ end
+
def tree_edit_project(project = @project)
if can?(current_user, :push_code, project)
project
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
index 0cdf53d6174..e93c1b82cd7 100644
--- a/app/helpers/user_callouts_helper.rb
+++ b/app/helpers/user_callouts_helper.rb
@@ -10,6 +10,7 @@ module UserCalloutsHelper
WEBHOOKS_MOVED = 'webhooks_moved'
CUSTOMIZE_HOMEPAGE = 'customize_homepage'
FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version'
+ REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout'
def show_admin_integrations_moved?
!user_dismissed?(ADMIN_INTEGRATIONS_MOVED)
@@ -55,6 +56,10 @@ module UserCalloutsHelper
!user_dismissed?(FEATURE_FLAGS_NEW_VERSION)
end
+ def show_registration_enabled_user_callout?
+ current_user&.admin? && signup_enabled? && !user_dismissed?(REGISTRATION_ENABLED_CALLOUT)
+ end
+
private
def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil)
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index f47937e6d57..7d4ab192f2f 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -91,18 +91,18 @@ module UsersHelper
end
end
- def work_information(user)
+ def work_information(user, with_schema_markup: false)
return unless user
organization = user.organization
job_title = user.job_title
if organization.present? && job_title.present?
- s_('Profile|%{job_title} at %{organization}') % { job_title: job_title, organization: organization }
+ render_job_title_and_organization(job_title, organization, with_schema_markup: with_schema_markup)
elsif job_title.present?
- job_title
+ render_job_title(job_title, with_schema_markup: with_schema_markup)
elsif organization.present?
- organization
+ render_organization(organization, with_schema_markup: with_schema_markup)
end
end
@@ -110,6 +110,32 @@ module UsersHelper
!user.confirmed?
end
+ def user_block_data(user, message)
+ {
+ path: block_admin_user_path(user),
+ method: 'put',
+ modal_attributes: {
+ title: s_('AdminUsers|Block user %{username}?') % { username: sanitize_name(user.name) },
+ messageHtml: message,
+ okVariant: 'warning',
+ okTitle: s_('AdminUsers|Block')
+ }.to_json
+ }
+ end
+
+ def user_block_effects
+ header = tag.p s_('AdminUsers|Blocking user has the following effects:')
+
+ list = tag.ul do
+ concat tag.li s_('AdminUsers|User will not be able to login')
+ concat tag.li s_('AdminUsers|User will not be able to access git repositories')
+ concat tag.li s_('AdminUsers|Personal projects will be left')
+ concat tag.li s_('AdminUsers|Owned groups will be left')
+ end
+
+ header + list
+ end
+
private
def blocked_user_badge(user)
@@ -151,6 +177,35 @@ module UsersHelper
items
end
+
+ def render_job_title(job_title, with_schema_markup: false)
+ if with_schema_markup
+ content_tag :span, itemprop: 'jobTitle' do
+ job_title
+ end
+ else
+ job_title
+ end
+ end
+
+ def render_organization(organization, with_schema_markup: false)
+ if with_schema_markup
+ content_tag :span, itemprop: 'worksFor' do
+ organization
+ end
+ else
+ organization
+ end
+ end
+
+ def render_job_title_and_organization(job_title, organization, with_schema_markup: false)
+ if with_schema_markup
+ job_title = '<span itemprop="jobTitle">'.html_safe + job_title + "</span>".html_safe
+ organization = '<span itemprop="worksFor">'.html_safe + organization + "</span>".html_safe
+ end
+
+ html_escape(s_('Profile|%{job_title} at %{organization}')) % { job_title: job_title, organization: organization }
+ end
end
UsersHelper.prepend_if_ee('EE::UsersHelper')
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index a7b9e17c898..896dcdd2caf 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -31,7 +31,7 @@ module VisibilityLevelHelper
when Gitlab::VisibilityLevel::PRIVATE
_("Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.")
when Gitlab::VisibilityLevel::INTERNAL
- _("The project can be accessed by any logged in user.")
+ _("The project can be accessed by any logged in user except external users.")
when Gitlab::VisibilityLevel::PUBLIC
_("The project can be accessed without any authentication.")
end
@@ -42,7 +42,7 @@ module VisibilityLevelHelper
when Gitlab::VisibilityLevel::PRIVATE
_("The group and its projects can only be viewed by members.")
when Gitlab::VisibilityLevel::INTERNAL
- _("The group and any internal projects can be viewed by any logged in user.")
+ _("The group and any internal projects can be viewed by any logged in user except external users.")
when Gitlab::VisibilityLevel::PUBLIC
_("The group and any public projects can be viewed without any authentication.")
end
diff --git a/app/helpers/whats_new_helper.rb b/app/helpers/whats_new_helper.rb
index c183ed7f12a..283d443f51b 100644
--- a/app/helpers/whats_new_helper.rb
+++ b/app/helpers/whats_new_helper.rb
@@ -5,7 +5,7 @@ module WhatsNewHelper
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
+ whats_new_release_items&.count
end
end
@@ -19,9 +19,7 @@ module WhatsNewHelper
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
+ whats_new_release_items&.first&.[]('release')
end
end
end
diff --git a/app/mailers/devise_mailer.rb b/app/mailers/devise_mailer.rb
index a02670aed90..61a23520d54 100644
--- a/app/mailers/devise_mailer.rb
+++ b/app/mailers/devise_mailer.rb
@@ -13,6 +13,10 @@ class DeviseMailer < Devise::Mailer
devise_mail(record, :password_change_by_admin, opts)
end
+ def user_admin_approval(record, opts = {})
+ devise_mail(record, :user_admin_approval, opts)
+ end
+
protected
def subject_for(key)
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index 57e4c7df440..0b5a8dfdc24 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -51,34 +51,20 @@ module Emails
return unless member_exists?
- subject_line = subject("Invitation to join the #{member_source.human_name} #{member_source.model_name.singular}")
-
- if member.invite_to_unknown_user? && Feature.enabled?(:invite_email_experiment)
- subject_line = subject("#{member.created_by.name} invited you to join GitLab") if member.created_by
- @invite_url_params = { new_user_invite: 'experiment' }
-
- member_email_with_layout(
- to: member.invite_email,
- subject: subject_line,
- template: 'member_invited_email_experiment',
- layout: 'experiment_mailer'
- )
-
- Gitlab::Tracking.event(Gitlab::Experimentation::EXPERIMENTS[:invite_email][:tracking_category], 'sent', property: 'experiment_group')
- else
- @invite_url_params = member.invite_to_unknown_user? ? { new_user_invite: 'control' } : {}
-
- member_email_with_layout(
- to: member.invite_email,
- subject: subject_line
- )
-
- if member.invite_to_unknown_user?
- Gitlab::Tracking.event(Gitlab::Experimentation::EXPERIMENTS[:invite_email][:tracking_category], 'sent', property: 'control_group')
+ subject_line =
+ if member.created_by
+ subject(s_("MemberInviteEmail|%{member_name} invited you to join GitLab") % { member_name: member.created_by.name })
+ else
+ subject(s_("MemberInviteEmail|Invitation to join the %{project_or_group} %{project_or_group_name}") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular })
end
- end
- if member.invite_to_unknown_user? && Gitlab::Experimentation.enabled?(:invitation_reminders)
+ member_email_with_layout(
+ to: member.invite_email,
+ subject: subject_line,
+ layout: 'unknown_user_mailer'
+ )
+
+ if Gitlab::Experimentation.enabled?(:invitation_reminders)
Gitlab::Tracking.event(
Gitlab::Experimentation.experiment(:invitation_reminders).tracking_category,
'sent',
@@ -105,7 +91,7 @@ module Emails
subject_line = subjects[reminder_index] % { inviter: member.created_by.name }
member_email_with_layout(
- layout: 'experiment_mailer',
+ layout: 'unknown_user_mailer',
to: member.invite_email,
subject: subject(subject_line)
)
@@ -162,15 +148,10 @@ module Emails
@member_source_type.classify.constantize
end
- def member_email_with_layout(to:, subject:, template: nil, layout: 'mailer')
+ def member_email_with_layout(to:, subject:, layout: 'mailer')
mail(to: to, subject: subject) do |format|
- if template
- format.html { render template, layout: layout }
- format.text { render template, layout: layout }
- else
- format.html { render layout: layout }
- format.text { render layout: layout }
- end
+ format.html { render layout: layout }
+ format.text { render layout: layout }
end
end
end
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index 96cf3571968..6f44b63f8d0 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -9,6 +9,15 @@ module Emails
mail(to: @user.notification_email, subject: subject("Account was created for you"))
end
+ def instance_access_request_email(user, recipient)
+ @user = user
+ @recipient = recipient
+
+ profile_email_with_layout(
+ to: recipient.notification_email,
+ subject: subject(_("GitLab Account Request")))
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def new_ssh_key_email(key_id)
@key = Key.find_by(id: key_id)
@@ -63,13 +72,9 @@ module Emails
@target_url = edit_profile_password_url
Gitlab::I18n.with_locale(@user.preferred_language) do
- mail(
+ profile_email_with_layout(
to: @user.notification_email,
- subject: subject(_("%{host} sign-in from new location") % { host: Gitlab.config.gitlab.host })
- ) do |format|
- format.html { render layout: 'mailer' }
- format.text { render layout: 'mailer' }
- end
+ subject: subject(_("%{host} sign-in from new location") % { host: Gitlab.config.gitlab.host }))
end
end
@@ -82,6 +87,15 @@ module Emails
mail(to: @user.notification_email, subject: subject(_("Two-factor authentication disabled")))
end
end
+
+ private
+
+ def profile_email_with_layout(to:, subject:, layout: 'mailer')
+ mail(to: to, subject: subject) do |format|
+ format.html { render layout: layout }
+ format.text { render layout: layout }
+ end
+ end
end
end
diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb
index 17ef8b41e79..a4b7b140169 100644
--- a/app/mailers/emails/projects.rb
+++ b/app/mailers/emails/projects.rb
@@ -56,12 +56,9 @@ module Emails
subject: @message.subject)
end
- def prometheus_alert_fired_email(project_id, user_id, alert_attributes)
- @project = ::Project.find(project_id)
- user = ::User.find(user_id)
-
- @alert = AlertManagement::Alert.new(alert_attributes.with_indifferent_access).present
- return unless @alert.parsed_payload.has_required_attributes?
+ def prometheus_alert_fired_email(project, user, alert)
+ @project = project
+ @alert = alert.present
subject_text = "Alert: #{@alert.email_title}"
mail(to: user.notification_email_for(@project.group), subject: subject(subject_text))
diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb
index 29fe608472d..fa646487819 100644
--- a/app/mailers/emails/service_desk.rb
+++ b/app/mailers/emails/service_desk.rb
@@ -58,10 +58,12 @@ module Emails
def template_content(email_type)
template = Gitlab::Template::ServiceDeskTemplate.find(email_type, @project)
-
text = substitute_template_replacements(template.content)
- markdown(text, project: @project)
+ context = { project: @project, pipeline: :email }
+ context[:author] = @note.author if email_type == 'new_note'
+
+ markdown(text, context)
rescue Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError
nil
end
diff --git a/app/mailers/previews/devise_mailer_preview.rb b/app/mailers/previews/devise_mailer_preview.rb
index 3b9ef0d3ac0..68f281f825e 100644
--- a/app/mailers/previews/devise_mailer_preview.rb
+++ b/app/mailers/previews/devise_mailer_preview.rb
@@ -24,6 +24,10 @@ class DeviseMailerPreview < ActionMailer::Preview
DeviseMailer.password_change(unsaved_user, {})
end
+ def user_admin_approval
+ DeviseMailer.user_admin_approval(unsaved_user, {})
+ end
+
private
def unsaved_user
diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb
index 61cc15a522e..7ce7f40b6a8 100644
--- a/app/models/alert_management/alert.rb
+++ b/app/models/alert_management/alert.rb
@@ -34,7 +34,7 @@ module AlertManagement
has_many :ordered_notes, -> { fresh }, as: :noteable, class_name: 'Note'
has_many :user_mentions, class_name: 'AlertManagement::AlertUserMention', foreign_key: :alert_management_alert_id
- has_internal_id :iid, scope: :project, init: ->(s) { s.project.alert_management_alerts.maximum(:iid) }
+ has_internal_id :iid, scope: :project
sha_attribute :fingerprint
diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb
index 7f954e1d384..ae5170867c3 100644
--- a/app/models/alert_management/http_integration.rb
+++ b/app/models/alert_management/http_integration.rb
@@ -2,6 +2,10 @@
module AlertManagement
class HttpIntegration < ApplicationRecord
+ include ::Gitlab::Routing
+ LEGACY_IDENTIFIER = 'legacy'
+ DEFAULT_NAME_SLUG = 'http-endpoint'
+
belongs_to :project, inverse_of: :alert_management_http_integrations
attr_encrypted :token,
@@ -9,19 +13,45 @@ module AlertManagement
key: Settings.attr_encrypted_db_key_base_truncated,
algorithm: 'aes-256-gcm'
+ default_value_for(:endpoint_identifier, allows_nil: false) { SecureRandom.hex(8) }
+ default_value_for(:token) { generate_token }
+
validates :project, presence: true
validates :active, inclusion: { in: [true, false] }
-
- validates :token, presence: true
+ validates :token, presence: true, format: { with: /\A\h{32}\z/ }
validates :name, presence: true, length: { maximum: 255 }
- validates :endpoint_identifier, presence: true, length: { maximum: 255 }
+ validates :endpoint_identifier, presence: true, length: { maximum: 255 }, format: { with: /\A[A-Za-z0-9]+\z/ }
validates :endpoint_identifier, uniqueness: { scope: [:project_id, :active] }, if: :active?
before_validation :prevent_token_assignment
+ before_validation :prevent_endpoint_identifier_assignment
before_validation :ensure_token
+ scope :for_endpoint_identifier, -> (endpoint_identifier) { where(endpoint_identifier: endpoint_identifier) }
+ scope :active, -> { where(active: true) }
+ scope :ordered_by_id, -> { order(:id) }
+
+ def url
+ return project_alerts_notify_url(project, format: :json) if legacy?
+
+ project_alert_http_integration_url(project, name_slug, endpoint_identifier, format: :json)
+ end
+
private
+ def self.generate_token
+ SecureRandom.hex
+ end
+
+ def name_slug
+ (name && Gitlab::Utils.slugify(name)) || DEFAULT_NAME_SLUG
+ end
+
+ def legacy?
+ endpoint_identifier == LEGACY_IDENTIFIER
+ end
+
+ # Blank token assignment triggers token reset
def prevent_token_assignment
if token.present? && token_changed?
self.token = nil
@@ -31,11 +61,13 @@ module AlertManagement
end
def ensure_token
- self.token = generate_token if token.blank?
+ self.token = self.class.generate_token if token.blank?
end
- def generate_token
- SecureRandom.hex
+ def prevent_endpoint_identifier_assignment
+ if endpoint_identifier_changed? && endpoint_identifier_was.present?
+ self.endpoint_identifier = endpoint_identifier_was
+ end
end
end
end
diff --git a/app/models/analytics/devops_adoption.rb b/app/models/analytics/devops_adoption.rb
new file mode 100644
index 00000000000..ed5a5b16a6e
--- /dev/null
+++ b/app/models/analytics/devops_adoption.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+module Analytics::DevopsAdoption
+ def self.table_name_prefix
+ 'analytics_devops_adoption_'
+ end
+end
diff --git a/app/models/analytics/devops_adoption/segment.rb b/app/models/analytics/devops_adoption/segment.rb
new file mode 100644
index 00000000000..71d4a312627
--- /dev/null
+++ b/app/models/analytics/devops_adoption/segment.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class Analytics::DevopsAdoption::Segment < ApplicationRecord
+ ALLOWED_SEGMENT_COUNT = 20
+
+ has_many :segment_selections
+ has_many :groups, through: :segment_selections
+
+ validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
+ validate :validate_segment_count
+
+ accepts_nested_attributes_for :segment_selections, allow_destroy: true
+
+ scope :ordered_by_name, -> { order(:name) }
+ scope :with_groups, -> { preload(:groups) }
+
+ private
+
+ def validate_segment_count
+ if self.class.count >= ALLOWED_SEGMENT_COUNT
+ errors.add(:name, s_('DevopsAdoptionSegment|The maximum number of segments has been reached'))
+ end
+ end
+end
diff --git a/app/models/analytics/devops_adoption/segment_selection.rb b/app/models/analytics/devops_adoption/segment_selection.rb
new file mode 100644
index 00000000000..6b70c13a773
--- /dev/null
+++ b/app/models/analytics/devops_adoption/segment_selection.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+class Analytics::DevopsAdoption::SegmentSelection < ApplicationRecord
+ ALLOWED_SELECTIONS_PER_SEGMENT = 20
+
+ belongs_to :segment
+ belongs_to :project
+ belongs_to :group
+
+ validates :segment, presence: true
+ validates :project, presence: { unless: :group }
+ validates :project_id, uniqueness: { scope: :segment_id, if: :project }
+ validates :group, presence: { unless: :project }
+ validates :group_id, uniqueness: { scope: :segment_id, if: :group }
+
+ validate :exclusive_project_or_group
+ validate :validate_selection_count
+
+ private
+
+ def exclusive_project_or_group
+ if project.present? && group.present?
+ errors.add(:group, s_('DevopsAdoptionSegmentSelection|The selection cannot be configured for a project and for a group at the same time'))
+ end
+ end
+
+ def validate_selection_count
+ return unless segment
+
+ selection_count_for_segment = self.class.where(segment: segment).count
+
+ if selection_count_for_segment >= ALLOWED_SELECTIONS_PER_SEGMENT
+ errors.add(:segment, s_('DevopsAdoptionSegmentSelection|The maximum number of selections has been reached'))
+ end
+ end
+end
diff --git a/app/models/analytics/instance_statistics/measurement.rb b/app/models/analytics/instance_statistics/measurement.rb
index 76cc1111e90..c8b76e005ef 100644
--- a/app/models/analytics/instance_statistics/measurement.rb
+++ b/app/models/analytics/instance_statistics/measurement.rb
@@ -15,35 +15,47 @@ module Analytics
pipelines_succeeded: 7,
pipelines_failed: 8,
pipelines_canceled: 9,
- pipelines_skipped: 10
+ pipelines_skipped: 10,
+ billable_users: 11
}
- IDENTIFIER_QUERY_MAPPING = {
- identifiers[:projects] => -> { Project },
- identifiers[:users] => -> { User },
- identifiers[:issues] => -> { Issue },
- identifiers[:merge_requests] => -> { MergeRequest },
- identifiers[:groups] => -> { Group },
- 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
validates :recorded_at, uniqueness: { scope: :identifier }
scope :order_by_latest, -> { order(recorded_at: :desc) }
scope :with_identifier, -> (identifier) { where(identifier: identifier) }
+ scope :recorded_after, -> (date) { where(self.model.arel_table[:recorded_at].gteq(date)) if date.present? }
+ scope :recorded_before, -> (date) { where(self.model.arel_table[:recorded_at].lteq(date)) if date.present? }
+
+ def self.identifier_query_mapping
+ {
+ identifiers[:projects] => -> { Project },
+ identifiers[:users] => -> { User },
+ identifiers[:issues] => -> { Issue },
+ identifiers[:merge_requests] => -> { MergeRequest },
+ identifiers[:groups] => -> { Group },
+ 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 }
+ }
+ end
+
+ # Customized min and max calculation, in some cases using the original scope is too slow.
+ def self.identifier_min_max_queries
+ {}
+ end
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
+ identifiers.values
+ end
+
+ def self.find_latest_or_fallback(identifier)
+ with_identifier(identifier).order_by_latest.first || identifier_query_mapping[identifiers[identifier]].call
end
end
end
end
+
+Analytics::InstanceStatistics::Measurement.prepend_if_ee('EE::Analytics::InstanceStatistics::Measurement')
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 3542bb90dc0..71235ed1002 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -48,6 +48,8 @@ class ApplicationRecord < ActiveRecord::Base
def self.safe_find_or_create_by!(*args, &block)
safe_find_or_create_by(*args, &block).tap do |record|
+ raise ActiveRecord::RecordNotFound unless record.present?
+
record.validate! unless record.persisted?
end
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index d034630a085..7bfa5fb4cb8 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -8,8 +8,6 @@ class ApplicationSetting < ApplicationRecord
include IgnorableColumns
ignore_column :namespace_storage_size_limit, remove_with: '13.5', remove_after: '2020-09-22'
- 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 ' \
@@ -42,8 +40,8 @@ class ApplicationSetting < ApplicationRecord
serialize :restricted_visibility_levels # rubocop:disable Cop/ActiveRecordSerialize
serialize :import_sources # rubocop:disable Cop/ActiveRecordSerialize
serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiveRecordSerialize
- serialize :domain_whitelist, Array # rubocop:disable Cop/ActiveRecordSerialize
- serialize :domain_blacklist, Array # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :domain_allowlist, Array # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :domain_denylist, Array # rubocop:disable Cop/ActiveRecordSerialize
serialize :repository_storages # rubocop:disable Cop/ActiveRecordSerialize
serialize :asset_proxy_whitelist, Array # rubocop:disable Cop/ActiveRecordSerialize
@@ -186,9 +184,9 @@ class ApplicationSetting < ApplicationRecord
validates :enabled_git_access_protocol,
inclusion: { in: %w(ssh http), allow_blank: true }
- validates :domain_blacklist,
- presence: { message: 'Domain blacklist cannot be empty if Blacklist is enabled.' },
- if: :domain_blacklist_enabled?
+ validates :domain_denylist,
+ presence: { message: 'Domain denylist cannot be empty if denylist is enabled.' },
+ if: :domain_denylist_enabled?
validates :housekeeping_incremental_repack_period,
presence: true,
@@ -294,6 +292,9 @@ class ApplicationSetting < ApplicationRecord
validates :container_registry_delete_tags_service_timeout,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :container_registry_expiration_policies_worker_capacity,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
@@ -385,6 +386,9 @@ class ApplicationSetting < ApplicationRecord
validates :raw_blob_request_limit,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :ci_jwt_signing_key,
+ rsa_key: true, allow_nil: true
+
attr_encrypted :asset_proxy_secret_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
@@ -410,6 +414,9 @@ class ApplicationSetting < ApplicationRecord
attr_encrypted :recaptcha_site_key, encryption_options_base_truncated_aes_256_gcm
attr_encrypted :slack_app_secret, encryption_options_base_truncated_aes_256_gcm
attr_encrypted :slack_app_verification_token, encryption_options_base_truncated_aes_256_gcm
+ attr_encrypted :ci_jwt_signing_key, encryption_options_base_truncated_aes_256_gcm
+ attr_encrypted :secret_detection_token_revocation_token, encryption_options_base_truncated_aes_256_gcm
+ attr_encrypted :cloud_license_auth_token, encryption_options_base_truncated_aes_256_gcm
before_validation :ensure_uuid!
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 8a7bd5a7ad9..5c7abbccd63 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -60,7 +60,7 @@ module ApplicationSettingImplementation
diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES,
disabled_oauth_sign_in_sources: [],
dns_rebinding_protection_enabled: true,
- domain_whitelist: Settings.gitlab['domain_whitelist'],
+ domain_allowlist: Settings.gitlab['domain_allowlist'],
dsa_key_restriction: 0,
ecdsa_key_restriction: 0,
ed25519_key_restriction: 0,
@@ -120,7 +120,7 @@ module ApplicationSettingImplementation
repository_checks_enabled: true,
repository_storages_weighted: { default: 100 },
repository_storages: ['default'],
- require_admin_approval_after_user_signup: false,
+ require_admin_approval_after_user_signup: true,
require_two_factor_authentication: false,
restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'],
rsa_key_restriction: 0,
@@ -167,7 +167,8 @@ module ApplicationSettingImplementation
user_default_internal_regex: nil,
user_show_add_ssh_key_message: true,
wiki_page_max_content_bytes: 50.megabytes,
- container_registry_delete_tags_service_timeout: 100
+ container_registry_delete_tags_service_timeout: 250,
+ container_registry_expiration_policies_worker_capacity: 0
}
end
@@ -201,38 +202,38 @@ module ApplicationSettingImplementation
super(sources)
end
- def domain_whitelist_raw
- array_to_string(self.domain_whitelist)
+ def domain_allowlist_raw
+ array_to_string(self.domain_allowlist)
end
- def domain_blacklist_raw
- array_to_string(self.domain_blacklist)
+ def domain_denylist_raw
+ array_to_string(self.domain_denylist)
end
- def domain_whitelist_raw=(values)
- self.domain_whitelist = strings_to_array(values)
+ def domain_allowlist_raw=(values)
+ self.domain_allowlist = strings_to_array(values)
end
- def domain_blacklist_raw=(values)
- self.domain_blacklist = strings_to_array(values)
+ def domain_denylist_raw=(values)
+ self.domain_denylist = strings_to_array(values)
end
- def domain_blacklist_file=(file)
- self.domain_blacklist_raw = file.read
+ def domain_denylist_file=(file)
+ self.domain_denylist_raw = file.read
end
- def outbound_local_requests_whitelist_raw
+ def outbound_local_requests_allowlist_raw
array_to_string(self.outbound_local_requests_whitelist)
end
- def outbound_local_requests_whitelist_raw=(values)
- clear_memoization(:outbound_local_requests_whitelist_arrays)
+ def outbound_local_requests_allowlist_raw=(values)
+ clear_memoization(:outbound_local_requests_allowlist_arrays)
self.outbound_local_requests_whitelist = strings_to_array(values)
end
def add_to_outbound_local_requests_whitelist(values_array)
- clear_memoization(:outbound_local_requests_whitelist_arrays)
+ clear_memoization(:outbound_local_requests_allowlist_arrays)
self.outbound_local_requests_whitelist ||= []
self.outbound_local_requests_whitelist += values_array
@@ -244,13 +245,13 @@ module ApplicationSettingImplementation
# application_setting.outbound_local_requests_whitelist array into 2 arrays;
# an array of IPAddr objects (`[IPAddr.new('127.0.0.1')]`), and an array of
# domain strings (`['www.example.com']`).
- def outbound_local_requests_whitelist_arrays
- strong_memoize(:outbound_local_requests_whitelist_arrays) do
+ def outbound_local_requests_allowlist_arrays
+ strong_memoize(:outbound_local_requests_allowlist_arrays) do
next [[], []] unless self.outbound_local_requests_whitelist
- ip_whitelist, domain_whitelist = separate_whitelists(self.outbound_local_requests_whitelist)
+ ip_allowlist, domain_allowlist = separate_allowlists(self.outbound_local_requests_whitelist)
- [ip_whitelist, domain_whitelist]
+ [ip_allowlist, domain_allowlist]
end
end
@@ -395,19 +396,19 @@ module ApplicationSettingImplementation
private
- def separate_whitelists(string_array)
- string_array.reduce([[], []]) do |(ip_whitelist, domain_whitelist), string|
+ def separate_allowlists(string_array)
+ string_array.reduce([[], []]) do |(ip_allowlist, domain_allowlist), string|
address, port = parse_addr_and_port(string)
ip_obj = Gitlab::Utils.string_to_ip_object(address)
if ip_obj
- ip_whitelist << Gitlab::UrlBlockers::IpWhitelistEntry.new(ip_obj, port: port)
+ ip_allowlist << Gitlab::UrlBlockers::IpAllowlistEntry.new(ip_obj, port: port)
else
- domain_whitelist << Gitlab::UrlBlockers::DomainWhitelistEntry.new(address, port: port)
+ domain_allowlist << Gitlab::UrlBlockers::DomainAllowlistEntry.new(address, port: port)
end
- [ip_whitelist, domain_whitelist]
+ [ip_allowlist, domain_allowlist]
end
end
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 34f03e769a0..55e8a5d4535 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -2,7 +2,6 @@
class AuditEvent < ApplicationRecord
include CreatedAtFilterable
- include IgnorableColumns
include BulkInsertSafe
include EachBatch
@@ -14,8 +13,6 @@ class AuditEvent < ApplicationRecord
:target_id
].freeze
- ignore_column :type, remove_with: '13.6', remove_after: '2020-11-22'
-
serialize :details, Hash # rubocop:disable Cop/ActiveRecordSerialize
belongs_to :user, foreign_key: :author_id
@@ -37,14 +34,6 @@ class AuditEvent < ApplicationRecord
# https://gitlab.com/groups/gitlab-org/-/epics/2765
after_validation :parallel_persist
- # Note: After loading records, do not attempt to type cast objects it finds.
- # We are in the process of deprecating STI (i.e. SecurityEvent) out of AuditEvent.
- #
- # https://gitlab.com/gitlab-org/gitlab/-/issues/216845
- def self.inheritance_column
- :_type_disabled
- end
-
def self.order_by(method)
case method.to_s
when 'created_asc'
diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb
index ac6e08caf50..9d191e6ae4d 100644
--- a/app/models/authentication_event.rb
+++ b/app/models/authentication_event.rb
@@ -3,6 +3,12 @@
class AuthenticationEvent < ApplicationRecord
include UsageStatistics
+ TWO_FACTOR = 'two-factor'.freeze
+ TWO_FACTOR_U2F = 'two-factor-via-u2f-device'.freeze
+ TWO_FACTOR_WEBAUTHN = 'two-factor-via-webauthn-device'.freeze
+ STANDARD = 'standard'.freeze
+ STATIC_PROVIDERS = [TWO_FACTOR, TWO_FACTOR_U2F, TWO_FACTOR_WEBAUTHN, STANDARD].freeze
+
belongs_to :user, optional: true
validates :provider, :user_name, :result, presence: true
@@ -17,6 +23,6 @@ class AuthenticationEvent < ApplicationRecord
scope :ldap, -> { where('provider LIKE ?', 'ldap%')}
def self.providers
- distinct.pluck(:provider)
+ STATIC_PROVIDERS | Devise.omniauth_providers.map(&:to_s)
end
end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index 856f86201ec..a8325e98095 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -103,6 +103,7 @@ class BroadcastMessage < ApplicationRecord
end
def matches_current_path(current_path)
+ return false if current_path.blank? && target_path.present?
return true if current_path.blank? || target_path.blank?
escaped = Regexp.escape(target_path).gsub('\\*', '.*')
diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb
index cabff86a9f9..5d646313423 100644
--- a/app/models/bulk_import.rb
+++ b/app/models/bulk_import.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
+# The BulkImport model links all models required for a bulk import of groups and
+# projects to a GitLab instance. It associates the import with the responsible
+# user.
class BulkImport < ApplicationRecord
belongs_to :user, optional: false
@@ -12,5 +15,20 @@ class BulkImport < ApplicationRecord
state_machine :status, initial: :created do
state :created, value: 0
+ state :started, value: 1
+ state :finished, value: 2
+ state :failed, value: -1
+
+ event :start do
+ transition created: :started
+ end
+
+ event :finish do
+ transition started: :finished
+ end
+
+ event :fail_op do
+ transition any => :failed
+ end
end
end
diff --git a/app/models/bulk_imports/configuration.rb b/app/models/bulk_imports/configuration.rb
index 8c3aff6f749..4c6f745c268 100644
--- a/app/models/bulk_imports/configuration.rb
+++ b/app/models/bulk_imports/configuration.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+# Stores the authentication data required to access another GitLab instance on
+# behalf of a user, to import Groups and Projects directly from that instance.
class BulkImports::Configuration < ApplicationRecord
self.table_name = 'bulk_import_configurations'
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index 2d0bba7bccc..34030e079c7 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -1,5 +1,22 @@
# frozen_string_literal: true
+# The BulkImport::Entity represents a Group or Project to be imported during the
+# bulk import process. An entity is nested under the parent group when it is not
+# a top level group.
+#
+# A full bulk import entity structure might look like this, where the links are
+# parents:
+#
+# **Before Import** **After Import**
+#
+# GroupEntity Group
+# | | | |
+# GroupEntity ProjectEntity Group Project
+# | |
+# ProjectEntity Project
+#
+# The tree structure of the entities results in the same structure for imported
+# Groups and Projects.
class BulkImports::Entity < ApplicationRecord
self.table_name = 'bulk_import_entities'
@@ -9,6 +26,10 @@ class BulkImports::Entity < ApplicationRecord
belongs_to :project, optional: true
belongs_to :group, foreign_key: :namespace_id, optional: true
+ has_many :trackers,
+ class_name: 'BulkImports::Tracker',
+ foreign_key: :bulk_import_entity_id
+
validates :project, absence: true, if: :group
validates :group, absence: true, if: :project
validates :source_type, :source_full_path, :destination_name,
@@ -21,6 +42,21 @@ class BulkImports::Entity < ApplicationRecord
state_machine :status, initial: :created do
state :created, value: 0
+ state :started, value: 1
+ state :finished, value: 2
+ state :failed, value: -1
+
+ event :start do
+ transition created: :started
+ end
+
+ event :finish do
+ transition started: :finished
+ end
+
+ event :fail_op do
+ transition any => :failed
+ end
end
private
@@ -33,11 +69,17 @@ class BulkImports::Entity < ApplicationRecord
def validate_imported_entity_type
if group.present? && project_entity?
- errors.add(:group, s_('BulkImport|expected an associated Project but has an associated Group'))
+ 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'))
+ errors.add(
+ :project,
+ s_('BulkImport|expected an associated Group but has an associated Project')
+ )
end
end
end
diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb
new file mode 100644
index 00000000000..02e0904e1af
--- /dev/null
+++ b/app/models/bulk_imports/tracker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+# This model is responsible for keeping track of the requests/pagination
+# happening during a Group Migration (BulkImport).
+class BulkImports::Tracker < ApplicationRecord
+ self.table_name = 'bulk_import_trackers'
+
+ belongs_to :entity,
+ class_name: 'BulkImports::Entity',
+ foreign_key: :bulk_import_entity_id,
+ optional: false
+
+ validates :relation,
+ presence: true,
+ uniqueness: { scope: :bulk_import_entity_id }
+
+ validates :next_page, presence: { if: :has_next_page? }
+end
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 2e725e0baff..5b23cf46fdb 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -7,6 +7,7 @@ module Ci
include Importable
include AfterCommitQueue
include Ci::HasRef
+ extend ::Gitlab::Utils::Override
InvalidBridgeTypeError = Class.new(StandardError)
InvalidTransitionError = Class.new(StandardError)
@@ -203,8 +204,11 @@ module Ci
end
end
+ override :dependency_variables
def dependency_variables
- []
+ return [] unless ::Feature.enabled?(:ci_bridge_dependency_variables, project)
+
+ super
end
def target_revision_ref
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 9ff70ece947..84abd01786d 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -103,6 +103,10 @@ module Ci
)
end
+ scope :in_pipelines, ->(pipelines) do
+ where(pipeline: pipelines)
+ end
+
scope :with_existing_job_artifacts, ->(query) do
where('EXISTS (?)', ::Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').merge(query))
end
@@ -571,14 +575,6 @@ module Ci
end
end
- def dependency_variables
- return [] if all_dependencies.empty?
-
- Gitlab::Ci::Variables::Collection.new.concat(
- Ci::JobVariable.where(job: all_dependencies).dotenv_source
- )
- end
-
def features
{ trace_sections: true }
end
@@ -828,10 +824,6 @@ module Ci
Gitlab::Ci::Build::Credentials::Factory.new(self).create!
end
- def all_dependencies
- dependencies.all
- end
-
def has_valid_build_dependencies?
dependencies.valid?
end
@@ -994,12 +986,6 @@ module Ci
end
end
- def dependencies
- strong_memoize(:dependencies) do
- Ci::BuildDependencies.new(self)
- end
- end
-
def build_data
@build_data ||= Gitlab::DataBuilder::Build.build(self)
end
@@ -1059,7 +1045,7 @@ module Ci
jwt = Gitlab::Ci::Jwt.for_build(self)
variables.append(key: 'CI_JOB_JWT', value: jwt, public: false, masked: true)
- rescue OpenSSL::PKey::RSAError => e
+ rescue OpenSSL::PKey::RSAError, Gitlab::Ci::Jwt::NoSigningKeyError => e
Gitlab::ErrorTracking.track_exception(e)
end
end
diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb
index cf6eb159f52..ceefb6a8b8a 100644
--- a/app/models/ci/build_trace_chunk.rb
+++ b/app/models/ci/build_trace_chunk.rb
@@ -22,20 +22,26 @@ module Ci
FailedToPersistDataError = Class.new(StandardError)
- # Note: The ordering of this enum is related to the precedence of persist store.
+ # Note: The ordering of this hash is related to the precedence of persist store.
# The bottom item takes the highest precedence, and the top item takes the lowest precedence.
- enum data_store: {
+ DATA_STORES = {
redis: 1,
database: 2,
fog: 3
- }
+ }.freeze
+
+ STORE_TYPES = DATA_STORES.keys.map do |store|
+ [store, "Ci::BuildTraceChunks::#{store.capitalize}".constantize]
+ end.to_h.freeze
+
+ enum data_store: DATA_STORES
scope :live, -> { redis }
scope :persisted, -> { not_redis.order(:chunk_index) }
class << self
def all_stores
- @all_stores ||= self.data_stores.keys
+ STORE_TYPES.keys
end
def persistable_store
@@ -44,12 +50,11 @@ module Ci
end
def get_store_class(store)
- @stores ||= {}
+ store = store.to_sym
- # Can't memoize this because the feature flag may alter this
- return fog_store_class.new if store.to_sym == :fog
+ raise "Unknown store type: #{store}" unless STORE_TYPES.key?(store)
- @stores[store] ||= "Ci::BuildTraceChunks::#{store.capitalize}".constantize.new
+ STORE_TYPES[store].new
end
##
@@ -78,14 +83,6 @@ module Ci
def metadata_attributes
attribute_names - %w[raw_data]
end
-
- def fog_store_class
- if Feature.enabled?(:ci_trace_new_fog_store, default_enabled: true)
- Ci::BuildTraceChunks::Fog
- else
- Ci::BuildTraceChunks::LegacyFog
- end
- end
end
def data
@@ -108,7 +105,7 @@ module Ci
raise ArgumentError, 'Offset is out of range' if offset < 0 || offset > size
raise ArgumentError, 'Chunk size overflow' if CHUNK_SIZE < (offset + new_data.bytesize)
- in_lock(*lock_params) { unsafe_append_data!(new_data, offset) }
+ in_lock(lock_key, **lock_params) { unsafe_append_data!(new_data, offset) }
schedule_to_persist! if full?
end
@@ -148,12 +145,13 @@ module Ci
# 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.
+ # We are using until_executed deduplication strategy for workers,
+ # which should prevent duplicated workers running in parallel for the same build trace,
+ # and causing an exception related to an exclusive lock not being
+ # acquired
#
def persist_data!
- in_lock(*lock_params) do # exclusive Redis lock is acquired first
+ in_lock(lock_key, **lock_params) do # exclusive Redis lock is acquired first
raise FailedToPersistDataError, 'Modifed build trace chunk detected' if has_changes_to_save?
self.reset.then do |chunk| # we ensure having latest lock_version
@@ -162,6 +160,8 @@ module Ci
end
rescue FailedToObtainLockError
metrics.increment_trace_operation(operation: :stalled)
+
+ raise FailedToPersistDataError, 'Data migration failed due to a worker duplication'
rescue ActiveRecord::StaleObjectError
raise FailedToPersistDataError, <<~MSG
Data migration race condition detected
@@ -289,11 +289,16 @@ module Ci
build.trace_chunks.maximum(:chunk_index).to_i
end
+ def lock_key
+ "trace_write:#{build_id}:chunks:#{chunk_index}"
+ end
+
def lock_params
- ["trace_write:#{build_id}:chunks:#{chunk_index}",
- { ttl: WRITE_LOCK_TTL,
- retries: WRITE_LOCK_RETRY,
- sleep_sec: WRITE_LOCK_SLEEP }]
+ {
+ ttl: WRITE_LOCK_TTL,
+ retries: WRITE_LOCK_RETRY,
+ sleep_sec: WRITE_LOCK_SLEEP
+ }
end
def metrics
diff --git a/app/models/ci/build_trace_chunks/legacy_fog.rb b/app/models/ci/build_trace_chunks/legacy_fog.rb
deleted file mode 100644
index b710ed2890b..00000000000
--- a/app/models/ci/build_trace_chunks/legacy_fog.rb
+++ /dev/null
@@ -1,77 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- module BuildTraceChunks
- class LegacyFog
- def available?
- object_store.enabled
- end
-
- def data(model)
- connection.get_object(bucket_name, key(model))[:body]
- rescue Excon::Error::NotFound
- # If the object does not exist in the object storage, this method returns nil.
- end
-
- def set_data(model, new_data)
- connection.put_object(bucket_name, key(model), new_data)
- end
-
- def append_data(model, new_data, offset)
- if offset > 0
- truncated_data = data(model).to_s.byteslice(0, offset)
- new_data = truncated_data + new_data
- end
-
- set_data(model, new_data)
- new_data.bytesize
- end
-
- def size(model)
- data(model).to_s.bytesize
- end
-
- def delete_data(model)
- delete_keys([[model.build_id, model.chunk_index]])
- end
-
- def keys(relation)
- return [] unless available?
-
- relation.pluck(:build_id, :chunk_index)
- end
-
- def delete_keys(keys)
- keys.each do |key|
- connection.delete_object(bucket_name, key_raw(*key))
- end
- end
-
- private
-
- def key(model)
- key_raw(model.build_id, model.chunk_index)
- end
-
- def key_raw(build_id, chunk_index)
- "tmp/builds/#{build_id.to_i}/chunks/#{chunk_index.to_i}.log"
- end
-
- def bucket_name
- return unless available?
-
- object_store.remote_directory
- end
-
- def connection
- return unless available?
-
- @connection ||= ::Fog::Storage.new(object_store.connection.to_hash.deep_symbolize_keys)
- end
-
- def object_store
- Gitlab.config.artifacts.object_store
- end
- end
- end
-end
diff --git a/app/models/ci/daily_build_group_report_result.rb b/app/models/ci/daily_build_group_report_result.rb
index e6f02f2e4f3..e9f3366b939 100644
--- a/app/models/ci/daily_build_group_report_result.rb
+++ b/app/models/ci/daily_build_group_report_result.rb
@@ -4,6 +4,7 @@ module Ci
class DailyBuildGroupReportResult < ApplicationRecord
extend Gitlab::Ci::Model
+ REPORT_WINDOW = 90.days
PARAM_TYPES = %w[coverage].freeze
belongs_to :last_pipeline, class_name: 'Ci::Pipeline', foreign_key: :last_pipeline_id
@@ -12,13 +13,30 @@ module Ci
validates :data, json_schema: { filename: "daily_build_group_report_result_data" }
scope :with_included_projects, -> { includes(:project) }
+ scope :by_projects, -> (ids) { where(project_id: ids) }
+ scope :with_coverage, -> { where("(data->'coverage') IS NOT NULL") }
+ scope :with_default_branch, -> { where(default_branch: true) }
+ scope :by_date, -> (start_date) { where(date: report_window(start_date)..Date.current) }
- def self.upsert_reports(data)
- upsert_all(data, unique_by: :index_daily_build_group_report_results_unique_columns) if data.any?
- end
+ store_accessor :data, :coverage
+
+ class << self
+ def upsert_reports(data)
+ upsert_all(data, unique_by: :index_daily_build_group_report_results_unique_columns) if data.any?
+ end
+
+ def recent_results(attrs, limit: nil)
+ where(attrs).order(date: :desc, group_name: :asc).limit(limit)
+ end
- def self.recent_results(attrs, limit: nil)
- where(attrs).order(date: :desc, group_name: :asc).limit(limit)
+ def report_window(start_date)
+ default_date = REPORT_WINDOW.ago.to_date
+ date = Date.parse(start_date) rescue default_date
+
+ [date, default_date].max
+ end
end
end
end
+
+Ci::DailyBuildGroupReportResult.prepend_if_ee('EE::Ci::DailyBuildGroupReportResult')
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 02e17afdab0..7cedd13b407 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -169,6 +169,7 @@ module Ci
scope :downloadable, -> { where(file_type: DOWNLOADABLE_TYPES) }
scope :unlocked, -> { joins(job: :pipeline).merge(::Ci::Pipeline.unlocked).order(expire_at: :desc) }
+ scope :with_destroy_preloads, -> { includes(project: [:route, :statistics]) }
scope :scoped_project, -> { where('ci_job_artifacts.project_id = projects.id') }
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 684b6387ab1..8707d635e03 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -42,9 +42,16 @@ module Ci
belongs_to :external_pull_request
belongs_to :ci_ref, class_name: 'Ci::Ref', foreign_key: :ci_ref_id, inverse_of: :pipelines
- has_internal_id :iid, scope: :project, presence: false, track_if: -> { !importing? }, ensure_if: -> { !importing? }, init: ->(s) do
- s&.project&.all_pipelines&.maximum(:iid) || s&.project&.all_pipelines&.count
- end
+ has_internal_id :iid, scope: :project, presence: false,
+ track_if: -> { !importing? },
+ ensure_if: -> { !importing? },
+ init: ->(pipeline, scope) do
+ if pipeline
+ pipeline.project&.all_pipelines&.maximum(:iid) || pipeline.project&.all_pipelines&.count
+ elsif scope
+ ::Ci::Pipeline.where(**scope).maximum(:iid)
+ end
+ end
has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
@@ -270,6 +277,7 @@ module Ci
scope :internal, -> { where(source: internal_sources) }
scope :no_child, -> { where.not(source: :parent_pipeline) }
scope :ci_sources, -> { where(source: Enums::Ci::Pipeline.ci_sources.values) }
+ scope :ci_and_parent_sources, -> { where(source: Enums::Ci::Pipeline.ci_and_parent_sources.values) }
scope :for_user, -> (user) { where(user: user) }
scope :for_sha, -> (sha) { where(sha: sha) }
scope :for_source_sha, -> (source_sha) { where(source_sha: source_sha) }
@@ -347,6 +355,14 @@ module Ci
end
end
+ def self.latest_running_for_ref(ref)
+ newest_first(ref: ref).running.take
+ end
+
+ def self.latest_failed_for_ref(ref)
+ newest_first(ref: ref).failed.take
+ end
+
# Returns a Hash containing the latest pipeline for every given
# commit.
#
@@ -926,7 +942,7 @@ module Ci
def accessibility_reports
Gitlab::Ci::Reports::AccessibilityReports.new.tap do |accessibility_reports|
- builds.latest.with_reports(Ci::JobArtifact.accessibility_reports).each do |build|
+ latest_report_builds(Ci::JobArtifact.accessibility_reports).each do |build|
build.collect_accessibility_reports!(accessibility_reports)
end
end
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index ac5785d9c91..6aaf6ac530b 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -103,5 +103,25 @@ module Ci
pipeline.ensure_scheduling_type!
reset
end
+
+ def dependency_variables
+ return [] if all_dependencies.empty?
+
+ Gitlab::Ci::Variables::Collection.new.concat(
+ Ci::JobVariable.where(job: all_dependencies).dotenv_source
+ )
+ end
+
+ def all_dependencies
+ dependencies.all
+ end
+
+ private
+
+ def dependencies
+ strong_memoize(:dependencies) do
+ Ci::BuildDependencies.new(self)
+ end
+ end
end
end
diff --git a/app/models/ci/test_case.rb b/app/models/ci/test_case.rb
new file mode 100644
index 00000000000..19ecc177436
--- /dev/null
+++ b/app/models/ci/test_case.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Ci
+ class TestCase < ApplicationRecord
+ extend Gitlab::Ci::Model
+
+ validates :project, :key_hash, presence: true
+
+ has_many :test_case_failures, class_name: 'Ci::TestCaseFailure'
+
+ belongs_to :project
+
+ scope :by_project_and_keys, -> (project, keys) { where(project_id: project.id, key_hash: keys) }
+
+ class << self
+ def find_or_create_by_batch(project, test_case_keys)
+ # Insert records first. Existing ones will be skipped.
+ insert_all(test_case_attrs(project, test_case_keys))
+
+ # Find all matching records now that we are sure they all are persisted.
+ by_project_and_keys(project, test_case_keys)
+ end
+
+ private
+
+ def test_case_attrs(project, test_case_keys)
+ # NOTE: Rails 6.1 will add support for insert_all on relation so that
+ # we will be able to do project.test_cases.insert_all.
+ test_case_keys.map do |hashed_key|
+ { project_id: project.id, key_hash: hashed_key }
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/ci/test_case_failure.rb b/app/models/ci/test_case_failure.rb
new file mode 100644
index 00000000000..8867b954240
--- /dev/null
+++ b/app/models/ci/test_case_failure.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Ci
+ class TestCaseFailure < ApplicationRecord
+ extend Gitlab::Ci::Model
+
+ REPORT_WINDOW = 14.days
+
+ validates :test_case, :build, :failed_at, presence: true
+
+ belongs_to :test_case, class_name: "Ci::TestCase", foreign_key: :test_case_id
+ belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id
+
+ def self.recent_failures_count(project:, test_case_keys:, date_range: REPORT_WINDOW.ago..Time.current)
+ joins(:test_case)
+ .where(
+ ci_test_cases: {
+ project_id: project.id,
+ key_hash: test_case_keys
+ },
+ ci_test_case_failures: {
+ failed_at: date_range
+ }
+ )
+ .group(:key_hash)
+ .count('ci_test_case_failures.id')
+ end
+ end
+end
diff --git a/app/models/clusters/agent_token.rb b/app/models/clusters/agent_token.rb
index e9f1ee4e033..5c9561ffa98 100644
--- a/app/models/clusters/agent_token.rb
+++ b/app/models/clusters/agent_token.rb
@@ -3,7 +3,7 @@
module Clusters
class AgentToken < ApplicationRecord
include TokenAuthenticatable
- add_authentication_token_field :token, encrypted: :required
+ add_authentication_token_field :token, encrypted: :required, token_generator: -> { Devise.friendly_token(50) }
self.table_name = 'cluster_agent_tokens'
diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb
index 1efa44c39c5..d32fff14590 100644
--- a/app/models/clusters/applications/cert_manager.rb
+++ b/app/models/clusters/applications/cert_manager.rb
@@ -30,7 +30,7 @@ module Clusters
end
def install_command
- Gitlab::Kubernetes::Helm::InstallCommand.new(
+ helm_command_module::InstallCommand.new(
name: 'certmanager',
repository: repository,
version: VERSION,
@@ -43,7 +43,7 @@ module Clusters
end
def uninstall_command
- Gitlab::Kubernetes::Helm::DeleteCommand.new(
+ helm_command_module::DeleteCommand.new(
name: 'certmanager',
rbac: cluster.platform_kubernetes_rbac?,
files: files,
diff --git a/app/models/clusters/applications/crossplane.rb b/app/models/clusters/applications/crossplane.rb
index 420e56c1742..2b1a86706a4 100644
--- a/app/models/clusters/applications/crossplane.rb
+++ b/app/models/clusters/applications/crossplane.rb
@@ -29,7 +29,7 @@ module Clusters
end
def install_command
- Gitlab::Kubernetes::Helm::InstallCommand.new(
+ helm_command_module::InstallCommand.new(
name: 'crossplane',
repository: repository,
version: VERSION,
diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb
index 77996748b81..db18a29ec84 100644
--- a/app/models/clusters/applications/elastic_stack.rb
+++ b/app/models/clusters/applications/elastic_stack.rb
@@ -26,7 +26,7 @@ module Clusters
end
def install_command
- Gitlab::Kubernetes::Helm::InstallCommand.new(
+ helm_command_module::InstallCommand.new(
name: 'elastic-stack',
version: VERSION,
rbac: cluster.platform_kubernetes_rbac?,
@@ -39,7 +39,7 @@ module Clusters
end
def uninstall_command
- Gitlab::Kubernetes::Helm::DeleteCommand.new(
+ helm_command_module::DeleteCommand.new(
name: 'elastic-stack',
rbac: cluster.platform_kubernetes_rbac?,
files: files,
@@ -96,7 +96,7 @@ module Clusters
def post_install_script
[
- "timeout -t60 sh /data/helm/elastic-stack/config/wait-for-elasticsearch.sh http://elastic-stack-elasticsearch-master:9200"
+ "timeout 60 sh /data/helm/elastic-stack/config/wait-for-elasticsearch.sh http://elastic-stack-elasticsearch-master:9200"
]
end
@@ -116,7 +116,7 @@ module Clusters
# Chart version 3.0.0 moves to our own chart at https://gitlab.com/gitlab-org/charts/elastic-stack
# and is not compatible with pre-existing resources. We first remove them.
[
- Gitlab::Kubernetes::Helm::DeleteCommand.new(
+ helm_command_module::DeleteCommand.new(
name: 'elastic-stack',
rbac: cluster.platform_kubernetes_rbac?,
files: files
diff --git a/app/models/clusters/applications/fluentd.rb b/app/models/clusters/applications/fluentd.rb
index c608d37be77..91aa422b859 100644
--- a/app/models/clusters/applications/fluentd.rb
+++ b/app/models/clusters/applications/fluentd.rb
@@ -30,7 +30,7 @@ module Clusters
end
def install_command
- Gitlab::Kubernetes::Helm::InstallCommand.new(
+ helm_command_module::InstallCommand.new(
name: 'fluentd',
repository: repository,
version: VERSION,
diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb
index 4a1bcac4bb7..d1d6defb713 100644
--- a/app/models/clusters/applications/helm.rb
+++ b/app/models/clusters/applications/helm.rb
@@ -4,6 +4,8 @@ require 'openssl'
module Clusters
module Applications
+ # DEPRECATED: This model represents the Helm 2 Tiller server, and is no longer being actively used.
+ # It is being kept around for a potential cleanup of the unused Tiller server.
class Helm < ApplicationRecord
self.table_name = 'clusters_applications_helm'
@@ -49,7 +51,7 @@ module Clusters
end
def install_command
- Gitlab::Kubernetes::Helm::InitCommand.new(
+ Gitlab::Kubernetes::Helm::V2::InitCommand.new(
name: name,
files: files,
rbac: cluster.platform_kubernetes_rbac?
@@ -57,7 +59,7 @@ module Clusters
end
def uninstall_command
- Gitlab::Kubernetes::Helm::ResetCommand.new(
+ Gitlab::Kubernetes::Helm::V2::ResetCommand.new(
name: name,
files: files,
rbac: cluster.platform_kubernetes_rbac?
@@ -86,19 +88,19 @@ module Clusters
end
def create_keys_and_certs
- ca_cert = Gitlab::Kubernetes::Helm::Certificate.generate_root
+ ca_cert = Gitlab::Kubernetes::Helm::V2::Certificate.generate_root
self.ca_key = ca_cert.key_string
self.ca_cert = ca_cert.cert_string
end
def tiller_cert
- @tiller_cert ||= ca_cert_obj.issue(expires_in: Gitlab::Kubernetes::Helm::Certificate::INFINITE_EXPIRY)
+ @tiller_cert ||= ca_cert_obj.issue(expires_in: Gitlab::Kubernetes::Helm::V2::Certificate::INFINITE_EXPIRY)
end
def ca_cert_obj
return unless has_ssl?
- Gitlab::Kubernetes::Helm::Certificate
+ Gitlab::Kubernetes::Helm::V2::Certificate
.from_strings(ca_key, ca_cert)
end
end
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index d5412714858..36324e7f3e0 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -62,7 +62,7 @@ module Clusters
end
def install_command
- Gitlab::Kubernetes::Helm::InstallCommand.new(
+ helm_command_module::InstallCommand.new(
name: name,
repository: repository,
version: VERSION,
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
index 056ea355de6..ff907c6847f 100644
--- a/app/models/clusters/applications/jupyter.rb
+++ b/app/models/clusters/applications/jupyter.rb
@@ -39,7 +39,7 @@ module Clusters
end
def install_command
- Gitlab::Kubernetes::Helm::InstallCommand.new(
+ helm_command_module::InstallCommand.new(
name: name,
version: VERSION,
rbac: cluster.platform_kubernetes_rbac?,
diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb
index 3047da12dd9..b1c3116d77c 100644
--- a/app/models/clusters/applications/knative.rb
+++ b/app/models/clusters/applications/knative.rb
@@ -70,7 +70,7 @@ module Clusters
end
def install_command
- Gitlab::Kubernetes::Helm::InstallCommand.new(
+ helm_command_module::InstallCommand.new(
name: name,
version: VERSION,
rbac: cluster.platform_kubernetes_rbac?,
@@ -94,7 +94,7 @@ module Clusters
end
def uninstall_command
- Gitlab::Kubernetes::Helm::DeleteCommand.new(
+ helm_command_module::DeleteCommand.new(
name: name,
rbac: cluster.platform_kubernetes_rbac?,
files: files,
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index 7679296699f..55a9a0ccb81 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -67,7 +67,7 @@ module Clusters
end
def install_command
- Gitlab::Kubernetes::Helm::InstallCommand.new(
+ helm_command_module::InstallCommand.new(
name: name,
repository: repository,
version: VERSION,
@@ -79,7 +79,7 @@ module Clusters
end
def patch_command(values)
- ::Gitlab::Kubernetes::Helm::PatchCommand.new(
+ helm_command_module::PatchCommand.new(
name: name,
repository: repository,
version: version,
@@ -90,7 +90,7 @@ module Clusters
end
def uninstall_command
- Gitlab::Kubernetes::Helm::DeleteCommand.new(
+ helm_command_module::DeleteCommand.new(
name: name,
rbac: cluster.platform_kubernetes_rbac?,
files: files,
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index d07ea7b71dc..03f4caccccd 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.21.1'
+ VERSION = '0.22.0'
self.table_name = 'clusters_applications_runners'
@@ -30,7 +30,7 @@ module Clusters
end
def install_command
- Gitlab::Kubernetes::Helm::InstallCommand.new(
+ helm_command_module::InstallCommand.new(
name: name,
version: VERSION,
rbac: cluster.platform_kubernetes_rbac?,
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index b94ec3c6dea..3cf5542ae76 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -79,6 +79,9 @@ module Clusters
validates :cluster_type, presence: true
validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true }
validates :namespace_per_environment, inclusion: { in: [true, false] }
+ validates :helm_major_version, inclusion: { in: [2, 3] }
+
+ default_value_for :helm_major_version, 3
validate :restrict_modification, on: :update
validate :no_groups, unless: :group_type?
diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb
index 760576ea1eb..b82b1887308 100644
--- a/app/models/clusters/concerns/application_core.rb
+++ b/app/models/clusters/concerns/application_core.rb
@@ -12,6 +12,17 @@ module Clusters
after_initialize :set_initial_status
+ def helm_command_module
+ case cluster.helm_major_version
+ when 3
+ Gitlab::Kubernetes::Helm::V3
+ when 2
+ Gitlab::Kubernetes::Helm::V2
+ else
+ raise "Invalid Helm major version"
+ end
+ end
+
def set_initial_status
return unless not_installable?
diff --git a/app/models/clusters/concerns/application_data.rb b/app/models/clusters/concerns/application_data.rb
index 22e597e9747..00aeb7669ad 100644
--- a/app/models/clusters/concerns/application_data.rb
+++ b/app/models/clusters/concerns/application_data.rb
@@ -4,7 +4,7 @@ module Clusters
module Concerns
module ApplicationData
def uninstall_command
- Gitlab::Kubernetes::Helm::DeleteCommand.new(
+ helm_command_module::DeleteCommand.new(
name: name,
rbac: cluster.platform_kubernetes_rbac?,
files: files
diff --git a/app/models/clusters/providers/aws.rb b/app/models/clusters/providers/aws.rb
index 35e8b751b3d..bfd01775620 100644
--- a/app/models/clusters/providers/aws.rb
+++ b/app/models/clusters/providers/aws.rb
@@ -5,9 +5,6 @@ module Clusters
class Aws < ApplicationRecord
include Gitlab::Utils::StrongMemoize
include Clusters::Concerns::ProviderStatus
- include IgnorableColumns
-
- ignore_column :created_by_user_id, remove_with: '13.4', remove_after: '2020-08-22'
self.table_name = 'cluster_providers_aws'
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 83400c9e533..80dd02981c1 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -335,7 +335,11 @@ class Commit
strong_memoize(:raw_signature_type) do
next unless @raw.instance_of?(Gitlab::Git::Commit)
- @raw.raw_commit.signature_type if defined? @raw.raw_commit.signature_type
+ if raw_commit_from_rugged? && gpg_commit.signature_text.present?
+ :PGP
+ elsif defined? @raw.raw_commit.signature_type
+ @raw.raw_commit.signature_type
+ end
end
end
@@ -347,7 +351,7 @@ class Commit
strong_memoize(:signature) do
case signature_type
when :PGP
- Gitlab::Gpg::Commit.new(self).signature
+ gpg_commit.signature
when :X509
Gitlab::X509::Commit.new(self).signature
else
@@ -356,6 +360,14 @@ class Commit
end
end
+ def raw_commit_from_rugged?
+ @raw.raw_commit.is_a?(Rugged::Commit)
+ end
+
+ def gpg_commit
+ @gpg_commit ||= Gitlab::Gpg::Commit.new(self)
+ end
+
def revert_branch_name
"revert-#{short_id}"
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 4498e08d754..ee9c2501bfc 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -205,13 +205,8 @@ class CommitStatus < ApplicationRecord
# 'rspec:linux: 1/10' => 'rspec:linux'
common_name = name.to_s.gsub(%r{\d+[\s:\/\\]+\d+\s*}, '')
- 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
+ # 'rspec:linux: [aws, max memory]' => 'rspec:linux', 'rspec:linux: [aws]' => 'rspec:linux'
+ common_name.gsub!(%r{: \[.*\]\s*\z}, '')
common_name.strip!
common_name
diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb
index 4a632e8cd0c..baa99fa5a7f 100644
--- a/app/models/concerns/atomic_internal_id.rb
+++ b/app/models/concerns/atomic_internal_id.rb
@@ -27,16 +27,42 @@ module AtomicInternalId
extend ActiveSupport::Concern
class_methods do
- def has_internal_id(column, scope:, init:, ensure_if: nil, track_if: nil, presence: true, backfill: false) # rubocop:disable Naming/PredicateName
- # We require init here to retain the ability to recalculate in the absence of a
- # InternalId record (we may delete records in `internal_ids` for example).
- raise "has_internal_id requires a init block, none given." unless init
+ def has_internal_id( # rubocop:disable Naming/PredicateName
+ column, scope:, init: :not_given, ensure_if: nil, track_if: nil,
+ presence: true, backfill: false, hook_names: :create)
+ raise "has_internal_id init must not be nil if given." if init.nil?
raise "has_internal_id needs to be defined on association." unless self.reflect_on_association(scope)
- before_validation :"track_#{scope}_#{column}!", on: :create, if: track_if
- before_validation :"ensure_#{scope}_#{column}!", on: :create, if: ensure_if
+ init = infer_init(scope) if init == :not_given
+ before_validation :"track_#{scope}_#{column}!", on: hook_names, if: track_if
+ before_validation :"ensure_#{scope}_#{column}!", on: hook_names, if: ensure_if
validates column, presence: presence
+ define_singleton_internal_id_methods(scope, column, init)
+ define_instance_internal_id_methods(scope, column, init, backfill)
+ end
+
+ private
+
+ def infer_init(scope)
+ case scope
+ when :project
+ AtomicInternalId.project_init(self)
+ when :group
+ AtomicInternalId.group_init(self)
+ else
+ # We require init here to retain the ability to recalculate in the absence of a
+ # InternalId record (we may delete records in `internal_ids` for example).
+ raise "has_internal_id - cannot infer init for scope: #{scope}"
+ end
+ end
+
+ # Defines instance methods:
+ # - ensure_{scope}_{column}!
+ # - track_{scope}_{column}!
+ # - reset_{scope}_{column}
+ # - {column}=
+ def define_instance_internal_id_methods(scope, column, init, backfill)
define_method("ensure_#{scope}_#{column}!") do
return if backfill && self.class.where(column => nil).exists?
@@ -103,19 +129,95 @@ module AtomicInternalId
read_attribute(column)
end
end
+
+ # Defines class methods:
+ #
+ # - with_{scope}_{column}_supply
+ # This method can be used to allocate a block of IID values during
+ # bulk operations (importing/copying, etc). This can be more efficient
+ # than creating instances one-by-one.
+ #
+ # Pass in a block that receives a `Supply` instance. To allocate a new
+ # IID value, call `Supply#next_value`.
+ #
+ # Example:
+ #
+ # MyClass.with_project_iid_supply(project) do |supply|
+ # attributes = MyClass.where(project: project).find_each do |record|
+ # record.attributes.merge(iid: supply.next_value)
+ # end
+ #
+ # bulk_insert(attributes)
+ # end
+ def define_singleton_internal_id_methods(scope, column, init)
+ define_singleton_method("with_#{scope}_#{column}_supply") do |scope_value, &block|
+ subject = find_by(scope => scope_value) || self
+ scope_attrs = ::AtomicInternalId.scope_attrs(scope_value)
+ usage = ::AtomicInternalId.scope_usage(self)
+
+ generator = InternalId::InternalIdGenerator.new(subject, scope_attrs, usage, init)
+
+ generator.with_lock do
+ supply = Supply.new(generator.record.last_value)
+ block.call(supply)
+ ensure
+ generator.track_greatest(supply.current_value) if supply
+ end
+ end
+ end
+ end
+
+ def self.scope_attrs(scope_value)
+ { scope_value.class.table_name.singularize.to_sym => scope_value } if scope_value
end
def internal_id_scope_attrs(scope)
scope_value = internal_id_read_scope(scope)
- { scope_value.class.table_name.singularize.to_sym => scope_value } if scope_value
+ ::AtomicInternalId.scope_attrs(scope_value)
end
def internal_id_scope_usage
- self.class.table_name.to_sym
+ ::AtomicInternalId.scope_usage(self.class)
+ end
+
+ def self.scope_usage(including_class)
+ including_class.table_name.to_sym
+ end
+
+ def self.project_init(klass, column_name = :iid)
+ ->(instance, scope) do
+ if instance
+ klass.where(project_id: instance.project_id).maximum(column_name)
+ elsif scope.present?
+ klass.where(**scope).maximum(column_name)
+ end
+ end
+ end
+
+ def self.group_init(klass, column_name = :iid)
+ ->(instance, scope) do
+ if instance
+ klass.where(group_id: instance.group_id).maximum(column_name)
+ elsif scope.present?
+ klass.where(group: scope[:namespace]).maximum(column_name)
+ end
+ end
end
def internal_id_read_scope(scope)
association(scope).reader
end
+
+ class Supply
+ attr_reader :current_value
+
+ def initialize(start_value)
+ @current_value = start_value
+ end
+
+ def next_value
+ @current_value += 1
+ end
+ end
end
diff --git a/app/models/concerns/enums/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb
index f1bc43a12d8..bb8df37f649 100644
--- a/app/models/concerns/enums/ci/pipeline.rb
+++ b/app/models/concerns/enums/ci/pipeline.rb
@@ -53,6 +53,10 @@ module Enums
sources.except(*dangling_sources.keys)
end
+ def self.ci_and_parent_sources
+ ci_sources.merge(sources.slice(:parent_pipeline))
+ end
+
# Returns the `Hash` to use for creating the `config_sources` enum for
# `Ci::Pipeline`.
def self.config_sources
diff --git a/app/models/concerns/enums/internal_id.rb b/app/models/concerns/enums/internal_id.rb
index 2d51d232e93..f01bd60ef16 100644
--- a/app/models/concerns/enums/internal_id.rb
+++ b/app/models/concerns/enums/internal_id.rb
@@ -14,7 +14,8 @@ module Enums
operations_feature_flags: 6,
operations_user_lists: 7,
alert_management_alerts: 8,
- sprints: 9 # iterations
+ sprints: 9, # iterations
+ design_management_designs: 10
}
end
end
diff --git a/app/models/concerns/featurable.rb b/app/models/concerns/featurable.rb
index 60aa46ce04c..20b72957ec2 100644
--- a/app/models/concerns/featurable.rb
+++ b/app/models/concerns/featurable.rb
@@ -37,7 +37,8 @@ module Featurable
class_methods do
def set_available_features(available_features = [])
- @available_features = available_features
+ @available_features ||= []
+ @available_features += available_features
class_eval do
available_features.each do |feature|
diff --git a/app/models/concerns/from_union.rb b/app/models/concerns/from_union.rb
index e25d603b802..be6744f1b2a 100644
--- a/app/models/concerns/from_union.rb
+++ b/app/models/concerns/from_union.rb
@@ -37,27 +37,6 @@ module FromUnion
# rubocop: disable Gitlab/Union
extend FromSetOperator
define_set_operator Gitlab::SQL::Union
-
- alias_method :from_union_set_operator, :from_union
- def from_union(members, remove_duplicates: true, alias_as: table_name)
- if Feature.enabled?(:sql_set_operators)
- from_union_set_operator(members, remove_duplicates: remove_duplicates, alias_as: alias_as)
- else
- # The original from_union method.
- standard_from_union(members, remove_duplicates: remove_duplicates, alias_as: alias_as)
- end
- end
-
- private
-
- def standard_from_union(members, remove_duplicates: true, alias_as: table_name)
- union = Gitlab::SQL::Union
- .new(members, remove_duplicates: remove_duplicates)
- .to_sql
-
- from(Arel.sql("(#{union}) #{alias_as}"))
- end
-
# rubocop: enable Gitlab/Union
end
end
diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb
index 978a54bdee7..3dea4a9f5fb 100644
--- a/app/models/concerns/has_repository.rb
+++ b/app/models/concerns/has_repository.rb
@@ -109,6 +109,11 @@ module HasRepository
Gitlab::RepositoryUrlBuilder.build(repository.full_path, protocol: :http)
end
+ # Is overridden in EE::Project for Geo support
+ def lfs_http_url_to_repo(_operation = nil)
+ http_url_to_repo
+ end
+
def web_url(only_path: nil)
Gitlab::UrlBuilder.build(self, only_path: only_path)
end
diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb
index 6efb8103b7b..886db133a94 100644
--- a/app/models/concerns/issue_available_features.rb
+++ b/app/models/concerns/issue_available_features.rb
@@ -6,18 +6,25 @@
module IssueAvailableFeatures
extend ActiveSupport::Concern
- # EE only features are listed on EE::IssueAvailableFeatures
- def available_features_for_issue_types
- {}.with_indifferent_access
+ class_methods do
+ # EE only features are listed on EE::IssueAvailableFeatures
+ def available_features_for_issue_types
+ {}.with_indifferent_access
+ end
+ end
+
+ included do
+ scope :with_feature, ->(feature) { where(issue_type: available_features_for_issue_types[feature]) }
end
def issue_type_supports?(feature)
- unless available_features_for_issue_types.has_key?(feature)
+ unless self.class.available_features_for_issue_types.has_key?(feature)
raise ArgumentError, 'invalid feature'
end
- available_features_for_issue_types[feature].include?(issue_type)
+ self.class.available_features_for_issue_types[feature].include?(issue_type)
end
end
IssueAvailableFeatures.prepend_if_ee('EE::IssueAvailableFeatures')
+IssueAvailableFeatures::ClassMethods.prepend_if_ee('EE::IssueAvailableFeatures::ClassMethods')
diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb
index 307d58a3a3c..5a5ce1809d0 100644
--- a/app/models/concerns/mentionable/reference_regexes.rb
+++ b/app/models/concerns/mentionable/reference_regexes.rb
@@ -22,7 +22,7 @@ module Mentionable
def self.default_pattern
strong_memoize(:default_pattern) do
issue_pattern = Issue.reference_pattern
- link_patterns = Regexp.union([Issue, Commit, MergeRequest, Epic].map(&:link_reference_pattern).compact)
+ link_patterns = Regexp.union([Issue, Commit, MergeRequest, Epic, Vulnerability].map(&:link_reference_pattern).compact)
reference_pattern(link_patterns, issue_pattern)
end
end
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index cedcf164a49..b69fb2931c3 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -88,3 +88,5 @@ module ProjectFeaturesCompatibility
project_feature.__send__(:write_attribute, field, value) # rubocop:disable GitlabSecurity/PublicSend
end
end
+
+ProjectFeaturesCompatibility.prepend_if_ee('EE::ProjectFeaturesCompatibility')
diff --git a/app/models/concerns/project_services_loggable.rb b/app/models/concerns/project_services_loggable.rb
index fecd77cdc98..e5385435138 100644
--- a/app/models/concerns/project_services_loggable.rb
+++ b/app/models/concerns/project_services_loggable.rb
@@ -16,8 +16,8 @@ module ProjectServicesLoggable
def build_message(message, params = {})
{
service_class: self.class.name,
- project_id: project.id,
- project_path: project.full_path,
+ project_id: project&.id,
+ project_path: project&.full_path,
message: message
}.merge(params)
end
diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb
index d1e3d9b2aff..28dc3366e51 100644
--- a/app/models/concerns/protected_ref_access.rb
+++ b/app/models/concerns/protected_ref_access.rb
@@ -36,10 +36,12 @@ module ProtectedRefAccess
HUMAN_ACCESS_LEVELS[self.access_level]
end
- # CE access levels are always role-based,
- # where as EE allows groups and users too
+ def type
+ :role
+ end
+
def role?
- true
+ type == :role
end
def check_access(user)
diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb
index 71b976c6f11..a82cf338039 100644
--- a/app/models/concerns/storage/legacy_namespace.rb
+++ b/app/models/concerns/storage/legacy_namespace.rb
@@ -90,7 +90,7 @@ module Storage
end
def old_repository_storages
- @old_repository_storage_paths ||= repository_storages
+ @old_repository_storage_paths ||= repository_storages(legacy_only: true)
end
def repository_storages(legacy_only: false)
diff --git a/app/models/concerns/todoable.rb b/app/models/concerns/todoable.rb
new file mode 100644
index 00000000000..d93ab463251
--- /dev/null
+++ b/app/models/concerns/todoable.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+# == Todoable concern
+#
+# Specify object types that supports todos.
+#
+# Used by Issue, MergeRequest, Design and Epic.
+#
+module Todoable
+end
diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb
index b64a9e4f70b..325a5531926 100644
--- a/app/models/concerns/triggerable_hooks.rb
+++ b/app/models/concerns/triggerable_hooks.rb
@@ -13,7 +13,9 @@ module TriggerableHooks
job_hooks: :job_events,
pipeline_hooks: :pipeline_events,
wiki_page_hooks: :wiki_page_events,
- deployment_hooks: :deployment_events
+ deployment_hooks: :deployment_events,
+ feature_flag_hooks: :feature_flag_events,
+ release_hooks: :releases_events
}.freeze
extend ActiveSupport::Concern
diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb
index 641d244b665..0441a5f0f5b 100644
--- a/app/models/container_expiration_policy.rb
+++ b/app/models/container_expiration_policy.rb
@@ -5,6 +5,13 @@ class ContainerExpirationPolicy < ApplicationRecord
include UsageStatistics
include EachBatch
+ POLICY_PARAMS = %w[
+ older_than
+ keep_n
+ name_regex
+ name_regex_keep
+ ].freeze
+
belongs_to :project, inverse_of: :container_expiration_policy
delegate :container_repositories, to: :project
@@ -14,14 +21,15 @@ class ContainerExpirationPolicy < ApplicationRecord
validates :cadence, presence: true, inclusion: { in: ->(_) { self.cadence_options.stringify_keys } }
validates :older_than, inclusion: { in: ->(_) { self.older_than_options.stringify_keys } }, allow_nil: true
validates :keep_n, inclusion: { in: ->(_) { self.keep_n_options.keys } }, allow_nil: true
+ validates :name_regex, presence: true, if: :enabled?
validates :name_regex, untrusted_regexp: true, if: :enabled?
validates :name_regex_keep, untrusted_regexp: true, if: :enabled?
scope :active, -> { where(enabled: true) }
scope :preloaded, -> { preload(project: [:route]) }
- def self.executable
- runnable_schedules.where(
+ def self.with_container_repositories
+ where(
'EXISTS (?)',
ContainerRepository.select(1)
.where(
@@ -67,4 +75,8 @@ class ContainerExpirationPolicy < ApplicationRecord
def disable!
update_attribute(:enabled, false)
end
+
+ def policy_params
+ attributes.slice(*POLICY_PARAMS)
+ end
end
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index d97b8776085..4adbd37608f 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -3,6 +3,9 @@
class ContainerRepository < ApplicationRecord
include Gitlab::Utils::StrongMemoize
include Gitlab::SQL::Pattern
+ include EachBatch
+
+ WAITING_CLEANUP_STATUSES = %i[cleanup_scheduled cleanup_unfinished].freeze
belongs_to :project
@@ -10,6 +13,7 @@ class ContainerRepository < ApplicationRecord
validates :name, uniqueness: { scope: :project_id }
enum status: { delete_scheduled: 0, delete_failed: 1 }
+ enum expiration_policy_cleanup_status: { cleanup_unscheduled: 0, cleanup_scheduled: 1, cleanup_unfinished: 2, cleanup_ongoing: 3 }
delegate :client, to: :registry
@@ -24,7 +28,9 @@ class ContainerRepository < ApplicationRecord
ContainerRepository
.joins("INNER JOIN (#{project_scope.to_sql}) projects on projects.id=container_repositories.project_id")
end
+ scope :for_project_id, ->(project_id) { where(project_id: project_id) }
scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) }
+ scope :waiting_for_cleanup, -> { where(expiration_policy_cleanup_status: WAITING_CLEANUP_STATUSES) }
def self.exists_by_path?(path)
where(
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 643b4060ad6..ed22d4ba231 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -3,14 +3,21 @@
class CustomEmoji < ApplicationRecord
belongs_to :namespace, inverse_of: :custom_emoji
+ belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id'
+
+ # For now only external emoji are supported. See https://gitlab.com/gitlab-org/gitlab/-/issues/230467
+ validates :external, inclusion: { in: [true] }
+
+ validates :file, public_url: true, if: :external
+
validate :valid_emoji_name
- validates :namespace, presence: true
+ validates :group, presence: true
validates :name,
uniqueness: { scope: [:namespace_id, :name] },
presence: true,
length: { maximum: 36 },
- format: { with: /\A\w+\z/ }
+ format: { with: /\A([a-z0-9]+[-_]?)+[a-z0-9]+\z/ }
private
diff --git a/app/models/dependency_proxy.rb b/app/models/dependency_proxy.rb
new file mode 100644
index 00000000000..510a304ff17
--- /dev/null
+++ b/app/models/dependency_proxy.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+module DependencyProxy
+ def self.table_name_prefix
+ 'dependency_proxy_'
+ end
+end
diff --git a/app/models/dependency_proxy/blob.rb b/app/models/dependency_proxy/blob.rb
new file mode 100644
index 00000000000..3a81112340a
--- /dev/null
+++ b/app/models/dependency_proxy/blob.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class DependencyProxy::Blob < ApplicationRecord
+ include FileStoreMounter
+
+ belongs_to :group
+
+ validates :group, presence: true
+ validates :file, presence: true
+ validates :file_name, presence: true
+
+ mount_file_store_uploader DependencyProxy::FileUploader
+
+ def self.total_size
+ sum(:size)
+ end
+
+ def self.find_or_build(file_name)
+ find_or_initialize_by(file_name: file_name)
+ end
+end
diff --git a/app/models/dependency_proxy/group_setting.rb b/app/models/dependency_proxy/group_setting.rb
new file mode 100644
index 00000000000..bcf09b27129
--- /dev/null
+++ b/app/models/dependency_proxy/group_setting.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class DependencyProxy::GroupSetting < ApplicationRecord
+ belongs_to :group
+
+ validates :group, presence: true
+
+ default_value_for :enabled, true
+end
diff --git a/app/models/dependency_proxy/registry.rb b/app/models/dependency_proxy/registry.rb
new file mode 100644
index 00000000000..471d5be2600
--- /dev/null
+++ b/app/models/dependency_proxy/registry.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class DependencyProxy::Registry
+ AUTH_URL = 'https://auth.docker.io'.freeze
+ LIBRARY_URL = 'https://registry-1.docker.io/v2'.freeze
+
+ class << self
+ def auth_url(image)
+ "#{AUTH_URL}/token?service=registry.docker.io&scope=repository:#{image_path(image)}:pull"
+ end
+
+ def manifest_url(image, tag)
+ "#{LIBRARY_URL}/#{image_path(image)}/manifests/#{tag}"
+ end
+
+ def blob_url(image, blob_sha)
+ "#{LIBRARY_URL}/#{image_path(image)}/blobs/#{blob_sha}"
+ end
+
+ private
+
+ def image_path(image)
+ if image.include?('/')
+ image
+ else
+ "library/#{image}"
+ end
+ end
+ end
+end
diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb
index 793ea3c29c3..db5fd167781 100644
--- a/app/models/deploy_key.rb
+++ b/app/models/deploy_key.rb
@@ -6,9 +6,11 @@ class DeployKey < Key
has_many :deploy_keys_projects, inverse_of: :deploy_key, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :deploy_keys_projects
+ has_many :protected_branch_push_access_levels, class_name: '::ProtectedBranch::PushAccessLevel'
- scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where('deploy_keys_projects.project_id in (?)', projects) }
- scope :are_public, -> { where(public: true) }
+ scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where(deploy_keys_projects: { project_id: projects }) }
+ scope :with_write_access, -> { joins(:deploy_keys_projects).merge(DeployKeysProject.with_write_access) }
+ scope :are_public, -> { where(public: true) }
scope :with_projects, -> { includes(deploy_keys_projects: { project: [:route, namespace: :route] }) }
ignore_column :can_push, remove_after: '2019-12-15', remove_with: '12.6'
@@ -54,4 +56,11 @@ class DeployKey < Key
def projects_with_write_access
Project.with_route.where(id: deploy_keys_projects.with_write_access.select(:project_id))
end
+
+ def self.with_write_access_for_project(project, deploy_key: nil)
+ query = in_projects(project).with_write_access
+ query = query.where(id: deploy_key) if deploy_key
+
+ query
+ end
end
diff --git a/app/models/deploy_keys_project.rb b/app/models/deploy_keys_project.rb
index a9cc56a7246..40c66d5bc4c 100644
--- a/app/models/deploy_keys_project.rb
+++ b/app/models/deploy_keys_project.rb
@@ -6,7 +6,6 @@ class DeployKeysProject < ApplicationRecord
scope :without_project_deleted, -> { joins(:project).where(projects: { pending_delete: false }) }
scope :in_project, ->(project) { where(project: project) }
scope :with_write_access, -> { where(can_push: true) }
- scope :with_deploy_keys, -> { includes(:deploy_key) }
accepts_nested_attributes_for :deploy_key
diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb
index 9355d73fae9..5fa9f2ef9f9 100644
--- a/app/models/deploy_token.rb
+++ b/app/models/deploy_token.rb
@@ -54,6 +54,10 @@ class DeployToken < ApplicationRecord
!revoked && !expired?
end
+ def deactivated?
+ !active?
+ end
+
def scopes
AVAILABLE_SCOPES.select { |token_scope| read_attribute(token_scope) }
end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 2d0d98136ec..36ac1bdb236 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -21,9 +21,7 @@ class Deployment < ApplicationRecord
has_one :deployment_cluster
- has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) do
- Deployment.where(project: s.project).maximum(:iid) if s&.project
- end
+ has_internal_id :iid, scope: :project, track_if: -> { !importing? }
validates :sha, presence: true
validates :ref, presence: true
@@ -79,8 +77,6 @@ class Deployment < ApplicationRecord
after_transition any => :running do |deployment|
deployment.run_after_commit do
- next unless Feature.enabled?(:ci_send_deployment_hook_when_start, deployment.project)
-
Deployments::ExecuteHooksWorker.perform_async(id)
end
end
diff --git a/app/models/deployment_merge_request.rb b/app/models/deployment_merge_request.rb
index b67f96906f5..64a578e16bf 100644
--- a/app/models/deployment_merge_request.rb
+++ b/app/models/deployment_merge_request.rb
@@ -14,7 +14,12 @@ class DeploymentMergeRequest < ApplicationRecord
end
def self.deployed_to(name)
+ # We filter by project ID again so the query uses the index on
+ # (project_id, name), instead of using the index on
+ # (name varchar_pattern_ops). This results in better performance on
+ # GitLab.com.
where('environments.name = ?', name)
+ .where('environments.project_id = merge_requests.target_project_id')
end
def self.deployed_after(time)
diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb
index 62e4bd6cebc..f5e52c04944 100644
--- a/app/models/design_management/design.rb
+++ b/app/models/design_management/design.rb
@@ -2,6 +2,7 @@
module DesignManagement
class Design < ApplicationRecord
+ include AtomicInternalId
include Importable
include Noteable
include Gitlab::FileTypeDetection
@@ -10,12 +11,15 @@ module DesignManagement
include Mentionable
include WhereComposite
include RelativePositioning
+ include Todoable
+ include Participable
belongs_to :project, inverse_of: :designs
belongs_to :issue
has_many :actions
has_many :versions, through: :actions, class_name: 'DesignManagement::Version', inverse_of: :designs
+ has_many :authors, -> { distinct }, through: :versions, class_name: 'User'
# This is a polymorphic association, so we can't count on FK's to delete the
# data
has_many :notes, as: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
@@ -23,6 +27,10 @@ module DesignManagement
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_internal_id :iid, scope: :project, presence: true,
+ hook_names: %i[create update], # Deal with old records
+ track_if: -> { !importing? }
+
validates :project, :filename, presence: true
validates :issue, presence: true, unless: :importing?
validates :filename, uniqueness: { scope: :issue_id }, length: { maximum: 255 }
@@ -30,6 +38,9 @@ module DesignManagement
alias_attribute :title, :filename
+ participant :authors
+ participant :notes_with_associations
+
# Pre-fetching scope to include the data necessary to construct a
# reference using `to_reference`.
scope :for_reference, -> { includes(issue: [{ project: [:route, :namespace] }]) }
diff --git a/app/models/design_management/version.rb b/app/models/design_management/version.rb
index 55c9084caf2..49aec8b9720 100644
--- a/app/models/design_management/version.rb
+++ b/app/models/design_management/version.rb
@@ -43,10 +43,7 @@ module DesignManagement
validates :sha, presence: true
validates :sha, uniqueness: { case_sensitive: false, scope: :issue_id }
validates :author, presence: true
- # We are not validating the issue object as it incurs an extra query to fetch
- # the record from the DB. Instead, we rely on the foreign key constraint to
- # ensure referential integrity.
- validates :issue_id, presence: true, unless: :importing?
+ validates :issue, presence: true, unless: :importing?
sha_attribute :sha
diff --git a/app/models/diff_viewer/image.rb b/app/models/diff_viewer/image.rb
index 62a3446a7b6..fca6c664196 100644
--- a/app/models/diff_viewer/image.rb
+++ b/app/models/diff_viewer/image.rb
@@ -10,5 +10,13 @@ module DiffViewer
self.binary = true
self.switcher_icon = 'doc-image'
self.switcher_title = _('image diff')
+
+ def self.can_render?(diff_file, verify_binary: true)
+ # When both blobs are missing, we often still have a textual diff that can
+ # be displayed
+ return false if diff_file.old_blob.nil? && diff_file.new_blob.nil?
+
+ super
+ end
end
end
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index 793cdb5dece..70aa02063cc 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -16,6 +16,7 @@ class Discussion
:commit_id,
:confidential?,
:for_commit?,
+ :for_design?,
:for_merge_request?,
:noteable_ability_name,
:to_ability_name,
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 66613869915..deded3eeae0 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -305,6 +305,10 @@ class Environment < ApplicationRecord
latest_opened_most_severe_alert.present?
end
+ def has_running_deployments?
+ all_deployments.running.exists?
+ end
+
def metrics
prometheus_adapter.query(:environment, self) if has_metrics_and_can_query?
end
@@ -395,7 +399,7 @@ class Environment < ApplicationRecord
# Overrides ReactiveCaching default to activate limit checking behind a FF
def reactive_cache_limit_enabled?
- Feature.enabled?(:reactive_caching_limit_environment, project)
+ Feature.enabled?(:reactive_caching_limit_environment, project, default_enabled: true)
end
end
diff --git a/app/models/experiment.rb b/app/models/experiment.rb
index 25640385536..f179a1fc6ce 100644
--- a/app/models/experiment.rb
+++ b/app/models/experiment.rb
@@ -2,26 +2,17 @@
class Experiment < ApplicationRecord
has_many :experiment_users
- has_many :users, through: :experiment_users
- has_many :control_group_users, -> { merge(ExperimentUser.control) }, through: :experiment_users, source: :user
- has_many :experimental_group_users, -> { merge(ExperimentUser.experimental) }, through: :experiment_users, source: :user
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
def self.add_user(name, group_type, user)
- experiment = find_or_create_by(name: name)
+ return unless experiment = find_or_create_by(name: name)
- return unless experiment
- return if experiment.experiment_users.where(user: user).exists?
-
- group_type == ::Gitlab::Experimentation::GROUP_CONTROL ? experiment.add_control_user(user) : experiment.add_experimental_user(user)
- end
-
- def add_control_user(user)
- control_group_users << user
+ experiment.record_user_and_group(user, group_type)
end
- def add_experimental_user(user)
- experimental_group_users << user
+ # Create or update the recorded experiment_user row for the user in this experiment.
+ def record_user_and_group(user, group_type)
+ experiment_users.find_or_initialize_by(user: user).update!(group_type: group_type)
end
end
diff --git a/app/models/experiment_user.rb b/app/models/experiment_user.rb
index 1571b0c3439..e447becc1bd 100644
--- a/app/models/experiment_user.rb
+++ b/app/models/experiment_user.rb
@@ -1,10 +1,14 @@
# frozen_string_literal: true
class ExperimentUser < ApplicationRecord
+ include ::Gitlab::Experimentation::GroupTypes
+
belongs_to :experiment
belongs_to :user
- enum group_type: { control: 0, experimental: 1 }
+ enum group_type: { GROUP_CONTROL => 0, GROUP_EXPERIMENTAL => 1 }
+ validates :experiment_id, presence: true
+ validates :user_id, presence: true
validates :group_type, presence: true
end
diff --git a/app/models/group.rb b/app/models/group.rb
index 74f7efd253d..3509299a579 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -71,6 +71,9 @@ class Group < Namespace
has_many :group_deploy_tokens
has_many :deploy_tokens, through: :group_deploy_tokens
+ has_one :dependency_proxy_setting, class_name: 'DependencyProxy::GroupSetting'
+ has_many :dependency_proxy_blobs, class_name: 'DependencyProxy::Blob'
+
accepts_nested_attributes_for :variables, allow_destroy: true
validate :visibility_level_allowed_by_projects
@@ -98,6 +101,19 @@ class Group < Namespace
scope :by_id, ->(groups) { where(id: groups) }
+ scope :for_authorized_group_members, -> (user_ids) do
+ joins(:group_members)
+ .where("members.user_id IN (?)", user_ids)
+ .where("access_level >= ?", Gitlab::Access::GUEST)
+ end
+
+ scope :for_authorized_project_members, -> (user_ids) do
+ joins(projects: :project_authorizations)
+ .where("project_authorizations.user_id IN (?)", user_ids)
+ end
+
+ delegate :default_branch_name, to: :namespace_settings
+
class << self
def sort_by_attribute(method)
if method == 'storage_size_desc'
@@ -190,6 +206,10 @@ class Group < Namespace
::Gitlab.config.packages.enabled
end
+ def dependency_proxy_feature_available?
+ ::Gitlab.config.dependency_proxy.enabled
+ end
+
def notification_email_for(user)
# Finds the closest notification_setting with a `notification_email`
notification_settings = notification_settings_for(user, hierarchy_order: :asc)
@@ -571,12 +591,16 @@ class Group < Namespace
ancestor_settings.allow_mfa_for_subgroups
end
+ def has_project_with_service_desk_enabled?
+ Gitlab::ServiceDesk.supported? && all_projects.service_desk_enabled.exists?
+ end
+
private
def update_two_factor_requirement
return unless saved_change_to_require_two_factor_authentication? || saved_change_to_two_factor_grace_period?
- members_with_descendants.find_each(&:update_two_factor_requirement)
+ direct_and_indirect_members.find_each(&:update_two_factor_requirement)
end
def path_changed_hook
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index 2d1bdecc770..b625a70b444 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -18,7 +18,9 @@ class ProjectHook < WebHook
:job_hooks,
:pipeline_hooks,
:wiki_page_hooks,
- :deployment_hooks
+ :deployment_hooks,
+ :feature_flag_hooks,
+ :release_hooks
]
belongs_to :project
diff --git a/app/models/instance_metadata.rb b/app/models/instance_metadata.rb
new file mode 100644
index 00000000000..96622d0b1b3
--- /dev/null
+++ b/app/models/instance_metadata.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class InstanceMetadata
+ attr_reader :version, :revision
+
+ def initialize(version: Gitlab::VERSION, revision: Gitlab.revision)
+ @version = version
+ @revision = revision
+ end
+end
diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb
index 4c0469d849a..c735e593da7 100644
--- a/app/models/internal_id.rb
+++ b/app/models/internal_id.rb
@@ -61,13 +61,13 @@ class InternalId < ApplicationRecord
class << self
def track_greatest(subject, scope, usage, new_value, init)
- InternalIdGenerator.new(subject, scope, usage)
- .track_greatest(init, new_value)
+ InternalIdGenerator.new(subject, scope, usage, init)
+ .track_greatest(new_value)
end
def generate_next(subject, scope, usage, init)
- InternalIdGenerator.new(subject, scope, usage)
- .generate(init)
+ InternalIdGenerator.new(subject, scope, usage, init)
+ .generate
end
def reset(subject, scope, usage, value)
@@ -99,15 +99,18 @@ class InternalId < ApplicationRecord
# 4) In the absence of a record in the internal_ids table, one will be created
# and last_value will be calculated on the fly.
#
- # subject: The instance we're generating an internal id for. Gets passed to init if called.
+ # subject: The instance or class we're generating an internal id for.
# scope: Attributes that define the scope for id generation.
+ # Valid keys are `project/project_id` and `namespace/namespace_id`.
# usage: Symbol to define the usage of the internal id, see InternalId.usages
- attr_reader :subject, :scope, :scope_attrs, :usage
+ # init: Proc that accepts the subject and the scope and returns Integer|NilClass
+ attr_reader :subject, :scope, :scope_attrs, :usage, :init
- def initialize(subject, scope, usage)
+ def initialize(subject, scope, usage, init = nil)
@subject = subject
@scope = scope
@usage = usage
+ @init = init
raise ArgumentError, 'Scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty?
@@ -119,13 +122,13 @@ class InternalId < ApplicationRecord
# Generates next internal id and returns it
# init: Block that gets called to initialize InternalId record if not present
# Make sure to not throw exceptions in the absence of records (if this is expected).
- def generate(init)
+ def generate
subject.transaction do
# Create a record in internal_ids if one does not yet exist
# and increment its last value
#
# Note this will acquire a ROW SHARE lock on the InternalId record
- (lookup || create_record(init)).increment_and_save!
+ record.increment_and_save!
end
end
@@ -148,12 +151,20 @@ class InternalId < ApplicationRecord
# and set its new_value if it is higher than the current last_value
#
# Note this will acquire a ROW SHARE lock on the InternalId record
- def track_greatest(init, new_value)
+ def track_greatest(new_value)
subject.transaction do
- (lookup || create_record(init)).track_greatest_and_save!(new_value)
+ record.track_greatest_and_save!(new_value)
end
end
+ def record
+ @record ||= (lookup || create_record)
+ end
+
+ def with_lock(&block)
+ record.with_lock(&block)
+ end
+
private
# Retrieve InternalId record for (project, usage) combination, if it exists
@@ -171,12 +182,16 @@ class InternalId < ApplicationRecord
# was faster in doing this, we'll realize once we hit the unique key constraint
# violation. We can safely roll-back the nested transaction and perform
# a lookup instead to retrieve the record.
- def create_record(init)
+ def create_record
+ raise ArgumentError, 'Cannot initialize without init!' unless init
+
+ instance = subject.is_a?(::Class) ? nil : subject
+
subject.transaction(requires_new: true) do
InternalId.create!(
**scope,
usage: usage_value,
- last_value: init.call(subject) || 0
+ last_value: init.call(instance, scope) || 0
)
end
rescue ActiveRecord::RecordNotUnique
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 5291b7890b6..7dc18cacd7c 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -21,6 +21,7 @@ class Issue < ApplicationRecord
include IdInOrdered
include Presentable
include IssueAvailableFeatures
+ include Todoable
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
@@ -47,7 +48,7 @@ class Issue < ApplicationRecord
belongs_to :moved_to, class_name: 'Issue'
has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id
- has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.issues&.maximum(:iid) }
+ has_internal_id :iid, scope: :project, track_if: -> { !importing? }
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
diff --git a/app/models/issue_link.rb b/app/models/issue_link.rb
index 9740b009396..5448ebdf50b 100644
--- a/app/models/issue_link.rb
+++ b/app/models/issue_link.rb
@@ -10,6 +10,7 @@ class IssueLink < ApplicationRecord
validates :target, presence: true
validates :source, uniqueness: { scope: :target_id, message: 'is already related' }
validate :check_self_relation
+ validate :check_opposite_relation
scope :for_source_issue, ->(issue) { where(source_id: issue.id) }
scope :for_target_issue, ->(issue) { where(target_id: issue.id) }
@@ -33,6 +34,14 @@ class IssueLink < ApplicationRecord
errors.add(:source, 'cannot be related to itself')
end
end
+
+ def check_opposite_relation
+ return unless source && target
+
+ if IssueLink.find_by(source: target, target: source)
+ errors.add(:source, 'is already related to this issue')
+ end
+ end
end
IssueLink.prepend_if_ee('EE::IssueLink')
diff --git a/app/models/issues/csv_import.rb b/app/models/issues/csv_import.rb
new file mode 100644
index 00000000000..d141f126ec9
--- /dev/null
+++ b/app/models/issues/csv_import.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class Issues::CsvImport < ApplicationRecord
+ self.table_name = 'csv_issue_imports'
+
+ belongs_to :project, optional: false
+ belongs_to :user, optional: false
+end
diff --git a/app/models/iteration.rb b/app/models/iteration.rb
index bd245de411c..ba7cd973e9d 100644
--- a/app/models/iteration.rb
+++ b/app/models/iteration.rb
@@ -17,8 +17,8 @@ class Iteration < ApplicationRecord
belongs_to :project
belongs_to :group
- has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.iterations&.maximum(:iid) }
- has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.iterations&.maximum(:iid) }
+ has_internal_id :iid, scope: :project
+ has_internal_id :iid, scope: :group
validates :start_date, presence: true
validates :due_date, presence: true
diff --git a/app/models/member.rb b/app/models/member.rb
index 498e03b2c1a..687830f5267 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -96,6 +96,8 @@ class Member < ApplicationRecord
scope :owners, -> { active.where(access_level: OWNER) }
scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) }
scope :with_user, -> (user) { where(user: user) }
+ scope :with_user_by_email, -> (email) { left_join_users.where(users: { email: email } ) }
+
scope :preload_user_and_notification_settings, -> { preload(user: :notification_settings) }
scope :with_source_id, ->(source_id) { where(source_id: source_id) }
@@ -417,6 +419,10 @@ class Member < ApplicationRecord
invite? && user_id.nil?
end
+ def created_by_name
+ created_by&.name
+ end
+
private
def send_invite
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 34958936c9f..2bbcdbbe5ce 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -21,6 +21,7 @@ class GroupMember < Member
scope :of_groups, ->(groups) { where(source_id: groups.select(:id)) }
scope :of_ldap_type, -> { where(ldap: true) }
scope :count_users_by_group_id, -> { group(:source_id).count }
+ scope :with_user, -> (user) { where(user: user) }
after_create :update_two_factor_requirement, unless: :invite?
after_destroy :update_two_factor_requirement, unless: :invite?
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 24541ba3218..d379f85bc15 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -22,6 +22,7 @@ class MergeRequest < ApplicationRecord
include StateEventable
include ApprovableBase
include IdInOrdered
+ include Todoable
extend ::Gitlab::Utils::Override
@@ -40,7 +41,14 @@ class MergeRequest < ApplicationRecord
belongs_to :merge_user, class_name: "User"
belongs_to :iteration, foreign_key: 'sprint_id'
- has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) }
+ has_internal_id :iid, scope: :target_project, track_if: -> { !importing? },
+ init: ->(mr, scope) do
+ if mr
+ mr.target_project&.merge_requests&.maximum(:iid)
+ elsif scope[:project]
+ where(target_project: scope[:project]).maximum(:iid)
+ end
+ end
has_many :merge_request_diffs
has_many :merge_request_context_commits, inverse_of: :merge_request
@@ -48,6 +56,7 @@ class MergeRequest < ApplicationRecord
has_one :merge_request_diff,
-> { order('merge_request_diffs.id DESC') }, inverse_of: :merge_request
+ has_one :cleanup_schedule, inverse_of: :merge_request
belongs_to :latest_merge_request_diff, class_name: 'MergeRequestDiff'
manual_inverse_association :latest_merge_request_diff, :merge_request
@@ -293,6 +302,7 @@ class MergeRequest < ApplicationRecord
scope :preload_author, -> { preload(:author) }
scope :preload_approved_by_users, -> { preload(:approved_by_users) }
scope :preload_metrics, -> (relation) { preload(metrics: relation) }
+ scope :with_web_entity_associations, -> { preload(:author, :target_project) }
scope :with_auto_merge_enabled, -> do
with_state(:opened).where(auto_merge_enabled: true)
@@ -302,6 +312,8 @@ class MergeRequest < ApplicationRecord
includes(:metrics)
end
+ scope :with_jira_issue_keys, -> { where('title ~ :regex OR merge_requests.description ~ :regex', regex: Gitlab::Regex.jira_issue_key_regex.source) }
+
after_save :keep_around_commit, unless: :importing?
alias_attribute :project, :target_project
diff --git a/app/models/merge_request/cleanup_schedule.rb b/app/models/merge_request/cleanup_schedule.rb
new file mode 100644
index 00000000000..79817269be2
--- /dev/null
+++ b/app/models/merge_request/cleanup_schedule.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class MergeRequest::CleanupSchedule < ApplicationRecord
+ belongs_to :merge_request, inverse_of: :cleanup_schedule
+
+ validates :scheduled_at, presence: true
+
+ def self.scheduled_merge_request_ids(limit)
+ where('completed_at IS NULL AND scheduled_at <= NOW()')
+ .order('scheduled_at DESC')
+ .limit(limit)
+ .pluck(:merge_request_id)
+ end
+end
diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb
index 55ff4250c2d..817e77bf12f 100644
--- a/app/models/merge_request_diff_file.rb
+++ b/app/models/merge_request_diff_file.rb
@@ -8,6 +8,10 @@ class MergeRequestDiffFile < ApplicationRecord
belongs_to :merge_request_diff, inverse_of: :merge_request_diff_files
alias_attribute :index, :relative_order
+ scope :by_paths, ->(paths) do
+ where("new_path in (?) OR old_path in (?)", paths, paths)
+ end
+
def utf8_diff
return '' if diff.blank?
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 0a315ba8db2..c8776be5e4a 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -12,8 +12,8 @@ class Milestone < ApplicationRecord
has_many :milestone_releases
has_many :releases, through: :milestone_releases
- has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.milestones&.maximum(:iid) }
- has_internal_id :iid, scope: :group, track_if: -> { !importing? }, init: ->(s) { s&.group&.milestones&.maximum(:iid) }
+ has_internal_id :iid, scope: :project, track_if: -> { !importing? }
+ has_internal_id :iid, scope: :group, track_if: -> { !importing? }
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index fd31042c2f6..232d0a6b05d 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -96,7 +96,8 @@ class Namespace < ApplicationRecord
'COALESCE(SUM(ps.snippets_size), 0) AS snippets_size',
'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size',
'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size',
- 'COALESCE(SUM(ps.packages_size), 0) AS packages_size'
+ 'COALESCE(SUM(ps.packages_size), 0) AS packages_size',
+ 'COALESCE(SUM(ps.uploads_size), 0) AS uploads_size'
)
end
@@ -117,8 +118,12 @@ class Namespace < ApplicationRecord
# query - The search query as a String.
#
# Returns an ActiveRecord::Relation.
- def search(query)
- fuzzy_search(query, [:name, :path])
+ def search(query, include_parents: false)
+ if include_parents
+ where(id: Route.for_routable_type(Namespace.name).fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name]]).select(:source_id))
+ else
+ fuzzy_search(query, [:path, :name])
+ end
end
def clean_path(path)
@@ -284,7 +289,8 @@ class Namespace < ApplicationRecord
# that belongs to this namespace
def all_projects
if Feature.enabled?(:recursive_approach_for_all_projects)
- Project.where(namespace: self_and_descendants)
+ namespace = user? ? self : self_and_descendants
+ Project.where(namespace: namespace)
else
Project.inside_path(full_path)
end
@@ -357,7 +363,7 @@ class Namespace < ApplicationRecord
def pages_virtual_domain
Pages::VirtualDomain.new(
- all_projects_with_pages.includes(:route, :project_feature),
+ all_projects_with_pages.includes(:route, :project_feature, pages_metadatum: :pages_deployment),
trim_prefix: full_path
)
end
@@ -388,7 +394,6 @@ class Namespace < ApplicationRecord
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'
@@ -397,7 +402,6 @@ class Namespace < ApplicationRecord
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?
diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb
index 5723a823e98..a3df82998c4 100644
--- a/app/models/namespace/root_storage_statistics.rb
+++ b/app/models/namespace/root_storage_statistics.rb
@@ -11,6 +11,7 @@ class Namespace::RootStorageStatistics < ApplicationRecord
packages_size
#{SNIPPETS_SIZE_STAT_NAME}
pipeline_artifacts_size
+ uploads_size
).freeze
self.primary_key = :namespace_id
@@ -50,7 +51,8 @@ class Namespace::RootStorageStatistics < ApplicationRecord
'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size',
'COALESCE(SUM(ps.packages_size), 0) AS packages_size',
"COALESCE(SUM(ps.snippets_size), 0) AS #{SNIPPETS_SIZE_STAT_NAME}",
- 'COALESCE(SUM(ps.pipeline_artifacts_size), 0) AS pipeline_artifacts_size'
+ 'COALESCE(SUM(ps.pipeline_artifacts_size), 0) AS pipeline_artifacts_size',
+ 'COALESCE(SUM(ps.uploads_size), 0) AS uploads_size'
)
end
diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb
index 6f31208f28b..50844403d7f 100644
--- a/app/models/namespace_setting.rb
+++ b/app/models/namespace_setting.rb
@@ -6,10 +6,18 @@ class NamespaceSetting < ApplicationRecord
validate :default_branch_name_content
validate :allow_mfa_for_group
+ before_validation :normalize_default_branch_name
+
NAMESPACE_SETTINGS_PARAMS = [:default_branch_name].freeze
self.primary_key = :namespace_id
+ private
+
+ def normalize_default_branch_name
+ self.default_branch_name = nil if default_branch_name.blank?
+ end
+
def default_branch_name_content
return if default_branch_name.nil?
diff --git a/app/models/note.rb b/app/models/note.rb
index 954843505d4..cfdac6c432f 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -197,8 +197,8 @@ class Note < ApplicationRecord
.map(&:position)
end
- def count_for_collection(ids, type)
- user.select('noteable_id', 'COUNT(*) as count')
+ def count_for_collection(ids, type, count_column = 'COUNT(*) as count')
+ user.select(:noteable_id, count_column)
.group(:noteable_id)
.where(noteable_type: type, noteable_id: ids)
end
diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb
index 104338b80d1..442f9d36c43 100644
--- a/app/models/operations/feature_flag.rb
+++ b/app/models/operations/feature_flag.rb
@@ -2,6 +2,7 @@
module Operations
class FeatureFlag < ApplicationRecord
+ include AfterCommitQueue
include AtomicInternalId
include IidRoutes
include Limitable
@@ -12,7 +13,7 @@ module Operations
belongs_to :project
- has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.operations_feature_flags&.maximum(:iid) }
+ has_internal_id :iid, scope: :project
default_value_for :active, true
@@ -77,6 +78,22 @@ module Operations
Ability.issues_readable_by_user(issues, current_user)
end
+ def execute_hooks(current_user)
+ run_after_commit do
+ feature_flag_data = Gitlab::DataBuilder::FeatureFlag.build(self, current_user)
+ project.execute_hooks(feature_flag_data, :feature_flag_hooks)
+ end
+ end
+
+ def hook_attrs
+ {
+ id: id,
+ name: name,
+ description: description,
+ active: active
+ }
+ end
+
private
def version_associations
diff --git a/app/models/operations/feature_flags/user_list.rb b/app/models/operations/feature_flags/user_list.rb
index b9bdcb59d5f..3e492eaa892 100644
--- a/app/models/operations/feature_flags/user_list.rb
+++ b/app/models/operations/feature_flags/user_list.rb
@@ -5,6 +5,7 @@ module Operations
class UserList < ApplicationRecord
include AtomicInternalId
include IidRoutes
+ include ::Gitlab::SQL::Pattern
self.table_name = 'operations_user_lists'
@@ -12,7 +13,7 @@ module Operations
has_many :strategy_user_lists
has_many :strategies, through: :strategy_user_lists
- has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.operations_feature_flags_user_lists&.maximum(:iid) }, presence: true
+ has_internal_id :iid, scope: :project, presence: true
validates :project, presence: true
validates :name,
@@ -23,6 +24,10 @@ module Operations
before_destroy :ensure_no_associated_strategies
+ scope :for_name_like, -> (query) do
+ fuzzy_search(query, [:name], use_minimum_char_limit: false)
+ end
+
private
def ensure_no_associated_strategies
diff --git a/app/models/packages/build_info.rb b/app/models/packages/build_info.rb
index df8cf68490e..1b0f0ed8ffd 100644
--- a/app/models/packages/build_info.rb
+++ b/app/models/packages/build_info.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
class Packages::BuildInfo < ApplicationRecord
- belongs_to :package, inverse_of: :build_info
+ belongs_to :package, inverse_of: :build_infos
belongs_to :pipeline, class_name: 'Ci::Pipeline'
end
diff --git a/app/models/packages/event.rb b/app/models/packages/event.rb
index f1d0af64ccd..959c94931ec 100644
--- a/app/models/packages/event.rb
+++ b/app/models/packages/event.rb
@@ -3,6 +3,7 @@
class Packages::Event < ApplicationRecord
belongs_to :package, optional: true
+ UNIQUE_EVENTS_ALLOWED = %i[push_package delete_package pull_package].freeze
EVENT_SCOPES = ::Packages::Package.package_types.merge(container: 1000, tag: 1001).freeze
enum event_scope: EVENT_SCOPES
@@ -22,4 +23,20 @@ class Packages::Event < ApplicationRecord
}
enum originator_type: { user: 0, deploy_token: 1, guest: 2 }
+
+ def self.allowed_event_name(event_scope, event_type, originator)
+ return unless event_allowed?(event_scope, event_type, originator)
+
+ # remove `package` from the event name to avoid issues with HLLRedisCounter class parsing
+ "i_package_#{event_scope}_#{originator}_#{event_type.gsub(/_packages?/, "")}"
+ end
+
+ # Remove some of the events, for now, so we don't hammer Redis too hard.
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/280770
+ def self.event_allowed?(event_scope, event_type, originator)
+ return false if originator.to_sym == :guest
+ return true if UNIQUE_EVENTS_ALLOWED.include?(event_type.to_sym)
+
+ false
+ end
end
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index b8f8d45ff62..60aab0a7222 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -3,6 +3,7 @@ class Packages::Package < ApplicationRecord
include Sortable
include Gitlab::SQL::Pattern
include UsageStatistics
+ include Gitlab::Utils::StrongMemoize
belongs_to :project
belongs_to :creator, class_name: 'User'
@@ -16,7 +17,8 @@ class Packages::Package < ApplicationRecord
has_one :maven_metadatum, inverse_of: :package, class_name: 'Packages::Maven::Metadatum'
has_one :nuget_metadatum, inverse_of: :package, class_name: 'Packages::Nuget::Metadatum'
has_one :composer_metadatum, inverse_of: :package, class_name: 'Packages::Composer::Metadatum'
- has_one :build_info, inverse_of: :package
+ has_many :build_infos, inverse_of: :package
+ has_many :pipelines, through: :build_infos
accepts_nested_attributes_for :conan_metadatum
accepts_nested_attributes_for :maven_metadatum
@@ -38,12 +40,13 @@ class Packages::Package < ApplicationRecord
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 :name, format: { with: Gitlab::Regex.nuget_package_name_regex }, if: :nuget?
- 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, format: { with: Gitlab::Regex.semver_regex }, if: -> { composer_tag_version? || npm? }
+
validates :version,
presence: true,
format: { with: Gitlab::Regex.generic_package_version_regex },
@@ -58,7 +61,7 @@ class Packages::Package < ApplicationRecord
scope :with_version, ->(version) { where(version: version) }
scope :without_version_like, -> (version) { where.not(arel_table[:version].matches(version)) }
scope :with_package_type, ->(package_type) { where(package_type: package_type) }
- scope :including_build_info, -> { includes(build_info: { pipeline: :user }) }
+ scope :including_build_info, -> { includes(pipelines: :user) }
scope :including_project_route, -> { includes(project: { namespace: :route }) }
scope :including_tags, -> { includes(:tags) }
@@ -165,8 +168,16 @@ class Packages::Package < ApplicationRecord
.order(:version)
end
+ # Technical debt: to be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/281937
+ def original_build_info
+ strong_memoize(:original_build_info) do
+ build_infos.first
+ end
+ end
+
+ # Technical debt: to be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/281937
def pipeline
- build_info&.pipeline
+ original_build_info&.pipeline
end
def tag_names
@@ -175,6 +186,10 @@ class Packages::Package < ApplicationRecord
private
+ def composer_tag_version?
+ composer? && !Gitlab::Regex.composer_dev_version_regex.match(version.to_s)
+ end
+
def valid_conan_package_recipe
recipe_exists = project.packages
.conan
diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb
index 4ebd96797db..d68f75140ac 100644
--- a/app/models/packages/package_file.rb
+++ b/app/models/packages/package_file.rb
@@ -8,6 +8,8 @@ class Packages::PackageFile < ApplicationRecord
belongs_to :package
has_one :conan_file_metadatum, inverse_of: :package_file, class_name: 'Packages::Conan::FileMetadatum'
+ has_many :package_file_build_infos, inverse_of: :package_file, class_name: 'Packages::PackageFileBuildInfo'
+ has_many :pipelines, through: :package_file_build_infos
accepts_nested_attributes_for :conan_file_metadatum
diff --git a/app/models/packages/package_file_build_info.rb b/app/models/packages/package_file_build_info.rb
new file mode 100644
index 00000000000..5cabed446aa
--- /dev/null
+++ b/app/models/packages/package_file_build_info.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class Packages::PackageFileBuildInfo < ApplicationRecord
+ belongs_to :package_file, inverse_of: :package_file_build_infos
+ belongs_to :pipeline, class_name: 'Ci::Pipeline'
+end
diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb
index 84d820e539c..9855731778f 100644
--- a/app/models/pages/lookup_path.rb
+++ b/app/models/pages/lookup_path.rb
@@ -22,11 +22,7 @@ module Pages
end
def source
- if artifacts_archive && !artifacts_archive.file_storage?
- zip_source
- else
- file_source
- end
+ zip_source || file_source
end
def prefix
@@ -42,18 +38,36 @@ module Pages
attr_reader :project, :trim_prefix, :domain
def artifacts_archive
- return unless Feature.enabled?(:pages_artifacts_archive, project)
+ return unless Feature.enabled?(:pages_serve_from_artifacts_archive, project)
+
+ project.pages_metadatum.artifacts_archive
+ end
+
+ def deployment
+ return unless Feature.enabled?(:pages_serve_from_deployments, project)
- # Using build artifacts is temporary solution for quick test
- # in production environment, we'll replace this with proper
- # `pages_deployments` later
- project.pages_metadatum.artifacts_archive&.file
+ project.pages_metadatum.pages_deployment
end
def zip_source
+ source = deployment || artifacts_archive
+
+ return unless source&.file
+
+ return if source.file.file_storage? && !Feature.enabled?(:pages_serve_with_zip_file_protocol, project)
+
+ # artifacts archive doesn't support this
+ file_count = source.file_count if source.respond_to?(:file_count)
+
+ global_id = ::Gitlab::GlobalId.build(source, id: source.id).to_s
+
{
type: 'zip',
- path: artifacts_archive.url(expire_at: 1.day.from_now)
+ path: source.file.url_or_file_path(expire_at: 1.day.from_now),
+ global_id: global_id,
+ sha256: source.file_sha256,
+ file_size: source.size,
+ file_count: file_count
}
end
diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb
index cd952c32046..61818a63764 100644
--- a/app/models/pages_deployment.rb
+++ b/app/models/pages_deployment.rb
@@ -4,19 +4,27 @@
class PagesDeployment < ApplicationRecord
include FileStoreMounter
+ attribute :file_store, :integer, default: -> { ::Pages::DeploymentUploader.default_store }
+
belongs_to :project, optional: false
belongs_to :ci_build, class_name: 'Ci::Build', optional: true
+ scope :older_than, -> (id) { where('id < ?', id) }
+
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 }
+ validates :file_count, presence: true, numericality: { greater_than_or_equal_to: 0, only_integer: true }
+ validates :file_sha256, presence: true
before_validation :set_size, if: :file_changed?
- default_value_for(:file_store) { ::Pages::DeploymentUploader.default_store }
-
mount_file_store_uploader ::Pages::DeploymentUploader
+ def log_geo_deleted_event
+ # this is to be adressed in https://gitlab.com/groups/gitlab-org/-/epics/589
+ end
+
private
def set_size
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 98db47deaa3..8192310ddfb 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -286,7 +286,7 @@ class PagesDomain < ApplicationRecord
return unless domain
if domain.downcase.ends_with?(Settings.pages.host.downcase)
- self.errors.add(:domain, "*.#{Settings.pages.host} is restricted")
+ self.errors.add(:domain, "*.#{Settings.pages.host} is restricted. Please compare our documentation at https://docs.gitlab.com/ee/administration/pages/#advanced-configuration against your configuration.")
end
end
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index e01cb0530a5..5aa5f2c842b 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -26,6 +26,7 @@ class PersonalAccessToken < ApplicationRecord
scope :revoked, -> { where(revoked: true) }
scope :not_revoked, -> { where(revoked: [false, nil]) }
scope :for_user, -> (user) { where(user: user) }
+ scope :for_users, -> (users) { where(user: users) }
scope :preload_users, -> { preload(:user) }
scope :order_expires_at_asc, -> { reorder(expires_at: :asc) }
scope :order_expires_at_desc, -> { reorder(expires_at: :desc) }
diff --git a/app/models/project.rb b/app/models/project.rb
index dbedd6d120c..ebd8e56246d 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -346,7 +346,8 @@ class Project < ApplicationRecord
# GitLab Pages
has_many :pages_domains
has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', inverse_of: :project
- has_many :pages_deployments
+ # we need to clean up files, not only remove records
+ has_many :pages_deployments, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
# Can be too many records. We need to implement delete_all in batches.
# Issue https://gitlab.com/gitlab-org/gitlab/-/issues/228637
@@ -378,7 +379,7 @@ class Project < ApplicationRecord
delegate :feature_available?, :builds_enabled?, :wiki_enabled?,
:merge_requests_enabled?, :forking_enabled?, :issues_enabled?,
- :pages_enabled?, :public_pages?, :private_pages?,
+ :pages_enabled?, :snippets_enabled?, :public_pages?, :private_pages?,
:merge_requests_access_level, :forking_access_level, :issues_access_level,
:wiki_access_level, :snippets_access_level, :builds_access_level,
:repository_access_level, :pages_access_level, :metrics_dashboard_access_level,
@@ -570,6 +571,7 @@ class Project < ApplicationRecord
scope :imported_from, -> (type) { where(import_type: type) }
scope :with_tracing_enabled, -> { joins(:tracing_setting) }
+ scope :with_enabled_error_tracking, -> { joins(:error_tracking_setting).where(project_error_tracking_settings: { enabled: true }) }
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
@@ -600,7 +602,7 @@ class Project < ApplicationRecord
# Returns a collection of projects that is either public or visible to the
# logged in user.
def self.public_or_visible_to_user(user = nil, min_access_level = nil)
- min_access_level = nil if user&.admin?
+ min_access_level = nil if user&.can_read_all_resources?
return public_to_user unless user
@@ -626,7 +628,7 @@ class Project < ApplicationRecord
def self.with_feature_available_for_user(feature, user)
visible = [ProjectFeature::ENABLED, ProjectFeature::PUBLIC]
- if user&.admin?
+ if user&.can_read_all_resources?
with_feature_enabled(feature)
elsif user
min_access_level = ProjectFeature.required_minimum_access_level(feature)
@@ -1193,7 +1195,6 @@ class Project < ApplicationRecord
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'
@@ -1340,8 +1341,7 @@ class Project < ApplicationRecord
end
def find_or_initialize_services
- available_services_names =
- Service.available_services_names + Service.project_specific_services_names - disabled_services
+ available_services_names = Service.available_services_names - disabled_services
available_services_names.map do |service_name|
find_or_initialize_service(service_name)
@@ -1468,11 +1468,6 @@ class Project < ApplicationRecord
services.public_send(hooks_scope).any? # rubocop:disable GitlabSecurity/PublicSend
end
- # Is overridden in EE
- def lfs_http_url_to_repo(_)
- http_url_to_repo
- end
-
def feature_usage
super.presence || build_feature_usage
end
@@ -1801,6 +1796,8 @@ class Project < ApplicationRecord
mark_pages_as_not_deployed unless destroyed?
+ DestroyPagesDeploymentsWorker.perform_async(id)
+
# 1. We rename pages to temporary directory
# 2. We wait 5 minutes, due to NFS caching
# 3. We asynchronously remove pages with force
@@ -1817,7 +1814,11 @@ class Project < ApplicationRecord
end
def mark_pages_as_not_deployed
- ensure_pages_metadatum.update!(deployed: false, artifacts_archive: nil)
+ ensure_pages_metadatum.update!(deployed: false, artifacts_archive: nil, pages_deployment: nil)
+ end
+
+ def update_pages_deployment!(deployment)
+ ensure_pages_metadatum.update!(pages_deployment: deployment)
end
def write_repository_config(gl_full_path: full_path)
@@ -2090,21 +2091,36 @@ class Project < ApplicationRecord
(auto_devops || build_auto_devops)&.predefined_variables
end
- # Tries to set repository as read_only, checking for existing Git transfers in progress beforehand
+ RepositoryReadOnlyError = Class.new(StandardError)
+
+ # Tries to set repository as read_only, checking for existing Git transfers in
+ # progress beforehand. Setting a repository read-only will fail if it is
+ # already in that state.
#
- # @return [Boolean] true when set to read_only or false when an existing git transfer is in progress
+ # @return nil. Failures will raise an exception
def set_repository_read_only!
with_lock do
- break false if git_transfer_in_progress?
+ raise RepositoryReadOnlyError, _('Git transfer in progress') if
+ git_transfer_in_progress?
- update_column(:repository_read_only, true)
+ raise RepositoryReadOnlyError, _('Repository already read-only') if
+ self.class.where(id: id).pick(:repository_read_only)
+
+ raise ActiveRecord::RecordNotSaved, _('Database update failed') unless
+ update_column(:repository_read_only, true)
+
+ nil
end
end
- # Set repository as writable again
+ # Set repository as writable again. Unlike setting it read-only, this will
+ # succeed if the repository is already writable.
def set_repository_writable!
with_lock do
- update_column(:repository_read_only, false)
+ raise ActiveRecord::RecordNotSaved, _('Database update failed') unless
+ update_column(:repository_read_only, false)
+
+ nil
end
end
diff --git a/app/models/project_repository_storage_move.rb b/app/models/project_repository_storage_move.rb
index 76f428fe925..3429dbe3a85 100644
--- a/app/models/project_repository_storage_move.rb
+++ b/app/models/project_repository_storage_move.rb
@@ -46,8 +46,15 @@ class ProjectRepositoryStorageMove < ApplicationRecord
transition replicated: :cleanup_failed
end
- after_transition initial: :scheduled do |storage_move|
- storage_move.project.update_column(:repository_read_only, true)
+ around_transition initial: :scheduled do |storage_move, block|
+ block.call
+
+ begin
+ storage_move.project.set_repository_read_only!
+ rescue => err
+ errors.add(:project, err.message)
+ next false
+ end
storage_move.run_after_commit do
ProjectUpdateRepositoryStorageWorker.perform_async(
@@ -56,17 +63,18 @@ class ProjectRepositoryStorageMove < ApplicationRecord
storage_move.id
)
end
+
+ true
end
- after_transition started: :replicated do |storage_move|
- storage_move.project.update_columns(
- repository_read_only: false,
- repository_storage: storage_move.destination_storage_name
- )
+ before_transition started: :replicated do |storage_move|
+ storage_move.project.set_repository_writable!
+
+ storage_move.project.update_column(:repository_storage, storage_move.destination_storage_name)
end
- after_transition started: :failed do |storage_move|
- storage_move.project.update_column(:repository_read_only, false)
+ before_transition started: :failed do |storage_move|
+ storage_move.project.set_repository_writable!
end
state :initial, value: 1
diff --git a/app/models/project_services/alerts_service.rb b/app/models/project_services/alerts_service.rb
index 28902114f3c..5b7d149ace1 100644
--- a/app/models/project_services/alerts_service.rb
+++ b/app/models/project_services/alerts_service.rb
@@ -14,6 +14,8 @@ class AlertsService < Service
before_validation :prevent_token_assignment
before_validation :ensure_token, if: :activated?
+ after_save :update_http_integration
+
def url
return if instance? || template?
@@ -77,6 +79,14 @@ class AlertsService < Service
def url_helpers
Gitlab::Routing.url_helpers
end
+
+ def update_http_integration
+ return unless project_id && type == 'AlertsService'
+
+ AlertManagement::SyncAlertServiceDataService # rubocop: disable CodeReuse/ServiceClass
+ .new(self)
+ .execute
+ end
end
AlertsService.prepend_if_ee('EE::AlertsService')
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 732da62863f..7814bdb7106 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -30,7 +30,7 @@ class JiraService < IssueTrackerService
# TODO: we can probably just delegate as part of
# https://gitlab.com/gitlab-org/gitlab/issues/29404
- data_field :username, :password, :url, :api_url, :jira_issue_transition_id, :project_key, :issues_enabled
+ data_field :username, :password, :url, :api_url, :jira_issue_transition_id, :project_key, :issues_enabled, :vulnerabilities_enabled, :vulnerabilities_issuetype
before_update :reset_password
after_commit :update_deployment_type, on: [:create, :update], if: :update_deployment_type?
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 0d2f89fb18d..c11a7fea1c6 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -19,14 +19,14 @@ class ProjectStatistics < ApplicationRecord
before_save :update_storage_size
- COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count, :snippets_size].freeze
+ COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count, :snippets_size, :uploads_size].freeze
INCREMENTABLE_COLUMNS = {
build_artifacts_size: %i[storage_size],
packages_size: %i[storage_size],
pipeline_artifacts_size: %i[storage_size],
snippets_size: %i[storage_size]
}.freeze
- NAMESPACE_RELATABLE_COLUMNS = [:repository_size, :wiki_size, :lfs_objects_size].freeze
+ NAMESPACE_RELATABLE_COLUMNS = [:repository_size, :wiki_size, :lfs_objects_size, :uploads_size].freeze
scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) }
@@ -72,6 +72,12 @@ class ProjectStatistics < ApplicationRecord
self.lfs_objects_size = project.lfs_objects.sum(:size)
end
+ def update_uploads_size
+ return uploads_size unless Feature.enabled?(:count_uploads_size_in_storage_stats, project)
+
+ self.uploads_size = project.uploads.sum(:size)
+ end
+
# `wiki_size` and `snippets_size` have no default value in the database
# and the column can be nil.
# This means that, when the columns were added, all rows had nil
@@ -98,6 +104,10 @@ class ProjectStatistics < ApplicationRecord
# might try to update project statistics before the `pipeline_artifacts_size` column has been created.
storage_size += pipeline_artifacts_size if self.class.column_names.include?('pipeline_artifacts_size')
+ # The `uploads_size` column was added on 20201105021637 but db/post_migrate/20190527194900_schedule_calculate_wiki_sizes.rb
+ # might try to update project statistics before the `uploads_size` column has been created.
+ storage_size += uploads_size if self.class.column_names.include?('uploads_size')
+
self.storage_size = storage_size
end
diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb
index bde1d29ad7f..63d577a4866 100644
--- a/app/models/protected_branch/push_access_level.rb
+++ b/app/models/protected_branch/push_access_level.rb
@@ -2,4 +2,29 @@
class ProtectedBranch::PushAccessLevel < ApplicationRecord
include ProtectedBranchAccess
+
+ belongs_to :deploy_key
+
+ validates :access_level, uniqueness: { scope: :protected_branch_id, if: :role?,
+ conditions: -> { where(user_id: nil, group_id: nil, deploy_key_id: nil) } }
+ validates :deploy_key_id, uniqueness: { scope: :protected_branch_id, allow_nil: true }
+ validate :validate_deploy_key_membership
+
+ def type
+ if self.deploy_key.present?
+ :deploy_key
+ else
+ super
+ end
+ end
+
+ private
+
+ def validate_deploy_key_membership
+ return unless deploy_key
+
+ unless project.deploy_keys_projects.where(deploy_key: deploy_key).exists?
+ self.errors.add(:deploy_key, 'is not enabled for this project')
+ end
+ end
end
diff --git a/app/models/release.rb b/app/models/release.rb
index f2162a0f674..c56df0a6aa3 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -83,6 +83,15 @@ class Release < ApplicationRecord
self.milestones.map {|m| m.title }.sort.join(", ")
end
+ def to_hook_data(action)
+ Gitlab::HookData::ReleaseBuilder.new(self).build(action)
+ end
+
+ def execute_hooks(action)
+ hook_data = to_hook_data(action)
+ project.execute_hooks(hook_data, :release_hooks)
+ end
+
private
def actual_sha
diff --git a/app/models/releases/link.rb b/app/models/releases/link.rb
index 82272f4857a..fc2fa639f56 100644
--- a/app/models/releases/link.rb
+++ b/app/models/releases/link.rb
@@ -30,5 +30,15 @@ module Releases
def external?
!internal?
end
+
+ def hook_attrs
+ {
+ id: id,
+ external: external?,
+ link_type: link_type,
+ name: name,
+ url: url
+ }
+ end
end
end
diff --git a/app/models/releases/source.rb b/app/models/releases/source.rb
index 2f00d25d768..44760541290 100644
--- a/app/models/releases/source.rb
+++ b/app/models/releases/source.rb
@@ -24,6 +24,13 @@ module Releases
format: format)
end
+ def hook_attrs
+ {
+ format: format,
+ url: url
+ }
+ end
+
private
def archive_prefix
diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb
index dbb2b428c7b..ac164783945 100644
--- a/app/models/resource_timebox_event.rb
+++ b/app/models/resource_timebox_event.rb
@@ -29,10 +29,10 @@ class ResourceTimeboxEvent < ResourceEvent
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
+
+ResourceTimeboxEvent.prepend_if_ee('EE::ResourceTimeboxEvent')
diff --git a/app/models/route.rb b/app/models/route.rb
index 706589e79b8..fe4846b3be5 100644
--- a/app/models/route.rb
+++ b/app/models/route.rb
@@ -20,6 +20,7 @@ class Route < ApplicationRecord
scope :inside_path, -> (path) { where('routes.path LIKE ?', "#{sanitize_sql_like(path)}/%") }
scope :for_routable, -> (routable) { where(source: routable) }
+ scope :for_routable_type, -> (routable_type) { where(source_type: routable_type) }
scope :sort_by_path_length, -> { order('LENGTH(routes.path)', :path) }
def rename_descendants
diff --git a/app/models/service.rb b/app/models/service.rb
index 764f417362f..2b6971954e3 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -8,6 +8,7 @@ class Service < ApplicationRecord
include ProjectServicesLoggable
include DataFields
include FromUnion
+ include EachBatch
SERVICE_NAMES = %w[
alerts asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord
@@ -16,6 +17,7 @@ class Service < ApplicationRecord
pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack
].freeze
+ # Fake services to help with local development.
DEV_SERVICE_NAMES = %w[
mock_ci mock_deployment mock_monitoring
].freeze
@@ -64,9 +66,9 @@ class Service < ApplicationRecord
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) }
+ scope :for_group, -> (group) { where(group_id: group, type: available_services_types(include_project_specific: false)) }
+ scope :for_template, -> { where(template: true, type: available_services_types(include_project_specific: false)) }
+ scope :for_instance, -> { where(instance: true, type: available_services_types(include_project_specific: false)) }
scope :push_hooks, -> { where(push_events: true, active: true) }
scope :tag_push_hooks, -> { where(tag_push_events: true, active: true) }
@@ -167,13 +169,13 @@ class Service < ApplicationRecord
end
private_class_method :create_nonexistent_templates
- def self.find_or_initialize_integration(name, instance: false, group_id: nil)
- if name.in?(available_services_names)
+ def self.find_or_initialize_non_project_specific_integration(name, instance: false, group_id: nil)
+ if name.in?(available_services_names(include_project_specific: false))
"#{name}_service".camelize.constantize.find_or_initialize_by(instance: instance, group_id: group_id)
end
end
- def self.find_or_initialize_all(scope)
+ def self.find_or_initialize_all_non_project_specific(scope)
scope + build_nonexistent_services_for(scope)
end
@@ -187,13 +189,14 @@ class Service < ApplicationRecord
def self.list_nonexistent_services_for(scope)
# Using #map instead of #pluck to save one query count. This is because
# ActiveRecord loaded the object here, so we don't need to query again later.
- available_services_types - scope.map(&:type)
+ available_services_types(include_project_specific: false) - scope.map(&:type)
end
private_class_method :list_nonexistent_services_for
- def self.available_services_names
+ def self.available_services_names(include_project_specific: true, include_dev: true)
service_names = services_names
- service_names += dev_services_names
+ service_names += project_specific_services_names if include_project_specific
+ service_names += dev_services_names if include_dev
service_names.sort_by(&:downcase)
end
@@ -212,12 +215,10 @@ class Service < ApplicationRecord
[]
end
- def self.available_services_types
- available_services_names.map { |service_name| "#{service_name}_service".camelize }
- end
-
- def self.services_types
- services_names.map { |service_name| "#{service_name}_service".camelize }
+ def self.available_services_types(include_project_specific: true, include_dev: true)
+ available_services_names(include_project_specific: include_project_specific, include_dev: include_dev).map do |service_name|
+ "#{service_name}_service".camelize
+ end
end
def self.build_from_integration(integration, project_id: nil, group_id: nil)
@@ -273,6 +274,17 @@ class Service < ApplicationRecord
end
end
+ def self.inherited_descendants_from_self_or_ancestors_from(integration)
+ inherit_from_ids =
+ where(type: integration.type, group: integration.group.self_and_ancestors)
+ .or(where(type: integration.type, instance: true)).select(:id)
+
+ from_union([
+ where(type: integration.type, inherit_from_id: inherit_from_ids, group: integration.group.descendants),
+ where(type: integration.type, inherit_from_id: inherit_from_ids, project: Project.in_namespace(integration.group.self_and_descendants))
+ ])
+ end
+
def activated?
active
end
@@ -294,7 +306,7 @@ class Service < ApplicationRecord
end
def initialize_properties
- self.properties = {} if properties.nil?
+ self.properties = {} if has_attribute?(:properties) && properties.nil?
end
def title
@@ -410,8 +422,12 @@ class Service < ApplicationRecord
ProjectServiceWorker.perform_async(id, data)
end
- def issue_tracker?
- self.category == :issue_tracker
+ def external_issue_tracker?
+ category == :issue_tracker && active?
+ end
+
+ def external_wiki?
+ type == 'ExternalWikiService' && active?
end
# override if needed
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index d71853e11cf..dc370b46bda 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -293,9 +293,7 @@ class Snippet < ApplicationRecord
@storage ||= Storage::Hashed.new(self, prefix: Storage::Hashed::SNIPPET_REPOSITORY_PATH_PREFIX)
end
- # This is the full_path used to identify the
- # the snippet repository. It will be used mostly
- # for logging purposes.
+ # This is the full_path used to identify the the snippet repository.
override :full_path
def full_path
return unless persisted?
@@ -303,7 +301,7 @@ class Snippet < ApplicationRecord
@full_path ||= begin
components = []
components << project.full_path if project_id?
- components << '@snippets'
+ components << 'snippets'
components << self.id
components.join('/')
end
diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb
index 9d88db27449..d329b429c9d 100644
--- a/app/models/terraform/state.rb
+++ b/app/models/terraform/state.rb
@@ -17,8 +17,15 @@ module Terraform
belongs_to :project
belongs_to :locked_by_user, class_name: 'User'
- has_many :versions, class_name: 'Terraform::StateVersion', foreign_key: :terraform_state_id
- has_one :latest_version, -> { ordered_by_version_desc }, class_name: 'Terraform::StateVersion', foreign_key: :terraform_state_id
+ has_many :versions,
+ class_name: 'Terraform::StateVersion',
+ foreign_key: :terraform_state_id,
+ inverse_of: :terraform_state
+
+ has_one :latest_version, -> { ordered_by_version_desc },
+ class_name: 'Terraform::StateVersion',
+ foreign_key: :terraform_state_id,
+ inverse_of: :terraform_state
scope :versioning_not_enabled, -> { where(versioning_enabled: false) }
scope :ordered_by_name, -> { order(:name) }
@@ -48,11 +55,11 @@ module Terraform
self.lock_xid.present?
end
- def update_file!(data, version:)
+ def update_file!(data, version:, build:)
if versioning_enabled?
- create_new_version!(data: data, version: version)
+ create_new_version!(data: data, version: version, build: build)
elsif latest_version.present?
- migrate_legacy_version!(data: data, version: version)
+ migrate_legacy_version!(data: data, version: version, build: build)
else
self.file = data
save!
@@ -81,18 +88,18 @@ module Terraform
# 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:)
+ def migrate_legacy_version!(data:, version:, build:)
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)
+ create_new_version!(data: data, version: version, build: build)
end
- def create_new_version!(data:, version:)
- new_version = versions.build(version: version, created_by_user: locked_by_user)
+ def create_new_version!(data:, version:, build:)
+ new_version = versions.build(version: version, created_by_user: locked_by_user, build: build)
new_version.assign_attributes(file: data)
new_version.save!
end
diff --git a/app/models/terraform/state_version.rb b/app/models/terraform/state_version.rb
index eff44485401..cc5d94b8e09 100644
--- a/app/models/terraform/state_version.rb
+++ b/app/models/terraform/state_version.rb
@@ -6,6 +6,7 @@ module Terraform
belongs_to :terraform_state, class_name: 'Terraform::State', optional: false
belongs_to :created_by_user, class_name: 'User', optional: true
+ belongs_to :build, class_name: 'Ci::Build', optional: true, foreign_key: :ci_build_id
scope :ordered_by_version_desc, -> { order(version: :desc) }
diff --git a/app/models/user.rb b/app/models/user.rb
index ef77e207215..be64e057d59 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -28,6 +28,8 @@ class User < ApplicationRecord
DEFAULT_NOTIFICATION_LEVEL = :participating
+ INSTANCE_ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10
+
add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) }
add_authentication_token_field :feed_token
add_authentication_token_field :static_object_token
@@ -341,6 +343,7 @@ class User < ApplicationRecord
# Scopes
scope :admins, -> { where(admin: true) }
+ scope :instance_access_request_approvers_to_be_notified, -> { admins.active.order_recent_sign_in.limit(INSTANCE_ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) }
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
scope :blocked_pending_approval, -> { with_states(:blocked_pending_approval) }
scope :external, -> { where(external: true) }
@@ -350,6 +353,9 @@ class User < ApplicationRecord
scope :deactivated, -> { with_state(:deactivated).non_internal }
scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) }
scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) }
+ scope :by_name, -> (names) { iwhere(name: Array(names)) }
+ scope :by_user_email, -> (emails) { iwhere(email: Array(emails)) }
+ scope :by_emails, -> (emails) { joins(:emails).where(emails: { email: Array(emails).map(&:downcase) }) }
scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) }
scope :with_emails, -> { preload(:emails) }
scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) }
@@ -514,17 +520,15 @@ class User < ApplicationRecord
# @param emails [String, Array<String>] email addresses to check
# @param confirmed [Boolean] Only return users where the email is confirmed
def by_any_email(emails, confirmed: false)
- emails = Array(emails).map(&:downcase)
-
- from_users = where(email: emails)
+ from_users = by_user_email(emails)
from_users = from_users.confirmed if confirmed
- from_emails = joins(:emails).where(emails: { email: emails })
+ from_emails = by_emails(emails)
from_emails = from_emails.confirmed.merge(Email.confirmed) if confirmed
items = [from_users, from_emails]
- user_ids = Gitlab::PrivateCommitEmail.user_ids_for_emails(emails)
+ user_ids = Gitlab::PrivateCommitEmail.user_ids_for_emails(Array(emails).map(&:downcase))
items << where(id: user_ids) if user_ids.present?
from_union(items)
@@ -909,10 +913,11 @@ class User < ApplicationRecord
# Returns the groups a user has access to, either through a membership or a project authorization
def authorized_groups
Group.unscoped do
- Group.from_union([
- groups,
- authorized_projects.joins(:namespace).select('namespaces.*')
- ])
+ if Feature.enabled?(:shared_group_membership_auth, self)
+ authorized_groups_with_shared_membership
+ else
+ authorized_groups_without_shared_membership
+ end
end
end
@@ -1807,6 +1812,26 @@ class User < ApplicationRecord
private
+ def authorized_groups_without_shared_membership
+ Group.from_union([
+ groups,
+ authorized_projects.joins(:namespace).select('namespaces.*')
+ ])
+ end
+
+ def authorized_groups_with_shared_membership
+ cte = Gitlab::SQL::CTE.new(:direct_groups, authorized_groups_without_shared_membership)
+ cte_alias = cte.table.alias(Group.table_name)
+
+ Group
+ .with(cte.to_arel)
+ .from_union([
+ Group.from(cte_alias),
+ Group.joins(:shared_with_group_links)
+ .where(group_group_links: { shared_with_group_id: Group.from(cte_alias) })
+ ])
+ end
+
def default_private_profile_to_false
return unless private_profile_changed? && private_profile.nil?
@@ -1843,15 +1868,15 @@ class User < ApplicationRecord
valid = true
error = nil
- if Gitlab::CurrentSettings.domain_blacklist_enabled?
- blocked_domains = Gitlab::CurrentSettings.domain_blacklist
+ if Gitlab::CurrentSettings.domain_denylist_enabled?
+ blocked_domains = Gitlab::CurrentSettings.domain_denylist
if domain_matches?(blocked_domains, email)
error = 'is not from an allowed domain.'
valid = false
end
end
- allowed_domains = Gitlab::CurrentSettings.domain_whitelist
+ allowed_domains = Gitlab::CurrentSettings.domain_allowlist
unless allowed_domains.blank?
if domain_matches?(allowed_domains, email)
valid = true
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
index e39ff8712fc..cfad58fc0db 100644
--- a/app/models/user_callout.rb
+++ b/app/models/user_callout.rb
@@ -25,7 +25,8 @@ class UserCallout < ApplicationRecord
personal_access_token_expiry: 21, # EE-only
suggest_pipeline: 22,
customize_homepage: 23,
- feature_flags_new_version: 24
+ feature_flags_new_version: 24,
+ registration_enabled_callout: 25
}
validates :user, presence: true
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index c05bc80415a..b49a7eb72dc 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -1,11 +1,15 @@
# frozen_string_literal: true
class UserPreference < ApplicationRecord
+ include IgnorableColumns
+
# We could use enums, but Rails 4 doesn't support multiple
# enum options with same name for multiple fields, also it creates
# extra methods that aren't really needed here.
NOTES_FILTERS = { all_notes: 0, only_comments: 1, only_activity: 2 }.freeze
+ ignore_column :feature_filter_type, remove_with: '13.8', remove_after: '2021-01-22'
+
belongs_to :user
scope :with_user, -> { joins(:user) }
diff --git a/app/models/user_status.rb b/app/models/user_status.rb
index 016b89bae81..0e1ae0b7338 100644
--- a/app/models/user_status.rb
+++ b/app/models/user_status.rb
@@ -9,6 +9,8 @@ class UserStatus < ApplicationRecord
belongs_to :user
+ enum availability: { not_set: 0, busy: 1 }
+
validates :user, presence: true
validates :emoji, inclusion: { in: Gitlab::Emoji.emojis_names }
validates :message, length: { maximum: 100 }, allow_blank: true
diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb
index a4338c4e2bd..ab29afd0d08 100644
--- a/app/models/vulnerability.rb
+++ b/app/models/vulnerability.rb
@@ -2,6 +2,27 @@
# Placeholder class for model that is implemented in EE
class Vulnerability < ApplicationRecord
+ include IgnorableColumns
+
+ def self.link_reference_pattern
+ nil
+ end
+
+ def self.reference_prefix
+ '[vulnerability:'
+ end
+
+ def self.reference_prefix_escaped
+ '[vulnerability&lbrack;'
+ end
+
+ def self.reference_postfix
+ ']'
+ end
+
+ def self.reference_postfix_escaped
+ '&rbrack;'
+ end
end
Vulnerability.prepend_if_ee('EE::Vulnerability')
diff --git a/app/policies/alert_management/http_integration_policy.rb b/app/policies/alert_management/http_integration_policy.rb
new file mode 100644
index 00000000000..77c936b9e0b
--- /dev/null
+++ b/app/policies/alert_management/http_integration_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class HttpIntegrationPolicy < ::BasePolicy
+ delegate { @subject.project }
+ end
+end
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 1c93073025d..580a348b408 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -57,6 +57,8 @@ class BasePolicy < DeclarativePolicy::Base
rule { default }.enable :read_cross_project
condition(:is_gitlab_com) { ::Gitlab.dev_env_or_com? }
+
+ rule { admin }.enable :change_repository_storage
end
BasePolicy.prepend_if_ee('EE::BasePolicy')
diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb
index 3073a2e5d10..7eca6f4c6c8 100644
--- a/app/policies/concerns/policy_actor.rb
+++ b/app/policies/concerns/policy_actor.rb
@@ -72,6 +72,10 @@ module PolicyActor
def try_obtain_ldap_lease
nil
end
+
+ def can_read_all_resources?
+ false
+ end
end
PolicyActor.prepend_if_ee('EE::PolicyActor')
diff --git a/app/policies/container_registry/tag_policy.rb b/app/policies/container_registry/tag_policy.rb
new file mode 100644
index 00000000000..8c75f2a6f20
--- /dev/null
+++ b/app/policies/container_registry/tag_policy.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+module ContainerRegistry
+ class TagPolicy < BasePolicy
+ delegate { @subject.repository }
+ end
+end
diff --git a/app/policies/custom_emoji_policy.rb b/app/policies/custom_emoji_policy.rb
new file mode 100644
index 00000000000..ba73b9a3782
--- /dev/null
+++ b/app/policies/custom_emoji_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class CustomEmojiPolicy < BasePolicy
+ delegate { @subject.group }
+end
diff --git a/app/policies/group_member_policy.rb b/app/policies/group_member_policy.rb
index f6e52def270..78a2be7a9f8 100644
--- a/app/policies/group_member_policy.rb
+++ b/app/policies/group_member_policy.rb
@@ -11,7 +11,10 @@ class GroupMemberPolicy < BasePolicy
condition(:is_target_user) { @user && @subject.user_id == @user.id }
rule { anonymous }.prevent_all
- rule { last_owner }.prevent_all
+ rule { last_owner }.policy do
+ prevent :update_group_member
+ prevent :destroy_group_member
+ end
rule { can?(:admin_group_member) }.policy do
enable :update_group_member
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index f9ec026a6d2..231843c5f23 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -46,6 +46,10 @@ class GroupPolicy < BasePolicy
group_projects_for(user: @user, group: @subject, only_owned: false).any? { |p| p.design_management_enabled? }
end
+ condition(:dependency_proxy_available) do
+ @subject.dependency_proxy_feature_available?
+ 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
@@ -59,6 +63,9 @@ class GroupPolicy < BasePolicy
with_scope :subject
condition(:resource_access_token_available) { resource_access_token_available? }
+ with_scope :subject
+ condition(:has_project_with_service_desk_enabled) { @subject.has_project_with_service_desk_enabled? }
+
rule { design_management_enabled }.policy do
enable :read_design_activity
end
@@ -94,6 +101,7 @@ class GroupPolicy < BasePolicy
enable :read_label
enable :read_board
enable :read_group_member
+ enable :read_custom_emoji
end
rule { ~can?(:read_group) }.policy do
@@ -107,6 +115,7 @@ class GroupPolicy < BasePolicy
enable :create_metrics_dashboard_annotation
enable :delete_metrics_dashboard_annotation
enable :update_metrics_dashboard_annotation
+ enable :create_custom_emoji
end
rule { reporter }.policy do
@@ -187,13 +196,24 @@ class GroupPolicy < BasePolicy
rule { write_package_registry_deploy_token }.policy do
enable :create_package
+ enable :read_package
enable :read_group
end
+ rule { can?(:read_group) & dependency_proxy_available }
+ .enable :read_dependency_proxy
+
+ rule { developer & dependency_proxy_available }
+ .enable :admin_dependency_proxy
+
rule { resource_access_token_available & can?(:admin_group) }.policy do
enable :admin_resource_access_tokens
end
+ rule { support_bot & has_project_with_service_desk_enabled }.policy do
+ enable :read_label
+ end
+
def access_level
return GroupMember::NO_ACCESS if @user.nil?
return GroupMember::NO_ACCESS unless user_is_user?
diff --git a/app/policies/instance_metadata_policy.rb b/app/policies/instance_metadata_policy.rb
new file mode 100644
index 00000000000..3386217044d
--- /dev/null
+++ b/app/policies/instance_metadata_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class InstanceMetadataPolicy < BasePolicy
+ delegate { :global }
+end
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index 44c448eb601..183f4d8f919 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -35,6 +35,10 @@ class IssuePolicy < IssuablePolicy
rule { ~can?(:read_design) }.policy do
prevent :move_design
end
+
+ rule { ~anonymous & can?(:read_issue) }.policy do
+ enable :create_todo
+ end
end
IssuePolicy.prepend_if_ee('EE::IssuePolicy')
diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb
index e5ac228b0ee..d5ba42d750c 100644
--- a/app/policies/merge_request_policy.rb
+++ b/app/policies/merge_request_policy.rb
@@ -14,6 +14,10 @@ class MergeRequestPolicy < IssuablePolicy
rule { can?(:update_merge_request) }.policy do
enable :approve_merge_request
end
+
+ rule { ~anonymous & can?(:read_merge_request) }.policy do
+ enable :create_todo
+ end
end
MergeRequestPolicy.prepend_if_ee('EE::MergeRequestPolicy')
diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb
index 2217aa1326c..2bf6b6c3161 100644
--- a/app/policies/note_policy.rb
+++ b/app/policies/note_policy.rb
@@ -7,13 +7,15 @@ class NotePolicy < BasePolicy
delegate { @subject.noteable if DeclarativePolicy.has_policy?(@subject.noteable) }
condition(:is_author) { @user && @subject.author == @user }
- condition(:is_noteable_author) { @user && @subject.noteable.author_id == @user.id }
+ condition(:is_noteable_author) { @user && @subject.noteable.try(:author_id) == @user.id }
condition(:editable, scope: :subject) { @subject.editable? }
condition(:can_read_noteable) { can?(:"read_#{@subject.noteable_ability_name}") }
condition(:commit_is_deleted) { @subject.for_commit? && @subject.noteable.blank? }
+ condition(:for_design) { @subject.for_design? }
+
condition(:is_visible) { @subject.system_note_with_references_visible_for?(@user) }
condition(:confidential, scope: :subject) { @subject.confidential? }
@@ -28,6 +30,7 @@ class NotePolicy < BasePolicy
rule { ~can_read_noteable }.policy do
prevent :admin_note
prevent :resolve_note
+ prevent :reposition_note
prevent :award_emoji
end
@@ -46,6 +49,7 @@ class NotePolicy < BasePolicy
prevent :read_note
prevent :admin_note
prevent :resolve_note
+ prevent :reposition_note
prevent :award_emoji
end
@@ -57,9 +61,14 @@ class NotePolicy < BasePolicy
prevent :read_note
prevent :admin_note
prevent :resolve_note
+ prevent :reposition_note
prevent :award_emoji
end
+ rule { can?(:admin_note) | (for_design & can?(:create_note)) }.policy do
+ enable :reposition_note
+ end
+
def parent_namespace
strong_memoize(:parent_namespace) do
next if @subject.is_a?(PersonalSnippet)
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 59e2d617bf7..13073ed68a1 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -546,8 +546,6 @@ class ProjectPolicy < BasePolicy
prevent :create_pipeline
end
- rule { admin }.enable :change_repository_storage
-
rule { can?(:read_issue) }.policy do
enable :read_design
enable :read_design_activity
@@ -570,6 +568,7 @@ class ProjectPolicy < BasePolicy
rule { write_package_registry_deploy_token }.policy do
enable :create_package
+ enable :read_package
enable :read_project
end
diff --git a/app/policies/service_policy.rb b/app/policies/service_policy.rb
new file mode 100644
index 00000000000..61aff444620
--- /dev/null
+++ b/app/policies/service_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ServicePolicy < BasePolicy
+ delegate(:project)
+end
diff --git a/app/policies/terraform/state_version_policy.rb b/app/policies/terraform/state_version_policy.rb
new file mode 100644
index 00000000000..ad0b2f6d594
--- /dev/null
+++ b/app/policies/terraform/state_version_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Terraform
+ class StateVersionPolicy < BasePolicy
+ alias_method :terraform_state_version, :subject
+
+ delegate { terraform_state_version.terraform_state }
+ end
+end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index c9dfa98b285..70e8fb32064 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -21,11 +21,13 @@ class UserPolicy < BasePolicy
enable :update_user
enable :update_user_status
enable :read_user_personal_access_tokens
+ enable :read_group_count
end
rule { default }.enable :read_user_profile
rule { (private_profile | blocked_user) & ~(user_is_self | admin) }.prevent :read_user_profile
rule { user_is_self | admin }.enable :disable_two_factor
+ rule { (user_is_self | admin) & ~blocked }.enable :create_user_personal_access_token
end
UserPolicy.prepend_if_ee('EE::UserPolicy')
diff --git a/app/presenters/environment_presenter.rb b/app/presenters/environment_presenter.rb
index 3fa31eb69a2..6f67bbe2a5a 100644
--- a/app/presenters/environment_presenter.rb
+++ b/app/presenters/environment_presenter.rb
@@ -6,8 +6,6 @@ class EnvironmentPresenter < Gitlab::View::Presenter::Delegated
presents :environment
def path
- if Feature.enabled?(:expose_environment_path_in_alert_details, project)
- project_environment_path(project, self)
- end
+ project_environment_path(project, self)
end
end
diff --git a/app/presenters/invitation_presenter.rb b/app/presenters/invitation_presenter.rb
new file mode 100644
index 00000000000..d8c07f327dd
--- /dev/null
+++ b/app/presenters/invitation_presenter.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class InvitationPresenter < Gitlab::View::Presenter::Delegated
+ presents :invitation
+end
diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb
index 0b498ce97d8..76bf3bf4577 100644
--- a/app/presenters/issue_presenter.rb
+++ b/app/presenters/issue_presenter.rb
@@ -10,6 +10,10 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated
def subscribed?
issue.subscribed?(current_user, issue.project)
end
+
+ def project_emails_disabled?
+ issue.project.emails_disabled?
+ end
end
IssuePresenter.prepend_if_ee('EE::IssuePresenter')
diff --git a/app/presenters/packages/detail/package_presenter.rb b/app/presenters/packages/detail/package_presenter.rb
index e8223d6498b..dbfcfcb67f3 100644
--- a/app/presenters/packages/detail/package_presenter.rb
+++ b/app/presenters/packages/detail/package_presenter.rb
@@ -29,7 +29,8 @@ module Packages
package_detail[:composer_metadatum] = @package.composer_metadatum if @package.composer_metadatum
package_detail[:conan_metadatum] = @package.conan_metadatum if @package.conan_metadatum
package_detail[:dependency_links] = @package.dependency_links.map(&method(:build_dependency_links))
- package_detail[:pipeline] = build_pipeline_info(@package.build_info.pipeline) if @package.build_info
+ package_detail[:pipeline] = build_pipeline_info(@package.pipeline) if @package.pipeline
+ package_detail[:pipelines] = build_pipeline_infos(@package.pipelines) if @package.pipelines.present?
package_detail
end
@@ -37,12 +38,20 @@ module Packages
private
def build_package_file_view(package_file)
- {
+ file_view = {
created_at: package_file.created_at,
download_path: package_file.download_path,
file_name: package_file.file_name,
size: package_file.size
}
+
+ file_view[:pipelines] = build_pipeline_infos(package_file.pipelines) if package_file.pipelines.present?
+
+ file_view
+ end
+
+ def build_pipeline_infos(pipeline_infos)
+ pipeline_infos.map { |pipeline_info| build_pipeline_info(pipeline_info) }
end
def build_pipeline_info(pipeline_info)
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 392eeafb2b4..0f5b601f2b0 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -13,7 +13,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
presents :project
- AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon)
+ AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon, :itemprop)
MAX_TOPICS_TO_SHOW = 3
def statistic_icon(icon_name = 'plus-square-o')
@@ -277,7 +277,9 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
AnchorData.new(false,
icon + content_tag(:span, license_short_name, class: 'project-stat-value'),
license_path,
- 'default')
+ 'default',
+ nil,
+ 'license')
else
if current_user && can_current_user_push_to_default_branch?
AnchorData.new(false,
diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb
index c27059c6d63..b11585d0d1c 100644
--- a/app/presenters/release_presenter.rb
+++ b/app/presenters/release_presenter.rb
@@ -23,18 +23,36 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
project_release_url(project, release)
end
- def merge_requests_url
+ def opened_merge_requests_url
return unless release_mr_issue_urls_available?
project_merge_requests_url(project, params_for_issues_and_mrs)
end
- def issues_url
+ def merged_merge_requests_url
+ return unless release_mr_issue_urls_available?
+
+ project_merge_requests_url(project, params_for_issues_and_mrs(state: 'merged'))
+ end
+
+ def closed_merge_requests_url
+ return unless release_mr_issue_urls_available?
+
+ project_merge_requests_url(project, params_for_issues_and_mrs(state: 'closed'))
+ end
+
+ def opened_issues_url
return unless release_mr_issue_urls_available?
project_issues_url(project, params_for_issues_and_mrs)
end
+ def closed_issues_url
+ return unless release_mr_issue_urls_available?
+
+ project_issues_url(project, params_for_issues_and_mrs(state: 'closed'))
+ end
+
def edit_url
return unless release_edit_page_available?
@@ -53,18 +71,24 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
can_download_code? ? release.name : "Release-#{release.id}"
end
+ def download_url(filepath)
+ filepath = filepath.sub(%r{^/}, '') if filepath.start_with?('/')
+
+ downloads_project_release_url(project, release, filepath)
+ end
+
private
def can_download_code?
can?(current_user, :download_code, project)
end
- def params_for_issues_and_mrs
- { scope: 'all', state: 'opened', release_tag: release.tag }
+ def params_for_issues_and_mrs(state: 'opened')
+ { scope: 'all', state: state, release_tag: release.tag }
end
def release_mr_issue_urls_available?
- ::Feature.enabled?(:release_mr_issue_urls, project)
+ ::Feature.enabled?(:release_mr_issue_urls, project, default_enabled: true)
end
def release_edit_page_available?
diff --git a/app/serializers/base_discussion_entity.rb b/app/serializers/base_discussion_entity.rb
new file mode 100644
index 00000000000..5ca4d1d6cc9
--- /dev/null
+++ b/app/serializers/base_discussion_entity.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+class BaseDiscussionEntity < Grape::Entity
+ include RequestAwareEntity
+ include NotesHelper
+
+ expose :id
+ expose :reply_id
+ expose :project_id
+ expose :commit_id
+
+ expose :confidential?, as: :confidential
+ expose :diff_discussion?, as: :diff_discussion
+ expose :expanded?, as: :expanded
+ expose :for_commit?, as: :for_commit
+ expose :individual_note?, as: :individual_note
+ expose :resolvable?, as: :resolvable
+
+ expose :truncated_diff_lines, using: DiffLineEntity, if: -> (d, _) { d.diff_discussion? && d.on_text? && (d.expanded? || render_truncated_diff_lines?) }
+
+ with_options if: -> (d, _) { d.diff_discussion? } do
+ expose :active?, as: :active
+ expose :line_code
+ expose :diff_file, using: DiscussionDiffFileEntity
+ end
+
+ with_options if: -> (d, _) { d.diff_discussion? && !d.legacy_diff_discussion? } do
+ expose :position
+ expose :original_position
+ end
+
+ expose :discussion_path do |discussion|
+ discussion_path(discussion)
+ end
+
+ with_options if: -> (d, _) { d.resolvable? } do
+ expose :resolve_path do |discussion|
+ resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id)
+ end
+
+ expose :resolve_with_issue_path do |discussion|
+ new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id)
+ end
+ end
+
+ expose :truncated_diff_lines_path, if: -> (d, _) { !d.expanded? && !render_truncated_diff_lines? } do |discussion|
+ project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion)
+ end
+
+ private
+
+ def render_truncated_diff_lines?
+ options.fetch(:render_truncated_diff_lines, false)
+ end
+end
diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb
index 596f5d686da..5036f28184c 100644
--- a/app/serializers/diff_file_base_entity.rb
+++ b/app/serializers/diff_file_base_entity.rb
@@ -48,7 +48,7 @@ class DiffFileBaseEntity < Grape::Entity
next unless has_edit_path?(merge_request)
- gitlab_ide_merge_request_path(merge_request)
+ ide_merge_request_path(merge_request, diff_file.new_path)
end
expose :old_path_html do |diff_file|
diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb
index e3fefbb46b6..9865af1e116 100644
--- a/app/serializers/diff_file_entity.rb
+++ b/app/serializers/diff_file_entity.rb
@@ -3,6 +3,7 @@
class DiffFileEntity < DiffFileBaseEntity
include CommitsHelper
include IconsHelper
+ include Gitlab::Utils::StrongMemoize
expose :added_lines
expose :removed_lines
@@ -54,11 +55,16 @@ class DiffFileEntity < DiffFileBaseEntity
# Used for inline diffs
expose :highlighted_diff_lines, using: DiffLineEntity, if: -> (diff_file, options) { inline_diff_view?(options, diff_file) && diff_file.text? } do |diff_file|
- diff_file.diff_lines_for_serializer
+ file = conflict_file(options, diff_file) || diff_file
+ file.diff_lines_for_serializer
end
expose :is_fully_expanded do |diff_file|
- diff_file.fully_expanded?
+ if conflict_file(options, diff_file)
+ false
+ else
+ diff_file.fully_expanded?
+ end
end
# Used for parallel diffs
@@ -79,4 +85,10 @@ class DiffFileEntity < DiffFileBaseEntity
# If nothing is present, inline will be the default.
options.fetch(:diff_view, :inline).to_sym == :inline
end
+
+ def conflict_file(options, diff_file)
+ strong_memoize(:conflict_file) do
+ options[:conflicts] && options[:conflicts][diff_file.new_path]
+ end
+ end
end
diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb
index 0b4f21c55f4..f573bbe8385 100644
--- a/app/serializers/diffs_entity.rb
+++ b/app/serializers/diffs_entity.rb
@@ -71,7 +71,7 @@ class DiffsEntity < Grape::Entity
submodule_links = Gitlab::SubmoduleLinks.new(merge_request.project.repository)
DiffFileEntity.represent(diffs.diff_files,
- options.merge(submodule_links: submodule_links, code_navigation_path: code_navigation_path(diffs)))
+ options.merge(submodule_links: submodule_links, code_navigation_path: code_navigation_path(diffs), conflicts: conflicts))
end
expose :merge_request_diffs, using: MergeRequestDiffEntity, if: -> (_, options) { options[:merge_request_diffs]&.any? } do |diffs|
@@ -88,10 +88,6 @@ class DiffsEntity < Grape::Entity
private
- def code_navigation_path(diffs)
- Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs&.head_sha)
- end
-
def commit_ids
@commit_ids ||= merge_request.recent_commits.map(&:id)
end
diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb
index 497471699b2..bcf6b331192 100644
--- a/app/serializers/discussion_entity.rb
+++ b/app/serializers/discussion_entity.rb
@@ -1,23 +1,8 @@
# frozen_string_literal: true
-class DiscussionEntity < Grape::Entity
- include RequestAwareEntity
- include NotesHelper
-
- expose :id, :reply_id
- expose :position, if: -> (d, _) { d.diff_discussion? && !d.legacy_diff_discussion? }
- expose :original_position, if: -> (d, _) { d.diff_discussion? && !d.legacy_diff_discussion? }
- expose :line_code, if: -> (d, _) { d.diff_discussion? }
- expose :expanded?, as: :expanded
- expose :active?, as: :active, if: -> (d, _) { d.diff_discussion? }
- expose :project_id
-
+class DiscussionEntity < BaseDiscussionEntity
expose :notes do |discussion, opts|
- request.note_entity.represent(discussion.notes, opts)
- end
-
- expose :discussion_path do |discussion|
- discussion_path(discussion)
+ request.note_entity.represent(discussion.notes, opts.merge(with_base_discussion: false))
end
expose :positions, if: -> (d, _) { display_merge_ref_discussions?(d) } do |discussion|
@@ -28,42 +13,13 @@ class DiscussionEntity < Grape::Entity
discussion.diff_note_positions.map(&:line_code)
end
- expose :individual_note?, as: :individual_note
- expose :resolvable do |discussion|
- discussion.resolvable?
- end
-
expose :resolved?, as: :resolved
expose :resolved_by_push?, as: :resolved_by_push
expose :resolved_by, using: NoteUserEntity
expose :resolved_at
- expose :resolve_path, if: -> (d, _) { d.resolvable? } do |discussion|
- resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id)
- end
- expose :resolve_with_issue_path, if: -> (d, _) { d.resolvable? } do |discussion|
- new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id)
- end
-
- expose :diff_file, using: DiscussionDiffFileEntity, if: -> (d, _) { d.diff_discussion? }
-
- expose :diff_discussion?, as: :diff_discussion
-
- expose :truncated_diff_lines_path, if: -> (d, _) { !d.expanded? && !render_truncated_diff_lines? } do |discussion|
- project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion)
- end
-
- expose :truncated_diff_lines, using: DiffLineEntity, if: -> (d, _) { d.diff_discussion? && d.on_text? && (d.expanded? || render_truncated_diff_lines?) }
-
- expose :for_commit?, as: :for_commit
- expose :commit_id
- expose :confidential?, as: :confidential
private
- def render_truncated_diff_lines?
- options[:render_truncated_diff_lines]
- end
-
def current_user
request.current_user
end
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index 7da5910a75b..0bd9c602bf5 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -4,6 +4,11 @@ class EnvironmentEntity < Grape::Entity
include RequestAwareEntity
expose :id
+
+ expose :global_id do |environment|
+ environment.to_global_id.to_s
+ end
+
expose :name
expose :state
expose :external_url
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index 44cbcfc5044..e46b269ea35 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: -> (_, opts) { opts[:experiment_enabled] == :suggest_pipeline } do |_merge_request|
+ expose :user_callouts_path, if: -> (*) { Feature.enabled?(:suggest_pipeline, default_enabled: true) } do |_merge_request|
user_callouts_path
end
- expose :suggest_pipeline_feature_id, if: -> (_, opts) { opts[:experiment_enabled] == :suggest_pipeline } do |_merge_request|
+ expose :suggest_pipeline_feature_id, if: -> (*) { Feature.enabled?(:suggest_pipeline, default_enabled: true) } do |_merge_request|
SUGGEST_PIPELINE
end
- expose :is_dismissed_suggest_pipeline, if: -> (_, opts) { opts[:experiment_enabled] == :suggest_pipeline } do |_merge_request|
+ expose :is_dismissed_suggest_pipeline, if: -> (*) { Feature.enabled?(:suggest_pipeline, default_enabled: true) } do |_merge_request|
current_user && current_user.dismissed_callout?(feature_name: SUGGEST_PIPELINE)
end
@@ -129,7 +129,7 @@ class MergeRequestWidgetEntity < Grape::Entity
end
expose :security_reports_docs_path do |merge_request|
- help_page_path('user/application_security/sast/index.md', anchor: 'reports-json-format')
+ help_page_path('user/application_security/index.md', anchor: 'viewing-security-scan-information-in-merge-requests')
end
private
@@ -151,6 +151,10 @@ class MergeRequestWidgetEntity < Grape::Entity
can?(current_user, :create_pipeline, merge_request.source_project)
end
+ def use_merge_base_with_merged_results?
+ object.actual_head_pipeline&.merge_request_event_type == :merged_result
+ end
+
def head_pipeline_downloadable_path_for_report_type(file_type)
object.head_pipeline&.present(current_user: current_user)
&.downloadable_path_for_report_type(file_type)
@@ -161,11 +165,6 @@ class MergeRequestWidgetEntity < Grape::Entity
&.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)
diff --git a/app/serializers/move_to_project_entity.rb b/app/serializers/move_to_project_entity.rb
index dac1124b0b3..fb1d1a64abd 100644
--- a/app/serializers/move_to_project_entity.rb
+++ b/app/serializers/move_to_project_entity.rb
@@ -3,4 +3,5 @@
class MoveToProjectEntity < Grape::Entity
expose :id
expose :name_with_namespace
+ expose :full_path
end
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
index ef305195e22..9a96778786b 100644
--- a/app/serializers/note_entity.rb
+++ b/app/serializers/note_entity.rb
@@ -34,6 +34,10 @@ class NoteEntity < API::Entities::Note
expose :can_resolve do |note|
note.resolvable? && can?(current_user, :resolve_note, note)
end
+
+ expose :can_resolve_discussion do |note|
+ note.discussion.resolvable? && note.discussion.can_resolve?(current_user)
+ end
end
expose :suggestions, using: SuggestionEntity
@@ -77,11 +81,25 @@ class NoteEntity < API::Entities::Note
expose :cached_markdown_version
+ # Correctly rendering a note requires some background information about any
+ # discussion it is part of. This is essential for the notes endpoint, but
+ # optional for the discussions endpoint, which will include the discussion
+ # along with the note
+ expose :discussion, as: :base_discussion, using: BaseDiscussionEntity, if: -> (_, _) { with_base_discussion? }
+
private
+ def discussion
+ @discussion ||= object.to_discussion(request.noteable)
+ end
+
def current_user
request.current_user
end
+
+ def with_base_discussion?
+ options.fetch(:with_base_discussion, true)
+ end
end
NoteEntity.prepend_if_ee('EE::NoteEntity')
diff --git a/app/serializers/paginated_diff_entity.rb b/app/serializers/paginated_diff_entity.rb
index f24571f7d7d..fe59686278c 100644
--- a/app/serializers/paginated_diff_entity.rb
+++ b/app/serializers/paginated_diff_entity.rb
@@ -7,12 +7,19 @@
#
class PaginatedDiffEntity < Grape::Entity
include RequestAwareEntity
+ include DiffHelper
expose :diff_files do |diffs, options|
submodule_links = Gitlab::SubmoduleLinks.new(merge_request.project.repository)
- DiffFileEntity.represent(diffs.diff_files,
- options.merge(submodule_links: submodule_links, code_navigation_path: code_navigation_path(diffs)))
+ DiffFileEntity.represent(
+ diffs.diff_files,
+ options.merge(
+ submodule_links: submodule_links,
+ code_navigation_path: code_navigation_path(diffs),
+ conflicts: conflicts
+ )
+ )
end
expose :pagination do
@@ -36,10 +43,6 @@ class PaginatedDiffEntity < Grape::Entity
private
- def code_navigation_path(diffs)
- Gitlab::CodeNavigationPath.new(merge_request.project, diffs.diff_refs&.head_sha)
- end
-
%i[current_page next_page total_pages].each do |method|
define_method method do
pagination_data[method]
diff --git a/app/serializers/test_case_entity.rb b/app/serializers/test_case_entity.rb
index b44aa62ad73..299160cd1bf 100644
--- a/app/serializers/test_case_entity.rb
+++ b/app/serializers/test_case_entity.rb
@@ -10,6 +10,7 @@ class TestCaseEntity < Grape::Entity
expose :execution_time
expose :system_output
expose :stack_trace
+ expose :recent_failures
expose :attachment_url, if: -> (*) { can_read_screenshots? } do |test_case|
expose_url(test_case.attachment_url)
end
diff --git a/app/serializers/test_suite_comparer_entity.rb b/app/serializers/test_suite_comparer_entity.rb
index a9f19564b60..aab805f9598 100644
--- a/app/serializers/test_suite_comparer_entity.rb
+++ b/app/serializers/test_suite_comparer_entity.rb
@@ -1,9 +1,6 @@
# frozen_string_literal: true
class TestSuiteComparerEntity < Grape::Entity
- DEFAULT_MAX_TESTS = 100
- DEFAULT_MIN_TESTS = 10
-
expose :name
expose :total_status, as: :status
@@ -14,39 +11,27 @@ class TestSuiteComparerEntity < Grape::Entity
expose :error_count, as: :errored
end
- # rubocop: disable CodeReuse/ActiveRecord
expose :new_failures, using: TestCaseEntity do |suite|
- suite.new_failures.take(max_tests)
+ suite.limited_tests.new_failures
end
expose :existing_failures, using: TestCaseEntity do |suite|
- suite.existing_failures.take(
- max_tests(suite.new_failures))
+ suite.limited_tests.existing_failures
end
expose :resolved_failures, using: TestCaseEntity do |suite|
- suite.resolved_failures.take(
- max_tests(suite.new_failures, suite.existing_failures))
+ suite.limited_tests.resolved_failures
end
expose :new_errors, using: TestCaseEntity do |suite|
- suite.new_errors.take(max_tests)
+ suite.limited_tests.new_errors
end
expose :existing_errors, using: TestCaseEntity do |suite|
- suite.existing_errors.take(
- max_tests(suite.new_errors))
+ suite.limited_tests.existing_errors
end
expose :resolved_errors, using: TestCaseEntity do |suite|
- suite.resolved_errors.take(
- max_tests(suite.new_errors, suite.existing_errors))
- end
-
- private
-
- def max_tests(*used)
- [DEFAULT_MAX_TESTS - used.map(&:count).sum, DEFAULT_MIN_TESTS].max
+ suite.limited_tests.resolved_errors
end
- # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/services/admin/propagate_integration_service.rb b/app/services/admin/propagate_integration_service.rb
index 96a6d861e47..ddd5add42bd 100644
--- a/app/services/admin/propagate_integration_service.rb
+++ b/app/services/admin/propagate_integration_service.rb
@@ -5,12 +5,12 @@ module Admin
include PropagateService
def propagate
- update_inherited_integrations
-
if integration.instance?
- create_integration_for_groups_without_integration if Feature.enabled?(:group_level_integrations)
+ update_inherited_integrations
+ create_integration_for_groups_without_integration if Feature.enabled?(:group_level_integrations, default_enabled: true)
create_integration_for_projects_without_integration
else
+ update_inherited_descendant_integrations
create_integration_for_groups_without_integration_belonging_to_group
create_integration_for_projects_without_integration_belonging_to_group
end
@@ -18,34 +18,39 @@ module Admin
private
- # rubocop: disable Cop/InBatches
def update_inherited_integrations
- 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
+ propagate_integrations(
+ Service.by_type(integration.type).inherit_from_id(integration.id),
+ PropagateIntegrationInheritWorker
+ )
+ end
+
+ def update_inherited_descendant_integrations
+ propagate_integrations(
+ Service.inherited_descendants_from_self_or_ancestors_from(integration),
+ PropagateIntegrationInheritDescendantWorker
+ )
end
- # rubocop: enable Cop/InBatches
def create_integration_for_groups_without_integration
- 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
+ propagate_integrations(
+ Group.without_integration(integration),
+ PropagateIntegrationGroupWorker
+ )
end
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
+ propagate_integrations(
+ integration.group.descendants.without_integration(integration),
+ PropagateIntegrationGroupWorker
+ )
end
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
+ propagate_integrations(
+ Project.without_integration(integration).in_namespace(integration.group.self_and_descendants),
+ PropagateIntegrationProjectWorker
+ )
end
end
end
diff --git a/app/services/alert_management/http_integrations/create_service.rb b/app/services/alert_management/http_integrations/create_service.rb
new file mode 100644
index 00000000000..576e38c23aa
--- /dev/null
+++ b/app/services/alert_management/http_integrations/create_service.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ module HttpIntegrations
+ class CreateService
+ # @param project [Project]
+ # @param current_user [User]
+ # @param params [Hash]
+ def initialize(project, current_user, params)
+ @project = project
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ return error_no_permissions unless allowed?
+ return error_multiple_integrations unless creation_allowed?
+
+ integration = project.alert_management_http_integrations.create(params)
+ return error_in_create(integration) unless integration.valid?
+
+ success(integration)
+ end
+
+ private
+
+ attr_reader :project, :current_user, :params
+
+ def allowed?
+ current_user&.can?(:admin_operations, project)
+ end
+
+ def creation_allowed?
+ project.alert_management_http_integrations.empty?
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def success(integration)
+ ServiceResponse.success(payload: { integration: integration })
+ end
+
+ def error_no_permissions
+ error(_('You have insufficient permissions to create an HTTP integration for this project'))
+ end
+
+ def error_multiple_integrations
+ error(_('Multiple HTTP integrations are not supported for this project'))
+ end
+
+ def error_in_create(integration)
+ error(integration.errors.full_messages.to_sentence)
+ end
+ end
+ end
+end
+
+::AlertManagement::HttpIntegrations::CreateService.prepend_if_ee('::EE::AlertManagement::HttpIntegrations::CreateService')
diff --git a/app/services/alert_management/http_integrations/destroy_service.rb b/app/services/alert_management/http_integrations/destroy_service.rb
new file mode 100644
index 00000000000..aeb3f6cb807
--- /dev/null
+++ b/app/services/alert_management/http_integrations/destroy_service.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ module HttpIntegrations
+ class DestroyService
+ # @param integration [AlertManagement::HttpIntegration]
+ # @param current_user [User]
+ def initialize(integration, current_user)
+ @integration = integration
+ @current_user = current_user
+ end
+
+ def execute
+ return error_no_permissions unless allowed?
+
+ if integration.destroy
+ success
+ else
+ error(integration.errors.full_messages.to_sentence)
+ end
+ end
+
+ private
+
+ attr_reader :integration, :current_user
+
+ def allowed?
+ current_user&.can?(:admin_operations, integration)
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def success
+ ServiceResponse.success(payload: { integration: integration })
+ end
+
+ def error_no_permissions
+ error(_('You have insufficient permissions to remove this HTTP integration'))
+ end
+ end
+ end
+end
diff --git a/app/services/alert_management/http_integrations/update_service.rb b/app/services/alert_management/http_integrations/update_service.rb
new file mode 100644
index 00000000000..220c4e759f0
--- /dev/null
+++ b/app/services/alert_management/http_integrations/update_service.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ module HttpIntegrations
+ class UpdateService
+ # @param integration [AlertManagement::HttpIntegration]
+ # @param current_user [User]
+ # @param params [Hash]
+ def initialize(integration, current_user, params)
+ @integration = integration
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ return error_no_permissions unless allowed?
+
+ params[:token] = nil if params.delete(:regenerate_token)
+
+ if integration.update(params)
+ success
+ else
+ error(integration.errors.full_messages.to_sentence)
+ end
+ end
+
+ private
+
+ attr_reader :integration, :current_user, :params
+
+ def allowed?
+ current_user&.can?(:admin_operations, integration)
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def success
+ ServiceResponse.success(payload: { integration: integration.reset })
+ end
+
+ def error_no_permissions
+ error(_('You have insufficient permissions to update this HTTP integration'))
+ end
+ end
+ end
+end
diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb
index 5c7698f724a..28ce5401a6c 100644
--- a/app/services/alert_management/process_prometheus_alert_service.rb
+++ b/app/services/alert_management/process_prometheus_alert_service.rb
@@ -9,6 +9,10 @@ module AlertManagement
return bad_request unless incoming_payload.has_required_attributes?
process_alert_management_alert
+ return bad_request unless alert.persisted?
+
+ process_incident_issues if process_issues?
+ send_alert_email if send_email?
ServiceResponse.success
end
@@ -30,8 +34,6 @@ module AlertManagement
else
create_alert_management_alert
end
-
- process_incident_issues if process_issues?
end
def reset_alert_management_alert_status
@@ -85,12 +87,17 @@ module AlertManagement
end
def process_incident_issues
- return unless alert.persisted?
- return if alert.issue
+ return if alert.issue || alert.resolved?
IncidentManagement::ProcessAlertWorker.perform_async(nil, nil, alert.id)
end
+ def send_alert_email
+ notification_service
+ .async
+ .prometheus_alerts_fired(project, [alert])
+ end
+
def logger
@logger ||= Gitlab::AppLogger
end
diff --git a/app/services/alert_management/sync_alert_service_data_service.rb b/app/services/alert_management/sync_alert_service_data_service.rb
new file mode 100644
index 00000000000..1ba197065c5
--- /dev/null
+++ b/app/services/alert_management/sync_alert_service_data_service.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class SyncAlertServiceDataService
+ # @param alert_service [AlertsService]
+ def initialize(alert_service)
+ @alert_service = alert_service
+ end
+
+ def execute
+ http_integration = find_http_integration
+
+ result = if http_integration
+ update_integration_data(http_integration)
+ else
+ create_integration
+ end
+
+ result ? ServiceResponse.success : ServiceResponse.error(message: 'Update failed')
+ end
+
+ private
+
+ attr_reader :alert_service
+
+ def find_http_integration
+ AlertManagement::HttpIntegrationsFinder.new(
+ alert_service.project,
+ endpoint_identifier: ::AlertManagement::HttpIntegration::LEGACY_IDENTIFIER
+ )
+ .execute
+ .first
+ end
+
+ def create_integration
+ new_integration = AlertManagement::HttpIntegration.create(
+ project_id: alert_service.project_id,
+ name: 'HTTP endpoint',
+ endpoint_identifier: AlertManagement::HttpIntegration::LEGACY_IDENTIFIER,
+ active: alert_service.active,
+ encrypted_token: alert_service.data.encrypted_token,
+ encrypted_token_iv: alert_service.data.encrypted_token_iv
+ )
+
+ new_integration.persisted?
+ end
+
+ def update_integration_data(http_integration)
+ http_integration.update(
+ active: alert_service.active,
+ encrypted_token: alert_service.data.encrypted_token,
+ encrypted_token_iv: alert_service.data.encrypted_token_iv
+ )
+ end
+ end
+end
diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb
index 3c21844ec62..d1558c60c3d 100644
--- a/app/services/audit_event_service.rb
+++ b/app/services/audit_event_service.rb
@@ -16,7 +16,7 @@ class AuditEventService
@author = build_author(author)
@entity = entity
@details = details
- @ip_address = (@details[:ip_address].presence || @author.current_sign_in_ip)
+ @ip_address = resolve_ip_address(@details, @author)
end
# Builds the @details attribute for authentication
@@ -64,6 +64,12 @@ class AuditEventService
end
end
+ def resolve_ip_address(details, author)
+ details[:ip_address].presence ||
+ Gitlab::RequestContext.instance.client_ip ||
+ author.current_sign_in_ip
+ end
+
def base_payload
{
author_id: @author.id,
diff --git a/app/services/bulk_create_integration_service.rb b/app/services/bulk_create_integration_service.rb
index 23b89b0d8a9..61c5565db60 100644
--- a/app/services/bulk_create_integration_service.rb
+++ b/app/services/bulk_create_integration_service.rb
@@ -11,6 +11,8 @@ class BulkCreateIntegrationService
service_list = ServiceList.new(batch, service_hash, association).to_array
Service.transaction do
+ run_callbacks(batch) if association == 'project'
+
results = bulk_insert(*service_list)
if integration.data_fields_present?
@@ -18,8 +20,6 @@ class BulkCreateIntegrationService
bulk_insert(*data_list)
end
-
- run_callbacks(batch) if association == 'project'
end
end
@@ -35,11 +35,11 @@ class BulkCreateIntegrationService
# rubocop: disable CodeReuse/ActiveRecord
def run_callbacks(batch)
- if integration.issue_tracker?
+ if integration.external_issue_tracker?
Project.where(id: batch.select(:id)).update_all(has_external_issue_tracker: true)
end
- if integration.type == 'ExternalWikiService'
+ if integration.external_wiki?
Project.where(id: batch.select(:id)).update_all(has_external_wiki: true)
end
end
@@ -49,7 +49,7 @@ class BulkCreateIntegrationService
if integration.template?
integration.to_service_hash
else
- integration.to_service_hash.tap { |json| json['inherit_from_id'] = integration.id }
+ integration.to_service_hash.tap { |json| json['inherit_from_id'] = integration.inherit_from_id || integration.id }
end
end
diff --git a/app/services/bulk_import_service.rb b/app/services/bulk_import_service.rb
new file mode 100644
index 00000000000..bebf9153ce7
--- /dev/null
+++ b/app/services/bulk_import_service.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+# Entry point of the BulkImport feature.
+# This service receives a Gitlab Instance connection params
+# and a list of groups to be imported.
+#
+# Process topography:
+#
+# sync | async
+# |
+# User +--> P1 +----> Pn +---+
+# | ^ | Enqueue new job
+# | +-----+
+#
+# P1 (sync)
+#
+# - Create a BulkImport record
+# - Create a BulkImport::Entity for each group to be imported
+# - Enqueue a BulkImportWorker job (P2) to import the given groups (entities)
+#
+# Pn (async)
+#
+# - For each group to be imported (BulkImport::Entity.with_status(:created))
+# - Import the group data
+# - Create entities for each subgroup of the imported group
+# - Enqueue a BulkImportService job (Pn) to import the new entities (subgroups)
+#
+class BulkImportService
+ attr_reader :current_user, :params, :credentials
+
+ def initialize(current_user, params, credentials)
+ @current_user = current_user
+ @params = params
+ @credentials = credentials
+ end
+
+ def execute
+ bulk_import = create_bulk_import
+
+ BulkImportWorker.perform_async(bulk_import.id)
+ end
+
+ private
+
+ def create_bulk_import
+ BulkImport.transaction do
+ bulk_import = BulkImport.create!(user: current_user, source_type: 'gitlab')
+ bulk_import.create_configuration!(credentials.slice(:url, :access_token))
+
+ params.each do |entity|
+ BulkImports::Entity.create!(
+ bulk_import: bulk_import,
+ source_type: entity[:source_type],
+ source_full_path: entity[:source_full_path],
+ destination_name: entity[:destination_name],
+ destination_namespace: entity[:destination_namespace]
+ )
+ end
+
+ bulk_import
+ end
+ end
+end
diff --git a/app/services/bulk_update_integration_service.rb b/app/services/bulk_update_integration_service.rb
index 74d77618f2c..5ddfdd359c2 100644
--- a/app/services/bulk_update_integration_service.rb
+++ b/app/services/bulk_update_integration_service.rb
@@ -9,7 +9,7 @@ class BulkUpdateIntegrationService
# rubocop: disable CodeReuse/ActiveRecord
def execute
Service.transaction do
- batch.update_all(service_hash)
+ Service.where(id: batch.select(:id)).update_all(service_hash)
if integration.data_fields_present?
integration.data_fields.class.where(service_id: batch.select(:id)).update_all(data_fields_hash)
@@ -23,7 +23,7 @@ class BulkUpdateIntegrationService
attr_reader :integration, :batch
def service_hash
- integration.to_service_hash.tap { |json| json['inherit_from_id'] = integration.id }
+ integration.to_service_hash.tap { |json| json['inherit_from_id'] = integration.inherit_from_id || integration.id }
end
def data_fields_hash
diff --git a/app/services/ci/append_build_trace_service.rb b/app/services/ci/append_build_trace_service.rb
new file mode 100644
index 00000000000..602f8c5030d
--- /dev/null
+++ b/app/services/ci/append_build_trace_service.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module Ci
+ class AppendBuildTraceService
+ Result = Struct.new(:status, :stream_size, keyword_init: true)
+ TraceRangeError = Class.new(StandardError)
+
+ attr_reader :build, :params
+
+ def initialize(build, params)
+ @build = build
+ @params = params
+ end
+
+ def execute(body_data)
+ # TODO:
+ # it seems that `Content-Range` as formatted by runner is wrong,
+ # the `byte_end` should point to final byte, but it points byte+1
+ # that means that we have to calculate end of body,
+ # as we cannot use `content_length[1]`
+ # Issue: https://gitlab.com/gitlab-org/gitlab-runner/issues/3275
+
+ content_range = stream_range.split('-')
+ body_start = content_range[0].to_i
+ body_end = body_start + body_data.bytesize
+
+ stream_size = build.trace.append(body_data, body_start)
+
+ unless stream_size == body_end
+ log_range_error(stream_size, body_end)
+
+ return Result.new(status: 416, stream_size: stream_size)
+ end
+
+ Result.new(status: 202, stream_size: stream_size)
+ end
+
+ private
+
+ def stream_range
+ params.fetch(:content_range)
+ end
+
+ def log_range_error(stream_size, body_end)
+ extra = {
+ build_id: build.id,
+ body_end: body_end,
+ stream_size: stream_size,
+ stream_class: stream_size.class,
+ stream_range: stream_range
+ }
+
+ build.trace_chunks.last.try do |chunk|
+ extra.merge!(
+ chunk_index: chunk.chunk_index,
+ chunk_store: chunk.data_store,
+ chunks_count: build.trace_chunks.count
+ )
+ end
+
+ ::Gitlab::ErrorTracking
+ .log_exception(TraceRangeError.new, extra)
+ end
+ end
+end
diff --git a/app/services/ci/build_report_result_service.rb b/app/services/ci/build_report_result_service.rb
index 76ecf428f11..f138aa91236 100644
--- a/app/services/ci/build_report_result_service.rb
+++ b/app/services/ci/build_report_result_service.rb
@@ -39,8 +39,6 @@ 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
diff --git a/app/services/ci/compare_reports_base_service.rb b/app/services/ci/compare_reports_base_service.rb
index 2e84f914db3..9aba3a50ec1 100644
--- a/app/services/ci/compare_reports_base_service.rb
+++ b/app/services/ci/compare_reports_base_service.rb
@@ -8,7 +8,9 @@ module Ci
# issue: https://gitlab.com/gitlab-org/gitlab/issues/34224
class CompareReportsBaseService < ::BaseService
def execute(base_pipeline, head_pipeline)
- comparer = build_comparer(base_pipeline, head_pipeline)
+ base_report = get_report(base_pipeline)
+ head_report = get_report(head_pipeline)
+ comparer = build_comparer(base_report, head_report)
{
status: :parsed,
@@ -31,8 +33,8 @@ module Ci
protected
- def build_comparer(base_pipeline, head_pipeline)
- comparer_class.new(get_report(base_pipeline), get_report(head_pipeline))
+ def build_comparer(base_report, head_report)
+ comparer_class.new(base_report, head_report)
end
private
diff --git a/app/services/ci/compare_test_reports_service.rb b/app/services/ci/compare_test_reports_service.rb
index 382d5b8995f..ed85ca8274c 100644
--- a/app/services/ci/compare_test_reports_service.rb
+++ b/app/services/ci/compare_test_reports_service.rb
@@ -13,5 +13,19 @@ module Ci
def get_report(pipeline)
pipeline&.test_reports
end
+
+ def build_comparer(base_report, head_report)
+ # We need to load the test failure history on the test comparer because we display
+ # this on the MR widget
+ super.tap do |test_reports_comparer|
+ ::Gitlab::Ci::Reports::TestFailureHistory.new(failed_test_cases(test_reports_comparer), project).load!
+ end
+ end
+
+ def failed_test_cases(test_reports_comparer)
+ test_reports_comparer.suite_comparers.flat_map do |suite_comparer|
+ suite_comparer.limited_tests.new_failures + suite_comparer.limited_tests.existing_failures
+ end
+ end
end
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index e7ede98fea4..e3bab2de44e 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -14,6 +14,7 @@ module Ci
Gitlab::Ci::Pipeline::Chain::Config::Process,
Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs,
Gitlab::Ci::Pipeline::Chain::Skip,
+ Gitlab::Ci::Pipeline::Chain::SeedBlock,
Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules,
Gitlab::Ci::Pipeline::Chain::Seed,
Gitlab::Ci::Pipeline::Chain::Limit::Size,
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 c32fc27c274..bc966fb9634 100644
--- a/app/services/ci/daily_build_group_report_result_service.rb
+++ b/app/services/ci/daily_build_group_report_result_service.rb
@@ -13,7 +13,8 @@ module Ci
project_id: pipeline.project_id,
ref_path: pipeline.source_ref_path,
date: pipeline.created_at.to_date,
- last_pipeline_id: pipeline.id
+ last_pipeline_id: pipeline.id,
+ default_branch: pipeline.default_branch?
}
aggregate(pipeline.builds.with_coverage).map do |group_name, group|
diff --git a/app/services/ci/destroy_expired_job_artifacts_service.rb b/app/services/ci/destroy_expired_job_artifacts_service.rb
index 438b5c7496d..6e7caba8545 100644
--- a/app/services/ci/destroy_expired_job_artifacts_service.rb
+++ b/app/services/ci/destroy_expired_job_artifacts_service.rb
@@ -4,47 +4,90 @@ module Ci
class DestroyExpiredJobArtifactsService
include ::Gitlab::ExclusiveLeaseHelpers
include ::Gitlab::LoopHelpers
+ include ::Gitlab::Utils::StrongMemoize
BATCH_SIZE = 100
- LOOP_TIMEOUT = 45.minutes
+ LOOP_TIMEOUT = 5.minutes
LOOP_LIMIT = 1000
EXCLUSIVE_LOCK_KEY = 'expired_job_artifacts:destroy:lock'
- LOCK_TIMEOUT = 50.minutes
+ LOCK_TIMEOUT = 6.minutes
##
# Destroy expired job artifacts on GitLab instance
#
- # This destroy process cannot run for more than 45 minutes. This is for
+ # This destroy process cannot run for more than 6 minutes. This is for
# preventing multiple `ExpireBuildArtifactsWorker` CRON jobs run concurrently,
- # which is scheduled at every hour.
+ # which is scheduled every 7 minutes.
def execute
in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do
loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do
- destroy_batch(Ci::JobArtifact) || destroy_batch(Ci::PipelineArtifact)
+ destroy_artifacts_batch
end
end
end
private
- def destroy_batch(klass)
- artifact_batch = if klass == Ci::JobArtifact
- klass.expired(BATCH_SIZE).unlocked
- else
- klass.expired(BATCH_SIZE)
- end
+ def destroy_artifacts_batch
+ destroy_job_artifacts_batch || destroy_pipeline_artifacts_batch
+ end
+
+ def destroy_job_artifacts_batch
+ artifacts = Ci::JobArtifact
+ .expired(BATCH_SIZE)
+ .unlocked
+ .with_destroy_preloads
+ .to_a
+
+ return false if artifacts.empty?
- artifacts = artifact_batch.to_a
+ parallel_destroy_batch(artifacts)
+ true
+ end
+ # TODO: Make sure this can also be parallelized
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/270973
+ def destroy_pipeline_artifacts_batch
+ artifacts = Ci::PipelineArtifact.expired(BATCH_SIZE).to_a
return false if artifacts.empty?
artifacts.each(&:destroy!)
- run_after_destroy(artifacts)
- true # This is required because of the design of `loop_until` method.
+ true
+ end
+
+ def parallel_destroy_batch(job_artifacts)
+ Ci::DeletedObject.transaction do
+ Ci::DeletedObject.bulk_import(job_artifacts)
+ Ci::JobArtifact.id_in(job_artifacts.map(&:id)).delete_all
+ destroy_related_records_for(job_artifacts)
+ end
+
+ # This is executed outside of the transaction because it depends on Redis
+ update_statistics_for(job_artifacts)
+ destroyed_artifacts_counter.increment({}, job_artifacts.size)
+ end
+
+ # This method is implemented in EE and it must do only database work
+ def destroy_related_records_for(job_artifacts); end
+
+ def update_statistics_for(job_artifacts)
+ artifacts_by_project = job_artifacts.group_by(&:project)
+ artifacts_by_project.each do |project, artifacts|
+ delta = -artifacts.sum { |artifact| artifact.size.to_i }
+ ProjectStatistics.increment_statistic(
+ project, Ci::JobArtifact.project_statistics_name, delta)
+ end
end
- def run_after_destroy(artifacts); end
+ def destroyed_artifacts_counter
+ strong_memoize(:destroyed_artifacts_counter) do
+ name = :destroyed_job_artifacts_count_total
+ comment = 'Counter of destroyed expired job artifacts'
+
+ ::Gitlab::Metrics.counter(name, comment)
+ end
+ end
end
end
diff --git a/app/services/ci/list_config_variables_service.rb b/app/services/ci/list_config_variables_service.rb
index b5dc192b512..4a5b3a92a2c 100644
--- a/app/services/ci/list_config_variables_service.rb
+++ b/app/services/ci/list_config_variables_service.rb
@@ -6,7 +6,10 @@ module Ci
config = project.ci_config_for(sha)
return {} unless config
- result = Gitlab::Ci::YamlProcessor.new(config).execute
+ result = Gitlab::Ci::YamlProcessor.new(config, project: project,
+ user: current_user,
+ sha: sha).execute
+
result.valid? ? result.variables_with_data : {}
end
end
diff --git a/app/services/ci/parse_dotenv_artifact_service.rb b/app/services/ci/parse_dotenv_artifact_service.rb
index 71b306864b2..2ee9be476bb 100644
--- a/app/services/ci/parse_dotenv_artifact_service.rb
+++ b/app/services/ci/parse_dotenv_artifact_service.rb
@@ -3,7 +3,7 @@
module Ci
class ParseDotenvArtifactService < ::BaseService
MAX_ACCEPTABLE_DOTENV_SIZE = 5.kilobytes
- MAX_ACCEPTABLE_VARIABLES_COUNT = 10
+ MAX_ACCEPTABLE_VARIABLES_COUNT = 20
SizeLimitError = Class.new(StandardError)
ParserError = Class.new(StandardError)
diff --git a/app/services/ci/test_cases_service.rb b/app/services/ci/test_cases_service.rb
new file mode 100644
index 00000000000..3139b567571
--- /dev/null
+++ b/app/services/ci/test_cases_service.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Ci
+ class TestCasesService
+ MAX_TRACKABLE_FAILURES = 200
+
+ def execute(build)
+ return unless Feature.enabled?(:test_failure_history, build.project)
+ return unless build.has_test_reports?
+ return unless build.project.default_branch_or_master == build.ref
+
+ test_suite = generate_test_suite_report(build)
+
+ track_failures(build, test_suite)
+ end
+
+ private
+
+ def generate_test_suite_report(build)
+ build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new)
+ end
+
+ def track_failures(build, test_suite)
+ return if test_suite.failed_count > MAX_TRACKABLE_FAILURES
+
+ test_suite.failed.keys.each_slice(100) do |keys|
+ Ci::TestCase.transaction do
+ test_cases = Ci::TestCase.find_or_create_by_batch(build.project, keys)
+ Ci::TestCaseFailure.insert_all(test_case_failures(test_cases, build))
+ end
+ end
+ end
+
+ def test_case_failures(test_cases, build)
+ test_cases.map do |test_case|
+ {
+ test_case_id: test_case.id,
+ build_id: build.id,
+ failed_at: build.finished_at
+ }
+ end
+ end
+ end
+end
diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb
index 22a27906700..fb67b0d2355 100644
--- a/app/services/ci/update_build_state_service.rb
+++ b/app/services/ci/update_build_state_service.rb
@@ -163,16 +163,18 @@ module Ci
end
def ensure_pending_state
- Ci::BuildPendingState.create_or_find_by!(
+ build_state = Ci::BuildPendingState.safe_find_or_create_by(
build_id: build.id,
state: params.fetch(:state),
trace_checksum: params.fetch(:checksum),
failure_reason: params.dig(:failure_reason)
)
- rescue ActiveRecord::RecordNotFound
- metrics.increment_trace_operation(operation: :conflict)
- build.pending_state
+ unless build_state.present?
+ metrics.increment_trace_operation(operation: :conflict)
+ end
+
+ build_state || build.pending_state
end
##
diff --git a/app/services/clusters/aws/authorize_role_service.rb b/app/services/clusters/aws/authorize_role_service.rb
index 2712a4b05bb..188c4aebc5f 100644
--- a/app/services/clusters/aws/authorize_role_service.rb
+++ b/app/services/clusters/aws/authorize_role_service.rb
@@ -17,7 +17,8 @@ module Clusters
def initialize(user, params:)
@user = user
- @params = params
+ @role_arn = params[:role_arn]
+ @region = params[:region]
end
def execute
@@ -33,14 +34,14 @@ module Clusters
private
- attr_reader :role, :params
+ attr_reader :role, :role_arn, :region
def ensure_role_exists!
@role = ::Aws::Role.find_by_user_id!(user.id)
end
def update_role_arn!
- role.update!(params)
+ role.update!(role_arn: role_arn, region: region)
end
def credentials
diff --git a/app/services/clusters/aws/fetch_credentials_service.rb b/app/services/clusters/aws/fetch_credentials_service.rb
index 33efc4cc120..96abbb43969 100644
--- a/app/services/clusters/aws/fetch_credentials_service.rb
+++ b/app/services/clusters/aws/fetch_credentials_service.rb
@@ -10,6 +10,7 @@ module Clusters
def initialize(provision_role, provider: nil)
@provision_role = provision_role
@provider = provider
+ @region = provider&.region || provision_role&.region || Clusters::Providers::Aws::DEFAULT_REGION
end
def execute
@@ -26,7 +27,7 @@ module Clusters
private
- attr_reader :provider
+ attr_reader :provider, :region
def client
::Aws::STS::Client.new(credentials: gitlab_credentials, region: region)
@@ -44,10 +45,6 @@ module Clusters
Gitlab::CurrentSettings.eks_secret_access_key
end
- def region
- provider&.region || Clusters::Providers::Aws::DEFAULT_REGION
- end
-
##
# If we haven't created a provider record yet,
# we restrict ourselves to read only access so
diff --git a/app/services/clusters/kubernetes.rb b/app/services/clusters/kubernetes.rb
index aafea64c820..819ac4c8464 100644
--- a/app/services/clusters/kubernetes.rb
+++ b/app/services/clusters/kubernetes.rb
@@ -7,7 +7,7 @@ module Clusters
GITLAB_ADMIN_TOKEN_NAME = 'gitlab-token'
GITLAB_CLUSTER_ROLE_BINDING_NAME = 'gitlab-admin'
GITLAB_CLUSTER_ROLE_NAME = 'cluster-admin'
- PROJECT_CLUSTER_ROLE_NAME = 'edit'
+ PROJECT_CLUSTER_ROLE_NAME = 'admin'
GITLAB_KNATIVE_SERVING_ROLE_NAME = 'gitlab-knative-serving-role'
GITLAB_KNATIVE_SERVING_ROLE_BINDING_NAME = 'gitlab-knative-serving-rolebinding'
GITLAB_CROSSPLANE_DATABASE_ROLE_NAME = 'gitlab-crossplane-database-role'
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 2725a3aeaa5..eabc428d0d2 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
@@ -69,7 +69,13 @@ module Clusters
def create_role_or_cluster_role_binding
if namespace_creator
- kubeclient.create_or_update_role_binding(role_binding_resource)
+ begin
+ kubeclient.delete_role_binding(role_binding_name, service_account_namespace)
+ rescue Kubeclient::ResourceNotFoundError
+ # Do nothing as we will create new role binding below
+ end
+
+ kubeclient.update_role_binding(role_binding_resource)
else
kubeclient.create_or_update_cluster_role_binding(cluster_role_binding_resource)
end
@@ -117,11 +123,9 @@ 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: role_name,
+ role_name: Clusters::Kubernetes::PROJECT_CLUSTER_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 065ab6f7ff9..03e422aec54 100644
--- a/app/services/concerns/admin/propagate_service.rb
+++ b/app/services/concerns/admin/propagate_service.rb
@@ -21,9 +21,16 @@ module Admin
attr_reader :integration
def create_integration_for_projects_without_integration
- 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)
+ propagate_integrations(
+ Project.without_integration(integration),
+ PropagateIntegrationProjectWorker
+ )
+ end
+
+ def propagate_integrations(relation, worker_class)
+ relation.each_batch(of: BATCH_SIZE) do |records|
+ min_id, max_id = records.pick("MIN(#{relation.table_name}.id), MAX(#{relation.table_name}.id)")
+ worker_class.perform_async(integration.id, min_id, max_id)
end
end
end
diff --git a/app/services/concerns/integrations/project_test_data.rb b/app/services/concerns/integrations/project_test_data.rb
index 4d551430315..72c12cfb394 100644
--- a/app/services/concerns/integrations/project_test_data.rb
+++ b/app/services/concerns/integrations/project_test_data.rb
@@ -58,5 +58,12 @@ module Integrations
Gitlab::DataBuilder::Deployment.build(deployment)
end
+
+ def releases_events_data
+ release = project.releases.first
+ return { error: s_('TestHooks|Ensure the project has releases.') } unless release.present?
+
+ release.to_hook_data('create')
+ end
end
end
diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb
index 6fde9abfdb0..fac8e91d216 100644
--- a/app/services/concerns/users/participable_service.rb
+++ b/app/services/concerns/users/participable_service.rb
@@ -45,7 +45,8 @@ module Users
type: user.class.name,
username: user.username,
name: user.name,
- avatar_url: user.avatar_url
+ avatar_url: user.avatar_url,
+ availability: user&.status&.availability
}
end
diff --git a/app/services/container_expiration_policies/cleanup_service.rb b/app/services/container_expiration_policies/cleanup_service.rb
new file mode 100644
index 00000000000..f2bc2beab63
--- /dev/null
+++ b/app/services/container_expiration_policies/cleanup_service.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module ContainerExpirationPolicies
+ class CleanupService
+ attr_reader :repository
+
+ def initialize(repository)
+ @repository = repository
+ end
+
+ def execute
+ return ServiceResponse.error(message: 'no repository') unless repository
+
+ repository.start_expiration_policy!
+
+ result = Projects::ContainerRepository::CleanupTagsService
+ .new(project, nil, policy_params.merge('container_expiration_policy' => true))
+ .execute(repository)
+
+ if result[:status] == :success
+ repository.update!(
+ expiration_policy_cleanup_status: :cleanup_unscheduled,
+ expiration_policy_started_at: nil
+ )
+ success(:finished)
+ else
+ repository.cleanup_unfinished!
+
+ success(:unfinished)
+ end
+ end
+
+ private
+
+ def success(cleanup_status)
+ ServiceResponse.success(message: "cleanup #{cleanup_status}", payload: { cleanup_status: cleanup_status, container_repository_id: repository.id })
+ end
+
+ def policy_params
+ return {} unless policy
+
+ policy.policy_params
+ end
+
+ def policy
+ project.container_expiration_policy
+ end
+
+ def project
+ repository&.project
+ end
+ end
+end
diff --git a/app/services/container_expiration_policy_service.rb b/app/services/container_expiration_policy_service.rb
index 80f32298323..cf5d702a9ef 100644
--- a/app/services/container_expiration_policy_service.rb
+++ b/app/services/container_expiration_policy_service.rb
@@ -4,20 +4,14 @@ class ContainerExpirationPolicyService < BaseService
InvalidPolicyError = Class.new(StandardError)
def execute(container_expiration_policy)
- unless container_expiration_policy.valid?
- container_expiration_policy.disable!
- raise InvalidPolicyError
- end
-
container_expiration_policy.schedule_next_run!
container_expiration_policy.container_repositories.find_each do |container_repository|
CleanupContainerRepositoryWorker.perform_async(
nil,
container_repository.id,
- container_expiration_policy.attributes
- .except('created_at', 'updated_at')
- .merge(container_expiration_policy: true)
+ container_expiration_policy.policy_params
+ .merge(container_expiration_policy: true)
)
end
end
diff --git a/app/services/dependency_proxy/base_service.rb b/app/services/dependency_proxy/base_service.rb
new file mode 100644
index 00000000000..1b2d4b14a27
--- /dev/null
+++ b/app/services/dependency_proxy/base_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ class BaseService < ::BaseService
+ private
+
+ def registry
+ DependencyProxy::Registry
+ end
+
+ def auth_headers
+ {
+ Authorization: "Bearer #{@token}"
+ }
+ end
+ end
+end
diff --git a/app/services/dependency_proxy/download_blob_service.rb b/app/services/dependency_proxy/download_blob_service.rb
new file mode 100644
index 00000000000..3c690683bf6
--- /dev/null
+++ b/app/services/dependency_proxy/download_blob_service.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ class DownloadBlobService < DependencyProxy::BaseService
+ class DownloadError < StandardError
+ attr_reader :http_status
+
+ def initialize(message, http_status)
+ @http_status = http_status
+
+ super(message)
+ end
+ end
+
+ def initialize(image, blob_sha, token)
+ @image = image
+ @blob_sha = blob_sha
+ @token = token
+ @temp_file = Tempfile.new
+ end
+
+ def execute
+ File.open(@temp_file.path, "wb") do |file|
+ Gitlab::HTTP.get(blob_url, headers: auth_headers, stream_body: true) do |fragment|
+ if [301, 302, 307].include?(fragment.code)
+ # do nothing
+ elsif fragment.code == 200
+ file.write(fragment)
+ else
+ raise DownloadError.new('Non-success response code on downloading blob fragment', fragment.code)
+ end
+ end
+ end
+
+ success(file: @temp_file)
+ rescue DownloadError => exception
+ error(exception.message, exception.http_status)
+ rescue Timeout::Error => exception
+ error(exception.message, 599)
+ end
+
+ private
+
+ def blob_url
+ registry.blob_url(@image, @blob_sha)
+ end
+ end
+end
diff --git a/app/services/dependency_proxy/find_or_create_blob_service.rb b/app/services/dependency_proxy/find_or_create_blob_service.rb
new file mode 100644
index 00000000000..bd06f9d7628
--- /dev/null
+++ b/app/services/dependency_proxy/find_or_create_blob_service.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ class FindOrCreateBlobService < DependencyProxy::BaseService
+ def initialize(group, image, token, blob_sha)
+ @group = group
+ @image = image
+ @token = token
+ @blob_sha = blob_sha
+ end
+
+ def execute
+ file_name = @blob_sha.sub('sha256:', '') + '.gz'
+ blob = @group.dependency_proxy_blobs.find_or_build(file_name)
+
+ unless blob.persisted?
+ result = DependencyProxy::DownloadBlobService
+ .new(@image, @blob_sha, @token).execute
+
+ if result[:status] == :error
+ log_failure(result)
+
+ return error('Failed to download the blob', result[:http_status])
+ end
+
+ blob.file = result[:file]
+ blob.size = result[:file].size
+ blob.save!
+ end
+
+ success(blob: blob)
+ end
+
+ private
+
+ def log_failure(result)
+ log_error(
+ "Dependency proxy: Failed to download the blob." \
+ "Blob sha: #{@blob_sha}." \
+ "Error message: #{result[:message][0, 100]}" \
+ "HTTP status: #{result[:http_status]}"
+ )
+ end
+ end
+end
diff --git a/app/services/dependency_proxy/pull_manifest_service.rb b/app/services/dependency_proxy/pull_manifest_service.rb
new file mode 100644
index 00000000000..fc54ef85c96
--- /dev/null
+++ b/app/services/dependency_proxy/pull_manifest_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ class PullManifestService < DependencyProxy::BaseService
+ def initialize(image, tag, token)
+ @image = image
+ @tag = tag
+ @token = token
+ end
+
+ def execute
+ response = Gitlab::HTTP.get(manifest_url, headers: auth_headers)
+
+ if response.success?
+ success(manifest: response.body)
+ else
+ error(response.body, response.code)
+ end
+ rescue Timeout::Error => exception
+ error(exception.message, 599)
+ end
+
+ private
+
+ def manifest_url
+ registry.manifest_url(@image, @tag)
+ end
+ end
+end
diff --git a/app/services/dependency_proxy/request_token_service.rb b/app/services/dependency_proxy/request_token_service.rb
new file mode 100644
index 00000000000..4ca7239b9f6
--- /dev/null
+++ b/app/services/dependency_proxy/request_token_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ class RequestTokenService < DependencyProxy::BaseService
+ def initialize(image)
+ @image = image
+ end
+
+ def execute
+ response = Gitlab::HTTP.get(auth_url)
+
+ if response.success?
+ success(token: Gitlab::Json.parse(response.body)['token'])
+ else
+ error('Expected 200 response code for an access token', response.code)
+ end
+ rescue Timeout::Error => exception
+ error(exception.message, 599)
+ rescue JSON::ParserError
+ error('Failed to parse a response body for an access token', 500)
+ end
+
+ private
+
+ def auth_url
+ registry.auth_url(@image)
+ end
+ end
+end
diff --git a/app/services/deploy_keys/collect_keys_service.rb b/app/services/deploy_keys/collect_keys_service.rb
deleted file mode 100644
index 2ef49bf0f30..00000000000
--- a/app/services/deploy_keys/collect_keys_service.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-module DeployKeys
- class CollectKeysService
- def initialize(project, current_user)
- @project = project
- @current_user = current_user
- end
-
- def execute
- return [] unless current_user && project && user_can_read_project
-
- project.deploy_keys_projects
- .with_deploy_keys
- .with_write_access
- .map(&:deploy_key)
- end
-
- private
-
- def user_can_read_project
- Ability.allowed?(current_user, :read_project, project)
- end
-
- attr_reader :project, :current_user
- 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
index 5099c2c5704..c0b32e1e9ae 100644
--- a/app/services/design_management/copy_design_collection/copy_service.rb
+++ b/app/services/design_management/copy_design_collection/copy_service.rb
@@ -172,20 +172,26 @@ module DesignManagement
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
+ ::DesignManagement::Design.with_project_iid_supply(target_project) do |supply|
+ new_rows = designs.each_with_index.map do |design, i|
+ design.attributes.slice(*design_attributes).merge(
+ issue_id: target_issue.id,
+ project_id: target_project.id,
+ iid: supply.next_value
+ )
+ end
+
+ # TODO Replace `Gitlab::Database.bulk_insert` with `BulkInsertSafe`
+ # once https://gitlab.com/gitlab-org/gitlab/-/issues/247718 is fixed.
+ # When this is fixed, we can remove the call to
+ # `with_project_iid_supply` above, since the objects will be instantiated
+ # and callbacks (including `ensure_project_iid!`) will fire.
+ ::Gitlab::Database.bulk_insert( # rubocop:disable Gitlab/BulkInsert
+ DesignManagement::Design.table_name,
+ new_rows,
+ return_ids: true
)
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!
diff --git a/app/services/discussions/capture_diff_note_position_service.rb b/app/services/discussions/capture_diff_note_position_service.rb
index 87aa27e455f..a95e149a6c9 100644
--- a/app/services/discussions/capture_diff_note_position_service.rb
+++ b/app/services/discussions/capture_diff_note_position_service.rb
@@ -9,8 +9,7 @@ module Discussions
def execute(discussion)
# The service has been implemented for text only
- # The impact of image notes on this service is being investigated in
- # https://gitlab.com/gitlab-org/gitlab/-/issues/213989
+ # We don't need to capture positions for images
return unless discussion.on_text?
result = tracer&.trace(discussion.position)
diff --git a/app/services/feature_flags/update_service.rb b/app/services/feature_flags/update_service.rb
index c837e50b104..ed5e2e794b4 100644
--- a/app/services/feature_flags/update_service.rb
+++ b/app/services/feature_flags/update_service.rb
@@ -22,6 +22,10 @@ module FeatureFlags
audit_event = audit_event(feature_flag)
+ if feature_flag.active_changed?
+ feature_flag.execute_hooks(current_user)
+ end
+
if feature_flag.save
save_audit_event(audit_event)
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
index 93a0d139001..d00ca83441a 100644
--- a/app/services/git/branch_hooks_service.rb
+++ b/app/services/git/branch_hooks_service.rb
@@ -84,7 +84,6 @@ module Git
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)
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index cf843d92862..016c31cbccc 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -34,7 +34,7 @@ module Groups
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)
+ Service.create_from_active_default_integrations(@group, :group_id) if Feature.enabled?(:group_level_integrations, default_enabled: true)
end
end
diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb
index a2923b1e4f9..948dba2d206 100644
--- a/app/services/import/github_service.rb
+++ b/app/services/import/github_service.rb
@@ -6,6 +6,10 @@ module Import
attr_reader :params, :current_user
def execute(access_params, provider)
+ if blocked_url?
+ return log_and_return_error("Invalid URL: #{url}", :bad_request)
+ end
+
unless authorized?
return error(_('This namespace has already been taken! Please choose another one.'), :unprocessable_entity)
end
@@ -56,6 +60,25 @@ module Import
can?(current_user, :create_projects, target_namespace)
end
+ def url
+ @url ||= params[:github_hostname]
+ end
+
+ def allow_local_requests?
+ Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services?
+ end
+
+ def blocked_url?
+ Gitlab::UrlBlocker.blocked_url?(
+ url,
+ {
+ allow_localhost: allow_local_requests?,
+ allow_local_network: allow_local_requests?,
+ schemes: %w(http https)
+ }
+ )
+ end
+
private
def log_error(exception)
diff --git a/app/services/integrations/test/project_service.rb b/app/services/integrations/test/project_service.rb
index 941d70c2cc4..39471d373f9 100644
--- a/app/services/integrations/test/project_service.rb
+++ b/app/services/integrations/test/project_service.rb
@@ -35,6 +35,8 @@ module Integrations
wiki_page_events_data
when 'deployment'
deployment_events_data
+ when 'release'
+ releases_events_data
else
push_events_data
end
diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb
index fbc72dc867a..fd2dc3787c2 100644
--- a/app/services/issuable/common_system_notes_service.rb
+++ b/app/services/issuable/common_system_notes_service.rb
@@ -51,11 +51,11 @@ module Issuable
end
end
- def create_wip_note(old_title)
+ def create_draft_note(old_title)
return unless issuable.is_a?(MergeRequest)
if MergeRequest.work_in_progress?(old_title) != issuable.work_in_progress?
- SystemNoteService.handle_merge_request_wip(issuable, issuable.project, current_user)
+ SystemNoteService.handle_merge_request_draft(issuable, issuable.project, current_user)
end
end
@@ -69,7 +69,7 @@ module Issuable
end
def create_title_change_note(old_title)
- create_wip_note(old_title)
+ create_draft_note(old_title)
if issuable.wipless_title_changed(old_title)
SystemNoteService.change_title(issuable, issuable.project, current_user, old_title)
diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb
new file mode 100644
index 00000000000..bf5f643a51b
--- /dev/null
+++ b/app/services/issuable/import_csv/base_service.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+module Issuable
+ module ImportCsv
+ class BaseService
+ def initialize(user, project, csv_io)
+ @user = user
+ @project = project
+ @csv_io = csv_io
+ @results = { success: 0, error_lines: [], parse_error: false }
+ end
+
+ def execute
+ process_csv
+ email_results_to_user
+
+ @results
+ end
+
+ private
+
+ def process_csv
+ with_csv_lines.each do |row, line_no|
+ issuable_attributes = {
+ title: row[:title],
+ description: row[:description]
+ }
+
+ if create_issuable(issuable_attributes).persisted?
+ @results[:success] += 1
+ else
+ @results[:error_lines].push(line_no)
+ end
+ end
+ rescue ArgumentError, CSV::MalformedCSVError
+ @results[:parse_error] = true
+ end
+
+ def with_csv_lines
+ csv_data = @csv_io.open(&:read).force_encoding(Encoding::UTF_8)
+ verify_headers!(csv_data)
+
+ csv_parsing_params = {
+ col_sep: detect_col_sep(csv_data.lines.first),
+ headers: true,
+ header_converters: :symbol
+ }
+
+ CSV.new(csv_data, csv_parsing_params).each.with_index(2)
+ end
+
+ def verify_headers!(data)
+ headers = data.lines.first.downcase
+ return if headers.include?('title') && headers.include?('description')
+
+ raise CSV::MalformedCSVError
+ end
+
+ def detect_col_sep(header)
+ if header.include?(",")
+ ","
+ elsif header.include?(";")
+ ";"
+ elsif header.include?("\t")
+ "\t"
+ else
+ raise CSV::MalformedCSVError
+ end
+ end
+
+ def create_issuable(attributes)
+ create_issuable_class.new(@project, @user, attributes).execute
+ end
+
+ def email_results_to_user
+ # defined in ImportCsvService
+ end
+
+ def create_issuable_class
+ # defined in ImportCsvService
+ end
+ end
+ end
+end
diff --git a/app/services/issues/import_csv_service.rb b/app/services/issues/import_csv_service.rb
index 60790ba3547..bce3ecc8bef 100644
--- a/app/services/issues/import_csv_service.rb
+++ b/app/services/issues/import_csv_service.rb
@@ -1,64 +1,25 @@
# frozen_string_literal: true
module Issues
- class ImportCsvService
- def initialize(user, project, csv_io)
- @user = user
- @project = project
- @csv_io = csv_io
- @results = { success: 0, error_lines: [], parse_error: false }
- end
-
+ class ImportCsvService < Issuable::ImportCsv::BaseService
def execute
- process_csv
- email_results_to_user
-
- @results
- end
-
- private
-
- def process_csv
- csv_data = @csv_io.open(&:read).force_encoding(Encoding::UTF_8)
+ record_import_attempt
- csv_parsing_params = {
- col_sep: detect_col_sep(csv_data.lines.first),
- headers: true,
- header_converters: :symbol
- }
-
- CSV.new(csv_data, csv_parsing_params).each.with_index(2) do |row, line_no|
- issue_attributes = {
- title: row[:title],
- description: row[:description]
- }
-
- issue = Issues::CreateService.new(@project, @user, issue_attributes).execute
-
- if issue.persisted?
- @results[:success] += 1
- else
- @results[:error_lines].push(line_no)
- end
- end
- rescue ArgumentError, CSV::MalformedCSVError
- @results[:parse_error] = true
+ super
end
def email_results_to_user
Notify.import_issues_csv_email(@user.id, @project.id, @results).deliver_later
end
- def detect_col_sep(header)
- if header.include?(",")
- ","
- elsif header.include?(";")
- ";"
- elsif header.include?("\t")
- "\t"
- else
- raise CSV::MalformedCSVError
- end
+ private
+
+ def create_issuable_class
+ Issues::CreateService
+ end
+
+ def record_import_attempt
+ Issues::CsvImport.create!(user: @user, project: @project)
end
end
end
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index 12dbff57ec5..e2b1b5400c7 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -5,8 +5,6 @@ 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')
@@ -23,14 +21,8 @@ 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_connect/sync_service.rb b/app/services/jira_connect/sync_service.rb
index 07a648bb8c9..f8855fb6deb 100644
--- a/app/services/jira_connect/sync_service.rb
+++ b/app/services/jira_connect/sync_service.rb
@@ -6,11 +6,11 @@ module JiraConnect
self.project = project
end
- def execute(commits: nil, branches: nil, merge_requests: nil)
+ def execute(commits: nil, branches: nil, merge_requests: nil, update_sequence_id: nil)
JiraConnectInstallation.for_project(project).each do |installation|
client = Atlassian::JiraConnect::Client.new(installation.base_url, installation.shared_secret)
- response = client.store_dev_info(project: project, commits: commits, branches: branches, merge_requests: merge_requests)
+ response = client.store_dev_info(project: project, commits: commits, branches: branches, merge_requests: merge_requests, update_sequence_id: update_sequence_id)
log_response(response)
end
diff --git a/app/services/jira_connect_subscriptions/create_service.rb b/app/services/jira_connect_subscriptions/create_service.rb
index 8e794d3acf7..b169d97615d 100644
--- a/app/services/jira_connect_subscriptions/create_service.rb
+++ b/app/services/jira_connect_subscriptions/create_service.rb
@@ -3,6 +3,8 @@
module JiraConnectSubscriptions
class CreateService < ::JiraConnectSubscriptions::BaseService
include Gitlab::Utils::StrongMemoize
+ MERGE_REQUEST_SYNC_BATCH_SIZE = 20
+ MERGE_REQUEST_SYNC_BATCH_DELAY = 1.minute.freeze
def execute
unless namespace && can?(current_user, :create_jira_connect_subscription, namespace)
@@ -18,6 +20,8 @@ module JiraConnectSubscriptions
subscription = JiraConnectSubscription.new(installation: jira_connect_installation, namespace: namespace)
if subscription.save
+ schedule_sync_project_jobs
+
success
else
error(subscription.errors.full_messages.join(', '), 422)
@@ -29,5 +33,18 @@ module JiraConnectSubscriptions
Namespace.find_by_full_path(params[:namespace_path])
end
end
+
+ def schedule_sync_project_jobs
+ return unless Feature.enabled?(:jira_connect_full_namespace_sync)
+
+ namespace.all_projects.each_batch(of: MERGE_REQUEST_SYNC_BATCH_SIZE) do |projects, index|
+ JiraConnect::SyncProjectWorker.bulk_perform_in_with_contexts(
+ index * MERGE_REQUEST_SYNC_BATCH_DELAY,
+ projects,
+ arguments_proc: -> (project) { [project.id, Atlassian::JiraConnect::Client.generate_update_sequence_id] },
+ context_proc: -> (project) { { project: project } }
+ )
+ end
+ end
end
end
diff --git a/app/services/jira_import/users_importer.rb b/app/services/jira_import/users_importer.rb
index 9babd468d56..438a74343a5 100644
--- a/app/services/jira_import/users_importer.rb
+++ b/app/services/jira_import/users_importer.rb
@@ -2,8 +2,6 @@
module JiraImport
class UsersImporter
- attr_reader :user, :project, :start_at
-
def initialize(user, project, start_at)
@project = project
@start_at = start_at
@@ -23,6 +21,8 @@ module JiraImport
private
+ attr_reader :user, :project, :start_at
+
def mapped_users
users_mapper_service.execute
end
@@ -44,9 +44,9 @@ module JiraImport
# TODO: use deployment_type enum from jira service when https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37003 is merged
case deployment_type.upcase
when JiraService::DEPLOYMENT_TYPES[:server]
- ServerUsersMapperService.new(project.jira_service, start_at)
+ ServerUsersMapperService.new(user, project, start_at)
when JiraService::DEPLOYMENT_TYPES[:cloud]
- CloudUsersMapperService.new(project.jira_service, start_at)
+ CloudUsersMapperService.new(user, project, start_at)
else
raise ArgumentError
end
diff --git a/app/services/jira_import/users_mapper_service.rb b/app/services/jira_import/users_mapper_service.rb
index 480c034f952..6c8610bfbf3 100644
--- a/app/services/jira_import/users_mapper_service.rb
+++ b/app/services/jira_import/users_mapper_service.rb
@@ -2,30 +2,37 @@
module JiraImport
class UsersMapperService
+ include Gitlab::Utils::StrongMemoize
+
# MAX_USERS must match the pageSize value in app/assets/javascripts/jira_import/utils/constants.js
MAX_USERS = 50
- attr_reader :jira_service, :start_at
-
- def initialize(jira_service, start_at)
- @jira_service = jira_service
+ # The class is called from UsersImporter and small batches of users are expected
+ # In case the mapping of a big batch of users is expected to be passed here
+ # the implementation needs to change here and handles the matching in batches
+ def initialize(current_user, project, start_at)
+ @current_user = current_user
+ @project = project
+ @jira_service = project.jira_service
@start_at = start_at
end
def execute
- users.to_a.map do |jira_user|
+ jira_users.to_a.map do |jira_user|
{
jira_account_id: jira_user_id(jira_user),
jira_display_name: jira_user_name(jira_user),
jira_email: jira_user['emailAddress']
- }.merge(match_user(jira_user))
+ }.merge(gitlab_id: find_gitlab_id(jira_user))
end
end
private
- def users
- @users ||= client.get(url)
+ attr_reader :current_user, :project, :jira_service, :start_at
+
+ def jira_users
+ @jira_users ||= client.get(url)
end
def client
@@ -44,10 +51,33 @@ module JiraImport
raise NotImplementedError
end
- # TODO: Matching user by email and displayName will be done as the part
- # of follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/219023
- def match_user(jira_user)
- { gitlab_id: nil, gitlab_username: nil, gitlab_name: nil }
+ def matched_users
+ strong_memoize(:matched_users) do
+ jira_emails = jira_users.map { |u| u['emailAddress']&.downcase }.compact
+ jira_names = jira_users.map { |u| jira_user_name(u)&.downcase }.compact
+
+ relations = []
+ relations << User.by_username(jira_names).select("users.id, users.name, users.username, users.email as user_email")
+ relations << User.by_name(jira_names).select("users.id, users.name, users.username, users.email as user_email")
+ relations << User.by_user_email(jira_emails).select("users.id, users.name, users.username, users.email as user_email")
+ relations << User.by_emails(jira_emails).select("users.id, users.name, users.username, emails.email as user_email")
+
+ User.from_union(relations).id_in(project_member_ids).select("users.id as user_id, users.name as name, users.username as username, user_email")
+ end
+ end
+
+ def find_gitlab_id(jira_user)
+ user = matched_users.find do |matched_user|
+ matched_user.user_email&.downcase == jira_user['emailAddress']&.downcase ||
+ matched_user.name&.downcase == jira_user_name(jira_user)&.downcase ||
+ matched_user.username&.downcase == jira_user_name(jira_user)&.downcase
+ end
+
+ user&.user_id
+ end
+
+ def project_member_ids
+ @project_member_ids ||= MembersFinder.new(project, current_user).execute.select(:user_id)
end
end
end
diff --git a/app/services/labels/promote_service.rb b/app/services/labels/promote_service.rb
index 9ed10f6a11b..fdf2cf13f92 100644
--- a/app/services/labels/promote_service.rb
+++ b/app/services/labels/promote_service.rb
@@ -10,81 +10,79 @@ module Labels
label.is_a?(ProjectLabel)
Label.transaction do
- new_label = clone_label_to_group_label(label)
+ # use the existing group label if it exists
+ group_label = find_or_create_group_label(label)
- label_ids_for_merge(new_label).find_in_batches(batch_size: BATCH_SIZE) do |batched_ids|
- update_old_label_relations(new_label, batched_ids)
+ label_ids_for_merge(group_label).find_in_batches(batch_size: BATCH_SIZE) do |batched_ids|
+ update_old_label_relations(group_label, batched_ids)
destroy_project_labels(batched_ids)
end
- # We skipped validations during creation. Let's run them now, after deleting conflicting labels
- raise ActiveRecord::RecordInvalid.new(new_label) unless new_label.valid?
-
- new_label
+ group_label
end
end
# rubocop: enable CodeReuse/ActiveRecord
private
- def update_old_label_relations(new_label, old_label_ids)
- update_issuables(new_label, old_label_ids)
- update_resource_label_events(new_label, old_label_ids)
- update_issue_board_lists(new_label, old_label_ids)
- update_priorities(new_label, old_label_ids)
- subscribe_users(new_label, old_label_ids)
+ def update_old_label_relations(group_label, old_label_ids)
+ update_issuables(group_label, old_label_ids)
+ update_resource_label_events(group_label, old_label_ids)
+ update_issue_board_lists(group_label, old_label_ids)
+ update_priorities(group_label, old_label_ids)
+ subscribe_users(group_label, old_label_ids)
end
# rubocop: disable CodeReuse/ActiveRecord
- def subscribe_users(new_label, label_ids)
+ def subscribe_users(group_label, label_ids)
# users can be subscribed to multiple labels that will be merged into the group one
# we want to keep only one subscription / user
ids_to_update = Subscription.where(subscribable_id: label_ids, subscribable_type: 'Label')
.group(:user_id)
.pluck('MAX(id)')
- Subscription.where(id: ids_to_update).update_all(subscribable_id: new_label.id)
+ Subscription.where(id: ids_to_update).update_all(subscribable_id: group_label.id)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
- def label_ids_for_merge(new_label)
+ def label_ids_for_merge(group_label)
LabelsFinder
- .new(current_user, title: new_label.title, group_id: project.group.id)
+ .new(current_user, title: group_label.title, group_id: project.group.id)
.execute(skip_authorization: true)
- .where.not(id: new_label)
+ .where.not(id: group_label)
.select(:id) # Can't use pluck() to avoid object-creation because of the batching
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
- def update_issuables(new_label, label_ids)
+ def update_issuables(group_label, label_ids)
LabelLink
.where(label: label_ids)
- .update_all(label_id: new_label.id)
+ .update_all(label_id: group_label.id)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
- def update_resource_label_events(new_label, label_ids)
+ def update_resource_label_events(group_label, label_ids)
ResourceLabelEvent
.where(label: label_ids)
- .update_all(label_id: new_label.id)
+ .update_all(label_id: group_label.id)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
- def update_issue_board_lists(new_label, label_ids)
+ def update_issue_board_lists(group_label, label_ids)
List
.where(label: label_ids)
- .update_all(label_id: new_label.id)
+ .update_all(label_id: group_label.id)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
- def update_priorities(new_label, label_ids)
+ def update_priorities(group_label, label_ids)
LabelPriority
.where(label: label_ids)
- .update_all(label_id: new_label.id)
+ .update_all(label_id: group_label.id)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -92,18 +90,12 @@ module Labels
def destroy_project_labels(label_ids)
Label.where(id: label_ids).destroy_all # rubocop: disable Cop/DestroyAll
end
- # rubocop: enable CodeReuse/ActiveRecord
- def clone_label_to_group_label(label)
+ def find_or_create_group_label(label)
params = label.attributes.slice('title', 'description', 'color')
- # Since the title of the new label has to be the same as the previous labels
- # and we're merging old labels in batches we'll skip validation to omit 2-step
- # merge process and do it in one batch
- # We'll be forcing validation at the end of the transaction to ensure everything
- # was merged correctly
- new_label = GroupLabel.new(params.merge(group: project.group))
- new_label.save(validate: false)
+ new_label = GroupLabel.create_with(params).find_or_initialize_by(group_id: project.group.id, title: label.title)
+ new_label.save! unless new_label.persisted?
new_label
end
end
diff --git a/app/services/members/invite_service.rb b/app/services/members/invite_service.rb
new file mode 100644
index 00000000000..cfab5c3ef9d
--- /dev/null
+++ b/app/services/members/invite_service.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+module Members
+ class InviteService < Members::BaseService
+ DEFAULT_LIMIT = 100
+
+ attr_reader :errors
+
+ def initialize(current_user, params)
+ @current_user, @params = current_user, params.dup
+ @errors = {}
+ end
+
+ def execute(source)
+ return error(s_('Email cannot be blank')) if params[:email].blank?
+
+ emails = params[:email].split(',').uniq.flatten
+ return error(s_("Too many users specified (limit is %{user_limit})") % { user_limit: user_limit }) if
+ user_limit && emails.size > user_limit
+
+ emails.each do |email|
+ next if existing_member?(source, email)
+
+ next if existing_invite?(source, email)
+
+ if existing_user?(email)
+ add_existing_user_as_member(current_user, source, params, email)
+ next
+ end
+
+ invite_new_member_and_user(current_user, source, params, email)
+ end
+
+ return success unless errors.any?
+
+ error(errors)
+ end
+
+ private
+
+ def invite_new_member_and_user(current_user, source, params, email)
+ new_member = (source.class.name + 'Member').constantize.create(source_id: source.id,
+ user_id: nil,
+ access_level: params[:access_level],
+ invite_email: email,
+ created_by_id: current_user.id,
+ expires_at: params[:expires_at],
+ requested_at: Time.current.utc)
+
+ unless new_member.valid? && new_member.persisted?
+ errors[params[:email]] = new_member.errors.full_messages.to_sentence
+ end
+ end
+
+ def add_existing_user_as_member(current_user, source, params, email)
+ new_member = create_member(current_user, existing_user(email), source, params.merge({ invite_email: email }))
+
+ unless new_member.valid? && new_member.persisted?
+ errors[email] = new_member.errors.full_messages.to_sentence
+ end
+ end
+
+ def create_member(current_user, user, source, params)
+ source.add_user(user, params[:access_level], current_user: current_user, expires_at: params[:expires_at])
+ end
+
+ def user_limit
+ limit = params.fetch(:limit, DEFAULT_LIMIT)
+
+ limit && limit < 0 ? nil : limit
+ end
+
+ def existing_member?(source, email)
+ existing_member = source.members.with_user_by_email(email).exists?
+
+ if existing_member
+ errors[email] = "Already a member of #{source.name}"
+ return true
+ end
+
+ false
+ end
+
+ def existing_invite?(source, email)
+ existing_invite = source.members.search_invite_email(email).exists?
+
+ if existing_invite
+ errors[email] = "Member already invited to #{source.name}"
+ return true
+ end
+
+ false
+ end
+
+ def existing_user(email)
+ User.find_by_email(email)
+ end
+
+ def existing_user?(email)
+ existing_user(email).present?
+ end
+ end
+end
diff --git a/app/services/merge_requests/cleanup_refs_service.rb b/app/services/merge_requests/cleanup_refs_service.rb
index d003124a112..23ac8e393f4 100644
--- a/app/services/merge_requests/cleanup_refs_service.rb
+++ b/app/services/merge_requests/cleanup_refs_service.rb
@@ -9,7 +9,7 @@ module MergeRequests
attr_reader :merge_request
def self.schedule(merge_request)
- MergeRequestCleanupRefsWorker.perform_in(TIME_THRESHOLD, merge_request.id)
+ merge_request.create_cleanup_schedule(scheduled_at: TIME_THRESHOLD.from_now)
end
def initialize(merge_request)
@@ -22,6 +22,7 @@ module MergeRequests
end
def execute
+ return error("Merge request is not scheduled to be cleaned up yet.") unless scheduled?
return error("Merge request has not been closed nor merged for #{TIME_THRESHOLD.inspect}.") unless eligible?
# Ensure that commit shas of refs are kept around so we won't lose them when GC runs.
@@ -30,7 +31,10 @@ module MergeRequests
return error('Failed to create keep around refs.') unless kept_around?
return error('Failed to cache merge ref sha.') unless cache_merge_ref_sha
- delete_refs
+ delete_refs if repository.exists?
+
+ return error('Failed to update schedule.') unless update_schedule
+
success
end
@@ -38,6 +42,10 @@ module MergeRequests
attr_reader :repository, :ref_path, :merge_ref_path, :ref_head_sha, :merge_ref_sha
+ def scheduled?
+ merge_request.cleanup_schedule.present? && merge_request.cleanup_schedule.scheduled_at <= Time.current
+ end
+
def eligible?
return met_time_threshold?(merge_request.metrics&.latest_closed_at) if merge_request.closed?
@@ -71,5 +79,9 @@ module MergeRequests
def delete_refs
repository.delete_refs(ref_path, merge_ref_path)
end
+
+ def update_schedule
+ merge_request.cleanup_schedule.update(completed_at: Time.current)
+ end
end
end
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index e5d0b216d6c..ed977a5a872 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -42,7 +42,7 @@ module MergeRequests
end
notify_about_push(mr)
- mark_mr_as_wip_from_commits(mr)
+ mark_mr_as_draft_from_commits(mr)
execute_mr_web_hooks(mr)
end
@@ -246,7 +246,7 @@ module MergeRequests
notification_service.push_to_merge_request(merge_request, @current_user, new_commits: new_commits, existing_commits: existing_commits)
end
- def mark_mr_as_wip_from_commits(merge_request)
+ def mark_mr_as_draft_from_commits(merge_request)
return unless @commits.present?
commit_shas = merge_request.commit_shas
@@ -257,7 +257,7 @@ module MergeRequests
if wip_commit && !merge_request.work_in_progress?
merge_request.update(title: merge_request.wip_title)
- SystemNoteService.add_merge_request_wip_from_commit(
+ SystemNoteService.add_merge_request_draft_from_commit(
merge_request,
merge_request.project,
@current_user,
diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb
index f87005bcb6c..bcedbc61c65 100644
--- a/app/services/merge_requests/reopen_service.rb
+++ b/app/services/merge_requests/reopen_service.rb
@@ -15,6 +15,8 @@ module MergeRequests
invalidate_cache_counts(merge_request, users: merge_request.assignees)
merge_request.update_project_counter_caches
merge_request.cache_merge_request_closes_issues!(current_user)
+ merge_request.cleanup_schedule&.destroy
+ merge_request.update_column(:merge_ref_sha, nil)
end
merge_request
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 48f44affb23..b2826b5c905 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -73,6 +73,8 @@ module Notes
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
+
+ track_note_creation_usage_for_issues(note) if note.for_issue?
end
def do_commands(note, update_params, message, only_commands)
@@ -113,5 +115,9 @@ module Notes
track_usage_event(:incident_management_incident_comment, user.id)
end
+
+ def track_note_creation_usage_for_issues(note)
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_added_action(author: note.author)
+ end
end
end
diff --git a/app/services/notes/destroy_service.rb b/app/services/notes/destroy_service.rb
index ee8a680fcb4..2b6ec47eaef 100644
--- a/app/services/notes/destroy_service.rb
+++ b/app/services/notes/destroy_service.rb
@@ -8,6 +8,13 @@ module Notes
end
clear_noteable_diffs_cache(note)
+ track_note_removal_usage_for_issues(note) if note.for_issue?
+ end
+
+ private
+
+ def track_note_removal_usage_for_issues(note)
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_removed_action(author: note.author)
end
end
end
diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb
index 193d3080078..37872f7fbdb 100644
--- a/app/services/notes/update_service.rb
+++ b/app/services/notes/update_service.rb
@@ -14,6 +14,8 @@ module Notes
note.save
end
+ track_note_edit_usage_for_issues(note) if note.for_issue?
+
only_commands = false
quick_actions_service = QuickActionsService.new(project, current_user)
@@ -89,6 +91,10 @@ module Notes
Note.id_in(note.discussion.notes.map(&:id)).update_all(confidential: params[:confidential])
end
+
+ def track_note_edit_usage_for_issues(note)
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_edited_action(author: note.author)
+ end
end
end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 7853ad11c64..85113d3ca22 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -370,6 +370,16 @@ class NotificationService
end
end
+ def new_instance_access_request(user)
+ recipients = User.instance_access_request_approvers_to_be_notified # https://gitlab.com/gitlab-org/gitlab/-/issues/277016 will change this
+
+ return true if recipients.empty?
+
+ recipients.each do |recipient|
+ mailer.instance_access_request_email(user, recipient).deliver_later
+ end
+ end
+
# Members
def new_access_request(member)
return true unless member.notifiable?(:subscription)
@@ -601,7 +611,7 @@ class NotificationService
return if project.emails_disabled?
owners_and_maintainers_without_invites(project).to_a.product(alerts).each do |recipient, alert|
- mailer.prometheus_alert_fired_email(project.id, recipient.user.id, alert).deliver_later
+ mailer.prometheus_alert_fired_email(project, recipient.user, alert).deliver_later
end
end
diff --git a/app/services/packages/composer/create_package_service.rb b/app/services/packages/composer/create_package_service.rb
index 7e16fc78599..2d2f1568187 100644
--- a/app/services/packages/composer/create_package_service.rb
+++ b/app/services/packages/composer/create_package_service.rb
@@ -10,11 +10,11 @@ module Packages
composer_json
::Packages::Package.transaction do
- ::Packages::Composer::Metadatum.upsert(
+ ::Packages::Composer::Metadatum.upsert({
package_id: created_package.id,
target_sha: target,
composer_json: composer_json
- )
+ })
end
end
diff --git a/app/services/packages/composer/version_parser_service.rb b/app/services/packages/composer/version_parser_service.rb
index 76dfd7a14bd..811cac0b3b7 100644
--- a/app/services/packages/composer/version_parser_service.rb
+++ b/app/services/packages/composer/version_parser_service.rb
@@ -9,7 +9,7 @@ module Packages
def execute
if @tag_name.present?
- @tag_name.match(Gitlab::Regex.composer_package_version_regex).captures[0]
+ @tag_name.delete_prefix('v')
elsif @branch_name.present?
branch_sufix_or_prefix(@branch_name.match(Gitlab::Regex.composer_package_version_regex))
end
diff --git a/app/services/packages/create_event_service.rb b/app/services/packages/create_event_service.rb
index d009cba2812..8350ff993bf 100644
--- a/app/services/packages/create_event_service.rb
+++ b/app/services/packages/create_event_service.rb
@@ -3,18 +3,30 @@
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
- )
+ if Feature.enabled?(:collect_package_events_redis) && redis_event_name
+ ::Gitlab::UsageDataCounters::HLLRedisCounter.track_event(current_user.id, redis_event_name)
+ end
+
+ if Feature.enabled?(:collect_package_events)
+ ::Packages::Event.create!(
+ event_type: event_name,
+ originator: current_user&.id,
+ originator_type: originator_type,
+ event_scope: event_scope
+ )
+ end
end
private
+ def redis_event_name
+ @redis_event_name ||= ::Packages::Event.allowed_event_name(event_scope, event_name, originator_type)
+ end
+
+ def event_scope
+ @event_scope ||= scope.is_a?(::Packages::Package) ? scope.package_type : scope
+ end
+
def scope
params[:scope]
end
diff --git a/app/services/packages/create_package_file_service.rb b/app/services/packages/create_package_file_service.rb
index 0ebceeee779..5723b0b4717 100644
--- a/app/services/packages/create_package_file_service.rb
+++ b/app/services/packages/create_package_file_service.rb
@@ -9,7 +9,7 @@ module Packages
end
def execute
- package.package_files.create!(
+ package_file = package.package_files.build(
file: params[:file],
size: params[:size],
file_name: params[:file_name],
@@ -17,6 +17,13 @@ module Packages
file_sha256: params[:file_sha256],
file_md5: params[:file_md5]
)
+
+ if params[:build].present?
+ package_file.package_file_build_infos << package_file.package_file_build_infos.build(pipeline: params[:build].pipeline)
+ end
+
+ package_file.save!
+ package_file
end
end
end
diff --git a/app/services/packages/debian/extract_deb_metadata_service.rb b/app/services/packages/debian/extract_deb_metadata_service.rb
new file mode 100644
index 00000000000..eb106f4cd30
--- /dev/null
+++ b/app/services/packages/debian/extract_deb_metadata_service.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Packages
+ module Debian
+ # Returns .deb file metadata
+ class ExtractDebMetadataService
+ CommandFailedError = Class.new(StandardError)
+
+ def initialize(file_path)
+ @file_path = file_path
+ end
+
+ def execute
+ unless success?
+ raise CommandFailedError, "The `#{cmd}` command failed (status: #{result.status}) with the following error:\n#{result.stderr}"
+ end
+
+ sections = ParseDebian822Service.new(result.stdout).execute
+
+ sections.each_value.first
+ end
+
+ private
+
+ def cmd
+ @cmd ||= begin
+ dpkg_deb_path = Gitlab.config.packages.dpkg_deb_path
+ [dpkg_deb_path, '--field', @file_path]
+ end
+ end
+
+ def result
+ @result ||= Gitlab::Popen.popen_with_detail(cmd)
+ end
+
+ def success?
+ result.status&.exitstatus == 0
+ end
+ end
+ end
+end
diff --git a/app/services/packages/debian/parse_debian822_service.rb b/app/services/packages/debian/parse_debian822_service.rb
new file mode 100644
index 00000000000..665929d2324
--- /dev/null
+++ b/app/services/packages/debian/parse_debian822_service.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+module Packages
+ module Debian
+ # Parse String as Debian RFC822 control data format
+ # https://manpages.debian.org/unstable/dpkg-dev/deb822.5
+ class ParseDebian822Service
+ InvalidDebian822Error = Class.new(StandardError)
+
+ def initialize(input)
+ @input = input
+ end
+
+ def execute
+ output = {}
+ @input.each_line('', chomp: true) do |block|
+ section = {}
+ section_name, field = nil
+ block.each_line(chomp: true) do |line|
+ next if comment_line?(line)
+
+ if continuation_line?(line)
+ raise InvalidDebian822Error, "Parse error. Unexpected continuation line" if field.nil?
+
+ section[field] += "\n"
+ section[field] += line[1..] unless paragraph_separator?(line)
+ elsif match = match_section_line(line)
+ section_name = match[:name] if section_name.nil?
+ field = match[:field].to_sym
+
+ raise InvalidDebian822Error, "Duplicate field '#{field}' in section '#{section_name}'" if section.include?(field)
+
+ section[field] = match[:value]
+ else
+ raise InvalidDebian822Error, "Parse error on line #{line}"
+ end
+ end
+
+ raise InvalidDebian822Error, "Duplicate section '#{section_name}'" if output[section_name]
+
+ output[section_name] = section
+ end
+
+ output
+ end
+
+ private
+
+ def comment_line?(line)
+ line.match?(/^#/)
+ end
+
+ def continuation_line?(line)
+ line.match?(/^ /)
+ end
+
+ def paragraph_separator?(line)
+ line == ' .'
+ end
+
+ def match_section_line(line)
+ line.match(/(?<name>(?<field>^\S+):\s*(?<value>.*))/)
+ end
+ end
+ end
+end
diff --git a/app/services/packages/generic/create_package_file_service.rb b/app/services/packages/generic/create_package_file_service.rb
index 4d49c63799f..f25e8b0ae56 100644
--- a/app/services/packages/generic/create_package_file_service.rb
+++ b/app/services/packages/generic/create_package_file_service.rb
@@ -28,7 +28,8 @@ module Packages
file: params[:file],
size: params[:file].size,
file_sha256: params[:file].sha256,
- file_name: params[:file_name]
+ file_name: params[:file_name],
+ build: params[:build]
}
::Packages::CreatePackageFileService.new(package, file_params).execute
diff --git a/app/services/packages/generic/find_or_create_package_service.rb b/app/services/packages/generic/find_or_create_package_service.rb
index 8a8459d167e..97f774a836b 100644
--- a/app/services/packages/generic/find_or_create_package_service.rb
+++ b/app/services/packages/generic/find_or_create_package_service.rb
@@ -6,7 +6,7 @@ module Packages
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)
+ package.build_infos.new(pipeline: params[:build].pipeline)
end
end
end
diff --git a/app/services/packages/maven/create_package_service.rb b/app/services/packages/maven/create_package_service.rb
index 3df17021499..540c7b1d4da 100644
--- a/app/services/packages/maven/create_package_service.rb
+++ b/app/services/packages/maven/create_package_service.rb
@@ -6,7 +6,7 @@ module Packages
app_group, _, app_name = params[:name].rpartition('/')
app_group.tr!('/', '.')
- package = create_package!(:maven,
+ create_package!(:maven,
maven_metadatum_attributes: {
path: params[:path],
app_group: app_group,
@@ -14,11 +14,6 @@ module Packages
app_version: params[:version]
}
)
-
- build = params[:build]
- package.create_build_info!(pipeline: build.pipeline) if build.present?
-
- package
end
end
end
diff --git a/app/services/packages/maven/find_or_create_package_service.rb b/app/services/packages/maven/find_or_create_package_service.rb
index 505f45a7b21..a2a61ff8d93 100644
--- a/app/services/packages/maven/find_or_create_package_service.rb
+++ b/app/services/packages/maven/find_or_create_package_service.rb
@@ -38,8 +38,7 @@ module Packages
package_params = {
name: package_name,
path: params[:path],
- version: version,
- build: params[:build]
+ version: version
}
package =
@@ -47,6 +46,8 @@ module Packages
.execute
end
+ package.build_infos.create!(pipeline: params[:build].pipeline) if params[:build].present?
+
package
end
end
diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb
index 7f868b71734..c4b75348bba 100644
--- a/app/services/packages/npm/create_package_service.rb
+++ b/app/services/packages/npm/create_package_service.rb
@@ -18,7 +18,7 @@ module Packages
package = create_package!(:npm, name: name, version: version)
if build.present?
- package.create_build_info!(pipeline: build.pipeline)
+ package.build_infos.create!(pipeline: build.pipeline)
end
::Packages::CreatePackageFileService.new(package, file_params).execute
@@ -75,7 +75,8 @@ module Packages
file: CarrierWaveStringFile.new(Base64.decode64(attachment['data'])),
size: attachment['length'],
file_sha1: version_data[:dist][:shasum],
- file_name: package_file_name
+ file_name: package_file_name,
+ build: params[:build]
}
end
diff --git a/app/services/pages/destroy_deployments_service.rb b/app/services/pages/destroy_deployments_service.rb
new file mode 100644
index 00000000000..45d906bec7a
--- /dev/null
+++ b/app/services/pages/destroy_deployments_service.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Pages
+ class DestroyDeploymentsService
+ def initialize(project, last_deployment_id = nil)
+ @project = project
+ @last_deployment_id = last_deployment_id
+ end
+
+ def execute
+ deployments_to_destroy = @project.pages_deployments
+ deployments_to_destroy = deployments_to_destroy.older_than(@last_deployment_id) if @last_deployment_id
+ deployments_to_destroy.find_each(&:destroy) # rubocop: disable CodeReuse/ActiveRecord
+ end
+ end
+end
diff --git a/app/services/personal_access_tokens/create_service.rb b/app/services/personal_access_tokens/create_service.rb
index ff9bb7d6802..93a0135669f 100644
--- a/app/services/personal_access_tokens/create_service.rb
+++ b/app/services/personal_access_tokens/create_service.rb
@@ -2,23 +2,30 @@
module PersonalAccessTokens
class CreateService < BaseService
- def initialize(current_user, params = {})
+ def initialize(current_user:, target_user:, params: {})
@current_user = current_user
+ @target_user = target_user
@params = params.dup
+ @ip_address = @params.delete(:ip_address)
end
def execute
- personal_access_token = current_user.personal_access_tokens.create(params.slice(*allowed_params))
+ return ServiceResponse.error(message: 'Not permitted to create') unless creation_permitted?
- if personal_access_token.persisted?
- ServiceResponse.success(payload: { personal_access_token: personal_access_token })
+ token = target_user.personal_access_tokens.create(params.slice(*allowed_params))
+
+ if token.persisted?
+ log_event(token)
+ ServiceResponse.success(payload: { personal_access_token: token })
else
- ServiceResponse.error(message: personal_access_token.errors.full_messages.to_sentence)
+ ServiceResponse.error(message: token.errors.full_messages.to_sentence, payload: { personal_access_token: token })
end
end
private
+ attr_reader :target_user, :ip_address
+
def allowed_params
[
:name,
@@ -27,5 +34,15 @@ module PersonalAccessTokens
:expires_at
]
end
+
+ def creation_permitted?
+ Ability.allowed?(current_user, :create_user_personal_access_token, target_user)
+ end
+
+ def log_event(token)
+ log_info("PAT CREATION: created_by: '#{current_user.username}', created_for: '#{token.user.username}', token_id: '#{token.id}'")
+ end
end
end
+
+PersonalAccessTokens::CreateService.prepend_if_ee('EE::PersonalAccessTokens::CreateService')
diff --git a/app/services/personal_access_tokens/revoke_service.rb b/app/services/personal_access_tokens/revoke_service.rb
index 17405002d8d..34d542acab1 100644
--- a/app/services/personal_access_tokens/revoke_service.rb
+++ b/app/services/personal_access_tokens/revoke_service.rb
@@ -4,16 +4,17 @@ module PersonalAccessTokens
class RevokeService
attr_reader :token, :current_user, :group
- def initialize(current_user = nil, params = { token: nil, group: nil })
+ def initialize(current_user = nil, token: nil, group: nil )
@current_user = current_user
- @token = params[:token]
- @group = params[:group]
+ @token = token
+ @group = group
end
def execute
return ServiceResponse.error(message: 'Not permitted to revoke') unless revocation_permitted?
if token.revoke!
+ log_event
ServiceResponse.success(message: success_message)
else
ServiceResponse.error(message: error_message)
@@ -33,6 +34,10 @@ module PersonalAccessTokens
def revocation_permitted?
Ability.allowed?(current_user, :revoke_token, token)
end
+
+ def log_event
+ Gitlab::AppLogger.info("PAT REVOCATION: revoked_by: '#{current_user.username}', revoked_for: '#{token.user.username}', token_id: '#{token.id}'")
+ end
end
end
diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb
index 69c9868c75c..79b613f6a88 100644
--- a/app/services/post_receive_service.rb
+++ b/app/services/post_receive_service.rb
@@ -29,9 +29,7 @@ class PostReceiveService
response.add_alert_message(message)
end
- broadcast_message = BroadcastMessage.current_banner_messages&.last&.message
response.add_alert_message(broadcast_message)
-
response.add_merge_request_urls(merge_request_urls)
# Neither User nor Project are guaranteed to be returned; an orphaned write deploy
@@ -74,6 +72,24 @@ class PostReceiveService
::MergeRequests::GetUrlsService.new(project).execute(params[:changes])
end
+
+ private
+
+ def broadcast_message
+ banner = nil
+
+ if project
+ scoped_messages = BroadcastMessage.current_banner_messages(project.full_path).select do |message|
+ message.target_path.present? && message.matches_current_path(project.full_path)
+ end
+
+ banner = scoped_messages.last
+ end
+
+ banner ||= BroadcastMessage.current_banner_messages.last
+
+ banner&.message
+ end
end
PostReceiveService.prepend_if_ee('EE::PostReceiveService')
diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb
index affac45fc3d..ab8f53a3757 100644
--- a/app/services/projects/alerting/notify_service.rb
+++ b/app/services/projects/alerting/notify_service.rb
@@ -6,9 +6,11 @@ module Projects
include Gitlab::Utils::StrongMemoize
include ::IncidentManagement::Settings
- def execute(token)
+ def execute(token, integration = nil)
+ @integration = integration
+
return bad_request unless valid_payload_size?
- return forbidden unless alerts_service_activated?
+ return forbidden unless active_integration?
return unauthorized unless valid_token?(token)
process_alert
@@ -22,7 +24,7 @@ module Projects
private
- delegate :alerts_service, :alerts_service_activated?, to: :project
+ attr_reader :integration
def process_alert
if alert.persisted?
@@ -66,14 +68,11 @@ module Projects
return unless alert.save
alert.execute_services
- SystemNoteService.create_new_alert(
- alert,
- alert.monitoring_tool || 'Generic Alert Endpoint'
- )
+ SystemNoteService.create_new_alert(alert, notification_source)
end
def process_incident_issues
- return if alert.issue
+ return if alert.issue || alert.resolved?
::IncidentManagement::ProcessAlertWorker.perform_async(nil, nil, alert.id)
end
@@ -81,7 +80,7 @@ module Projects
def send_alert_email
notification_service
.async
- .prometheus_alerts_fired(project, [alert.attributes])
+ .prometheus_alerts_fired(project, [alert])
end
def alert
@@ -106,12 +105,20 @@ module Projects
end
end
+ def notification_source
+ alert.monitoring_tool || integration&.name || 'Generic Alert Endpoint'
+ end
+
def valid_payload_size?
Gitlab::Utils::DeepSize.new(params).valid?
end
+ def active_integration?
+ integration&.active?
+ end
+
def valid_token?(token)
- token == alerts_service.token
+ token == integration.token
end
def bad_request
diff --git a/app/services/projects/cleanup_service.rb b/app/services/projects/cleanup_service.rb
index 4ced9feff00..6e3b320afbe 100644
--- a/app/services/projects/cleanup_service.rb
+++ b/app/services/projects/cleanup_service.rb
@@ -11,6 +11,24 @@ module Projects
include Gitlab::Utils::StrongMemoize
+ class << self
+ def enqueue(project, current_user, bfg_object_map)
+ Projects::UpdateService.new(project, current_user, bfg_object_map: bfg_object_map).execute.tap do |result|
+ next unless result[:status] == :success
+
+ project.set_repository_read_only!
+ RepositoryCleanupWorker.perform_async(project.id, current_user.id)
+ end
+ rescue Project::RepositoryReadOnlyError => err
+ { status: :error, message: (_('Failed to make repository read-only. %{reason}') % { reason: err.message }) }
+ end
+
+ def cleanup_after(project)
+ project.bfg_object_map.remove!
+ project.set_repository_writable!
+ end
+ end
+
# Attempt to clean up the project following the push. Warning: this is
# destructive!
#
@@ -22,14 +40,14 @@ module Projects
apply_bfg_object_map!
# Remove older objects that are no longer referenced
- GitGarbageCollectWorker.new.perform(project.id, :gc, "project_cleanup:gc:#{project.id}")
+ GitGarbageCollectWorker.new.perform(project.id, :prune, "project_cleanup:gc:#{project.id}")
# The cache may now be inaccurate, and holding onto it could prevent
# bugs assuming the presence of some object from manifesting for some
# time. Better to feel the pain immediately.
project.repository.expire_all_method_caches
- project.bfg_object_map.remove!
+ self.class.cleanup_after(project)
end
private
diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb
index 9fc3ec0aafb..505ddaf50e3 100644
--- a/app/services/projects/container_repository/delete_tags_service.rb
+++ b/app/services/projects/container_repository/delete_tags_service.rb
@@ -36,11 +36,11 @@ module Projects
def log_response(response)
log_data = LOG_DATA_BASE.merge(
container_repository_id: @container_repository.id,
- message: 'deleted tags'
- )
+ message: 'deleted tags',
+ deleted_tags_count: response[:deleted]&.size
+ ).compact
if response[:status] == :success
- log_data[:deleted_tags_count] = response[:deleted].size
log_info(log_data)
else
log_data[:message] = response[:message]
diff --git a/app/services/projects/container_repository/gitlab/delete_tags_service.rb b/app/services/projects/container_repository/gitlab/delete_tags_service.rb
index cee94b994a3..e4e22dd9543 100644
--- a/app/services/projects/container_repository/gitlab/delete_tags_service.rb
+++ b/app/services/projects/container_repository/gitlab/delete_tags_service.rb
@@ -14,6 +14,7 @@ module Projects
def initialize(container_repository, tag_names)
@container_repository = container_repository
@tag_names = tag_names
+ @deleted_tags = []
end
# Delete tags by name with a single DELETE request. This is only supported
@@ -25,7 +26,7 @@ module Projects
delete_tags
rescue TimeoutError => e
::Gitlab::ErrorTracking.track_exception(e, tags_count: @tag_names&.size, container_repository_id: @container_repository&.id)
- error('timeout while deleting tags')
+ error('timeout while deleting tags', nil, pass_back: { deleted: @deleted_tags })
end
private
@@ -33,13 +34,15 @@ module Projects
def delete_tags
start_time = Time.zone.now
- deleted_tags = @tag_names.select do |name|
+ @tag_names.each do |name|
raise TimeoutError if timeout?(start_time)
- @container_repository.delete_tag_by_name(name)
+ if @container_repository.delete_tag_by_name(name)
+ @deleted_tags.append(name)
+ end
end
- deleted_tags.any? ? success(deleted: deleted_tags) : error('could not delete tags')
+ @deleted_tags.any? ? success(deleted: @deleted_tags) : error('could not delete tags')
end
def timeout?(start_time)
diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb
index 065bf8725be..349d4d367be 100644
--- a/app/services/projects/hashed_storage/base_repository_service.rb
+++ b/app/services/projects/hashed_storage/base_repository_service.rb
@@ -79,13 +79,12 @@ module Projects
end
def try_to_set_repository_read_only!
- # Mitigate any push operation to start during migration
- unless project.set_repository_read_only!
- migration_error = "Target repository '#{old_disk_path}' cannot be made read-only as there is a git transfer in progress"
- logger.error migration_error
+ project.set_repository_read_only!
+ rescue Project::RepositoryReadOnlyError => err
+ migration_error = "Target repository '#{old_disk_path}' cannot be made read-only: #{err.message}"
+ logger.error migration_error
- raise RepositoryInUseError, migration_error
- end
+ raise RepositoryInUseError, migration_error
end
def wiki_path_suffix
diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb
index d6e5b825e13..525f8a25d04 100644
--- a/app/services/projects/lfs_pointers/lfs_download_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_download_service.rb
@@ -22,7 +22,7 @@ module Projects
def execute
return unless project&.lfs_enabled? && lfs_download_object
return error("LFS file with oid #{lfs_oid} has invalid attributes") unless lfs_download_object.valid?
- return link_existing_lfs_object! if Feature.enabled?(:lfs_link_existing_object, project, default_enabled: true) && lfs_size > LARGE_FILE_SIZE && lfs_object
+ return link_existing_lfs_object! if lfs_size > LARGE_FILE_SIZE && lfs_object
wrap_download_errors do
download_lfs_file!
diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb
index c002aca32db..8ad4f59373d 100644
--- a/app/services/projects/prometheus/alerts/notify_service.rb
+++ b/app/services/projects/prometheus/alerts/notify_service.rb
@@ -17,13 +17,12 @@ module Projects
SUPPORTED_VERSION = '4'
- def execute(token)
+ def execute(token, _integration = nil)
return bad_request unless valid_payload_size?
return unprocessable_entity unless self.class.processable?(params)
return unauthorized unless valid_alert_manager_token?(token)
process_prometheus_alerts
- send_alert_email if send_email?
ServiceResponse.success
end
@@ -120,14 +119,6 @@ module Projects
ActiveSupport::SecurityUtils.secure_compare(expected, actual)
end
- def send_alert_email
- return unless firings.any?
-
- notification_service
- .async
- .prometheus_alerts_fired(project, alerts_attributes)
- end
-
def process_prometheus_alerts
alerts.each do |alert|
AlertManagement::ProcessPrometheusAlertService
@@ -136,18 +127,6 @@ 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/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index 64b9eca9014..b9c579a130f 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -12,6 +12,11 @@ module Projects
# as it shares the namespace with groups
TMP_EXTRACT_PATH = '@pages.tmp'
+ # old deployment can be cached by pages daemon
+ # so we need to give pages daemon some time update cache
+ # 10 minutes is enough, but 30 feels safer
+ OLD_DEPLOYMENTS_DESTRUCTION_DELAY = 30.minutes.freeze
+
attr_reader :build
def initialize(project, build)
@@ -97,7 +102,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)
+ create_pages_deployment(artifacts_path, build)
end
rescue SafeZip::Extract::Error => e
raise FailedToExtractError, e.message
@@ -119,19 +124,28 @@ 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)
+ def create_pages_deployment(artifacts_path, build)
+ return unless Feature.enabled?(:zip_pages_deployments, project, default_enabled: true)
+
+ # we're using the full archive and pages daemon needs to read it
+ # so we want the total count from entries, not only "public/" directory
+ # because it better approximates work we need to do before we can serve the site
+ entries_count = build.artifacts_metadata_entry("", recursive: true).entries.count
+ sha256 = build.job_artifacts_archive.file_sha256
+ deployment = nil
File.open(artifacts_path) do |file|
- deployment = project.pages_deployments.create!(file: file)
- project.pages_metadatum.update!(pages_deployment: deployment)
+ deployment = project.pages_deployments.create!(file: file,
+ file_count: entries_count,
+ file_sha256: sha256)
+ project.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)
+ DestroyPagesDeploymentsWorker.perform_in(
+ OLD_DEPLOYMENTS_DESTRUCTION_DELAY,
+ project.id,
+ deployment.id
+ )
end
def latest?
diff --git a/app/services/projects/update_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb
index a479d53a43a..e0d2398bc66 100644
--- a/app/services/projects/update_repository_storage_service.rb
+++ b/app/services/projects/update_repository_storage_service.rb
@@ -54,7 +54,7 @@ module Projects
end
def mirror_repositories
- mirror_repository
+ mirror_repository if project.repository_exists?
if project.wiki.repository_exists?
mirror_repository(type: Gitlab::GlRepository::WIKI)
@@ -92,12 +92,14 @@ module Projects
end
def remove_old_paths
- Gitlab::Git::Repository.new(
- source_storage_name,
- "#{project.disk_path}.git",
- nil,
- nil
- ).remove
+ if project.repository_exists?
+ Gitlab::Git::Repository.new(
+ source_storage_name,
+ "#{project.disk_path}.git",
+ nil,
+ nil
+ ).remove
+ end
if project.wiki.repository_exists?
Gitlab::Git::Repository.new(
diff --git a/app/services/releases/base_service.rb b/app/services/releases/base_service.rb
index 15d040287a3..38ef80ced56 100644
--- a/app/services/releases/base_service.rb
+++ b/app/services/releases/base_service.rb
@@ -81,6 +81,10 @@ module Releases
params.key?(:milestones)
end
+ def execute_hooks(release, action = 'create')
+ release.execute_hooks(action)
+ end
+
# overridden in EE
def project_group_id; end
end
diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb
index 887c2d355ee..deefe559d5d 100644
--- a/app/services/releases/create_service.rb
+++ b/app/services/releases/create_service.rb
@@ -52,6 +52,8 @@ module Releases
notify_create_release(release)
+ execute_hooks(release, 'create')
+
create_evidence!(release, evidence_pipeline)
success(tag: tag, release: release)
diff --git a/app/services/releases/update_service.rb b/app/services/releases/update_service.rb
index 4786d35f31e..4e78120ac05 100644
--- a/app/services/releases/update_service.rb
+++ b/app/services/releases/update_service.rb
@@ -3,11 +3,9 @@
module Releases
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
- return error('Access Denied', 403) unless allowed?
- return error('params is empty', 400) if empty_params?
- return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any?
+ if error = validate
+ return error
+ end
if param_for_milestone_titles_provided?
previous_milestones = release.milestones.map(&:title)
@@ -20,6 +18,7 @@ module Releases
# see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43385
ActiveRecord::Base.transaction do
if release.update(params)
+ execute_hooks(release, 'update')
success(tag: existing_tag, release: release, milestones_updated: milestones_updated?(previous_milestones))
else
error(release.errors.messages || '400 Bad request', 400)
@@ -31,6 +30,14 @@ module Releases
private
+ def validate
+ return error('Tag does not exist', 404) unless existing_tag
+ return error('Release does not exist', 404) unless release
+ return error('Access Denied', 403) unless allowed?
+ return error('params is empty', 400) if empty_params?
+ return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any?
+ end
+
def allowed?
Ability.allowed?(current_user, :update_release, release)
end
diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb
index cdeb57627a8..70e09be9407 100644
--- a/app/services/resource_access_tokens/create_service.rb
+++ b/app/services/resource_access_tokens/create_service.rb
@@ -15,13 +15,20 @@ module ResourceAccessTokens
user = create_user
return error(user.errors.full_messages.to_sentence) unless user.persisted?
- return error("Failed to provide maintainer access") unless provision_access(resource, user)
+
+ member = create_membership(resource, user)
+
+ unless member.persisted?
+ delete_failed_user(user)
+ return error("Could not provision maintainer access to project access token")
+ end
token_response = create_personal_access_token(user)
if token_response.success?
success(token_response.payload[:personal_access_token])
else
+ delete_failed_user(user)
error(token_response.message)
end
end
@@ -43,6 +50,10 @@ module ResourceAccessTokens
Users::CreateService.new(current_user, default_user_params).execute(skip_authorization: true)
end
+ def delete_failed_user(user)
+ DeleteUserWorker.perform_async(current_user.id, user.id, hard_delete: true, skip_authorization: true)
+ end
+
def default_user_params
{
name: params[:name] || "#{resource.name.to_s.humanize} bot",
@@ -72,7 +83,9 @@ module ResourceAccessTokens
end
def create_personal_access_token(user)
- PersonalAccessTokens::CreateService.new(user, personal_access_token_params).execute
+ PersonalAccessTokens::CreateService.new(
+ current_user: user, target_user: user, params: personal_access_token_params
+ ).execute
end
def personal_access_token_params
@@ -88,7 +101,7 @@ module ResourceAccessTokens
Gitlab::Auth.resource_bot_scopes
end
- def provision_access(resource, user)
+ def create_membership(resource, user)
resource.add_user(user, :maintainer, expires_at: params[:expires_at])
end
diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb
index 5f80b07aa59..9038650adb7 100644
--- a/app/services/search/global_service.rb
+++ b/app/services/search/global_service.rb
@@ -16,6 +16,7 @@ module Search
Gitlab::SearchResults.new(current_user,
params[:search],
projects,
+ order_by: params[:order_by],
sort: params[:sort],
filters: { state: params[:state], confidential: params[:confidential] })
end
diff --git a/app/services/search/group_service.rb b/app/services/search/group_service.rb
index e17522dcd68..4b2d8499582 100644
--- a/app/services/search/group_service.rb
+++ b/app/services/search/group_service.rb
@@ -16,6 +16,7 @@ module Search
params[:search],
projects,
group: group,
+ order_by: params[:order_by],
sort: params[:sort],
filters: { state: params[:state], confidential: params[:confidential] }
)
diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb
index d32534303be..e5fc5a7a438 100644
--- a/app/services/search/project_service.rb
+++ b/app/services/search/project_service.rb
@@ -17,6 +17,7 @@ module Search
params[:search],
project: project,
repository_ref: params[:repository_ref],
+ order_by: params[:order_by],
sort: params[:sort],
filters: { confidential: params[:confidential], state: params[:state] }
)
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 3ccd67c8d30..84d7e33c3d0 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -62,7 +62,9 @@ class SearchService
end
def search_objects(preload_method = nil)
- @search_objects ||= redact_unauthorized_results(search_results.objects(scope, page: params[:page], per_page: per_page, preload_method: preload_method))
+ @search_objects ||= redact_unauthorized_results(
+ search_results.objects(scope, page: page, per_page: per_page, preload_method: preload_method)
+ )
end
def search_highlight
@@ -71,6 +73,10 @@ class SearchService
private
+ def page
+ [1, params[:page].to_i].max
+ end
+
def per_page
per_page_param = params[:per_page].to_i
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index d7181883c39..0881be73eaf 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -56,7 +56,7 @@ module Snippets
snippet_saved
rescue => e # Rescuing all because we can receive Creation exceptions, GRPC exceptions, Git exceptions, ...
- log_error(e.message)
+ Gitlab::ErrorTracking.log_exception(e, service: 'Snippets::CreateService')
# If the commit action failed we need to remove the repository if exists
delete_repository(@snippet) if @snippet.repository_exists?
diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb
index 0115cd19287..b982ff98747 100644
--- a/app/services/snippets/update_service.rb
+++ b/app/services/snippets/update_service.rb
@@ -74,7 +74,7 @@ module Snippets
add_snippet_repository_error(snippet: snippet, error: e)
- log_error(e.message)
+ Gitlab::ErrorTracking.log_exception(e, service: 'Snippets::UpdateService')
# If the commit action failed we remove it because
# we don't want to leave empty repositories
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 1a4374f2e94..eacc88f98a3 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -130,12 +130,12 @@ module SystemNoteService
::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).abort_merge_when_pipeline_succeeds(reason)
end
- def handle_merge_request_wip(noteable, project, author)
- ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).handle_merge_request_wip
+ def handle_merge_request_draft(noteable, project, author)
+ ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).handle_merge_request_draft
end
- def add_merge_request_wip_from_commit(noteable, project, author, commit)
- ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).add_merge_request_wip_from_commit(commit)
+ def add_merge_request_draft_from_commit(noteable, project, author, commit)
+ ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).add_merge_request_draft_from_commit(commit)
end
def resolve_all_discussions(merge_request, project, author)
diff --git a/app/services/system_notes/merge_requests_service.rb b/app/services/system_notes/merge_requests_service.rb
index 9b5c9ba20b2..a51e2053394 100644
--- a/app/services/system_notes/merge_requests_service.rb
+++ b/app/services/system_notes/merge_requests_service.rb
@@ -26,16 +26,16 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'merge'))
end
- def handle_merge_request_wip
- prefix = noteable.work_in_progress? ? "marked" : "unmarked"
+ def handle_merge_request_draft
+ action = noteable.work_in_progress? ? "draft" : "ready"
- body = "#{prefix} as a **Work In Progress**"
+ body = "marked this merge request as **#{action}**"
create_note(NoteSummary.new(noteable, project, author, body, action: 'title'))
end
- def add_merge_request_wip_from_commit(commit)
- body = "marked as a **Work In Progress** from #{commit.to_reference(project)}"
+ def add_merge_request_draft_from_commit(commit)
+ body = "marked this merge request as **draft** from #{commit.to_reference(project)}"
create_note(NoteSummary.new(noteable, project, author, body, action: 'title'))
end
diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb
index 4e554dce357..dcd92ac2b8c 100644
--- a/app/services/test_hooks/project_service.rb
+++ b/app/services/test_hooks/project_service.rb
@@ -30,6 +30,8 @@ module TestHooks
pipeline_events_data
when 'wiki_page_events'
wiki_page_events_data
+ when 'releases_events'
+ releases_events_data
end
end
end
diff --git a/app/services/users/approve_service.rb b/app/services/users/approve_service.rb
index 228cfbd6947..27668e9430e 100644
--- a/app/services/users/approve_service.rb
+++ b/app/services/users/approve_service.rb
@@ -15,7 +15,9 @@ module Users
# Please see Devise's implementation of `resend_confirmation_instructions` for detail.
user.resend_confirmation_instructions
user.accept_pending_invitations! if user.active_for_authentication?
+ DeviseMailer.user_admin_approval(user).deliver_later
+ after_approve_hook(user)
success
else
error(user.errors.full_messages.uniq.join('. '))
@@ -26,6 +28,10 @@ module Users
attr_reader :current_user
+ def after_approve_hook(user)
+ # overridden by EE module
+ end
+
def allowed?
can?(current_user, :approve_user)
end
@@ -35,3 +41,5 @@ module Users
end
end
end
+
+Users::ApproveService.prepend_if_ee('EE::Users::ApproveService')
diff --git a/app/services/users/set_status_service.rb b/app/services/users/set_status_service.rb
index 89008368d5f..356c8782af1 100644
--- a/app/services/users/set_status_service.rb
+++ b/app/services/users/set_status_service.rb
@@ -14,7 +14,7 @@ module Users
def execute
return false unless can?(current_user, :update_user_status, target_user)
- if params[:emoji].present? || params[:message].present?
+ if params[:emoji].present? || params[:message].present? || params[:availability].present?
set_status
else
remove_status
@@ -25,6 +25,9 @@ module Users
def set_status
params[:emoji] = UserStatus::DEFAULT_EMOJI if params[:emoji].blank?
+ params.delete(:availability) if params[:availability].blank?
+ return false if params[:availability].present? && UserStatus.availabilities.keys.exclude?(params[:availability])
+
user_status.update(params)
end
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
index d6cb0729d6f..aef07b13cae 100644
--- a/app/services/web_hook_service.rb
+++ b/app/services/web_hook_service.rb
@@ -120,6 +120,7 @@ class WebHookService
@headers ||= begin
{
'Content-Type' => 'application/json',
+ 'User-Agent' => "GitLab/#{Gitlab::VERSION}",
GITLAB_EVENT_HEADER => self.class.hook_to_event(hook_name)
}.tap do |hash|
hash['X-Gitlab-Token'] = Gitlab::Utils.remove_line_breaks(hook.token) if hook.token.present?
diff --git a/app/uploaders/dependency_proxy/file_uploader.rb b/app/uploaders/dependency_proxy/file_uploader.rb
new file mode 100644
index 00000000000..b67a22bae4d
--- /dev/null
+++ b/app/uploaders/dependency_proxy/file_uploader.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class DependencyProxy::FileUploader < GitlabUploader
+ include ObjectStorage::Concern
+
+ storage_options Gitlab.config.dependency_proxy
+
+ alias_method :upload, :model
+
+ def filename
+ model.file_name
+ end
+
+ def store_dir
+ dynamic_segment
+ end
+
+ private
+
+ def dynamic_segment
+ Gitlab::HashedPath.new('dependency_proxy', model.group_id, 'files', model.id, root_hash: model.group_id)
+ end
+end
diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb
index 411d8b2614f..9758d3c87aa 100644
--- a/app/uploaders/gitlab_uploader.rb
+++ b/app/uploaders/gitlab_uploader.rb
@@ -118,6 +118,14 @@ class GitlabUploader < CarrierWave::Uploader::Base
storage.store!(file)
end
+ def url_or_file_path(url_options = {})
+ if file_storage?
+ 'file://' + path
+ else
+ url(url_options)
+ end
+ end
+
private
# Designed to be overridden by child uploaders that have a dynamic path
diff --git a/app/validators/rsa_key_validator.rb b/app/validators/rsa_key_validator.rb
new file mode 100644
index 00000000000..64595454a8c
--- /dev/null
+++ b/app/validators/rsa_key_validator.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+# RsaKeyValidator
+#
+# Custom validator for RSA private keys.
+#
+# class Project < ActiveRecord::Base
+# validates :signing_key, rsa_key: true
+# end
+#
+class RsaKeyValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ unless valid_rsa_key?(value)
+ record.errors.add(attribute, "is not a valid RSA key")
+ end
+ end
+
+ private
+
+ def valid_rsa_key?(value)
+ return false unless value
+
+ OpenSSL::PKey::RSA.new(value)
+ rescue OpenSSL::PKey::RSAError
+ false
+ end
+end
diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml
index 68324425ef9..5c0e544eaad 100644
--- a/app/views/admin/application_settings/_eks.html.haml
+++ b/app/views/admin/application_settings/_eks.html.haml
@@ -3,7 +3,7 @@
.settings-header
%h4
= _('Amazon EKS')
- %button.btn.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
= _('Amazon EKS integration allows you to provision EKS clusters from GitLab.')
diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml
index f0a1fd5e763..1baec07fa25 100644
--- a/app/views/admin/application_settings/_gitpod.html.haml
+++ b/app/views/admin/application_settings/_gitpod.html.haml
@@ -5,10 +5,10 @@
.settings-header
%h4
= _('Gitpod')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- = gitpod_enable_description
+ %integration-help-text{ "id" => "js-gitpod-settings-help-text", "message" => gitpod_enable_description, "message-url" => "https://gitpod.io/" }
= link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information')
diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml
index 4f38ab3ab7a..db0a87c366e 100644
--- a/app/views/admin/application_settings/_outbound.html.haml
+++ b/app/views/admin/application_settings/_outbound.html.haml
@@ -13,9 +13,9 @@
= _('Allow requests to the local network from system hooks')
.form-group
- = f.label :outbound_local_requests_whitelist_raw, class: 'label-bold' do
+ = f.label :outbound_local_requests_allowlist_raw, class: 'label-bold' do
= _('Local IP addresses and domain names that hooks and services may access.')
- = f.text_area :outbound_local_requests_whitelist_raw, placeholder: "example.com, 192.168.1.1", class: 'form-control', rows: 8
+ = f.text_area :outbound_local_requests_allowlist_raw, placeholder: "example.com, 192.168.1.1", class: 'form-control', rows: 8
%span.form-text.text-muted
= _('Requests to these domain(s)/address(es) on the local network will be allowed when local requests from hooks and services are not allowed. IP ranges such as 1:0:0:0:0:0:0:0/124 or 127.0.0.0/28 are supported. Domain wildcards are not supported currently. Use comma, semicolon, or newline to separate multiple entries. The allowlist can hold a maximum of 1000 entries. Domains should use IDNA encoding. Ex: example.com, 192.168.1.1, 127.0.0.0/28, xn--itlab-j1a.com.')
diff --git a/app/views/admin/application_settings/_package_registry.html.haml b/app/views/admin/application_settings/_package_registry.html.haml
index 257a90252cc..8c956a43e22 100644
--- a/app/views/admin/application_settings/_package_registry.html.haml
+++ b/app/views/admin/application_settings/_package_registry.html.haml
@@ -3,7 +3,7 @@
.settings-header
%h4
= _('Package Registry')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _("Settings related to the use and experience of using GitLab's Package Registry.")
diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml
index 324f544a108..30acb773424 100644
--- a/app/views/admin/application_settings/_plantuml.html.haml
+++ b/app/views/admin/application_settings/_plantuml.html.haml
@@ -3,7 +3,7 @@
.settings-header
%h4
= _('PlantUML')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= _('Allow rendering of PlantUML diagrams in Asciidoc documents.')
diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml
index 4a8616beff6..66fd0087c3e 100644
--- a/app/views/admin/application_settings/_signin.html.haml
+++ b/app/views/admin/application_settings/_signin.html.haml
@@ -50,11 +50,11 @@
= f.text_field :home_page_url, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'home_help_block'
%span.form-text.text-muted#home_help_block We will redirect non-logged in users to this page
.form-group
- = f.label :after_sign_out_path, class: 'label-bold'
+ = f.label :after_sign_out_path, _('After sign-out path'), class: 'label-bold'
= f.text_field :after_sign_out_path, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'after_sign_out_path_help_block'
%span.form-text.text-muted#after_sign_out_path_help_block We will redirect users to this page after they sign out
.form-group
- = f.label :sign_in_text, class: 'label-bold'
+ = f.label :sign_in_text, _('Sign-in text'), class: 'label-bold'
= f.text_area :sign_in_text, class: 'form-control', rows: 4
.form-text.text-muted Markdown enabled
= 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 98b49a236a3..c3deb8af99e 100644
--- a/app/views/admin/application_settings/_signup.html.haml
+++ b/app/views/admin/application_settings/_signup.html.haml
@@ -9,19 +9,21 @@
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 :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'
= f.label :send_user_confirmation_email, class: 'form-check-label' do
Send confirmation email on sign-up
+
+ = render_if_exists 'admin/application_settings/new_user_signups_cap', form: f
+
.form-group
= f.label :minimum_password_length, _('Minimum password length (number of characters)'), class: 'label-bold'
= f.number_field :minimum_password_length, class: 'form-control', rows: 4, min: ApplicationSetting::DEFAULT_MINIMUM_PASSWORD_LENGTH, max: Devise.password_length.max
@@ -29,33 +31,33 @@
.form-text.text-muted
= _("See GitLab's %{password_policy_guidelines}").html_safe % { password_policy_guidelines: password_policy_guidelines_link }
.form-group
- = f.label :domain_whitelist, 'Whitelisted domains for sign-ups', class: 'label-bold'
- = f.text_area :domain_whitelist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8
+ = f.label :domain_allowlist, _('Allowed domains for sign-ups'), class: 'label-bold'
+ = f.text_area :domain_allowlist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8
.form-text.text-muted ONLY users with e-mail addresses that match these domain(s) will be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com
.form-group
- = f.label :domain_blacklist_enabled, 'Domain Blacklist', class: 'label-bold'
+ = f.label :domain_denylist_enabled, _('Domain denylist'), class: 'label-bold'
.form-check
- = f.check_box :domain_blacklist_enabled, class: 'form-check-input'
- = f.label :domain_blacklist_enabled, class: 'form-check-label' do
- Enable domain blacklist for sign ups
+ = f.check_box :domain_denylist_enabled, class: 'form-check-input'
+ = f.label :domain_denylist_enabled, class: 'form-check-label' do
+ Enable domain denylist for sign ups
.form-group
.form-check
- = radio_button_tag :blacklist_type, :file, false, class: 'form-check-input'
- = label_tag :blacklist_type_file, class: 'form-check-label' do
+ = radio_button_tag :denylist_type, :file, false, class: 'form-check-input'
+ = label_tag :denylist_type_file, class: 'form-check-label' do
.option-title
- Upload blacklist file
+ Upload denylist file
.form-check
- = radio_button_tag :blacklist_type, :raw, @application_setting.domain_blacklist.present? || @application_setting.domain_blacklist.blank?, class: 'form-check-input'
- = label_tag :blacklist_type_raw, class: 'form-check-label' do
+ = radio_button_tag :denylist_type, :raw, @application_setting.domain_denylist.present? || @application_setting.domain_denylist.blank?, class: 'form-check-input'
+ = label_tag :denylist_type_raw, class: 'form-check-label' do
.option-title
- Enter blacklist manually
- .form-group.blacklist-file
- = f.label :domain_blacklist_file, 'Blacklist file', class: 'label-bold'
- = f.file_field :domain_blacklist_file, class: 'form-control', accept: '.txt,.conf'
+ Enter denylist manually
+ .form-group.js-denylist-file
+ = f.label :domain_denylist_file, _('Denylist file'), class: 'label-bold'
+ = f.file_field :domain_denylist_file, class: 'form-control', accept: '.txt,.conf'
.form-text.text-muted Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines or commas for multiple entries.
- .form-group.blacklist-raw
- = f.label :domain_blacklist, 'Blacklisted domains for sign-ups', class: 'label-bold'
- = f.text_area :domain_blacklist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8
+ .form-group.js-denylist-raw
+ = f.label :domain_denylist, _('Denied domains for sign-ups'), class: 'label-bold'
+ = f.text_area :domain_denylist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8
.form-text.text-muted Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com
.form-group
= f.label :email_restrictions_enabled, _('Email restrictions'), class: 'label-bold'
diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml
index 7c2c5e0b3dc..c1edaf9ff29 100644
--- a/app/views/admin/application_settings/_snowplow.html.haml
+++ b/app/views/admin/application_settings/_snowplow.html.haml
@@ -3,7 +3,7 @@
.settings-header
%h4
= _('Snowplow')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= _('Configure the %{link} integration.').html_safe % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank') }
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 7e3e063118e..32023b11993 100644
--- a/app/views/admin/application_settings/_third_party_offers.html.haml
+++ b/app/views/admin/application_settings/_third_party_offers.html.haml
@@ -3,7 +3,7 @@
.settings-header
%h4
= _('Third party offers')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= _('Control the display of third party offers.')
diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml
index 6ff35a42efd..0a0f8aaf032 100644
--- a/app/views/admin/application_settings/ci/_header.html.haml
+++ b/app/views/admin/application_settings/ci/_header.html.haml
@@ -4,7 +4,7 @@
= _('Variables')
= link_to sprite_icon('question-o', css_class: 'gl-vertical-align-baseline!'), help_page_path('ci/variables/README', anchor: 'custom-environment-variables'), target: '_blank', rel: 'noopener noreferrer'
-%button.btn.btn-default.js-settings-toggle{ type: 'button' }
+%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml
index cdb69d33b12..b05e8621d07 100644
--- a/app/views/admin/application_settings/ci_cd.html.haml
+++ b/app/views/admin/application_settings/ci_cd.html.haml
@@ -17,7 +17,7 @@
.settings-header
%h4
= _('Continuous Integration and Deployment')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Auto DevOps, runners and job artifacts')
@@ -33,7 +33,7 @@
.settings-header
%h4
= _('Container Registry')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Various container registry settings.')
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index 2d336bebc8d..5c3f68843a2 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -6,7 +6,7 @@
.settings-header
%h4
= _('Visibility and access controls')
- %button.btn.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Set default and restrict visibility levels. Configure import sources and git access protocol.')
@@ -17,7 +17,7 @@
.settings-header
%h4
= _('Account and limit')
- %button.btn.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Set projects and maximum size limits, session duration, user options, and check feature availability for namespace plan.')
@@ -28,7 +28,7 @@
.settings-header
%h4
= _('Diff limits')
- %button.btn.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Diff content limits')
@@ -39,7 +39,7 @@
.settings-header
%h4
= _('Sign-up restrictions')
- %button.btn.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Configure the way a user creates a new account.')
@@ -50,7 +50,7 @@
.settings-header
%h4
= _('Sign-in restrictions')
- %button.btn.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Set requirements for a user to sign-in. Enable mandatory two-factor authentication.')
@@ -61,7 +61,7 @@
.settings-header
%h4
= _('Terms of Service and Privacy Policy')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Include a Terms of Service agreement and Privacy Policy that all users must accept.')
@@ -74,7 +74,7 @@
.settings-header
%h4
= _('Web terminal')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Set max session time for web terminal.')
@@ -85,7 +85,7 @@
.settings-header
%h4
= _('Web IDE')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Manage Web IDE features')
@@ -108,7 +108,7 @@
.settings-header
%h4
= _('Maintenance mode')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Prevent users from performing write operations on GitLab while performing maintenance.')
diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml
index 40fa86d8ea3..f977a8c93fa 100644
--- a/app/views/admin/application_settings/network.html.haml
+++ b/app/views/admin/application_settings/network.html.haml
@@ -6,7 +6,7 @@
.settings-header
%h4
= _('Performance optimization')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Various settings that affect GitLab performance.')
@@ -17,7 +17,7 @@
.settings-header
%h4
= _('User and IP Rate Limits')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Configure limits for web and API requests.')
@@ -28,7 +28,7 @@
.settings-header
%h4
= _('Outbound requests')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Allow requests to the local network from hooks and services.')
@@ -39,10 +39,14 @@
.settings-header
%h4
= _('Protected Paths')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Configure paths to be protected by Rack Attack.')
+ .help-block
+ = _('These paths are protected for POST requests.')
+ = link_to _('More information'), help_page_path('security/rack_attack', anchor: 'protected-paths-throttle'), target: '_blank'
+
.settings-content
= render 'protected_paths'
@@ -50,7 +54,7 @@
.settings-header
%h4
= _('Issues Rate Limits')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Configure limit for issues created per minute by web and API requests.')
@@ -61,7 +65,7 @@
.settings-header
%h4
= _('Import/Export Rate Limits')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Configure limits for Project/Group Import/Export.')
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index b0d4a3fd8f5..220a211cca6 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -1,5 +1,7 @@
- breadcrumb_title _("Dashboard")
- page_title _("Dashboard")
+- billable_users_url = help_page_path('subscriptions/self_managed/index', anchor: 'billable-users')
+- billable_users_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer nofollow">'.html_safe % { url: billable_users_url }
- if @notices
- @notices.each do |notice|
@@ -8,7 +10,9 @@
= notice[:message].html_safe
- if @license.present? && show_license_breakdown?
- = render_if_exists 'admin/licenses/breakdown'
+ .license-panel.gl-mt-5
+ = render_if_exists 'admin/licenses/summary'
+ = render_if_exists 'admin/licenses/breakdown'
.admin-dashboard.gl-mt-3
.row
@@ -22,10 +26,20 @@
= 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
+ .well-segment.well-centered.gl-text-center
= link_to admin_users_path do
- %h3.text-center
+ %h3.gl-display-inline-block.gl-mb-0
= s_('AdminArea|Users: %{number_of_users}') % { number_of_users: approximate_count_with_delimiters(@counts, User) }
+
+ %span.gl-outline-0.gl-ml-2{ href: "#", tabindex: "0", data: { container: "body",
+ toggle: "popover",
+ placement: "top",
+ html: "true",
+ trigger: "focus",
+ content: s_("AdminArea|All users created in the instance, including users who are not %{billable_users_link_start}billable users%{billable_users_link_end}.").html_safe % { billable_users_link_start: billable_users_link_start, billable_users_link_end: '</a>'.html_safe },
+ } }
+ = sprite_icon('question', size: 16, css_class: 'gl-text-gray-700 gl-mb-1')
+
%hr
.btn-group.d-flex{ role: 'group' }
= link_to s_('AdminArea|New user'), new_admin_user_path, class: "btn gl-button btn-success gl-w-full"
@@ -126,6 +140,10 @@
%span.float-right
= Gitlab::Database.version
%p
+ = _('Redis')
+ %span.float-right
+ = @redis_versions&.join(", ")
+ %p
= link_to _("Gitaly Servers"), admin_gitaly_servers_path
.row
.col-md-4
diff --git a/app/views/admin/dev_ops_report/_report.html.haml b/app/views/admin/dev_ops_report/_report.html.haml
new file mode 100644
index 00000000000..24c805d273a
--- /dev/null
+++ b/app/views/admin/dev_ops_report/_report.html.haml
@@ -0,0 +1,30 @@
+- usage_ping_enabled = Gitlab::CurrentSettings.usage_ping_enabled
+
+- if usage_ping_enabled && show_callout?('dev_ops_report_intro_callout_dismissed')
+ = render 'callout'
+
+- 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/product_analytics/usage_ping') } }
+- elsif @metric.blank?
+ = render 'no_data'
+- else
+ .devops
+ .devops-header
+ %h2.devops-header-title{ class: "devops-#{score_level(@metric.average_percentage_score)}-score" }
+ = number_to_percentage(@metric.average_percentage_score, precision: 1)
+ .devops-header-subtitle
+ = s_('DevopsReport|DevOps')
+ %br
+ = s_('DevopsReport|Score')
+ = link_to sprite_icon('question-o', css_class: 'devops-header-icon'), help_page_path('user/admin_area/analytics/dev_ops_report')
+
+ .devops-cards.board-card-container
+ - @metric.cards.each do |card|
+ = render 'card', card: card
+
+ .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}")
+ %h4.devops-step-title
+ = step.title
diff --git a/app/views/admin/dev_ops_report/show.html.haml b/app/views/admin/dev_ops_report/show.html.haml
index 88105be70fb..dc3bda3a994 100644
--- a/app/views/admin/dev_ops_report/show.html.haml
+++ b/app/views/admin/dev_ops_report/show.html.haml
@@ -1,34 +1,10 @@
- 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')
- = render 'callout'
-
.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/product_analytics/usage_ping') } }
- - elsif @metric.blank?
- = render 'no_data'
- - else
- .devops
- .devops-header
- %h2.devops-header-title{ class: "devops-#{score_level(@metric.average_percentage_score)}-score" }
- = number_to_percentage(@metric.average_percentage_score, precision: 1)
- .devops-header-subtitle
- = _('DevOps')
- %br
- = _('Score')
- = link_to sprite_icon('question-o', css_class: 'devops-header-icon'), help_page_path('user/admin_area/analytics/dev_ops_report')
-
- .devops-cards.board-card-container
- - @metric.cards.each do |card|
- = render 'card', card: card
+ - if Gitlab.ee? && Feature.enabled?(:devops_adoption_feature) && License.feature_available?(:devops_adoption)
+ = render_if_exists 'admin/dev_ops_report/devops_tabs'
+ - else
+ = render 'report'
- .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}")
- %h4.devops-step-title
- = step.title
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index 424251f543e..386df99717b 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -1,6 +1,7 @@
- add_to_breadcrumbs _("Groups"), admin_groups_path
- breadcrumb_title @group.name
- page_title @group.name, _("Groups")
+- current_user_is_group_owner = @group && @group.has_owner?(current_user)
.js-remove-member-modal
%h3.page-title
@@ -116,7 +117,7 @@
= select_tag :access_level, options_for_select(@group.access_level_roles), class: "project-access-select select2"
%hr
= 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
+ = render 'shared/members/requests', membership_source: @group, group: @group, requesters: @requesters, force_mobile_view: true
.card
.card-header
@@ -127,6 +128,11 @@
= sprite_icon('pencil-square', css_class: 'gl-icon')
= _('Manage access')
%ul.content-list.group-users-list.content-list.members-list
- = render partial: 'shared/members/member', collection: @members, as: :member, locals: { show_controls: false }
+ = render partial: 'shared/members/member',
+ collection: @members, as: :member,
+ locals: { membership_source: @group,
+ group: @group,
+ show_controls: false,
+ current_user_is_group_owner: current_user_is_group_owner }
.card-footer
= paginate @members, param_name: 'members_page', theme: 'gitlab'
diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml
index 76e4fa971a3..78f0fd325fb 100644
--- a/app/views/admin/health_check/show.html.haml
+++ b/app/views/admin/health_check/show.html.haml
@@ -30,7 +30,7 @@
= sprite_icon('check', css_class: 'cgreen')
#{ s_('HealthCheck|Healthy') }
- else
- = icon('warning', class: 'cred')
+ = sprite_icon('warning-solid', css_class: 'cred')
#{ s_('HealthCheck|Unhealthy') }
.card-body
- if no_errors
diff --git a/app/views/admin/identities/_identity.html.haml b/app/views/admin/identities/_identity.html.haml
index d8facbb780a..d852e4a2463 100644
--- a/app/views/admin/identities/_identity.html.haml
+++ b/app/views/admin/identities/_identity.html.haml
@@ -1,6 +1,6 @@
%tr
%td
- #{Gitlab::Auth::OAuth::Provider.label_for(identity.provider)} (#{identity.provider})
+ #{Gitlab::Auth::OAuth::Provider.label_for(identity.provider)} (#{identity.provider}) #{identity.saml_provider_id.present? ? "for #{link_to identity.saml_provider.group.path, identity.saml_provider.group} ID: #{identity.saml_provider_id}".html_safe : ""}
%td
= identity.extern_uid
%td
diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml
index d482ae04c08..8eaf84c8df9 100644
--- a/app/views/admin/jobs/index.html.haml
+++ b/app/views/admin/jobs/index.html.haml
@@ -6,11 +6,9 @@
= render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
- if @all_builds.running_or_pending.any?
- #stop-jobs-modal
+ #js-stop-jobs-modal
.nav-controls
- %button#stop-jobs-button.btn.gl-button.btn-danger{ data: { toggle: 'modal',
- target: '#stop-jobs-modal',
- url: cancel_all_admin_jobs_path } }
+ %button#js-stop-jobs-button.btn.gl-button.btn-danger{ data: { url: cancel_all_admin_jobs_path } }
= s_('AdminArea|Stop all jobs')
.row-content-block.second-block
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 417fd1d60eb..aae1d5b6a4e 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -2,6 +2,7 @@
- breadcrumb_title @project.full_name
- page_title @project.full_name, _("Projects")
- @content_class = "admin-projects"
+- current_user_is_group_owner = @group && @group.has_owner?(current_user)
.js-remove-member-modal
%h3.page-title
@@ -13,7 +14,7 @@
- if @project.last_repository_check_failed?
.row
.col-md-12
- .gl-alert.gl-alert-danger.gl-mb-5
+ .gl-alert.gl-alert-danger.gl-mb-5{ data: { testid: 'last-repository-check-failed-alert' } }
= sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
.gl-alert-body
- last_check_message = _("Last repository check (%{last_check_timestamp}) failed. See the 'repocheck.log' file for error messages.")
@@ -183,11 +184,16 @@
= sprite_icon('pencil-square', css_class: 'gl-icon')
= _('Manage access')
%ul.content-list.members-list
- = render partial: 'shared/members/member', collection: @group_members, as: :member, locals: { show_controls: false }
+ = render partial: 'shared/members/member',
+ collection: @group_members, as: :member,
+ locals: { membership_source: @project,
+ group: @group,
+ show_controls: false,
+ current_user_is_group_owner: current_user_is_group_owner }
.card-footer
= paginate @group_members, param_name: 'group_members_page', theme: 'gitlab'
- = render 'shared/members/requests', membership_source: @project, requesters: @requesters, force_mobile_view: true
+ = render 'shared/members/requests', membership_source: @project, group: @group, requesters: @requesters, force_mobile_view: true
.card
.card-header
@@ -199,6 +205,11 @@
= sprite_icon('pencil-square', css_class: 'gl-icon')
= _('Manage access')
%ul.content-list.project_members.members-list
- = render partial: 'shared/members/member', collection: @project_members, as: :member, locals: { show_controls: false }
+ = render partial: 'shared/members/member',
+ collection: @project_members, as: :member,
+ locals: { membership_source: @project,
+ group: @group,
+ show_controls: false,
+ current_user_is_group_owner: current_user_is_group_owner }
.card-footer
= paginate @project_members, param_name: 'project_members_page', theme: 'gitlab'
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index 3d3b8c28a17..c2d7b63f1f9 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -39,7 +39,9 @@
= render partial: 'ci/runner/how_to_setup_runner',
locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token,
type: 'shared',
- reset_token_url: reset_registration_token_admin_application_settings_path }
+ reset_token_url: reset_registration_token_admin_application_settings_path,
+ project_path: '',
+ group_path: '' }
.row
.col-sm-9
diff --git a/app/views/admin/serverless/domains/_form.html.haml b/app/views/admin/serverless/domains/_form.html.haml
index 8f0dd0cab8e..e4b054c7480 100644
--- a/app/views/admin/serverless/domains/_form.html.haml
+++ b/app/views/admin/serverless/domains/_form.html.haml
@@ -36,7 +36,7 @@
= clipboard_button(target: '#serverless_domain_verification', class: 'btn-default d-none d-sm-block')
%p.form-text.text-muted
- link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership'))
- = _("To %{link_to_help} of your domain, add the above key to a TXT record within to your DNS configuration.").html_safe % { link_to_help: link_to_help }
+ = _("To %{link_to_help} of your domain, add the above key to a TXT record within your DNS configuration.").html_safe % { link_to_help: link_to_help }
- else
.form-group
diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml
index 312ca62cfdf..ca6efe9b095 100644
--- a/app/views/admin/system_info/show.html.haml
+++ b/app/views/admin/system_info/show.html.haml
@@ -9,7 +9,7 @@
- if @cpus
%h2= _('%{cores} cores') % { cores: @cpus.length }
- else
- = icon('warning', class: 'text-warning')
+ = sprite_icon('warning-solid', css_class: 'text-warning')
= _('Unable to collect CPU info')
.bg-light.light-well.gl-mt-3
%h4= _('Memory Usage')
@@ -17,7 +17,7 @@
- if @memory
%h2 #{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)}
- else
- = icon('warning', class: 'text-warning')
+ = sprite_icon('warning-solid', css_class: 'text-warning')
= _('Unable to collect memory info')
.bg-light.light-well.gl-mt-3
%h4= _('Uptime')
diff --git a/app/views/admin/users/_block_user.html.haml b/app/views/admin/users/_block_user.html.haml
index b07a72c3e28..29029986345 100644
--- a/app/views/admin/users/_block_user.html.haml
+++ b/app/views/admin/users/_block_user.html.haml
@@ -2,10 +2,7 @@
.card-header.bg-warning.text-white
= s_('AdminUsers|Block this user')
.card-body
- = render partial: 'admin/users/user_block_effects'
+ = 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) } }
+ %button.btn.gl-button.btn-warning.js-confirm-modal-button{ data: user_block_data(user, s_('AdminUsers|You can always unblock their account, their data will remain intact.')) }
= s_('AdminUsers|Block user')
diff --git a/app/views/admin/users/_modals.html.haml b/app/views/admin/users/_modals.html.haml
index a8e5d962e5b..e56bbd06575 100644
--- a/app/views/admin/users/_modals.html.haml
+++ b/app/views/admin/users/_modals.html.haml
@@ -5,11 +5,6 @@
action: s_("AdminUsers|Deactivate") } }
= render partial: 'admin/users/user_deactivation_effects'
- %div{ data: { modal: "block",
- title: s_("AdminUsers|Block user %{username}?"),
- action: s_("AdminUsers|Block") } }
- = render partial: 'admin/users/user_block_effects'
-
%div{ data: { modal: "delete",
title: s_("AdminUsers|Delete User %{username}?"),
action: s_('AdminUsers|Delete user'),
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index 70ab95bfa61..679c4805280 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -24,10 +24,10 @@
.table-action-buttons
= link_to _('Edit'), edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn gl-button btn-default'
- unless user == current_user
- %button.dropdown-new.btn.gl-button.btn-default{ type: 'button', data: { toggle: 'dropdown' } }
+ %button.dropdown-new.btn.gl-button.btn-default{ type: 'button', data: { testid: "user-action-button-#{user.id}", toggle: 'dropdown' } }
= sprite_icon('settings')
= sprite_icon('chevron-down')
- %ul.dropdown-menu.dropdown-menu-right
+ %ul.dropdown-menu.dropdown-menu-right{ data: { testid: "user-action-dropdown-#{user.id}" } }
%li.dropdown-header
= _('Settings')
%li
@@ -37,16 +37,12 @@
- elsif user.blocked?
- 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) } }
+ %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_block_data(user, user_block_effects) }
= s_('AdminUsers|Block')
- else
= link_to _('Unblock'), unblock_admin_user_path(user), method: :put
- else
- %button.btn.btn-default-tertiary{ data: { 'gl-modal-action': 'block',
- url: block_admin_user_path(user),
- username: sanitize_name(user.name) } }
+ %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_block_data(user, user_block_effects) }
= s_('AdminUsers|Block')
- if user.can_be_deactivated?
%li
diff --git a/app/views/admin/users/_user_block_effects.html.haml b/app/views/admin/users/_user_block_effects.html.haml
deleted file mode 100644
index 8ffbe145169..00000000000
--- a/app/views/admin/users/_user_block_effects.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-%p
- = s_('AdminUsers|Blocking user has the following effects:')
-%ul
- %li
- = s_('AdminUsers|User will not be able to login')
- %li
- = s_('AdminUsers|User will not be able to access git repositories')
- %li
- = s_('AdminUsers|Personal projects will be left')
- %li
- = s_('AdminUsers|Owned groups will be left')
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index 33faef92646..2e179d2d845 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -30,11 +30,10 @@
= 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] == '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')
diff --git a/app/views/ci/runner/_how_to_setup_runner.html.haml b/app/views/ci/runner/_how_to_setup_runner.html.haml
index 6c2bd2a5d2f..0ff6fdc6354 100644
--- a/app/views/ci/runner/_how_to_setup_runner.html.haml
+++ b/app/views/ci/runner/_how_to_setup_runner.html.haml
@@ -15,7 +15,9 @@
= clipboard_button(target: '#registration_token', title: _("Copy token"), class: "btn-transparent btn-clipboard")
.gl-mt-3.gl-mb-3
= button_to _("Reset runners registration token"), reset_token_url,
- method: :put, class: 'btn btn-default',
+ method: :put, class: 'gl-button btn btn-default',
data: { confirm: _("Are you sure you want to reset registration token?") }
%li
= _("Start the Runner!")
+
+#js-install-runner{ data: { project_path: project_path, group_path: group_path } }
diff --git a/app/views/ci/runner/_how_to_setup_runner_automatically.html.haml b/app/views/ci/runner/_how_to_setup_runner_automatically.html.haml
index 42710039757..343abf6099e 100644
--- a/app/views/ci/runner/_how_to_setup_runner_automatically.html.haml
+++ b/app/views/ci/runner/_how_to_setup_runner_automatically.html.haml
@@ -19,4 +19,4 @@
= link_to _('Install Runner on Kubernetes'),
clusters_path,
- class: 'btn btn-info'
+ class: 'gl-button btn btn-info'
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index b90e672cca9..660fd1a48a7 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -5,42 +5,20 @@
- link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protect-a-custom-variable') }
= s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
-- if Feature.enabled?(:new_variables_ui, @project || @group, default_enabled: true)
- - is_group = !@group.nil?
+- is_group = !@group.nil?
- #js-ci-project-variables{ data: { endpoint: save_endpoint,
- project_id: @project&.id || '',
- group: is_group.to_s,
- maskable_regex: ci_variable_maskable_regex,
- protected_by_default: ci_variable_protected_by_default?.to_s,
- aws_logo_svg_path: image_path('aws_logo.svg'),
- aws_tip_deploy_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'deploy-your-application-to-the-aws-elastic-container-service-ecs'),
- aws_tip_commands_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'run-aws-commands-from-gitlab-cicd'),
- aws_tip_learn_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'aws'),
- protected_environment_variables_link: help_page_path('ci/variables/README', anchor: 'protect-a-custom-variable'),
- masked_environment_variables_link: help_page_path('ci/variables/README', anchor: 'mask-a-custom-variable'),
- } }
-
-- else
- .row
- .col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint, maskable_regex: ci_variable_maskable_regex } }
- .hide.gl-alert.gl-alert-danger.js-ci-variable-error-box
-
- %ul.ci-variable-list
- = render 'ci/variables/variable_header'
- - @variables.each.each do |variable|
- = render 'ci/variables/variable_row', form_field: 'variables', variable: variable
- = render 'ci/variables/variable_row', form_field: 'variables'
- .prepend-top-20
- %button.btn.btn-success.js-ci-variables-save-button{ type: 'button' }
- %span.hide.js-ci-variables-save-loading-icon
- .spinner.spinner-light.mr-1
- = _('Save variables')
- %button.btn.btn-info.btn-inverted.gl-ml-3.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@variables.size == 0}" } }
- - if @variables.size == 0
- = n_('Hide value', 'Hide values', @variables.size)
- - else
- = n_('Reveal value', 'Reveal values', @variables.size)
+#js-ci-project-variables{ data: { endpoint: save_endpoint,
+ project_id: @project&.id || '',
+ group: is_group.to_s,
+ maskable_regex: ci_variable_maskable_regex,
+ protected_by_default: ci_variable_protected_by_default?.to_s,
+ aws_logo_svg_path: image_path('aws_logo.svg'),
+ aws_tip_deploy_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'deploy-your-application-to-the-aws-elastic-container-service-ecs'),
+ aws_tip_commands_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'run-aws-commands-from-gitlab-cicd'),
+ aws_tip_learn_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'aws'),
+ protected_environment_variables_link: help_page_path('ci/variables/README', anchor: 'protect-a-custom-variable'),
+ masked_environment_variables_link: help_page_path('ci/variables/README', anchor: 'mask-a-custom-variable'),
+} }
- if !@group && @project.group
.settings-header.border-top.prepend-top-20
diff --git a/app/views/clusters/clusters/_details.html.haml b/app/views/clusters/clusters/_details.html.haml
index fb0a1aaebc4..3079e024369 100644
--- a/app/views/clusters/clusters/_details.html.haml
+++ b/app/views/clusters/clusters/_details.html.haml
@@ -4,7 +4,7 @@
%section.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
%h4= s_('ClusterIntegration|Provider details')
- %button.btn.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p= s_('ClusterIntegration|See and edit the details for your Kubernetes cluster')
.settings-content
diff --git a/app/views/clusters/clusters/_empty_state.html.haml b/app/views/clusters/clusters/_empty_state.html.haml
index 1798ba81075..676a8a8111a 100644
--- a/app/views/clusters/clusters/_empty_state.html.haml
+++ b/app/views/clusters/clusters/_empty_state.html.haml
@@ -11,4 +11,4 @@
- if clusterable.can_add_cluster?
.gl-text-center
- = link_to s_('ClusterIntegration|Integrate with a cluster certificate'), clusterable.new_path, class: 'btn btn-success'
+ = link_to s_('ClusterIntegration|Integrate with a cluster certificate'), clusterable.new_path, class: 'gl-button btn btn-success'
diff --git a/app/views/clusters/clusters/_provider_details_form.html.haml b/app/views/clusters/clusters/_provider_details_form.html.haml
index 16891c7fc21..7fc803e9579 100644
--- a/app/views/clusters/clusters/_provider_details_form.html.haml
+++ b/app/views/clusters/clusters/_provider_details_form.html.haml
@@ -55,4 +55,4 @@
= render('clusters/clusters/namespace', platform_field: platform_field, field: field)
.form-group
- = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success'
+ = field.submit s_('ClusterIntegration|Save changes'), class: 'gl-button btn btn-success'
diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml
index b1a277faae9..4407b27df1e 100644
--- a/app/views/clusters/clusters/aws/_new.html.haml
+++ b/app/views/clusters/clusters/aws/_new.html.haml
@@ -14,4 +14,4 @@
'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index'),
'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'new-eks-cluster'),
'create-role-arn-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'new-eks-cluster'),
- 'external-link-icon' => icon('external-link') } }
+ 'external-link-icon' => sprite_icon('external-link') } }
diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml
index ceb6e1d46b0..d1ea7fec49d 100644
--- a/app/views/clusters/clusters/gcp/_form.html.haml
+++ b/app/views/clusters/clusters/gcp/_form.html.haml
@@ -1,5 +1,5 @@
= javascript_include_tag 'https://apis.google.com/js/api.js'
-- external_link_icon = icon('external-link')
+- external_link_icon = sprite_icon('external-link')
- zones_link_url = 'https://cloud.google.com/compute/docs/regions-zones/regions-zones'
- machine_type_link_url = 'https://cloud.google.com/compute/docs/machine-types'
- pricing_link_url = 'https://cloud.google.com/compute/pricing#machinetype'
@@ -86,4 +86,4 @@
.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
+ class: 'js-gke-cluster-creation-submit gl-button btn btn-success', disabled: true
diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml
index a6097038b2e..e2779d1b683 100644
--- a/app/views/clusters/clusters/user/_form.html.haml
+++ b/app/views/clusters/clusters/user/_form.html.haml
@@ -60,4 +60,4 @@
= render('clusters/clusters/namespace', platform_field: platform_kubernetes_field)
.form-group
- = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'btn btn-success', data: { qa_selector: 'add_kubernetes_cluster_button' }
+ = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'gl-button btn btn-success', data: { qa_selector: 'add_kubernetes_cluster_button' }
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index b2fadb77418..c24fe5c6307 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -3,7 +3,7 @@
- if current_user.can_create_group?
.page-title-controls
- = link_to _("New group"), new_group_path, class: "btn btn-success"
+ = link_to _("New group"), new_group_path, class: "gl-button btn btn-success"
.top-area
%ul.nav-links.mobile-separator.nav.nav-tabs
diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml
index fe91f9859f9..6c994f3b230 100644
--- a/app/views/dashboard/_projects_head.html.haml
+++ b/app/views/dashboard/_projects_head.html.haml
@@ -9,7 +9,7 @@
- if current_user.can_create_project?
.page-title-controls
- = link_to _("New project"), new_project_path, class: "btn btn-success"
+ = link_to _("New project"), new_project_path, class: "gl-button btn btn-success"
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left= sprite_icon('chevron-lg-left', size: 12)
diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml
index d2fb4a3cd43..2640d483615 100644
--- a/app/views/dashboard/_snippets_head.html.haml
+++ b/app/views/dashboard/_snippets_head.html.haml
@@ -4,7 +4,7 @@
- if current_user && current_user.snippets.any? || @snippets.any?
.page-title-controls
- if can?(current_user, :create_snippet)
- = link_to _("New snippet"), new_snippet_path, class: "btn btn-success", title: _("New snippet")
+ = link_to _("New snippet"), new_snippet_path, class: "gl-button btn btn-success", title: _("New snippet")
.top-area
%ul.nav-links.nav.nav-tabs
diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml
index 9536ff940f5..afe4f1b84c2 100644
--- a/app/views/dashboard/groups/index.html.haml
+++ b/app/views/dashboard/groups/index.html.haml
@@ -6,6 +6,7 @@
= render 'dashboard/groups_head'
- if params[:filter].blank? && @groups.empty?
- = render 'shared/groups/empty_state'
+ .empty-state
+ = render 'shared/groups/empty_state'
- else
= render 'groups'
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index 6fb387ecca3..8e038baf14d 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -48,14 +48,14 @@
- if todo.pending?
.todo-actions
- = link_to dashboard_todo_path(todo), method: :delete, class: 'btn btn-loading d-flex align-items-center js-done-todo', data: { href: dashboard_todo_path(todo) } do
+ = link_to dashboard_todo_path(todo), method: :delete, class: 'gl-button btn btn-loading d-flex align-items-center js-done-todo', data: { href: dashboard_todo_path(todo) } do
Done
%span.spinner.ml-1
- = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'btn btn-loading d-flex align-items-center js-undo-todo hidden', data: { href: restore_dashboard_todo_path(todo) } do
+ = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-loading d-flex align-items-center js-undo-todo hidden', data: { href: restore_dashboard_todo_path(todo) } do
Undo
%span.spinner.ml-1
- 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
+ = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-loading d-flex align-items-center js-add-todo', data: { href: restore_dashboard_todo_path(todo) } 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 56506370ee0..9a9fbfc1ee8 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -27,10 +27,10 @@
.nav-controls
- if @todos.any?(&:pending?)
.gl-mr-3
- = link_to destroy_all_dashboard_todos_path(todos_filter_params), class: 'btn btn-loading align-items-center js-todos-mark-all', method: :delete, data: { href: destroy_all_dashboard_todos_path(todos_filter_params) } do
+ = link_to destroy_all_dashboard_todos_path(todos_filter_params), class: 'gl-button btn btn-loading align-items-center js-todos-mark-all', method: :delete, data: { href: destroy_all_dashboard_todos_path(todos_filter_params) } do
Mark all as done
%span.spinner.ml-1
- = link_to bulk_restore_dashboard_todos_path, class: 'btn btn-loading align-items-center js-todos-undo-all hidden', method: :patch , data: { href: bulk_restore_dashboard_todos_path(todos_filter_params) } do
+ = link_to bulk_restore_dashboard_todos_path, class: 'gl-button btn btn-loading align-items-center js-todos-undo-all hidden', method: :patch , data: { href: bulk_restore_dashboard_todos_path(todos_filter_params) } do
Undo mark all as done
%span.spinner.ml-1
@@ -114,7 +114,7 @@
.todos-empty
.todos-empty-hero.svg-content
= image_tag 'illustrations/todos_empty.svg'
- .todos-empty-content
+ .todos-empty-content.gl-mx-5
%h4
Your To-Do List shows what to work on next
%p
diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml
index f8aa3cf98dc..49112ed6cd5 100644
--- a/app/views/devise/confirmations/new.html.haml
+++ b/app/views/devise/confirmations/new.html.haml
@@ -8,7 +8,7 @@
= f.label :email
= f.email_field :email, class: "form-control", required: true, title: 'Please provide a valid email address.'
.clearfix
- = f.submit "Resend", class: 'btn btn-success'
+ = f.submit "Resend", class: 'gl-button btn btn-success'
.clearfix.prepend-top-20
= render 'devise/shared/sign_in_link'
diff --git a/app/views/devise/mailer/user_admin_approval.html.haml b/app/views/devise/mailer/user_admin_approval.html.haml
new file mode 100644
index 00000000000..199a143ba63
--- /dev/null
+++ b/app/views/devise/mailer/user_admin_approval.html.haml
@@ -0,0 +1,8 @@
+= email_default_heading(say_hi(@resource))
+
+%p
+ = _('Your GitLab account request has been approved!')
+%p
+ = _('Your username is %{username}.') % { username: @resource.username }
+%p
+ = _('Your sign-in page is %{url}.').html_safe % { url: link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url) }
diff --git a/app/views/devise/mailer/user_admin_approval.text.erb b/app/views/devise/mailer/user_admin_approval.text.erb
new file mode 100644
index 00000000000..5242981e514
--- /dev/null
+++ b/app/views/devise/mailer/user_admin_approval.text.erb
@@ -0,0 +1,7 @@
+<%= say_hi(@resource) %>
+
+<%= _('Your GitLab account request has been approved!') %>
+
+<%= _('Your username is %{username}.' % { username: @resource.username }) %>
+
+<%= _('Your sign-in page is %{url}.' % { url: Gitlab.config.gitlab.url }) %>
diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml
index fee87c6324c..42e301d88ae 100644
--- a/app/views/devise/passwords/edit.html.haml
+++ b/app/views/devise/passwords/edit.html.haml
@@ -12,7 +12,7 @@
= f.label 'Confirm new password', for: "user_password_confirmation"
= f.password_field :password_confirmation, class: "form-control bottom", title: 'This field is required', data: { qa_selector: 'password_confirmation_field' }, required: true
.clearfix
- = f.submit "Change your password", class: "btn btn-primary", data: { qa_selector: 'change_password_button' }
+ = f.submit "Change your password", class: "gl-button btn btn-primary", data: { qa_selector: 'change_password_button' }
.clearfix.prepend-top-20
%p
diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml
index 2f75203ac62..00429f1acbc 100644
--- a/app/views/devise/registrations/new.html.haml
+++ b/app/views/devise/registrations/new.html.haml
@@ -1,16 +1,13 @@
- page_title _("Sign up")
-- if experiment_enabled?(:signup_flow)
- .row
- .col-lg-7
- %h1.mb-3.font-weight-bold.text-6.mt-0
- = html_escape(_("Speed up your DevOps%{br_tag}with GitLab")) % { br_tag: '<br/>'.html_safe }
- %p.text-3
- = _("GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.")
- .col-lg-5.order-12
- .text-center.mb-3
- %h2.font-weight-bold= _('Register for GitLab')
- = render 'devise/shared/experimental_separate_sign_up_flow_box'
- = render 'devise/shared/sign_in_link'
-- else
- = render 'devise/shared/signup_box'
+- add_page_specific_style 'page_bundles/signup'
+- content_for :page_specific_javascripts do
+ = render "layouts/google_tag_manager_head"
+= render "layouts/google_tag_manager_body"
+
+.signup-page
+ = render 'devise/shared/signup_box',
+ url: registration_path(resource_name),
+ button_text: _('Register'),
+ show_omniauth_providers: omniauth_enabled? && button_based_providers_enabled?,
+ suggestion_path: nil
= render 'devise/shared/sign_in_link'
diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml
index 8c0ca6d4345..a1a1a767847 100644
--- a/app/views/devise/sessions/_new_base.html.haml
+++ b/app/views/devise/sessions/_new_base.html.haml
@@ -20,4 +20,4 @@
= recaptcha_tags
.submit-container.move-submit-down
- = f.submit _('Sign in'), class: 'btn btn-success', data: { qa_selector: 'sign_in_button' }
+ = f.submit _('Sign in'), class: 'gl-button btn btn-success', data: { qa_selector: 'sign_in_button' }
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index c466d2ce936..cce0a3b926e 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -1,24 +1,25 @@
- page_title _("Sign in")
+- content_for :page_specific_javascripts do
+ = render "layouts/google_tag_manager_head"
+= render "layouts/google_tag_manager_body"
#signin-container
- if any_form_based_providers_enabled?
- = render 'devise/shared/tabs_ldap'
- - else
- - unless experiment_enabled?(:signup_flow)
- = render 'devise/shared/tabs_normal'
+ = render 'devise/shared/tabs_ldap', render_signup_link: false
.tab-content
- if password_authentication_enabled_for_web? || ldap_sign_in_enabled? || crowd_enabled?
= render 'devise/shared/signin_box'
- -# Signup only makes sense if you can also sign-in
- - if allow_signup?
- = render 'devise/shared/signup_box'
-
-# Show a message if none of the mechanisms above are enabled
- if !password_authentication_enabled_for_web? && !ldap_sign_in_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?)
%div
No authentication methods configured.
+ - if allow_signup?
+ %p.gl-mt-3
+ = _("Don't have an account yet?")
+ = link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { qa_selector: 'register_link' }
+
- if omniauth_enabled? && devise_mapping.omniauthable? && button_based_providers_enabled?
.clearfix
= render 'devise/shared/omniauth_box'
diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml
index 8e05488c091..f5f76eb92b1 100644
--- a/app/views/devise/sessions/two_factor.html.haml
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -11,6 +11,6 @@
= f.text_field :otp_attempt, class: 'form-control', required: true, autofocus: true, autocomplete: 'off', title: 'This field is required.', data: { qa_selector: 'two_fa_code_field' }
%p.form-text.text-muted.hint 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.
.prepend-top-20
- = f.submit "Verify code", class: "btn btn-success", data: { qa_selector: 'verify_code_button' }
+ = f.submit "Verify code", class: "gl-button btn btn-success", data: { qa_selector: 'verify_code_button' }
- if @user.two_factor_webauthn_u2f_enabled?
= render "authentication/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path
diff --git a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml
deleted file mode 100644
index 621bbb32a13..00000000000
--- a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml
+++ /dev/null
@@ -1,39 +0,0 @@
-- max_first_name_length = max_last_name_length = 127
-- max_username_length = 255
-- min_username_length = 2
-.signup-box.p-3.mb-2
- .signup-body
- = form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f|
- .devise-errors.mt-0
- = render "devise/shared/error_messages", resource: resource
- - if Feature.enabled?(:invisible_captcha)
- = invisible_captcha
- .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.")
- .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.")
- .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.")
- %p.validation-error.gl-field-error-ignore.field-validation.mt-1.hide.cred= _('Username is already taken.')
- %p.validation-success.gl-field-error-ignore.field-validation.mt-1.hide.cgreen= _('Username is available.')
- %p.validation-pending.gl-field-error-ignore.field-validation.mt-1.hide= _('Checking username availability...')
- .form-group
- = f.label :email, class: 'label-bold'
- = f.email_field :email, class: "form-control middle", data: { qa_selector: 'new_user_email_field' }, required: true, title: _("Please provide a valid email address.")
- .form-group.append-bottom-20#password-strength
- = 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 }
- = 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' }
- = 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/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index 6cf48f89876..67e6e510923 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -7,7 +7,7 @@
.d-flex.justify-content-between.flex-wrap
- providers.each do |provider|
- has_icon = provider_has_icon?(provider)
- = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn d-flex align-items-center omniauth-btn text-left oauth-login #{qa_class_for_provider(provider)}" do
+ = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "gl-button btn d-flex align-items-center omniauth-btn text-left oauth-login #{qa_class_for_provider(provider)}" do
- if has_icon
= provider_image_tag(provider)
%span
diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml
index d217b47527a..ff93449194a 100644
--- a/app/views/devise/shared/_signin_box.html.haml
+++ b/app/views/devise/shared/_signin_box.html.haml
@@ -22,8 +22,3 @@
.login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
.login-body
= render 'devise/sessions/new_base'
-
-- if experiment_enabled?(:signup_flow)
- %p.light.mt-2
- = _("Don't have an account yet?")
- = link_to _("Register now"), new_registration_path(:user)
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index f4ac9ad696b..0dc98001881 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -1,37 +1,37 @@
- 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' }
- .login-body
- = form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f|
- .devise-errors
- = render "devise/shared/error_messages", resource: resource
- - if Feature.enabled?(:invisible_captcha)
- = invisible_captcha
- .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.")
- %p.validation-error.gl-field-error-ignore.field-validation.hide= _('Username is already taken.')
- %p.validation-success.gl-field-error-ignore.field-validation.hide= _('Username is available.')
- %p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking username availability...')
- .form-group
- = f.label :email, class: 'label-bold'
- = f.email_field :email, value: @invite_email, class: "form-control middle", data: { qa_selector: 'new_user_email_field' }, required: true, title: _("Please provide a valid email address.")
- .form-group.append-bottom-20#password-strength
- = 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 }
- = 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'
+.gl-mb-3.gl-p-4.gl-border-gray-100.gl-border-1.gl-border-solid.gl-rounded-base
+ = form_for(resource, as: "new_#{resource_name}", url: url, html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive' }) do |f|
+ .devise-errors
+ = render 'devise/shared/error_messages', resource: resource
+ - if Feature.enabled?(:invisible_captcha)
+ = invisible_captcha
+ .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 => s_('SignUp|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 => s_('SignUp|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 => { :api_path => suggestion_path, :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.')
+ %p.validation-error.gl-text-red-500.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is already taken.')
+ %p.validation-success.gl-text-green-600.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is available.')
+ %p.validation-pending.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Checking username availability...')
+ .form-group
+ = f.label :email, class: 'label-bold'
+ = f.email_field :email, value: @invite_email, class: 'form-control middle', data: { qa_selector: 'new_user_email_field' }, required: true, title: _('Please provide a valid email address.')
+ .form-group.append-bottom-20#password-strength
+ = 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: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length }
+ %p.gl-field-hint.text-secondary= s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length }
+ %div
+ - if show_recaptcha_sign_up?
+ = recaptcha_tags
+ .submit-container
+ = f.submit button_text, class: 'btn gl-button btn-success', data: { qa_selector: 'new_user_register_button' }
+ = render 'devise/shared/terms_of_service_notice'
+ - if show_omniauth_providers
+ = render 'devise/shared/signup_omniauth_providers'
diff --git a/app/views/devise/shared/_experimental_separate_sign_up_flow_omniauth_box.haml b/app/views/devise/shared/_signup_omniauth_providers.haml
index d9143d90430..68098f1865b 100644
--- a/app/views/devise/shared/_experimental_separate_sign_up_flow_omniauth_box.haml
+++ b/app/views/devise/shared/_signup_omniauth_providers.haml
@@ -6,7 +6,7 @@
.d-flex.justify-content-between.flex-wrap
- providers.each do |provider|
- has_icon = provider_has_icon?(provider)
- = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn d-flex align-items-center omniauth-btn text-left oauth-login mb-2 p-2 #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do
+ = link_to omniauth_authorize_path(:user, provider), method: :post, class: "gl-button btn d-flex align-items-center omniauth-btn text-left oauth-login mb-2 p-2 #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do
- if has_icon
= provider_image_tag(provider)
%span.ml-2
diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml
index acd41fb011a..27057d023b1 100644
--- a/app/views/devise/shared/_tabs_ldap.html.haml
+++ b/app/views/devise/shared/_tabs_ldap.html.haml
@@ -4,17 +4,17 @@
%ul.nav-links.new-session-tabs.nav-tabs.nav{ class: ('custom-provider-tabs' if any_form_based_providers_enabled?) }
- if crowd_enabled?
%li.nav-item
- = link_to "Crowd", "#crowd", class: "nav-link #{active_when(form_based_auth_provider_has_active_class?(:crowd))}", 'data-toggle' => 'tab'
+ = link_to "Crowd", "#crowd", class: "nav-link #{active_when(form_based_auth_provider_has_active_class?(:crowd))}", 'data-toggle' => 'tab', role: 'tab'
= render_if_exists "devise/shared/kerberos_tab"
- ldap_servers.each_with_index do |server, i|
%li.nav-item
- = link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain))}", data: { toggle: 'tab', qa_selector: 'ldap_tab' }
+ = link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain))}", data: { toggle: 'tab', qa_selector: 'ldap_tab' }, role: 'tab'
= render_if_exists 'devise/shared/tab_smartcard'
- if show_password_form
%li.nav-item
- = link_to _('Standard'), '#login-pane', class: 'nav-link', data: { toggle: 'tab', qa_selector: 'standard_tab' }
+ = link_to _('Standard'), '#login-pane', class: 'nav-link', data: { toggle: 'tab', qa_selector: 'standard_tab' }, role: 'tab'
- if render_signup_link && allow_signup?
%li.nav-item
- = link_to 'Register', '#register-pane', class: 'nav-link', data: { toggle: 'tab', qa_selector: 'register_tab' }
+ = link_to 'Register', '#register-pane', class: 'nav-link', data: { toggle: 'tab', qa_selector: 'register_tab' }, role: 'tab'
diff --git a/app/views/devise/unlocks/new.html.haml b/app/views/devise/unlocks/new.html.haml
index 1167f1718d6..96f4f07176e 100644
--- a/app/views/devise/unlocks/new.html.haml
+++ b/app/views/devise/unlocks/new.html.haml
@@ -8,7 +8,7 @@
= f.label :email
= f.email_field :email, class: 'form-control', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', title: 'Please provide a valid email address.'
.clearfix
- = f.submit 'Resend unlock instructions', class: 'btn btn-success'
+ = f.submit 'Resend unlock instructions', class: 'gl-button btn btn-success'
.clearfix.prepend-top-20
= render 'devise/shared/sign_in_link'
diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml
index 4a27284cbae..075eb99fc36 100644
--- a/app/views/discussions/_discussion.html.haml
+++ b/app/views/discussions/_discussion.html.haml
@@ -2,17 +2,15 @@
%li.note.note-discussion.timeline-entry.unstyled-comments
.timeline-entry-inner
.timeline-content
- .discussion.js-toggle-container{ data: { discussion_id: discussion.id } }
+ .discussion.js-toggle-container{ data: { discussion_id: discussion.id, is_expanded: expanded.to_s } }
.discussion-header
.timeline-icon
= link_to user_path(discussion.author) do
= image_tag avatar_icon_for_user(discussion.author), class: "avatar s40"
.discussion-actions
%button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button", class: ("js-toggle-lazy-diff" unless expanded) }
- - if expanded
- = icon("chevron-up")
- - else
- = icon("chevron-down")
+ = sprite_icon('chevron-up', css_class: "js-sidebar-collapse #{'hidden' unless expanded}")
+ = sprite_icon('chevron-down', css_class: "js-sidebar-expand #{'hidden' if expanded}")
= _('Toggle thread')
= link_to_member(@project, discussion.author, avatar: false)
diff --git a/app/views/doorkeeper/applications/_delete_form.html.haml b/app/views/doorkeeper/applications/_delete_form.html.haml
index 64b872b5610..3d6361a90ca 100644
--- a/app/views/doorkeeper/applications/_delete_form.html.haml
+++ b/app/views/doorkeeper/applications/_delete_form.html.haml
@@ -2,7 +2,7 @@
= form_tag oauth_application_path(application) do
%input{ :name => "_method", :type => "hidden", :value => "delete" }/
- if defined? small
- = button_tag type: "submit", class: "btn btn-transparent", data: { confirm: _("Are you sure?") } do
+ = button_tag type: "submit", class: "gl-button btn btn-transparent", data: { confirm: _("Are you sure?") } do
%span.sr-only
= _('Destroy')
= sprite_icon('remove')
diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml
index f99db696fd6..fbae24410bb 100644
--- a/app/views/doorkeeper/applications/_form.html.haml
+++ b/app/views/doorkeeper/applications/_form.html.haml
@@ -23,4 +23,4 @@
= render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes
.gl-mt-3
- = f.submit _('Save application'), class: "btn btn-success"
+ = f.submit _('Save application'), class: "gl-button btn btn-success"
diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml
index 6f781a635ba..2daba4586e1 100644
--- a/app/views/doorkeeper/applications/index.html.haml
+++ b/app/views/doorkeeper/applications/index.html.haml
@@ -41,7 +41,7 @@
%div= uri
%td= application.access_tokens.count
%td
- = link_to edit_oauth_application_path(application), class: "btn btn-transparent gl-mr-2" do
+ = link_to edit_oauth_application_path(application), class: "gl-button btn btn-transparent gl-mr-2" do
%span.sr-only
= _('Edit')
= sprite_icon('pencil')
diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml
index 280b5d90793..0a091aa7586 100644
--- a/app/views/doorkeeper/applications/show.html.haml
+++ b/app/views/doorkeeper/applications/show.html.haml
@@ -16,7 +16,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')
@@ -25,7 +25,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')
@@ -43,5 +43,5 @@
= render "shared/tokens/scopes_list", token: @application
.form-actions
- = link_to _('Edit'), edit_oauth_application_path(@application), class: 'btn btn-primary wide float-left'
+ = link_to _('Edit'), edit_oauth_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/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml
index 62e66486a3e..bf17eb4fe3e 100644
--- a/app/views/doorkeeper/authorizations/new.html.haml
+++ b/app/views/doorkeeper/authorizations/new.html.haml
@@ -38,7 +38,7 @@
= hidden_field_tag :response_type, @pre_auth.response_type
= hidden_field_tag :scope, @pre_auth.scope
= hidden_field_tag :nonce, @pre_auth.nonce
- = submit_tag _("Deny"), class: "btn btn-danger"
+ = submit_tag _("Deny"), class: "gl-button btn btn-danger"
= form_tag oauth_authorization_path, method: :post, class: 'inline' do
= hidden_field_tag :client_id, @pre_auth.client.uid
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
@@ -46,4 +46,4 @@
= hidden_field_tag :response_type, @pre_auth.response_type
= hidden_field_tag :scope, @pre_auth.scope
= hidden_field_tag :nonce, @pre_auth.nonce
- = submit_tag _("Authorize"), class: "btn btn-success gl-ml-3", data: { qa_selector: 'authorization_button' }
+ = submit_tag _("Authorize"), class: "gl-button btn btn-success gl-ml-3", data: { qa_selector: 'authorization_button' }
diff --git a/app/views/errors/access_denied.html.haml b/app/views/errors/access_denied.html.haml
index 1ed7b56db1d..ce921060cab 100644
--- a/app/views/errors/access_denied.html.haml
+++ b/app/views/errors/access_denied.html.haml
@@ -11,6 +11,6 @@
%p
= s_('403|Please contact your GitLab administrator to get permission.')
.action-container.js-go-back{ hidden: true }
- %button{ type: 'button', class: 'btn btn-success' }
+ %button{ type: 'button', class: 'gl-button btn btn-success' }
= s_('Go Back')
= render "errors/footer"
diff --git a/app/views/errors/not_found.html.haml b/app/views/errors/not_found.html.haml
index 13f07e2f5d5..291adbc0ae8 100644
--- a/app/views/errors/not_found.html.haml
+++ b/app/views/errors/not_found.html.haml
@@ -11,5 +11,5 @@
= form_tag search_path, method: :get, class: 'form-inline-flex' do |f|
.field
= search_field_tag :search, '', placeholder: _('Search for projects, issues, etc.'), class: 'form-control'
- = button_tag _('Search'), class: 'btn btn-sm btn-success', name: nil, type: 'submit'
+ = button_tag _('Search'), class: 'gl-button btn btn-sm btn-success', name: nil, type: 'submit'
= render 'errors/footer'
diff --git a/app/views/errors/omniauth_error.html.haml b/app/views/errors/omniauth_error.html.haml
index a2a4c75daad..0e9041d07ea 100644
--- a/app/views/errors/omniauth_error.html.haml
+++ b/app/views/errors/omniauth_error.html.haml
@@ -8,8 +8,8 @@
%p Try logging in using your username or email. If you have forgotten your password, try recovering it
- = link_to "Sign in", new_session_path(:user), class: 'btn primary'
- = link_to "Recover password", new_password_path(:user), class: 'btn secondary'
+ = link_to "Sign in", new_session_path(:user), class: 'gl-button btn primary'
+ = link_to "Recover password", new_password_path(:user), class: 'gl-button btn secondary'
%hr
%p.light If none of the options work, try contacting a GitLab administrator.
diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml
index 4e936025c74..2fa595503e5 100644
--- a/app/views/events/event/_note.html.haml
+++ b/app/views/events/event/_note.html.haml
@@ -25,5 +25,5 @@
= image_tag note.attachment.url, class: 'note-image-attach'
- else
= link_to note.attachment.url, target: '_blank', class: 'note-file-attach' do
- %i.fa.fa-paperclip
+ = sprite_icon("paperclip")
= note.attachment_identifier
diff --git a/app/views/explore/projects/_projects.html.haml b/app/views/explore/projects/_projects.html.haml
index d819c4ea554..4275f76c046 100644
--- a/app/views/explore/projects/_projects.html.haml
+++ b/app/views/explore/projects/_projects.html.haml
@@ -1,2 +1 @@
-- is_explore_page = defined?(explore_page) && explore_page
-= render 'shared/projects/list', projects: projects, user: current_user, explore_page: is_explore_page, pipeline_status: Feature.enabled?(:dashboard_pipeline_status, default_enabled: true)
+= render 'shared/projects/list', projects: projects, user: current_user, explore_page: true, pipeline_status: Feature.enabled?(:dashboard_pipeline_status, default_enabled: true)
diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml
index 341ad681c7c..44456b6c015 100644
--- a/app/views/explore/projects/index.html.haml
+++ b/app/views/explore/projects/index.html.haml
@@ -1,6 +1,7 @@
- @hide_top_links = true
- page_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
+- page_canonical_link explore_projects_url
= render_dashboard_gold_trial(current_user)
diff --git a/app/views/explore/projects/page_out_of_bounds.html.haml b/app/views/explore/projects/page_out_of_bounds.html.haml
index 57114dd0752..0ee77ffd7d7 100644
--- a/app/views/explore/projects/page_out_of_bounds.html.haml
+++ b/app/views/explore/projects/page_out_of_bounds.html.haml
@@ -18,4 +18,4 @@
%h5= _("Maximum page reached")
%p= _("Sorry, you have exceeded the maximum browsable page number. Please use the API to explore further.")
- = link_to _("Back to page %{number}") % { number: @max_page_number }, request.params.merge(page: @max_page_number), class: 'btn btn-inverted'
+ = link_to _("Back to page %{number}") % { number: @max_page_number }, request.params.merge(page: @max_page_number), class: 'gl-button btn btn-inverted'
diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml
index 153c90e534e..ed508fa2506 100644
--- a/app/views/explore/projects/trending.html.haml
+++ b/app/views/explore/projects/trending.html.haml
@@ -10,4 +10,4 @@
= render 'explore/head'
= render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user
-= render 'projects', projects: @projects, explore_page: true
+= render 'projects', projects: @projects
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index 97e48cdec8c..ee08829d990 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -20,37 +20,16 @@
%span.access-request-links.gl-ml-3
= render 'shared/members/access_request_links', source: @group
- .home-panel-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end
+ .home-panel-buttons.col-md-12.col-lg-6
- if current_user
- .group-buttons
- = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn', emails_disabled: emails_disabled
- - new_project_label = _("New project")
- - new_subgroup_label = _("New subgroup")
- - if can_create_projects and can_create_subgroups
- .btn-group.new-project-subgroup.droplab-dropdown.home-panel-action-button.gl-mt-3.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } }
- %input.btn.btn-success.dropdown-primary.js-new-group-child.qa-new-in-group-button{ type: "button", value: new_project_label, data: { action: "new-project" } }
- %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle.qa-new-project-or-subgroup-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } }
- = sprite_icon("chevron-down", css_class: "icon dropdown-btn-icon")
- %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-right{ data: { dropdown: true } }
- %li.droplab-item-selected.qa-new-project-option{ role: "button", data: { value: "new-project", text: new_project_label } }
- .menu-item
- .icon-container
- = icon("check", class: "list-item-checkmark")
- .description
- %strong= new_project_label
- %span= s_("GroupsTree|Create a project in this group.")
- %li.divider.droplap-item-ignore
- %li.qa-new-subgroup-option{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } }
- .menu-item
- .icon-container
- = icon("check", class: "list-item-checkmark")
- .description
- %strong= new_subgroup_label
- %span= s_("GroupsTree|Create a subgroup in this group.")
- - elsif can_create_projects
- = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success gl-mt-3"
- - elsif can_create_subgroups
- = link_to new_subgroup_label, new_group_path(parent_id: @group.id), class: "btn btn-success gl-mt-3"
+ .gl-display-flex.gl-flex-wrap.gl-lg-justify-content-end.gl-mx-n2{ data: { testid: 'group-buttons' } }
+ = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn gl-button gl-sm-w-auto gl-w-full', dropdown_container_class: 'gl-mr-0 gl-px-2 gl-sm-w-auto gl-w-full', emails_disabled: emails_disabled
+ - if can_create_subgroups
+ .gl-px-2.gl-sm-w-auto.gl-w-full
+ = link_to _("New subgroup"), new_group_path(parent_id: @group.id), class: "btn btn-success btn-md gl-button btn-success-secondary gl-mt-3 gl-sm-w-auto gl-w-full", data: { qa_selector: 'new_subgroup_button' }
+ - if can_create_projects
+ .gl-px-2.gl-sm-w-auto.gl-w-full
+ = link_to _("New project"), new_project_path(namespace_id: @group.id), class: "btn btn-success btn-md gl-button gl-mt-3 gl-sm-w-auto gl-w-full", data: { qa_selector: 'new_project_button' }
- if @group.description.present?
.group-home-desc.mt-1
diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml
index 51f41d58029..3aae81cef8d 100644
--- a/app/views/groups/_invite_members_modal.html.haml
+++ b/app/views/groups/_invite_members_modal.html.haml
@@ -1,6 +1,7 @@
- if invite_members_allowed?(group)
- .js-invite-members-modal{ data: { group_id: group.id,
- group_name: group.name,
+ .js-invite-members-modal{ data: { id: group.id,
+ name: group.name,
+ is_project: false,
access_levels: GroupMember.access_level_roles.to_json,
default_access_level: Gitlab::Access::GUEST,
help_link: help_page_url('user/permissions') } }
diff --git a/app/views/groups/_invite_members_side_nav_link.html.haml b/app/views/groups/_invite_members_side_nav_link.html.haml
index 1c90eaee992..4f1c06d9fe3 100644
--- a/app/views/groups/_invite_members_side_nav_link.html.haml
+++ b/app/views/groups/_invite_members_side_nav_link.html.haml
@@ -1,3 +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' } }
+ .js-invite-members-trigger{ data: { icon: 'plus', display_text: _('Invite team members') } }
diff --git a/app/views/groups/dependency_proxies/_url.html.haml b/app/views/groups/dependency_proxies/_url.html.haml
new file mode 100644
index 00000000000..9242954b684
--- /dev/null
+++ b/app/views/groups/dependency_proxies/_url.html.haml
@@ -0,0 +1,12 @@
+- proxy_url = "#{group_url(@group)}/dependency_proxy/containers"
+
+%h5.prepend-top-20= _('Dependency proxy URL')
+
+.row
+ .col-lg-8.col-md-12.input-group
+ = text_field_tag :url, "#{proxy_url}", class: 'js-dependency-proxy-url form-control', readonly: true
+ = clipboard_button(text: "#{proxy_url}", title: _("Copy %{proxy_url}") % { proxy_url: proxy_url })
+
+.row
+ .col-12.help-block.gl-mt-3
+ = _('Contains %{count} blobs of images (%{size})') % { count: @blobs_count, size: number_to_human_size(@blobs_total_size) }
diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml
new file mode 100644
index 00000000000..ff1312eb763
--- /dev/null
+++ b/app/views/groups/dependency_proxies/show.html.haml
@@ -0,0 +1,28 @@
+- page_title _("Dependency Proxy")
+
+.settings-header
+ %h4= _('Dependency proxy')
+
+ %p
+ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('user/packages/dependency_proxy/index') }
+ = _('Create a local proxy for storing frequently used upstream images. %{link_start}Learn more%{link_end} about dependency proxies.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+
+- if @group.public?
+ - if can?(current_user, :admin_dependency_proxy, @group)
+ = form_for(@dependency_proxy, method: :put, url: group_dependency_proxy_path(@group)) do |f|
+ .form-group
+ %h5.prepend-top-20= _('Enable proxy')
+ .js-dependency-proxy-toggle-area
+ = render "shared/buttons/project_feature_toggle", is_checked: @dependency_proxy.enabled?, label: s_("DependencyProxy|Toggle Dependency Proxy") do
+ = f.hidden_field :enabled, { class: 'js-project-feature-toggle-input'}
+
+ - if @dependency_proxy.enabled
+ = render 'groups/dependency_proxies/url'
+
+ - else
+ - if @dependency_proxy.enabled
+ = render 'groups/dependency_proxies/url'
+- else
+ .gl-alert.gl-alert-info
+ = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ = _('Dependency proxy feature is limited to public groups for now.')
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index d999f20ef91..2a87b42ef13 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -3,7 +3,8 @@
- show_invited_members = can_manage_members && @invited_members.exists?
- 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)
+- vue_members_list_enabled = Feature.enabled?(:vue_group_members_list, @group, default_enabled: true)
+- current_user_is_group_owner = @group && @group.has_owner?(current_user)
- form_item_label_css_class = 'label-bold gl-mr-2 gl-mb-0 gl-py-2 align-self-md-center'
@@ -69,9 +70,15 @@
= render 'shared/members/sort_dropdown'
- if vue_members_list_enabled
.js-group-members-list{ data: group_members_list_data_attributes(@group, @members) }
+ .loading
+ .spinner.spinner-md
- else
%ul.content-list.members-list{ data: { qa_selector: 'members_list' } }
- = render partial: 'shared/members/member', collection: @members, as: :member
+ = render partial: 'shared/members/member',
+ collection: @members, as: :member,
+ locals: { membership_source: @group,
+ group: @group,
+ current_user_is_group_owner: current_user_is_group_owner }
= paginate @members, theme: 'gitlab', params: { invited_members_page: nil, search_invited: nil }
- if @group.shared_with_group_links.any?
#tab-groups.tab-pane
@@ -81,6 +88,8 @@
= 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: linked_groups_list_data_attributes(@group) }
+ .loading
+ .spinner.spinner-md
- else
%ul.content-list.members-list{ data: { qa_selector: 'groups_list' } }
- @group.shared_with_group_links.each do |group_link|
@@ -95,9 +104,15 @@
= render 'shared/members/search_field', name: 'search_invited'
- if vue_members_list_enabled
.js-group-invited-members-list{ data: group_members_list_data_attributes(@group, @invited_members) }
+ .loading
+ .spinner.spinner-md
- else
%ul.content-list.members-list
- = render partial: 'shared/members/member', collection: @invited_members, as: :member
+ = render partial: 'shared/members/member',
+ collection: @invited_members, as: :member,
+ locals: { membership_source: @group,
+ group: @group,
+ current_user_is_group_owner: current_user_is_group_owner }
= paginate @invited_members, param_name: 'invited_members_page', theme: 'gitlab', params: { page: nil }
- if show_access_requests
#tab-access-requests.tab-pane
@@ -107,6 +122,12 @@
= 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: group_members_list_data_attributes(@group, @requesters) }
+ .loading
+ .spinner.spinner-md
- else
%ul.content-list.members-list
- = render partial: 'shared/members/member', collection: @requesters, as: :member
+ = render partial: 'shared/members/member',
+ collection: @requesters, as: :member,
+ locals: { membership_source: @group,
+ group: @group,
+ current_user_is_group_owner: current_user_is_group_owner }
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index 33a9f423da6..ef7e3efdc68 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -30,6 +30,7 @@
'can-bulk-edit': @can_bulk_update.to_json,
'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') },
'sort-key': @sort,
- type: 'issues' } }
+ type: 'issues',
+ 'scoped-labels-available': scoped_labels_available?(@group).to_json } }
- else
= render 'shared/issues'
diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml
index debbe95d2aa..804d2da2c4b 100644
--- a/app/views/groups/labels/index.html.haml
+++ b/app/views/groups/labels/index.html.haml
@@ -5,7 +5,7 @@
- labels_or_filters = @labels.exists? || search.present? || subscribed.present?
- if labels_or_filters
- #promote-label-modal
+ #js-promote-label-modal
= render 'shared/labels/nav', labels_or_filters: labels_or_filters, can_admin_label: can_admin_label
.labels-container.gl-mt-2
diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml
index df82b264f9a..ffb0ade4f73 100644
--- a/app/views/groups/milestones/_form.html.haml
+++ b/app/views/groups/milestones/_form.html.haml
@@ -19,8 +19,8 @@
.form-actions
- if @milestone.new_record?
- = f.submit 'Create milestone', class: "btn-success btn", data: { qa_selector: "create_milestone_button" }
- = link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel"
+ = f.submit 'Create milestone', class: "btn-success gl-button btn", data: { qa_selector: "create_milestone_button" }
+ = link_to "Cancel", group_milestones_path(@group), class: "btn gl-button btn-cancel"
- else
- = f.submit 'Update milestone', class: "btn-success btn"
- = link_to "Cancel", group_milestone_path(@group, @milestone), class: "btn btn-cancel"
+ = f.submit 'Update milestone', class: "btn-success gl-button btn"
+ = link_to "Cancel", group_milestone_path(@group, @milestone), class: "btn gl-button btn-cancel"
diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml
index d20fa938a68..c93b24d14f0 100644
--- a/app/views/groups/milestones/index.html.haml
+++ b/app/views/groups/milestones/index.html.haml
@@ -8,7 +8,7 @@
= render 'shared/milestones/search_form'
= render 'shared/milestones_sort_dropdown'
- if can?(current_user, :admin_milestone, @group)
- = link_to "New milestone", new_group_milestone_path(@group), class: "btn btn-success", data: { qa_selector: "new_group_milestone_link" }
+ = link_to "New milestone", new_group_milestone_path(@group), class: "btn gl-button btn-success", data: { qa_selector: "new_group_milestone_link" }
.milestones
%ul.content-list
diff --git a/app/views/groups/runners/_group_runners.html.haml b/app/views/groups/runners/_group_runners.html.haml
index 554240b7aef..087c38c7b86 100644
--- a/app/views/groups/runners/_group_runners.html.haml
+++ b/app/views/groups/runners/_group_runners.html.haml
@@ -17,4 +17,6 @@
= render partial: 'ci/runner/how_to_setup_runner',
locals: { registration_token: @group.runners_token,
type: 'group',
- reset_token_url: reset_registration_token_group_settings_ci_cd_path }
+ reset_token_url: reset_registration_token_group_settings_ci_cd_path,
+ project_path: '',
+ group_path: @group.path }
diff --git a/app/views/groups/settings/repository/_initial_branch_name.html.haml b/app/views/groups/settings/repository/_initial_branch_name.html.haml
new file mode 100644
index 00000000000..3ef8dccae08
--- /dev/null
+++ b/app/views/groups/settings/repository/_initial_branch_name.html.haml
@@ -0,0 +1,22 @@
+%section.settings.as-default-branch-name.no-animate#js-default-branch-name{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4
+ = _('Default initial branch name')
+ %button.gl-button.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Set the default name of the initial branch when creating new repositories through the user interface.')
+ .settings-content
+ = form_for @group, url: group_path(@group, anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@group)
+ - fallback_branch_name = '<code>master</code>'
+
+ %fieldset
+ .form-group
+ = f.label :default_branch_name, _('Default initial branch name'), class: 'label-light'
+ = f.text_field :default_branch_name, value: group.namespace_settings&.default_branch_name, placeholder: 'master', class: 'form-control'
+ %span.form-text.text-muted
+ = (_("Changes affect new repositories only. If not specified, either the configured application-wide default or Git's default name %{branch_name_default} will be used.") % { branch_name_default: fallback_branch_name }).html_safe
+
+ = f.hidden_field :redirect_target, value: "repository_settings"
+ = f.submit _('Save changes'), class: 'gl-button btn-success'
diff --git a/app/views/groups/settings/repository/show.html.haml b/app/views/groups/settings/repository/show.html.haml
index ff0c9de4fef..a5819320405 100644
--- a/app/views/groups/settings/repository/show.html.haml
+++ b/app/views/groups/settings/repository/show.html.haml
@@ -4,3 +4,4 @@
- deploy_token_description = s_('DeployTokens|Group deploy tokens allow access to the packages, repositories, and registry images within the group.')
= render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description
+= render "initial_branch_name", group: @group
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index fa560942c5d..9d5ec5008dc 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -1,7 +1,9 @@
- breadcrumb_title _("Details")
-- page_title _("Groups")
- @content_class = "limit-container-width" unless fluid_layout
+- if show_thanks_for_purchase_banner?
+ = render_if_exists 'shared/thanks_for_purchase_banner', plan_title: plan_title, quantity: params[:purchased_quantity].to_i
+
- if show_invite_banner?(@group)
= content_for :group_invite_members_banner do
.container-fluid.container-limited{ class: "gl-pb-2! gl-pt-6! #{@content_class}" }
diff --git a/app/views/groups/sidebar/_packages.html.haml b/app/views/groups/sidebar/_packages.html.haml
index 54510d5df0c..7e0ee032aeb 100644
--- a/app/views/groups/sidebar/_packages.html.haml
+++ b/app/views/groups/sidebar/_packages.html.haml
@@ -1,7 +1,7 @@
- packages_link = group_packages_list_nav? ? group_packages_path(@group) : group_container_registries_path(@group)
- if group_packages_nav?
- = nav_link(controller: ['groups/packages', 'groups/registry/repositories']) do
+ = nav_link(controller: ['groups/packages', 'groups/registry/repositories', 'groups/dependency_proxies']) do
= link_to packages_link, title: _('Packages') do
.nav-icon-container
= sprite_icon('package')
@@ -21,3 +21,7 @@
= nav_link(controller: 'groups/registry/repositories') do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
%span= _('Container Registry')
+ - if group_dependency_proxy_nav?
+ = nav_link(controller: 'groups/dependency_proxies') do
+ = link_to group_dependency_proxy_path(@group), title: _('Dependency Proxy') do
+ %span= _('Dependency Proxy')
diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml
index 79cba2a54b0..70ac532e69f 100644
--- a/app/views/ide/_show.html.haml
+++ b/app/views/ide/_show.html.haml
@@ -1,6 +1,7 @@
- @body_class = 'ide-layout'
- page_title _('IDE')
+- add_page_specific_style 'page_bundles/build'
- add_page_specific_style 'page_bundles/ide'
#ide.ide-loading{ data: ide_data }
diff --git a/app/views/import/_project_status.html.haml b/app/views/import/_project_status.html.haml
deleted file mode 100644
index b968db58d38..00000000000
--- a/app/views/import/_project_status.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-- case project.import_status
-- when 'finished'
- = icon('check')
- = _('Done')
-- when 'started'
- = loading_icon
- = _('Started')
-- when 'failed'
- = _('Failed')
-- else
- = project.human_import_status_name
diff --git a/app/views/import/google_code/status.html.haml b/app/views/import/google_code/status.html.haml
index 72112c128cb..0004f0de69f 100644
--- a/app/views/import/google_code/status.html.haml
+++ b/app/views/import/google_code/status.html.haml
@@ -43,7 +43,7 @@
- case project.import_status
- when 'finished'
%span
- %i.fa.fa-check
+ = sprite_icon('check')
= _("done")
- when 'started'
= loading_icon
diff --git a/app/views/import/shared/_new_project_form.html.haml b/app/views/import/shared/_new_project_form.html.haml
index a558b21b461..b053d14a851 100644
--- a/app/views/import/shared/_new_project_form.html.haml
+++ b/app/views/import/shared/_new_project_form.html.haml
@@ -10,7 +10,7 @@
.input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url }
.input-group-text
= root_url
- = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace block-truncated', tabindex: 1
+ = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace block-truncated'
- else
.input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' }
.input-group-text.border-0
@@ -18,4 +18,4 @@
= hidden_field_tag :namespace_id, current_user.namespace_id
.form-group.col-12.col-sm-6.project-path
= label_tag :path, _('Project slug'), class: 'label-bold'
- = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", tabindex: 2, required: true
+ = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", required: true
diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml
index 37143799132..1492fea7fb2 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 gl-button btn-success"
+ = link_to _("Accept invitation"), accept_invite_url(@token), 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 655c413f2a6..355ffabd7ec 100644
--- a/app/views/jira_connect/subscriptions/index.html.haml
+++ b/app/views/jira_connect/subscriptions/index.html.haml
@@ -21,6 +21,8 @@
.gl-mt-5
%p Note: this integration only works with accounts on GitLab.com (SaaS).
- else
+ .js-jira-connect-app
+
%form#add-subscription-form.subscription-form{ action: jira_connect_subscriptions_path }
.ak-field-group
%label
@@ -50,12 +52,15 @@
%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
+ 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.
+ 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
+= webpack_bundle_tag 'performance_bar' if performance_bar_enabled?
+= webpack_bundle_tag 'jira_connect_app'
+
= page_specific_javascript_tag('jira_connect.js')
- add_page_specific_style 'page_bundles/jira_connect'
diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml
index a0b57f8dd52..35fefe40d39 100644
--- a/app/views/layouts/_flash.html.haml
+++ b/app/views/layouts/_flash.html.haml
@@ -1,6 +1,6 @@
-# We currently only support `alert`, `notice`, `success`, 'toast'
- icons = {'alert' => 'error', 'notice' => 'information-o', 'success' => 'check-circle'};
-.flash-container.flash-container-page.sticky
+.flash-container.flash-container-page.sticky{ data: { qa_selector: 'flash_container' } }
- flash.each do |key, value|
- if key == 'toast' && value
.js-toast-message{ data: { message: value } }
diff --git a/app/views/layouts/_google_tag_manager_body.html.haml b/app/views/layouts/_google_tag_manager_body.html.haml
new file mode 100644
index 00000000000..d62e52dc91b
--- /dev/null
+++ b/app/views/layouts/_google_tag_manager_body.html.haml
@@ -0,0 +1,4 @@
+- return unless google_tag_manager_enabled?
+
+<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=#{extra_config.google_tag_manager_id}"
+height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
diff --git a/app/views/layouts/_google_tag_manager_head.html.haml b/app/views/layouts/_google_tag_manager_head.html.haml
new file mode 100644
index 00000000000..ab03f1e7670
--- /dev/null
+++ b/app/views/layouts/_google_tag_manager_head.html.haml
@@ -0,0 +1,8 @@
+- if google_tag_manager_enabled?
+ = javascript_tag nonce: true do
+ :plain
+ (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
+ new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
+ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
+ 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
+ })(window,document,'script','dataLayer','#{extra_config.google_tag_manager_id}');
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 9d0c3ad5787..1d12b30c58c 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -1,17 +1,4 @@
- page_description brand_title unless page_description
-
--# Needs a redirect on the client side since it's using an anchor to distinguish
--# between sign in and registration. We need to inline the JS to not render
--# anything from this page beforehand.
--# Part of an experiment to build a new sign up flow. Will be removed again with
--# https://gitlab.com/gitlab-org/growth/engineering/issues/64
-- if experiment_enabled?(:signup_flow) && current_path?("sessions#new")
- = javascript_tag nonce: true do
- :plain
- if (window.location.hash === '#register-pane') {
- window.location.replace("/users/sign_up")
- }
-
- site_name = "GitLab"
%head{ prefix: "og: http://ogp.me/ns#" }
%meta{ charset: "utf-8" }
diff --git a/app/views/layouts/_mailer.html.haml b/app/views/layouts/_mailer.html.haml
index 24b8138078d..74d05be7f95 100644
--- a/app/views/layouts/_mailer.html.haml
+++ b/app/views/layouts/_mailer.html.haml
@@ -34,13 +34,7 @@
= render_if_exists 'layouts/mailer/additional_text'
- %tr.footer
- %td
- %img{ alt: "GitLab", height: "33", width: "90", src: image_url('mailers/gitlab_footer_logo.gif') }
- %div
- - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, class: 'mng-notif-link')
- - help_link = link_to(_("Help"), help_url, class: 'help-link')
- = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link }
+ = yield :footer
= yield :additional_footer
%tr
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 9b925369660..f6fc49393d8 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -4,12 +4,13 @@
.content-wrapper{ class: "#{@content_wrapper_class}" }
.mobile-overlay
= yield :group_invite_members_banner
- .alert-wrapper
+ .alert-wrapper.gl-force-block-formatting-context
= render 'shared/outdated_browser'
= render_if_exists "layouts/header/licensed_user_count_threshold"
= render_if_exists "layouts/header/token_expiry_notification"
= render "layouts/broadcast"
= render "layouts/header/read_only_banner"
+ = render "layouts/header/registration_enabled_callout"
= render "layouts/nav/classification_level_banner"
= yield :flash_message
= render "shared/ping_consent"
@@ -20,6 +21,6 @@
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
- .content{ id: "content-body" }
+ .content{ id: "content-body", **page_itemtype }
= render "layouts/flash", extra_flash_class: 'limit-container-width'
= yield
diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml
index 0c6932e59a9..c902c687378 100644
--- a/app/views/layouts/_search.html.haml
+++ b/app/views/layouts/_search.html.haml
@@ -6,14 +6,13 @@
= search_field_tag 'search', nil, placeholder: _('Search or jump to…'),
class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options',
spellcheck: false,
- tabindex: '1',
autocomplete: 'off',
data: { issues_path: issues_dashboard_path,
mr_path: merge_requests_dashboard_path,
qa_selector: 'search_term_field' },
aria: { label: _('Search or jump to…') }
%button.hidden.js-dropdown-search-toggle{ type: 'button', data: { toggle: 'dropdown' } }
- .dropdown-menu.dropdown-select.js-dashboard-search-options
+ .dropdown-menu.dropdown-select{ data: { testid: 'dashboard-search-options' } }
= dropdown_content do
%ul
%li.dropdown-menu-empty-item
diff --git a/app/views/layouts/_startup_js.html.haml b/app/views/layouts/_startup_js.html.haml
index f312e00c394..9c488e4f40d 100644
--- a/app/views/layouts/_startup_js.html.haml
+++ b/app/views/layouts/_startup_js.html.haml
@@ -25,7 +25,7 @@
};
gl.startup_graphql_calls = gl.startup_graphql_calls.map(call => ({
- operationName: call.query.match(/^query (.+)\(/)[1],
+ ...call,
fetchCall: fetch(url, {
...opts,
credentials: 'same-origin',
diff --git a/app/views/layouts/devise_experimental_onboarding_issues.html.haml b/app/views/layouts/devise_experimental_onboarding_issues.html.haml
index ec9867f9e1f..f768fba84ca 100644
--- a/app/views/layouts/devise_experimental_onboarding_issues.html.haml
+++ b/app/views/layouts/devise_experimental_onboarding_issues.html.haml
@@ -1,6 +1,6 @@
!!! 5
%html.devise-layout-html.navless{ class: system_message_class }
- - add_page_specific_style 'page_bundles/experimental_separate_sign_up'
+ - add_page_specific_style 'page_bundles/signup'
= 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
deleted file mode 100644
index 6be62645768..00000000000
--- a/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml
+++ /dev/null
@@ -1,20 +0,0 @@
-!!! 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"
- = render "layouts/init_client_detection_flags"
- .page-wrap
- .container.signup-box-container.navless-container
- = render "layouts/broadcast"
- .content
- = render "layouts/flash"
- = yield
- %hr.footer-fixed
- .footer-container
- .container
- .footer-links
- = link_to _("Help"), help_path
- = link_to _("About GitLab"), "https://about.gitlab.com/"
- = footer_message
diff --git a/app/views/layouts/experiment_mailer.html.haml b/app/views/layouts/experiment_mailer.html.haml
deleted file mode 100644
index 5a342c400d6..00000000000
--- a/app/views/layouts/experiment_mailer.html.haml
+++ /dev/null
@@ -1,48 +0,0 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-%html{ lang: "en" }
- %head
- %meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }/
- %meta{ content: "width=device-width, initial-scale=1", name: "viewport" }/
- %meta{ content: "IE=edge", "http-equiv" => "X-UA-Compatible" }/
- %title= message.subject
-
- -# Avoid premailer processing of client-specific styles (@media tag not supported)
- -# We need to inline the contents here because mail clients (e.g. iOS Mail, Outlook)
- -# do not support linked stylesheets.
- %style{ type: 'text/css', 'data-premailer': 'ignore' }
- = asset_to_string('mailer_client_specific.css').html_safe
-
- = stylesheet_link_tag 'mailer.css'
- %body
- %table#body{ border: "0", cellpadding: "0", cellspacing: "0" }
- %tbody
- %tr.line
- %td
- %tr.header
- %td
- = html_header_message
- = header_logo
- %tr
- %td
- %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0" }
- %tbody
- %tr
- %td.wrapper-cell{ style: "padding: 0" }
- %table.content{ border: "0", cellpadding: "0", cellspacing: "0" }
- %tbody
- = yield
-
- = render_if_exists 'layouts/mailer/additional_text'
-
- %tr.footer
- %td{ style: "padding: 24px 0" }
- %img{ alt: "GitLab", height: "33", width: "90", src: image_url('mailers/gitlab_footer_logo.gif') }
- %p{ style: "color: #949ba5; max-width: 640px; margin: 0 auto; text-align: left; font-size: 12px;" }
- GitLab is a complete DevOps platform, delivered as a single application, fundamentally changing the way
- %br
- Development, Security, and Ops teams collaborate.
-
- = yield :additional_footer
- %tr
- %td.footer-message
- = html_footer_message
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index 4c6bfc0b33c..addf2375222 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -2,13 +2,16 @@
%ul
%li.current-user
- .user-name.bold
+ .user-name.gl-font-weight-bold
= current_user.name
+ - if current_user&.status && user_status_set_to_busy?(current_user.status)
+ %span.gl-font-weight-normal.gl-text-gray-500= s_("UserProfile|(Busy)")
= current_user.to_reference
- if current_user.status
.user-status.d-flex.align-items-center.gl-mt-2.has-tooltip{ title: current_user.status.message_html, data: { html: 'true', placement: 'bottom' } }
- %span.user-status-emoji.d-flex.align-items-center
- = emoji_icon current_user.status.emoji
+ - if show_status_emoji?(current_user.status)
+ .user-status-emoji.d-flex.align-items-center
+ = emoji_icon current_user.status.emoji
%span.user-status-message.str-truncated
= current_user.status.message_html.html_safe
%li.divider
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index f6dc808aa55..794d1589172 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -1,4 +1,5 @@
- has_impersonation_link = header_link?(:admin_impersonation)
+- user_status_data = user_status_properties(current_user)
%header.navbar.navbar-gitlab.navbar-expand-sm.js-navbar{ data: { qa_selector: 'navbar' } }
%a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content
@@ -103,4 +104,4 @@
#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 : '' } }
+ .js-set-status-modal-wrapper{ data: user_status_data }
diff --git a/app/views/layouts/header/_registration_enabled_callout.html.haml b/app/views/layouts/header/_registration_enabled_callout.html.haml
new file mode 100644
index 00000000000..1b1804edcc7
--- /dev/null
+++ b/app/views/layouts/header/_registration_enabled_callout.html.haml
@@ -0,0 +1,15 @@
+- return unless show_registration_enabled_user_callout?
+
+%div{ class: [container_class, @content_class, 'gl-pt-5!'] }
+ .gl-alert.gl-alert-warning.js-registration-enabled-callout{ role: 'alert', data: { feature_id: UserCalloutsHelper::REGISTRATION_ENABLED_CALLOUT, dismiss_endpoint: user_callouts_path } }
+ = sprite_icon('warning', size: 16, css_class: 'gl-alert-icon')
+ %button.gl-alert-dismiss.js-close{ type: 'button', aria: { label: _('Close') }, data: { testid: 'close-registration-enabled-callout' } }
+ = sprite_icon('close', size: 16)
+ .gl-alert-title
+ = _('Open registration is enabled on your instance.')
+ .gl-alert-body
+ = html_escape(_('%{anchorOpen}Learn more%{anchorClose} about how you can customize / disable registration on your instance.')) % { anchorOpen: "<a href=\"#{help_page_path('user/admin_area/settings/sign_up_restrictions')}\">".html_safe, anchorClose: '</a>'.html_safe }
+ .gl-alert-actions
+ = link_to general_admin_application_settings_path(anchor: 'js-signup-settings'), class: 'btn gl-alert-action btn-info btn-md gl-button' do
+ %span.gl-button-text
+ = _('View setting')
diff --git a/app/views/layouts/mailer.html.haml b/app/views/layouts/mailer.html.haml
index 28dcbce7183..c2eb6b68024 100644
--- a/app/views/layouts/mailer.html.haml
+++ b/app/views/layouts/mailer.html.haml
@@ -1 +1,10 @@
+= content_for :footer do
+ %tr.footer
+ %td
+ %img.footer-logo{ alt: "GitLab", src: image_url('mailers/gitlab_footer_logo.gif') }
+ %div
+ - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, class: 'mng-notif-link')
+ - help_link = link_to(_("Help"), help_url, class: 'help-link')
+ = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link }
+
= render 'layouts/mailer'
diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml
index 547d005a93e..f0cdb3d1a51 100644
--- a/app/views/layouts/nav/_breadcrumbs.html.haml
+++ b/app/views/layouts/nav/_breadcrumbs.html.haml
@@ -1,5 +1,6 @@
- container = @no_breadcrumb_container ? 'container-fluid' : container_class
- hide_top_links = @hide_top_links || false
+- push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link)
%nav.breadcrumbs{ role: "navigation", class: [container, @content_class] }
.breadcrumbs-container{ class: ("border-bottom-0" if @no_breadcrumb_border) }
@@ -17,4 +18,7 @@
= render "layouts/nav/breadcrumbs/collapsed_dropdown", location: :after
%li
%h2.breadcrumbs-sub-title= link_to @breadcrumb_title, breadcrumb_title_link
+ %script{ type:'application/ld+json' }
+ :plain
+ #{schema_breadcrumb_json}
= yield :header_content
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index abaadc89a9e..7cbef6b00b1 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -2,7 +2,7 @@
-# https://gitlab.com/gitlab-org/gitlab-foss/issues/49713 for more information.
%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
+ = 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" } }) do
%button{ type: 'button', data: { toggle: "dropdown" } }
= _('Projects')
= sprite_icon('chevron-down', css_class: 'caret-down')
@@ -10,7 +10,7 @@
= 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
+ = 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" } }) do
%button{ type: 'button', data: { toggle: "dropdown" } }
= _('Groups')
= sprite_icon('chevron-down', css_class: 'caret-down')
@@ -18,7 +18,7 @@
= render "layouts/nav/groups_dropdown/show"
- if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets])
- %li.header-more.dropdown{ **tracking_attrs('main_navigation', 'click_more_link', 'navigation') }
+ = nav_link(html_options: { id: 'nav-more-dropdown', class: "header-more dropdown", data: { track_label: "more_dropdown", track_event: "click_more_link" } }) do
%a{ href: "#", data: { toggle: "dropdown", qa_selector: 'more_dropdown' } }
= _('More')
= sprite_icon('chevron-down', css_class: 'caret-down')
@@ -71,11 +71,11 @@
- if Feature.enabled?(:user_mode_in_session)
- if header_link?(:admin_mode)
- = nav_link(controller: 'admin/sessions', html_options: { class: "d-none d-lg-block d-xl-block"}) do
+ = nav_link(controller: 'admin/sessions', html_options: { class: "d-none d-lg-block"}) do
= link_to destroy_admin_session_path, method: :post, title: _('Leave Admin Mode'), aria: { label: _('Leave Admin Mode') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= sprite_icon('lock-open', size: 18)
- elsif current_user.admin?
- = nav_link(controller: 'admin/sessions', html_options: { class: "d-none d-lg-block d-xl-block"}) do
+ = nav_link(controller: 'admin/sessions', html_options: { class: "d-none d-lg-block"}) do
= link_to new_admin_session_path, title: _('Enter Admin Mode'), aria: { label: _('Enter Admin Mode') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= sprite_icon('lock', size: 18)
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 0da4d4f7ddd..1e0e9628c89 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -69,7 +69,7 @@
= link_to admin_cohorts_path, title: _('Cohorts') do
%span
= _('Cohorts')
- - if Feature.enabled?(:instance_statistics)
+ - if Feature.enabled?(:instance_statistics, default_enabled: true)
= nav_link(controller: :instance_statistics) do
= link_to admin_instance_statistics_path, title: _('Instance Statistics') do
%span
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index d3d71f91176..5ff774d5d9c 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -167,7 +167,7 @@
= render_if_exists "layouts/nav/requirements_link", project: @project
- if project_nav_tab? :pipelines
- = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts, :test_cases], unless: -> { current_path?('projects/pipelines#charts') }) do
+ = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts, :test_cases, :pipeline_editor], unless: -> { current_path?('projects/pipelines#charts') }) do
= link_to project_pipelines_path(@project), class: 'shortcuts-pipelines qa-link-pipelines rspec-link-pipelines', data: { qa_selector: 'ci_cd_link' } do
.nav-icon-container
= sprite_icon('rocket')
@@ -175,7 +175,7 @@
= _('CI / CD')
%ul.sidebar-sub-level-items
- = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts, :test_cases], html_options: { class: "fly-out-top-item" }) do
+ = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts, :test_cases, :pipeline_editor], html_options: { class: "fly-out-top-item" }) do
= link_to project_pipelines_path(@project) do
%strong.fly-out-top-item-name
= _('CI / CD')
@@ -186,6 +186,12 @@
%span
= _('Pipelines')
+ - if can_view_pipeline_editor?(@project)
+ = nav_link(controller: :pipeline_editor, action: :show) do
+ = link_to project_ci_pipeline_editor_path(@project), title: s_('Pipelines|Editor') do
+ %span
+ = s_('Pipelines|Editor')
+
- if project_nav_tab? :builds
= nav_link(controller: :jobs) do
= link_to project_jobs_path(@project), title: _('Jobs'), class: 'shortcuts-builds' do
@@ -262,6 +268,12 @@
%span
= _('Serverless')
+ - if project_nav_tab? :terraform
+ = nav_link(controller: :terraform) do
+ = link_to project_terraform_index_path(@project), title: _('Terraform') do
+ %span
+ = _('Terraform')
+
- if project_nav_tab? :clusters
- show_cluster_hint = show_gke_cluster_integration_callout?(@project)
= nav_link(controller: [:clusters, :user, :gcp]) do
@@ -272,7 +284,6 @@
.feature-highlight.js-feature-highlight{ disabled: true,
data: { trigger: 'manual',
container: 'body',
- toggle: 'popover',
placement: 'right',
highlight: UserCalloutsHelper::GKE_CLUSTER_INTEGRATION,
highlight_priority: UserCallout.feature_names[:GKE_CLUSTER_INTEGRATION],
@@ -369,6 +380,8 @@
%strong.fly-out-top-item-name
= _('Members')
+ = render_if_exists 'projects/invite_members_side_nav_link', project: @project
+
- if project_nav_tab? :settings
= nav_link(path: sidebar_settings_paths) do
= link_to edit_project_path(@project) do
@@ -412,7 +425,7 @@
= link_to project_settings_ci_cd_path(@project), title: _('CI / CD') do
%span
= _('CI / CD')
- - if !@project.archived? && settings_operations_available?
+ - if settings_operations_available?
= nav_link(controller: [:operations]) do
= link_to project_settings_operations_path(@project), title: _('Operations'), data: { qa_selector: 'operations_settings_link' } do
= _('Operations')
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index a0c82380023..62e5431e290 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -2,6 +2,7 @@
- page_description @project.description_html unless page_description
- header_title project_title(@project) unless header_title
- nav "project"
+- page_itemtype 'http://schema.org/SoftwareSourceCode'
- display_subscription_banner!
- display_namespace_storage_limit_alert!
- @left_sidebar = true
diff --git a/app/views/layouts/unknown_user_mailer.html.haml b/app/views/layouts/unknown_user_mailer.html.haml
new file mode 100644
index 00000000000..2eb7b400604
--- /dev/null
+++ b/app/views/layouts/unknown_user_mailer.html.haml
@@ -0,0 +1,8 @@
+= content_for :footer do
+ %tr.footer
+ %td.gitlab-info
+ %img.footer-logo{ alt: "GitLab", src: image_url('mailers/gitlab_footer_logo.gif') }
+ %p.gitlab-info-text
+ = html_escape(_("GitLab is a complete DevOps platform, delivered as a single application, fundamentally changing the way%{br_tag}Development, Security, and Ops teams collaborate")) % { br_tag: '<br/>'.html_safe }
+
+= render 'layouts/mailer'
diff --git a/app/views/layouts/unknown_user_mailer.text.erb b/app/views/layouts/unknown_user_mailer.text.erb
new file mode 100644
index 00000000000..f3d8f13b7bf
--- /dev/null
+++ b/app/views/layouts/unknown_user_mailer.text.erb
@@ -0,0 +1,9 @@
+<%= text_header_message %>
+
+<%= yield -%>
+
+-- <%# signature marker %>
+<%= _("GitLab is a complete DevOps platform, delivered as a single application, fundamentally changing the way Development, Security, and Ops teams collaborate") %>
+<%= render_if_exists 'layouts/mailer/additional_text' %>
+
+<%= text_footer_message %>
diff --git a/app/views/layouts/welcome.html.haml b/app/views/layouts/welcome.html.haml
new file mode 100644
index 00000000000..48921e9ff89
--- /dev/null
+++ b/app/views/layouts/welcome.html.haml
@@ -0,0 +1,8 @@
+!!! 5
+%html.subscriptions-layout-html{ lang: 'en' }
+ = render 'layouts/head'
+ %body.ui-indigo.d-flex.vh-100.gl-bg-gray-10
+ = render "layouts/header/logo_with_title"
+ = render "layouts/broadcast"
+ .container.d-flex.flex-grow-1.m-0
+ = yield
diff --git a/app/views/notify/_issuable_csv_export.html.haml b/app/views/notify/_issuable_csv_export.html.haml
index 239b5b14966..5a581811179 100644
--- a/app/views/notify/_issuable_csv_export.html.haml
+++ b/app/views/notify/_issuable_csv_export.html.haml
@@ -1,6 +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 }
+ = _('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.titleize.downcase), 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/_note_email.html.haml b/app/views/notify/_note_email.html.haml
index c558358725c..2cef6f97d48 100644
--- a/app/views/notify/_note_email.html.haml
+++ b/app/views/notify/_note_email.html.haml
@@ -9,7 +9,7 @@
= succeed ':' do
= link_to note.author_name, user_url(note.author)
- if discussion.nil?
- commented
+ = link_to 'commented', target_url
- else
- if note.start_of_discussion?
started a new
diff --git a/app/views/notify/instance_access_request_email.html.haml b/app/views/notify/instance_access_request_email.html.haml
new file mode 100644
index 00000000000..87bc655164b
--- /dev/null
+++ b/app/views/notify/instance_access_request_email.html.haml
@@ -0,0 +1,10 @@
+#content
+ = email_default_heading(say_hello(@recipient))
+ %p
+ = instance_access_request_text(@user, format: :html)
+ %p
+ = _("Username: %{username}") % { username: @user.username }
+ %p
+ = _("Email: %{email}") % { email: @user.email }
+ %p
+ = instance_access_request_link(@user, format: :html)
diff --git a/app/views/notify/instance_access_request_email.text.erb b/app/views/notify/instance_access_request_email.text.erb
new file mode 100644
index 00000000000..317f962a29c
--- /dev/null
+++ b/app/views/notify/instance_access_request_email.text.erb
@@ -0,0 +1,8 @@
+<%= say_hello(@recipient) %>
+
+<%= instance_access_request_text(@user) %>
+
+<%= _("Username: %{username}") % { username: @user.username } %>
+<%= _("Email: %{email}") % { email: @user.email } %>
+
+<%= instance_access_request_link(@user) %>
diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml
index 4fcd2936d25..5ff1e2393c9 100644
--- a/app/views/notify/member_invited_email.html.haml
+++ b/app/views/notify/member_invited_email.html.haml
@@ -1,16 +1,12 @@
+- placeholders = { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, project_or_group_name: member_source.human_name, project_or_group: member_source.model_name.singular, br_tag: '<br/>'.html_safe, role: member.human_access.downcase }
%tr
%td.text-content
+ %h2.invite-header
+ = s_('InviteEmail|You are invited!')
%p
- You have been invited
- if member.created_by
- by
- = link_to member.created_by.name, user_url(member.created_by)
- to join the
- = link_to member_source.human_name, member_source.public? ? member_source.web_url : invite_url(@token), class: :highlight
- #{member_source.model_name.singular} as #{content_tag :span, member.human_access, class: :highlight}.
-
- %p
- = link_to 'Accept invitation', invite_url(@token, @invite_url_params)
- or
- = link_to 'decline', decline_invite_url(@token)
-
+ = html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to member.created_by.name, user_url(member.created_by)).html_safe })
+ - else
+ = html_escape(s_("InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders
+ %p.invite-actions
+ = link_to s_('InviteEmail|Join now'), invite_url(@token), class: 'invite-btn-join'
diff --git a/app/views/notify/member_invited_email.text.erb b/app/views/notify/member_invited_email.text.erb
index e6e6a685f92..e58dfc10810 100644
--- a/app/views/notify/member_invited_email.text.erb
+++ b/app/views/notify/member_invited_email.text.erb
@@ -1,4 +1,9 @@
-You have been invited <%= "by #{sanitize_name(member.created_by.name)} " if member.created_by %>to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> as <%= member.human_access %>.
+<% placeholders = { project_or_group_name: member_source.human_name, project_or_group: member_source.model_name.singular, role: member.human_access.downcase } %>
-Accept invitation: <%= invite_url(@token, @invite_url_params) %>
-Decline invitation: <%= decline_invite_url(@token) %>
+<% if member.created_by %>
+<%= s_('InviteEmail|%{inviter} invited you to join the %{project_or_group_name} %{project_or_group} as a %{role}') % placeholders.merge({ inviter: sanitize_name(member.created_by.name) }) %>
+<% else %>
+<%= s_('InviteEmail|You have been invited to join the %{project_or_group_name} %{project_or_group} as a %{role}') % placeholders %>
+<% end %>
+
+<%= s_('InviteEmail|Join now') %>: <%= invite_url(@token) %>
diff --git a/app/views/notify/member_invited_email_experiment.html.haml b/app/views/notify/member_invited_email_experiment.html.haml
deleted file mode 100644
index 5cfb6acee05..00000000000
--- a/app/views/notify/member_invited_email_experiment.html.haml
+++ /dev/null
@@ -1,12 +0,0 @@
-%tr
- %td.text-content
- %h2.invite-header
- = s_('InviteEmail|You are invited!')
- %p
- - if member.created_by
- = html_escape(s_("InviteEmail|%{inviter} invited you")) % { inviter: (link_to member.created_by.name, user_url(member.created_by)).html_safe }
- = html_escape(s_("InviteEmail|to join the %{strong_start}%{project_or_group_name}%{strong_end}")) % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, project_or_group_name: member_source.human_name }
- %br
- = s_("InviteEmail|%{project_or_group} as a %{role}") % { project_or_group: member_source.model_name.singular, role: member.human_access.downcase }
- %p.invite-actions
- = link_to s_('InviteEmail|Join now'), invite_url(@token, @invite_url_params), class: 'invite-btn-join'
diff --git a/app/views/notify/member_invited_email_experiment.text.erb b/app/views/notify/member_invited_email_experiment.text.erb
deleted file mode 100644
index 6843cea4df7..00000000000
--- a/app/views/notify/member_invited_email_experiment.text.erb
+++ /dev/null
@@ -1,10 +0,0 @@
-<% project_and_role = s_('InviteEmail|to join the %{project_or_group_name} %{project_or_group} as a %{role}') \
- % { project_or_group_name: member_source.human_name, project_or_group: member_source.model_name.singular, role: member.human_access.downcase } %>
-
-<% if member.created_by %>
-<%= s_('InviteEmail|%{inviter} invited you') % { inviter: sanitize_name(member.created_by.name) } %> <%= project_and_role %>
-<% else %>
-<%= s_('InviteEmail|You have been invited') %> <%= project_and_role %>
-<% end %>
-
-Join now: <%= invite_url(@token, @invite_url_params) %>
diff --git a/app/views/notify/prometheus_alert_fired_email.html.haml b/app/views/notify/prometheus_alert_fired_email.html.haml
index 75ba66b44f9..cdc97d583df 100644
--- a/app/views/notify/prometheus_alert_fired_email.html.haml
+++ b/app/views/notify/prometheus_alert_fired_email.html.haml
@@ -1,5 +1,9 @@
+- body = @alert.resolved? ? _('An alert has been resolved in %{project_path}.') : _('An alert has been triggered in %{project_path}.')
+
+%p
+ = body % { project_path: @alert.project.full_path }
%p
- = _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project.full_path }
+ = link_to(_('View alert details.'), @alert.details_url)
- if description = @alert.description
%p
diff --git a/app/views/notify/prometheus_alert_fired_email.text.erb b/app/views/notify/prometheus_alert_fired_email.text.erb
index 8853f2a317b..b23cd8b6ccc 100644
--- a/app/views/notify/prometheus_alert_fired_email.text.erb
+++ b/app/views/notify/prometheus_alert_fired_email.text.erb
@@ -1,4 +1,7 @@
-<%= _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project.full_path } %>.
+<% body = @alert.resolved? ? _('An alert has been resolved in %{project_path}.') : _('An alert has been triggered in %{project_path}.') %>
+
+<%= body % { project_path: @alert.project.full_path } %>
+<%= _('View alert details at') %> <%= @alert.details_url %>
<% if description = @alert.description %>
<%= _('Description:') %> <%= description %>
diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml
index b952868e4e3..f74902a3c3b 100644
--- a/app/views/profiles/_event_table.html.haml
+++ b/app/views/profiles/_event_table.html.haml
@@ -5,7 +5,7 @@
- events.each do |event|
%li
%span.description
- = audit_icon(event.details[:with], class: "gl-mr-2")
+ = audit_icon(event.details[:with], css_class: 'gl-mr-2')
= _('Signed in with %{authentication} authentication') % { authentication: event.details[:with]}
%span.float-right= time_ago_with_tooltip(event.created_at)
diff --git a/app/views/profiles/preferences/_gitpod.html.haml b/app/views/profiles/preferences/_gitpod.html.haml
deleted file mode 100644
index 589c3a27c18..00000000000
--- a/app/views/profiles/preferences/_gitpod.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-%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')
-.form-group.form-check
- = f.check_box :gitpod_enabled, class: 'form-check-input'
- = f.label :gitpod_enabled, class: 'form-check-label' do
- = s_('Gitpod|Enable Gitpod integration').html_safe
- .form-text.text-muted
- = gitpod_enable_description
diff --git a/app/views/profiles/preferences/_integrations.html.haml b/app/views/profiles/preferences/_integrations.html.haml
deleted file mode 100644
index 037fe5df263..00000000000
--- a/app/views/profiles/preferences/_integrations.html.haml
+++ /dev/null
@@ -1,18 +0,0 @@
-- views = integration_views
-- return unless views.any?
-
-.col-sm-12
- %hr
-
-.col-lg-4.profile-settings-sidebar#integrations
- %h4.gl-mt-0
- = s_('Preferences|Integrations')
- %p
- = s_('Preferences|Customize integrations with third party services.')
- = succeed '.' do
- = link_to _('Learn more'), help_page_path('user/profile/preferences.md', anchor: 'integrations'), target: '_blank'
-
-.col-lg-8
- - views.each do |view|
- = render view, f: f
-
diff --git a/app/views/profiles/preferences/_sourcegraph.html.haml b/app/views/profiles/preferences/_sourcegraph.html.haml
deleted file mode 100644
index fdd0be22664..00000000000
--- a/app/views/profiles/preferences/_sourcegraph.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-%label.label-bold
- = s_('Preferences|Sourcegraph')
-= link_to sprite_icon('question-o'), help_page_path('user/profile/preferences.md', anchor: 'sourcegraph'), target: '_blank', class: 'has-tooltip', title: _('More information')
-.form-group.form-check
- = f.check_box :sourcegraph_enabled, class: 'form-check-input'
- = f.label :sourcegraph_enabled, class: 'form-check-label' do
- = s_('Preferences|Enable integrated code intelligence on code views').html_safe
- .form-text.text-muted
- = sourcegraph_url_message
- = sourcegraph_experimental_message
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index b8d7e1af005..ca5972f1b46 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -1,146 +1,151 @@
- page_title _('Preferences')
- @content_class = "limit-container-width" unless fluid_layout
+- user_fields = { gitpod_enabled: @user.gitpod_enabled, sourcegraph_enabled: @user.sourcegraph_enabled }
+- user_theme_id = Gitlab::Themes.for_user(@user).id
+- data_attributes = { integration_views: integration_views.to_json, user_fields: user_fields.to_json }
- Gitlab::Themes.each do |theme|
= 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
- %h4.gl-mt-0
- = s_('Preferences|Navigation theme')
- %p
- = s_('Preferences|Customize the appearance of the application header and navigation sidebar.')
- .col-lg-8.application-theme
- .row
- - Gitlab::Themes.each do |theme|
- %label.col-6.col-sm-4.col-md-3.gl-mb-5.gl-text-center
- .preview{ class: theme.css_class }
- = f.radio_button :theme_id, theme.id, checked: Gitlab::Themes.for_user(@user).id == theme.id
- = theme.name
+= form_for @user, url: profile_preferences_path, remote: true, method: :put do |f|
+ .row.gl-mt-3.js-preferences-form
+ .col-lg-4.application-theme#navigation-theme
+ %h4.gl-mt-0
+ = s_('Preferences|Navigation theme')
+ %p
+ = s_('Preferences|Customize the appearance of the application header and navigation sidebar.')
+ .col-lg-8.application-theme
+ .row
+ - Gitlab::Themes.each do |theme|
+ %label.col-6.col-sm-4.col-md-3.gl-mb-5.gl-text-center
+ .preview{ class: theme.css_class }
+ = f.radio_button :theme_id, theme.id, checked: user_theme_id == theme.id
+ = theme.name
- .col-sm-12
- %hr
+ .col-sm-12
+ %hr
- .col-lg-4.profile-settings-sidebar#syntax-highlighting-theme
- %h4.gl-mt-0
- = s_('Preferences|Syntax highlighting theme')
- %p
- = s_('Preferences|This setting allows you to customize the appearance of the syntax.')
- = succeed '.' do
- = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank'
- .col-lg-8.syntax-theme
- - Gitlab::ColorSchemes.each do |scheme|
- = label_tag do
- .preview= image_tag "#{scheme.css_class}-scheme-preview.png"
- = f.radio_button :color_scheme_id, scheme.id
- = scheme.name
+ .col-lg-4.profile-settings-sidebar#syntax-highlighting-theme
+ %h4.gl-mt-0
+ = s_('Preferences|Syntax highlighting theme')
+ %p
+ = s_('Preferences|This setting allows you to customize the appearance of the syntax.')
+ = succeed '.' do
+ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank'
+ .col-lg-8.syntax-theme
+ - Gitlab::ColorSchemes.each do |scheme|
+ = label_tag do
+ .preview= image_tag "#{scheme.css_class}-scheme-preview.png"
+ = f.radio_button :color_scheme_id, scheme.id
+ = scheme.name
- .col-sm-12
- %hr
+ .col-sm-12
+ %hr
- .col-lg-4.profile-settings-sidebar#behavior
- %h4.gl-mt-0
- = s_('Preferences|Behavior')
- %p
- = s_('Preferences|This setting allows you to customize the behavior of the system layout and default views.')
- = succeed '.' do
- = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank'
- .col-lg-8
- .form-group
- = f.label :layout, class: 'label-bold' do
- = s_('Preferences|Layout width')
- = f.select :layout, layout_choices, {}, class: 'select2'
- .form-text.text-muted
- = s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' }
- .form-group
- = f.label :dashboard, class: 'label-bold' do
- = s_('Preferences|Homepage content')
- = f.select :dashboard, dashboard_choices, {}, class: 'select2'
- .form-text.text-muted
- = s_('Preferences|Choose what content you want to see on your homepage.')
+ .col-lg-4.profile-settings-sidebar#behavior
+ %h4.gl-mt-0
+ = s_('Preferences|Behavior')
+ %p
+ = s_('Preferences|This setting allows you to customize the behavior of the system layout and default views.')
+ = succeed '.' do
+ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank'
+ .col-lg-8
+ .form-group
+ = f.label :layout, class: 'label-bold' do
+ = s_('Preferences|Layout width')
+ = f.select :layout, layout_choices, {}, class: 'select2'
+ .form-text.text-muted
+ = s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' }
+ .form-group
+ = f.label :dashboard, class: 'label-bold' do
+ = s_('Preferences|Homepage content')
+ = f.select :dashboard, dashboard_choices, {}, class: 'select2'
+ .form-text.text-muted
+ = s_('Preferences|Choose what content you want to see on your homepage.')
- = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific
+ = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific
- .form-group
- = f.label :project_view, class: 'label-bold' do
- = s_('Preferences|Project overview content')
- = f.select :project_view, project_view_choices, {}, class: 'select2'
- .form-text.text-muted
- = s_('Preferences|Choose what content you want to see on a project’s overview page.')
- .form-group.form-check
- = f.check_box :render_whitespace_in_code, class: 'form-check-input'
- = f.label :render_whitespace_in_code, class: 'form-check-label' do
- = s_('Preferences|Render whitespace characters in the Web IDE')
- .form-group.form-check
- = f.check_box :show_whitespace_in_diffs, class: 'form-check-input'
- = f.label :show_whitespace_in_diffs, class: 'form-check-label' do
- = s_('Preferences|Show whitespace changes in diffs')
- - if Feature.enabled?(:view_diffs_file_by_file, default_enabled: true)
+ .form-group
+ = f.label :project_view, class: 'label-bold' do
+ = s_('Preferences|Project overview content')
+ = f.select :project_view, project_view_choices, {}, class: 'select2'
+ .form-text.text-muted
+ = s_('Preferences|Choose what content you want to see on a project’s overview page.')
+ .form-group.form-check
+ = f.check_box :render_whitespace_in_code, class: 'form-check-input'
+ = f.label :render_whitespace_in_code, class: 'form-check-label' do
+ = s_('Preferences|Render whitespace characters in the Web IDE')
.form-group.form-check
- = f.check_box :view_diffs_file_by_file, class: 'form-check-input'
- = f.label :view_diffs_file_by_file, class: 'form-check-label' do
- = s_("Preferences|Show one file at a time on merge request's Changes tab")
+ = f.check_box :show_whitespace_in_diffs, class: 'form-check-input'
+ = f.label :show_whitespace_in_diffs, class: 'form-check-label' do
+ = s_('Preferences|Show whitespace changes in diffs')
+ - if Feature.enabled?(:view_diffs_file_by_file, default_enabled: true)
+ .form-group.form-check
+ = f.check_box :view_diffs_file_by_file, class: 'form-check-input'
+ = f.label :view_diffs_file_by_file, class: 'form-check-label' do
+ = s_("Preferences|Show one file at a time on merge request's Changes tab")
+ .form-text.text-muted
+ = s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.")
+ .form-group
+ = f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold'
+ = f.number_field :tab_width,
+ class: 'form-control',
+ min: Gitlab::TabWidth::MIN,
+ max: Gitlab::TabWidth::MAX,
+ required: true
.form-text.text-muted
- = s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.")
- .form-group
- = f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold'
- = f.number_field :tab_width,
- class: 'form-control',
- min: Gitlab::TabWidth::MIN,
- max: Gitlab::TabWidth::MAX,
- required: true
- .form-text.text-muted
- = s_('Preferences|Must be a number between %{min} and %{max}') % { min: Gitlab::TabWidth::MIN, max: Gitlab::TabWidth::MAX }
-
- .col-sm-12
- %hr
+ = s_('Preferences|Must be a number between %{min} and %{max}') % { min: Gitlab::TabWidth::MIN, max: Gitlab::TabWidth::MAX }
- .col-lg-4.profile-settings-sidebar#localization
- %h4.gl-mt-0
- = _('Localization')
- %p
- = _('Customize language and region related settings.')
- = succeed '.' do
- = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank'
- .col-lg-8
- .form-group
- = f.label :preferred_language, class: 'label-bold' do
- = _('Language')
- = f.select :preferred_language, language_choices, {}, class: 'select2'
- .form-text.text-muted
- = s_('Preferences|This feature is experimental and translations are not complete yet')
- .form-group
- = f.label :first_day_of_week, class: 'label-bold' do
- = _('First day of the week')
- = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'select2'
- - if Feature.enabled?(:user_time_settings)
.col-sm-12
%hr
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0= s_('Preferences|Time preferences')
- %p= s_('Preferences|These settings will update how dates and times are displayed for you.')
+
+ .col-lg-4.profile-settings-sidebar#localization
+ %h4.gl-mt-0
+ = _('Localization')
+ %p
+ = _('Customize language and region related settings.')
+ = succeed '.' do
+ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank'
.col-lg-8
.form-group
- %h5= s_('Preferences|Time format')
- .checkbox-icon-inline-wrapper
- - time_format_label = capture do
- = s_('Preferences|Display time in 24-hour format')
- = f.check_box :time_format_in_24h
- = f.label :time_format_in_24h do
- = time_format_label
- %h5= s_('Preferences|Time display')
- .checkbox-icon-inline-wrapper
- - time_display_label = capture do
- = s_('Preferences|Use relative times')
- = f.check_box :time_display_relative
- = f.label :time_display_relative do
- = time_display_label
- .form-text.text-muted
- = s_('Preferences|For example: 30 mins ago.')
+ = f.label :preferred_language, class: 'label-bold' do
+ = _('Language')
+ = f.select :preferred_language, language_choices, {}, class: 'select2'
+ .form-text.text-muted
+ = s_('Preferences|This feature is experimental and translations are not complete yet')
+ .form-group
+ = f.label :first_day_of_week, class: 'label-bold' do
+ = _('First day of the week')
+ = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'select2'
+ - if Feature.enabled?(:user_time_settings)
+ .col-sm-12
+ %hr
+ .col-lg-4.profile-settings-sidebar
+ %h4.gl-mt-0= s_('Preferences|Time preferences')
+ %p= s_('Preferences|These settings will update how dates and times are displayed for you.')
+ .col-lg-8
+ .form-group
+ %h5= s_('Preferences|Time format')
+ .checkbox-icon-inline-wrapper
+ - time_format_label = capture do
+ = s_('Preferences|Display time in 24-hour format')
+ = f.check_box :time_format_in_24h
+ = f.label :time_format_in_24h do
+ = time_format_label
+ %h5= s_('Preferences|Time display')
+ .checkbox-icon-inline-wrapper
+ - time_display_label = capture do
+ = s_('Preferences|Use relative times')
+ = f.check_box :time_display_relative
+ = f.label :time_display_relative do
+ = time_display_label
+ .form-text.text-muted
+ = s_('Preferences|For example: 30 mins ago.')
- = render 'integrations', f: f
+ #js-profile-preferences-app{ data: data_attributes, user_fields: user_fields.to_json }
- .col-lg-4.profile-settings-sidebar
- .col-lg-8
- .form-group
- = f.submit _('Save changes'), class: 'gl-button btn btn-success'
+ .row.gl-mt-3.js-preferences-form
+ .col-lg-4.profile-settings-sidebar
+ .col-lg-8
+ .form-group
+ = 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 f5fab727a57..bf9f1336a4f 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -2,6 +2,8 @@
- page_title s_("Profiles|Edit Profile")
- @content_class = "limit-container-width" unless fluid_layout
- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host
+- availability = availability_values
+- custom_emoji = show_status_emoji?(@user.status)
= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f|
= form_errors(@user)
@@ -48,9 +50,9 @@
- emoji_button = button_tag type: :button,
class: 'js-toggle-emoji-menu emoji-menu-toggle-button gl-button btn has-tooltip',
title: s_("Profiles|Add status emoji") do
- - if @user.status
+ - if custom_emoji
= emoji_icon @user.status.emoji
- %span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if @user.status) }
+ %span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if custom_emoji) }
= sprite_icon('slight-smile', css_class: 'award-control-icon-neutral')
= sprite_icon('smiley', css_class: 'award-control-icon-positive')
= sprite_icon('smile', css_class: 'award-control-icon-super-positive')
@@ -68,6 +70,10 @@
prepend: emoji_button,
append: reset_message_button,
placeholder: s_("Profiles|What's your status?")
+ - if Feature.enabled?(:set_user_availability_status, @user)
+ .checkbox-icon-inline-wrapper
+ = status_form.check_box :availability, { data: { testid: "user-availability-checkbox" }, label: s_("Profiles|Busy"), wrapper_class: 'gl-mr-0 gl-font-weight-bold' }, availability["busy"], availability["not_set"]
+ .gl-text-gray-600.gl-ml-5= s_('Profiles|"Busy" will be shown next to your name')
- if Feature.enabled?(:user_time_settings)
%hr
.row.user-time-preferences
diff --git a/app/views/projects/_find_file_link.html.haml b/app/views/projects/_find_file_link.html.haml
index 74cdb0f7409..c3b4a61c28a 100644
--- a/app/views/projects/_find_file_link.html.haml
+++ b/app/views/projects/_find_file_link.html.haml
@@ -1,2 +1,2 @@
-= link_to project_find_file_path(@project, @ref), class: 'btn shortcuts-find-file', rel: 'nofollow' do
+= link_to project_find_file_path(@project, @ref), class: 'gl-button btn shortcuts-find-file', rel: 'nofollow' do
= _('Find file')
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 9f4496e7a13..569255ec2e5 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -3,21 +3,23 @@
- max_project_topic_length = 15
- emails_disabled = @project.emails_disabled?
+= render_if_exists 'projects/invite_members_modal', project: @project
+
.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-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)
+ = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'image')
.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.gl-font-size-h1.gl-line-height-24.gl-font-weight-bold{ 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' }, itemprop: 'name' }
= @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.gl-font-base.gl-font-weight-normal.gl-line-height-normal
- if can?(current_user, :read_project, @project)
- %span.text-secondary
+ %span.text-secondary{ itemprop: 'identifier' }
= s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id }
- if current_user
%span.access-request-links.gl-ml-3
@@ -30,10 +32,10 @@
- project_topics_classes = "badge badge-pill badge-secondary gl-mr-2"
- explore_project_topic_path = explore_projects_path(tag: topic)
- if topic.length > max_project_topic_length
- %a{ class: "#{ project_topics_classes } str-truncated-30 has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path }
+ %a{ class: "#{ project_topics_classes } str-truncated-30 has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' }
= topic.titleize
- else
- %a{ class: project_topics_classes, href: explore_project_topic_path }
+ %a{ class: project_topics_classes, href: explore_project_topic_path, itemprop: 'keywords' }
= topic.titleize
- if @project.has_extra_topics?
@@ -44,7 +46,7 @@
.project-repo-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end
- if current_user
.d-inline-flex
- = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn-xs', emails_disabled: emails_disabled
+ = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn-xs', dropdown_container_class: 'gl-mr-3', emails_disabled: emails_disabled
.count-buttons.d-inline-flex
= render 'projects/buttons/star'
@@ -61,7 +63,7 @@
.home-panel-home-desc.mt-1
- if @project.description.present?
.home-panel-description.text-break
- .home-panel-description-markdown.read-more-container
+ .home-panel-description-markdown.read-more-container{ itemprop: 'abstract' }
= markdown_field(@project, :description)
%button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" }
= _("Read more")
diff --git a/app/views/projects/_invite_members_modal.html.haml b/app/views/projects/_invite_members_modal.html.haml
new file mode 100644
index 00000000000..ad95f39bbfa
--- /dev/null
+++ b/app/views/projects/_invite_members_modal.html.haml
@@ -0,0 +1,7 @@
+- if invite_members_allowed?(project.group)
+ .js-invite-members-modal{ data: { id: project.id,
+ name: project.name,
+ is_project: true,
+ 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/projects/_invite_members_side_nav_link.html.haml b/app/views/projects/_invite_members_side_nav_link.html.haml
new file mode 100644
index 00000000000..15e0b75cf57
--- /dev/null
+++ b/app/views/projects/_invite_members_side_nav_link.html.haml
@@ -0,0 +1,3 @@
+- if invite_members_allowed?(project.group) && body_data_page == 'projects:show'
+ %li
+ .js-invite-members-trigger{ data: { icon: 'plus', display_text: _('Invite team members') } }
diff --git a/app/views/projects/_merge_request_merge_options_settings.html.haml b/app/views/projects/_merge_request_merge_options_settings.html.haml
index 047b4dafbfc..8951f2ed22f 100644
--- a/app/views/projects/_merge_request_merge_options_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_options_settings.html.haml
@@ -4,6 +4,7 @@
%b= s_('ProjectSettings|Merge options')
%p.text-secondary= s_('ProjectSettings|Additional merge request capabilities that influence how and when merges will be performed')
= render_if_exists 'projects/merge_pipelines_settings', form: form
+ = render_if_exists 'projects/merge_trains_settings', form: form
.form-check.mb-2
= form.check_box :resolve_outdated_diff_discussions, class: 'form-check-input'
= form.label :resolve_outdated_diff_discussions, class: 'form-check-label' do
diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml
index 05eab3b3245..c246c45d0f7 100644
--- a/app/views/projects/_remove.html.haml
+++ b/app/views/projects/_remove.html.haml
@@ -1,5 +1,4 @@
- return unless can?(current_user, :remove_project, project)
-- confirm_phrase = s_('DeleteProject|Delete %{name}') % { name: project.full_name }
.sub-section
%h4.danger-title= _('Delete project')
@@ -7,4 +6,4 @@
%strong= _('Deleting the project will delete its repository and all related resources including issues, merge requests etc.')
%p
%strong= _('Deleted projects cannot be restored!')
- #js-project-delete-button{ data: { form_path: project_path(project), confirm_phrase: confirm_phrase } }
+ #js-project-delete-button{ data: { form_path: project_path(project), confirm_phrase: project.path } }
diff --git a/app/views/projects/_remove_fork.html.haml b/app/views/projects/_remove_fork.html.haml
new file mode 100644
index 00000000000..2a7453902a8
--- /dev/null
+++ b/app/views/projects/_remove_fork.html.haml
@@ -0,0 +1,10 @@
+- return unless @project.forked? && can?(current_user, :remove_fork_project, @project)
+
+.sub-section
+ %h4.danger-title= _('Remove fork relationship')
+ %p= remove_fork_project_description_message(@project)
+
+ = 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. This project will no longer be able to receive or send merge requests to the source project or other forks.')
+ = button_to _('Remove fork relationship'), '#', class: "gl-button btn btn-danger js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_warning_message(@project) }
diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml
index fceef0624d7..7c08955983a 100644
--- a/app/views/projects/_service_desk_settings.html.haml
+++ b/app/views/projects/_service_desk_settings.html.haml
@@ -10,7 +10,7 @@
- if ::Gitlab::ServiceDesk.supported?
.js-service-desk-setting-root{ data: { endpoint: project_service_desk_path(@project),
enabled: "#{@project.service_desk_enabled}",
- incoming_email: (@project.service_desk_address if @project.service_desk_enabled),
+ incoming_email: (@project.service_desk_incoming_address if @project.service_desk_enabled),
custom_email: (@project.service_desk_custom_address if @project.service_desk_enabled),
selected_template: "#{@project.service_desk_setting&.issue_template_key}",
outgoing_name: "#{@project.service_desk_setting&.outgoing_name}",
diff --git a/app/views/projects/_stat_anchor_list.html.haml b/app/views/projects/_stat_anchor_list.html.haml
index 516790fb6d9..8a93d93a538 100644
--- a/app/views/projects/_stat_anchor_list.html.haml
+++ b/app/views/projects/_stat_anchor_list.html.haml
@@ -5,5 +5,5 @@
%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
+ = link_to_if(anchor.link, anchor.label, anchor.link, stat_anchor_attrs(anchor)) do
.stat-text.d-flex.align-items-center{ class: ('btn btn-default disabled' if project_buttons) }= anchor.label
diff --git a/app/views/projects/_transfer.html.haml b/app/views/projects/_transfer.html.haml
new file mode 100644
index 00000000000..eb7feb7bd3b
--- /dev/null
+++ b/app/views/projects/_transfer.html.haml
@@ -0,0 +1,16 @@
+- return unless can?(current_user, :change_namespace, @project)
+
+.sub-section
+ %h4.danger-title= _('Transfer project')
+ = form_for @project, url: transfer_project_path(@project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } do |f|
+ .form-group
+ = label_tag :new_namespace_id, nil, class: 'label-bold' do
+ %span= _('Select a new namespace')
+ .form-group
+ = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2'
+ %ul
+ %li= _("Be careful. Changing the project's namespace can have unintended side effects.")
+ %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.')
+ = f.submit 'Transfer project', class: "gl-button btn btn-danger js-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) }
diff --git a/app/views/projects/alert_management/details.html.haml b/app/views/projects/alert_management/details.html.haml
index 5230d5e3476..b1d680e4f3d 100644
--- a/app/views/projects/alert_management/details.html.haml
+++ b/app/views/projects/alert_management/details.html.haml
@@ -1,4 +1,5 @@
- add_to_breadcrumbs s_('AlertManagement|Alerts'), project_alert_management_index_path(@project)
- page_title s_('AlertManagement|Alert detail')
+- add_page_specific_style 'page_bundles/alert_management_details'
#js-alert_details{ data: alert_management_detail_data(@project, @alert_id) }
diff --git a/app/views/projects/artifacts/_artifact.html.haml b/app/views/projects/artifacts/_artifact.html.haml
index 30f30fe922f..233a41a37b5 100644
--- a/app/views/projects/artifacts/_artifact.html.haml
+++ b/app/views/projects/artifacts/_artifact.html.haml
@@ -50,10 +50,10 @@
.table-action-buttons
.btn-group
- if can?(current_user, :read_build, @project)
- = link_to download_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', download: '', title: _('Download artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Download artifacts') }, class: 'btn btn-build has-tooltip ml-0' do
+ = link_to download_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', download: '', title: _('Download artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Download artifacts') }, class: 'gl-button btn btn-build has-tooltip ml-0' do
= sprite_icon('download')
- = link_to browse_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', title: _('Browse artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Browse artifacts') }, class: 'btn btn-build has-tooltip' do
+ = link_to browse_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', title: _('Browse artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Browse artifacts') }, class: 'gl-button btn btn-build has-tooltip' do
= sprite_icon('folder-open')
- if can?(current_user, :destroy_artifacts, @project)
diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml
index f42d5128715..03d35c1c989 100644
--- a/app/views/projects/artifacts/_tree_file.html.haml
+++ b/app/views/projects/artifacts/_tree_file.html.haml
@@ -9,7 +9,7 @@
= link_to path_to_file, class: 'tree-item-file-external-link js-artifact-tree-tooltip str-truncated',
target: '_blank', rel: 'noopener noreferrer', title: _('Opens in a new window') do
%span>= blob.name
- = icon('external-link', class: 'js-artifact-tree-external-icon')
+ = sprite_icon('external-link', css_class: 'js-artifact-tree-external-icon')
- else
= link_to path_to_file, class: 'str-truncated' do
%span= blob.name
diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml
index ff56cb53720..b363f0d4325 100644
--- a/app/views/projects/artifacts/browse.html.haml
+++ b/app/views/projects/artifacts/browse.html.haml
@@ -17,7 +17,7 @@
.tree-controls<
= link_to download_project_job_artifacts_path(@project, @build),
- rel: 'nofollow', download: '', class: 'btn btn-default download' do
+ rel: 'nofollow', download: '', class: 'gl-button btn btn-default download' do
= sprite_icon('download')
Download artifacts archive
diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml
index 810c8b9082f..710417f90e3 100644
--- a/app/views/projects/blob/_breadcrumb.html.haml
+++ b/app/views/projects/blob/_breadcrumb.html.haml
@@ -23,13 +23,13 @@
- if blob.readable_text?
- if blame
= link_to 'Normal view', project_blob_path(@project, @id),
- class: 'btn'
+ class: 'gl-button btn'
- else
= link_to 'Blame', project_blame_path(@project, @id),
- class: 'btn js-blob-blame-link' unless blob.empty?
+ class: 'gl-button btn js-blob-blame-link' unless blob.empty?
= link_to 'History', project_commits_path(@project, @id),
- class: 'btn'
+ class: 'gl-button btn'
= link_to 'Permalink', project_blob_path(@project,
- tree_join(@commit.sha, @path)), class: 'btn js-data-file-blob-permalink-url'
+ tree_join(@commit.sha, @path)), class: 'gl-button btn js-data-file-blob-permalink-url'
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index 55ae9cded1c..6d01206a128 100644
--- a/app/views/projects/blob/_header.html.haml
+++ b/app/views/projects/blob/_header.html.haml
@@ -2,9 +2,9 @@
.js-file-title.file-title-flex-parent
= render 'projects/blob/header_content', blob: blob
- .file-actions<
+ .file-actions.gl-display-flex.gl-flex-fill-1.gl-align-self-start.gl-md-justify-content-end<
= render 'projects/blob/viewer_switcher', blob: blob unless blame
- - if Feature.enabled?(:consolidated_edit_button)
+ - if Feature.enabled?(:consolidated_edit_button, @project)
= render 'shared/web_ide_button', blob: blob
- else
= edit_blob_button(@project, @ref, @path, blob: blob)
diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml
index 30356348941..b310939c5a3 100644
--- a/app/views/projects/blob/_header_content.html.haml
+++ b/app/views/projects/blob/_header_content.html.haml
@@ -1,7 +1,7 @@
.file-header-content
= blob_icon blob.mode, blob.name
- %strong.file-title-name
+ %strong.file-title-name.gl-word-break-all{ data: { qa_selector: 'file_name_content' } }
= blob.name
= copy_file_path_button(blob.path)
diff --git a/app/views/projects/blob/_pipeline_tour_success.html.haml b/app/views/projects/blob/_pipeline_tour_success.html.haml
index 3ea2defb2b3..ef1fe25ba1b 100644
--- a/app/views/projects/blob/_pipeline_tour_success.html.haml
+++ b/app/views/projects/blob/_pipeline_tour_success.html.haml
@@ -1,4 +1,6 @@
.js-success-pipeline-modal{ data: { 'commit-cookie': suggest_pipeline_commit_cookie_name,
'go-to-pipelines-path': project_pipelines_path(@project),
'project-merge-requests-path': project_merge_requests_path(@project),
+ 'example-link': help_page_path('ci/examples/README.md', anchor: 'gitlab-cicd-examples'),
+ 'code-quality-link': help_page_path('user/project/merge_requests/code_quality'),
'human-access': @project.team.human_max_access(current_user&.id) } }
diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml
index 4dbfa2b1e3c..f400c7de5eb 100644
--- a/app/views/projects/blob/_upload.html.haml
+++ b/app/views/projects/blob/_upload.html.haml
@@ -21,7 +21,7 @@
.form-actions
= 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' )
+ .spinner.spinner-sm.gl-mr-2.js-loading-icon.hidden
= button_title
= link_to _("Cancel"), '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal"
diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml
index 61d67a88a5a..3326cded42a 100644
--- a/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml
+++ b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml
@@ -1,8 +1,8 @@
- if viewer.valid?(project: @project, sha: @commit.sha, user: @current_user)
- = icon('check fw')
+ = sprite_icon('check')
This GitLab CI configuration is valid.
- else
- = icon('warning fw')
+ = sprite_icon('warning-solid')
This GitLab CI configuration is invalid:
= viewer.validation_message(project: @project, sha: @commit.sha, user: @current_user)
diff --git a/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml b/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml
index de9c6c5320f..9c3f9b6c9fd 100644
--- a/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml
+++ b/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml
@@ -1,8 +1,8 @@
- if viewer.valid?
- = icon('check fw')
+ = sprite_icon('check')
= _('Metrics Dashboard YAML definition is valid.')
- else
- = icon('warning fw')
+ = sprite_icon('warning-solid')
= _('Metrics Dashboard YAML definition is invalid:')
%ul
- viewer.errors.each do |error|
diff --git a/app/views/projects/blob/viewers/_route_map.html.haml b/app/views/projects/blob/viewers/_route_map.html.haml
index 024e9b4ddb2..068cf400cd5 100644
--- a/app/views/projects/blob/viewers/_route_map.html.haml
+++ b/app/views/projects/blob/viewers/_route_map.html.haml
@@ -1,8 +1,8 @@
- if viewer.valid?
- = icon('check fw')
+ = sprite_icon('check')
This Route Map is valid.
- else
- = icon('warning fw')
+ = sprite_icon('warning-solid')
This Route Map is invalid:
= viewer.validation_message
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 30e710ead7f..8f5fac1a40b 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -36,12 +36,12 @@
%svg.s24
- if merge_project && create_mr_button?(@repository.root_ref, branch.name)
- = link_to create_mr_path(@repository.root_ref, branch.name), class: 'btn btn-default' do
+ = link_to create_mr_path(@repository.root_ref, branch.name), class: 'gl-button btn btn-default' do
= _('Merge request')
- if branch.name != @repository.root_ref
= link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name),
- class: "btn btn-default js-onboarding-compare-branches #{'gl-ml-3' unless merge_project}",
+ class: "gl-button btn btn-default js-onboarding-compare-branches #{'gl-ml-3' unless merge_project}",
method: :post,
title: s_('Branches|Compare') do
= s_('Branches|Compare')
diff --git a/app/views/projects/branches/_delete_protected_modal.html.haml b/app/views/projects/branches/_delete_protected_modal.html.haml
index 8aa79d2d464..24beeeb0ae1 100644
--- a/app/views/projects/branches/_delete_protected_modal.html.haml
+++ b/app/views/projects/branches/_delete_protected_modal.html.haml
@@ -36,7 +36,7 @@
.modal-footer
%button.btn{ data: { dismiss: 'modal' } } Cancel
= link_to s_('Branches|Delete protected branch'), '',
- class: "btn btn-danger js-delete-branch",
+ class: "gl-button btn btn-danger js-delete-branch",
title: s_('Branches|Delete branch'),
method: :delete,
'aria-label' => s_('Branches|Delete branch')
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index f3561ed5078..46cce59f67a 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -24,7 +24,7 @@
%button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.light
= branches_sort_options_hash[@sort]
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
%li.dropdown-header
= s_('Branches|Sort by')
@@ -40,7 +40,7 @@
data: { confirm: s_('Branches|Deleting the merged branches cannot be undone. Are you sure?'),
container: 'body' } do
= s_('Branches|Delete merged branches')
- = link_to new_project_branch_path(@project), class: 'btn btn-success' do
+ = link_to new_project_branch_path(@project), class: 'gl-button btn btn-success' do
= s_('Branches|New branch')
= render_if_exists 'projects/commits/mirror_status'
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index 7a8bc45a272..24dfb59dc85 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -22,10 +22,10 @@
= hidden_field_tag :ref, default_ref
= button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide js-branch-select monospace', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do
.text-left.dropdown-toggle-text= default_ref
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
= render 'shared/ref_dropdown', dropdown_class: 'wide'
.form-text.text-muted Existing branch name, tag, or commit SHA
.form-actions
- = button_tag 'Create branch', class: 'btn btn-success', tabindex: 3
- = link_to 'Cancel', project_branches_path(@project), class: 'btn btn-cancel'
+ = button_tag 'Create branch', class: 'gl-button btn btn-success'
+ = link_to 'Cancel', project_branches_path(@project), class: 'gl-button btn btn-cancel'
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml
index 7ce143a86b3..cf58cff7445 100644
--- a/app/views/projects/buttons/_clone.html.haml
+++ b/app/views/projects/buttons/_clone.html.haml
@@ -2,7 +2,7 @@
- dropdown_class = local_assigns.fetch(:dropdown_class, '')
.git-clone-holder.js-git-clone-holder
- %a#clone-dropdown.btn.btn-primary.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
+ %a#clone-dropdown.gl-button.btn.btn-primary.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
%span.gl-mr-2.js-clone-dropdown-label
= _('Clone')
= sprite_icon("chevron-down", css_class: "icon")
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index c04687bd846..0fcbf2ca1eb 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -3,7 +3,7 @@
- if !project.empty_repo? && can?(current_user, :download_code, project)
- archive_prefix = "#{project.path}-#{ref.tr('/', '-')}"
.project-action-button.dropdown.inline>
- %button.btn.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } }
+ %button.gl-button.btn.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } }
= sprite_icon('download')
%span.sr-only= _('Select Archive Format')
= sprite_icon("chevron-down")
diff --git a/app/views/projects/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml
index 990f3ff526b..c997df578c0 100644
--- a/app/views/projects/buttons/_download_links.html.haml
+++ b/app/views/projects/buttons/_download_links.html.haml
@@ -1,4 +1,4 @@
.btn-group.ml-0.w-100
- Gitlab::Workhorse::ARCHIVE_FORMATS.each_with_index do |fmt, index|
- archive_path = project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt)
- = link_to fmt, external_storage_url_or_path(archive_path), rel: 'nofollow', download: '', class: "btn btn-xs #{"btn-primary" if index == 0}"
+ = link_to fmt, external_storage_url_or_path(archive_path), rel: 'nofollow', download: '', class: "gl-button btn btn-xs #{"btn-primary" if index == 0}"
diff --git a/app/views/projects/buttons/_xcode_link.html.haml b/app/views/projects/buttons/_xcode_link.html.haml
index a8b32fb0ef5..e0f47f1ca3d 100644
--- a/app/views/projects/buttons/_xcode_link.html.haml
+++ b/app/views/projects/buttons/_xcode_link.html.haml
@@ -1,2 +1,2 @@
-%a.btn.btn-default{ href: xcode_uri_to_repo(@project) }
+%a.gl-button.btn.btn-default{ href: xcode_uri_to_repo(@project) }
= _("Open in Xcode")
diff --git a/app/views/projects/ci/lints/_create.html.haml b/app/views/projects/ci/lints/_create.html.haml
deleted file mode 100644
index 4b7cda0ef57..00000000000
--- a/app/views/projects/ci/lints/_create.html.haml
+++ /dev/null
@@ -1,51 +0,0 @@
-- if @result.valid?
- .bs-callout.bs-callout-success
- %p
- %b= _("Status:")
- = _("syntax is correct")
-
- = render "projects/ci/lints/lint_warnings", warnings: @result.warnings
-
- .table-holder
- %table.table.table-bordered
- %thead
- %tr
- %th= _("Parameter")
- %th= _("Value")
- %tbody
- - @result.jobs.each do |job|
- %tr
- %td #{job[:stage].capitalize} Job - #{job[:name]}
- %td
- %pre= job[:before_script].to_a.join('\n')
- %pre= job[:script].to_a.join('\n')
- %pre= job[:after_script].to_a.join('\n')
- %br
- %b= _("Tag list:")
- = job[:tag_list].to_a.join(", ")
- - unless @dry_run
- %br
- %b= _("Only policy:")
- = job[:only].to_a.join(", ")
- %br
- %b= _("Except policy:")
- = job[:except].to_a.join(", ")
- %br
- %b= _("Environment:")
- = job[:environment]
- %br
- %b= _("When:")
- = job[:when]
- - if job[:allow_failure]
- %b= _("Allowed to fail")
-
-- else
- .bs-callout.bs-callout-danger
- %p
- %b= _("Status:")
- = _("syntax is incorrect")
- %pre
- - @result.errors.each do |message|
- %p= message
-
- = render "projects/ci/lints/lint_warnings", warnings: @result.warnings
diff --git a/app/views/projects/ci/lints/_lint_warnings.html.haml b/app/views/projects/ci/lints/_lint_warnings.html.haml
deleted file mode 100644
index 90db65e6c27..00000000000
--- a/app/views/projects/ci/lints/_lint_warnings.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-- if warnings
- - total_warnings = warnings.length
- - message = warning_header(total_warnings)
-
- - if warnings.any?
- .bs-callout.bs-callout-warning
- %details
- %summary.gl-mb-2= message
- - warnings.each do |warning|
- = markdown(warning)
diff --git a/app/views/projects/ci/lints/show.html.haml b/app/views/projects/ci/lints/show.html.haml
index 64f250bd607..feccea6cfc0 100644
--- a/app/views/projects/ci/lints/show.html.haml
+++ b/app/views/projects/ci/lints/show.html.haml
@@ -3,32 +3,4 @@
%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), help_page_path: help_page_path('ci/lint', anchor: 'pipeline-simulation') } }
-
-- else
- .project-ci-linter
- = form_tag project_ci_lint_path(@project), method: :post, class: 'js-ci-lint-form' do
- .row
- .col-sm-12
- .file-holder
- .js-file-title.file-title.clearfix
- = _("Contents of .gitlab-ci.yml")
- .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
- = submit_tag(_('Validate'), class: 'btn btn-success submit-yml')
- - if Gitlab::Ci::Features.lint_creates_pipeline_with_dry_run?(@project)
- = check_box_tag(:dry_run, 'true', params[:dry_run])
- = label_tag(:dry_run, _('Simulate a pipeline created for the default branch'))
- = link_to sprite_icon('question-o'), help_page_path('ci/lint', anchor: 'pipeline-simulation'), target: '_blank', rel: 'noopener noreferrer'
- .float-right.prepend-top-10
- = button_tag(_('Clear'), type: 'button', class: 'btn btn-default clear-yml')
-
- .row.prepend-top-20
- .col-sm-12
- .results.project-ci-template
- = render partial: 'create' if defined?(@result)
+#js-ci-lint{ data: { endpoint: project_ci_lint_path(@project), pipeline_simulation_help_page_path: help_page_path('ci/lint', anchor: 'pipeline-simulation') , lint_help_page_path: help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax') } }
diff --git a/app/views/projects/ci/pipeline_editor/show.html.haml b/app/views/projects/ci/pipeline_editor/show.html.haml
new file mode 100644
index 00000000000..0e032f2575e
--- /dev/null
+++ b/app/views/projects/ci/pipeline_editor/show.html.haml
@@ -0,0 +1,6 @@
+- page_title s_('Pipelines|Pipeline Editor')
+
+#js-pipeline-editor{ data: { "ci-config-path": @project.ci_config_path_or_default,
+ "project-path" => @project.full_path,
+ "default-branch" => @project.default_branch,
+} }
diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml
index 02d35e690ca..37ec63cc871 100644
--- a/app/views/projects/cleanup/_show.html.haml
+++ b/app/views/projects/cleanup/_show.html.haml
@@ -26,4 +26,4 @@
.form-text.text-muted
= _("The maximum file size allowed is %{size}.") % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) }
- = f.submit _('Start cleanup'), class: 'btn btn-success'
+ = f.submit _('Start cleanup'), class: 'gl-button btn btn-success'
diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml
index 4f5d69c614c..11adc7fd64a 100644
--- a/app/views/projects/commit/_change.html.haml
+++ b/app/views/projects/commit/_change.html.haml
@@ -33,7 +33,7 @@
- else
= hidden_field_tag 'create_merge_request', 1, id: nil
.form-actions
- = submit_tag label, class: 'btn btn-success'
- = link_to _("Cancel"), '#', class: "btn btn-cancel", "data-dismiss" => "modal"
+ = submit_tag label, class: 'gl-button btn btn-success'
+ = link_to _("Cancel"), '#', class: "gl-button btn btn-cancel", "data-dismiss" => "modal"
= render 'shared/projects/edit_information'
diff --git a/app/views/projects/commit/diff_files.html.haml b/app/views/projects/commit/diff_files.html.haml
index 3a473be3840..0c52c1a15a4 100644
--- a/app/views/projects/commit/diff_files.html.haml
+++ b/app/views/projects/commit/diff_files.html.haml
@@ -1,3 +1 @@
-- diff_files = diffs.diff_files
-
-= render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, diff_page_context: 'is-commit' }
+= render partial: 'projects/diffs/file', collection: diffs.diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, diff_page_context: 'is-commit' }
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index cd61576a96a..179b0c5efbd 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -23,7 +23,7 @@
= author_avatar(commit, size: 40, has_tooltip: false)
.commit-detail.flex-list
- .commit-content.qa-commit-content
+ .commit-content{ data: { qa_selector: 'commit_content' } }
- if view_details && merge_request
= link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: ["commit-row-message item-title js-onboarding-commit-item", ("font-italic" if commit.message.empty?)]
- else
@@ -57,8 +57,8 @@
.js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } }
- .commit-sha-group.d-none.d-sm-flex
+ .commit-sha-group.btn-group.d-none.d-sm-flex
.label.label-monospace.monospace
= commit.short_id
- = clipboard_button(text: commit.id, title: _("Copy commit SHA"), class: "btn btn-default", container: "body")
+ = clipboard_button(text: commit.id, title: _("Copy commit SHA"), class: "gl-button btn btn-default", container: "body")
= link_to_browse_code(project, commit)
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 40dd3a685d4..94bdab53cd0 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -34,4 +34,5 @@
%div{ id: dom_id(@project) }
%ol#commits-list.list-unstyled.content_list
= render 'commits', project: @project, ref: @ref
- = spinner
+ .loading.hide
+ = loading_icon(size: "lg")
diff --git a/app/views/projects/confluences/show.html.haml b/app/views/projects/confluences/show.html.haml
index 5814b7a00f5..1eeafac5f1e 100644
--- a/app/views/projects/confluences/show.html.haml
+++ b/app/views/projects/confluences/show.html.haml
@@ -8,7 +8,7 @@
- wiki_confluence_epic_link_url = 'https://gitlab.com/groups/gitlab-org/-/epics/3629'
- wiki_confluence_epic_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wiki_confluence_epic_link_url }
= s_("WikiEmpty|You've enabled the Confluence Workspace integration. Your wiki will be viewable directly within Confluence. We are hard at work integrating Confluence more seamlessly into GitLab. If you'd like to stay up to date, follow our %{wiki_confluence_epic_link_start}Confluence epic%{wiki_confluence_epic_link_end}.").html_safe % { wiki_confluence_epic_link_start: wiki_confluence_epic_link_start, wiki_confluence_epic_link_end: '</a>'.html_safe }
- = link_to @project.confluence_service.confluence_url, target: '_blank', rel: 'noopener noreferrer', class: 'btn btn-success external-url', title: s_('WikiEmpty|Go to Confluence') do
+ = link_to @project.confluence_service.confluence_url, target: '_blank', rel: 'noopener noreferrer', class: 'gl-button btn btn-success external-url', title: s_('WikiEmpty|Go to Confluence') do
= sprite_icon('external-link')
= s_('WikiEmpty|Go to Confluence')
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index d99579c25c0..b98ab9757fa 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -22,7 +22,7 @@
.dropdown.inline.js-ca-dropdown
%button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" }
%span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }}
- %i.fa.fa-chevron-down
+ = sprite_icon("chevron-down", css_class: "dropdown-menu-toggle-icon gl-top-3")
%ul.dropdown-menu.dropdown-menu-right
%li
%a{ "href" => "#", "data-value" => "7" }
diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml
index 2ba12601c79..a1c7f5027c5 100644
--- a/app/views/projects/default_branch/_show.html.haml
+++ b/app/views/projects/default_branch/_show.html.haml
@@ -28,4 +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'
- = f.submit _('Save changes'), class: "btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-success"
diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml
index 805d4983002..780ec128d63 100644
--- a/app/views/projects/deploy_keys/edit.html.haml
+++ b/app/views/projects/deploy_keys/edit.html.haml
@@ -7,4 +7,4 @@
= 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', project_settings_repository_path(@project), class: 'btn btn-cancel'
+ = link_to 'Cancel', project_settings_repository_path(@project), class: 'gl-button btn btn-cancel'
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index 43aaa7cb405..8364311796f 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -9,7 +9,7 @@
.files-changed-inner
.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'
+ = link_to _('Expand all'), url_for(safe_params.merge(expanded: 1, format: nil)), class: 'gl-button btn btn-default'
- if show_whitespace_toggle
- if current_controller?(:commit)
= commit_diff_whitespace_link(diffs.project, @commit, class: 'd-none d-sm-inline-block')
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index 187ebcb739c..18da238d445 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -14,16 +14,15 @@
= submodule_diff_compare_link(diff_file)
- unless diff_file.submodule?
- - blob = diff_file.blob
.file-actions.d-none.d-sm-block
- - if blob&.readable_text?
- = link_to '#', class: 'js-toggle-diff-comments btn active has-tooltip', title: _("Toggle comments for this file"), disabled: @diff_notes_disabled do
+ - if diff_file.blob&.readable_text?
+ = link_to '#', class: 'js-toggle-diff-comments gl-button btn active has-tooltip', title: _("Toggle comments for this file"), disabled: @diff_notes_disabled do
= sprite_icon('comment')
\
- if editable_diff?(diff_file)
- link_opts = @merge_request.persisted? ? { from_merge_request_iid: @merge_request.iid } : {}
= edit_blob_button(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path,
- blob: blob, link_opts: link_opts)
+ blob: diff_file.blob, link_opts: link_opts)
- if image_diff && image_replaced
= view_file_button(diff_file.old_content_sha, diff_file.old_path, project, replaced: true)
diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml
index e9dfda4e927..cb43527def1 100644
--- a/app/views/projects/diffs/_file_header.html.haml
+++ b/app/views/projects/diffs/_file_header.html.haml
@@ -1,30 +1,27 @@
-- show_toggle = local_assigns.fetch(:show_toggle, true)
-
-- if show_toggle
+- if local_assigns.fetch(:show_toggle, true)
%i.fa.diff-toggle-caret.fa-fw
- if diff_file.submodule?
- - blob = diff_file.blob
%span
= sprite_icon('archive')
%strong.file-title-name
- = submodule_link(blob, diff_file.content_sha, diff_file.repository)
+ = submodule_link(diff_file.blob, diff_file.content_sha, diff_file.repository)
- = copy_file_path_button(blob.path)
+ = copy_file_path_button(diff_file.blob.path)
- else
= conditional_link_to url.present?, url do
= blob_icon diff_file.b_mode, diff_file.file_path
- if diff_file.renamed_file?
- old_path, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path)
- %strong.file-title-name.has-tooltip{ data: { title: diff_file.old_path, container: 'body' } }
+ %strong.file-title-name.has-tooltip.gl-word-break-all{ data: { title: diff_file.old_path, container: 'body' } }
= old_path
&rarr;
- %strong.file-title-name.has-tooltip{ data: { title: diff_file.new_path, container: 'body' } }
+ %strong.file-title-name.has-tooltip.gl-word-break-all{ data: { title: diff_file.new_path, container: 'body' } }
= new_path
- else
- %strong.file-title-name.has-tooltip{ data: { title: diff_file.file_path, container: 'body' } }
+ %strong.file-title-name.has-tooltip.gl-word-break-all{ data: { title: diff_file.file_path, container: 'body' } }
= diff_file.file_path
- if diff_file.deleted_file?
@@ -33,7 +30,7 @@
= copy_file_path_button(diff_file.file_path)
- if diff_file.mode_changed?
- %small
+ %small.gl-mr-2
#{diff_file.a_mode} → #{diff_file.b_mode}
- if diff_file.stored_externally? && diff_file.external_storage == :lfs
diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml
index 4d40071e07c..de7f9eba158 100644
--- a/app/views/projects/diffs/_line.html.haml
+++ b/app/views/projects/diffs/_line.html.haml
@@ -1,12 +1,11 @@
-- email = local_assigns.fetch(:email, false)
- plain = local_assigns.fetch(:plain, false)
- discussions = local_assigns.fetch(:discussions, nil)
-- type = line.type
- line_code = diff_file.line_code(line)
- if discussions && line.discussable?
- line_discussions = discussions[line_code]
-%tr.line_holder{ class: type, id: (line_code unless plain) }
- - case type
+
+%tr.line_holder{ class: line.type, id: (line_code unless plain) }
+ - case line.type
- when 'match'
= diff_match_line line.old_pos, line.new_pos, text: line.text
- when 'old-nonewline', 'new-nonewline'
@@ -14,21 +13,21 @@
%td.new_line.diff-line-num
%td.line_content.match= line.text
- else
- %td.old_line.diff-line-num{ class: [type, ("js-avatar-container" if !plain)], data: { linenumber: line.old_pos } }
- - link_text = type == "new" ? " " : line.old_pos
+ %td.old_line.diff-line-num{ class: [line.type, ("js-avatar-container" if !plain)], data: { linenumber: line.old_pos } }
- if plain
- = link_text
+ = diff_link_number(line.type, "new", line.old_pos)
- else
- = add_diff_note_button(line_code, diff_file.position(line), type)
- %a{ href: "##{line_code}", data: { linenumber: link_text } }
- %td.new_line.diff-line-num{ class: type, data: { linenumber: line.new_pos } }
- - link_text = type == "old" ? " " : line.new_pos
+ = add_diff_note_button(line_code, diff_file.position(line), line.type)
+ %a{ href: "##{line_code}", data: { linenumber: diff_link_number(line.type, "new", line.old_pos) } }
+
+ %td.new_line.diff-line-num{ class: line.type, data: { linenumber: line.new_pos } }
- if plain
- = link_text
+ = diff_link_number(line.type, "old", line.new_pos)
- else
- %a{ href: "##{line_code}", data: { linenumber: link_text } }
- %td.line_content{ class: type }<
- - if email
+ %a{ href: "##{line_code}", data: { linenumber: diff_link_number(line.type, "old", line.new_pos) } }
+
+ %td.line_content{ class: line.type }<
+ - if local_assigns.fetch(:email, false)
%pre= line.rich_text
- else
= diff_line_content(line.rich_text)
diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml
index cee479aab0a..6429cf31bc3 100644
--- a/app/views/projects/diffs/_stats.html.haml
+++ b/app/views/projects/diffs/_stats.html.haml
@@ -10,7 +10,7 @@
%strong.cgreen= pluralize(sum_added_lines, 'addition')
and
%strong.cred= pluralize(sum_removed_lines, 'deletion')
- .diff-stats-additions-deletions-collapsed.float-right.d-none.d-sm-none{ "aria-hidden": "true", "aria-describedby": "diff-stats" }
+ .diff-stats-additions-deletions-collapsed.float-right.d-none{ "aria-hidden": "true", "aria-describedby": "diff-stats" }
%strong.cgreen<
+#{sum_added_lines}
%strong.cred<
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 63d571e718e..10dd80501e0 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -93,31 +93,9 @@
%li= _('Your deployment services will be broken, you will need to manually fix the services after renaming.')
= f.submit _('Change path'), class: "btn btn-warning qa-change-path-button"
- - if can?(current_user, :change_namespace, @project)
- .sub-section
- %h4.danger-title= _('Transfer project')
- = form_for @project, url: transfer_project_path(@project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } do |f|
- .form-group
- = label_tag :new_namespace_id, nil, class: 'label-bold' do
- %span= _('Select a new namespace')
- .form-group
- = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2'
- %ul
- %li= _("Be careful. Changing the project's namespace can have unintended side effects.")
- %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.')
- = 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
- %h4.danger-title= _('Remove fork relationship')
- %p= remove_fork_project_description_message(@project)
-
- = 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: "gl-button btn btn-danger js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_warning_message(@project) }
+ = render 'transfer', project: @project
+
+ = render 'remove_fork', project: @project
= render 'remove', project: @project
diff --git a/app/views/projects/environments/_external_url.html.haml b/app/views/projects/environments/_external_url.html.haml
index 82567f88ccc..b9208969fb3 100644
--- a/app/views/projects/environments/_external_url.html.haml
+++ b/app/views/projects/environments/_external_url.html.haml
@@ -1,4 +1,4 @@
- if environment.external_url && can?(current_user, :read_environment, environment)
- = link_to environment.external_url, target: '_blank', rel: 'noopener noreferrer', class: 'btn external-url has-tooltip qa-view-deployment', title: s_('Environments|Open live environment') do
+ = link_to environment.external_url, target: '_blank', rel: 'noopener noreferrer', class: 'gl-button btn external-url has-tooltip qa-view-deployment', title: s_('Environments|Open live environment') do
= sprite_icon('external-link')
= _("View deployment")
diff --git a/app/views/projects/environments/_form.html.haml b/app/views/projects/environments/_form.html.haml
index 0b8f9fe220d..10890bf1921 100644
--- a/app/views/projects/environments/_form.html.haml
+++ b/app/views/projects/environments/_form.html.haml
@@ -17,5 +17,5 @@
= f.url_field :external_url, class: 'form-control'
.form-actions
- = f.submit _('Save'), class: 'btn btn-success'
- = link_to _('Cancel'), project_environments_path(@project), class: 'btn btn-cancel'
+ = f.submit _('Save'), class: 'gl-button btn btn-success'
+ = link_to _('Cancel'), project_environments_path(@project), class: 'gl-button btn btn-cancel'
diff --git a/app/views/projects/environments/_metrics_button.html.haml b/app/views/projects/environments/_metrics_button.html.haml
index c4f19ea79e7..5a691676a68 100644
--- a/app/views/projects/environments/_metrics_button.html.haml
+++ b/app/views/projects/environments/_metrics_button.html.haml
@@ -2,6 +2,6 @@
- return unless can?(current_user, :read_environment, environment)
-= link_to environment_metrics_path(environment), title: _('See metrics'), class: 'btn metrics-button' do
+= link_to environment_metrics_path(environment), title: _('See metrics'), class: 'gl-button btn metrics-button' do
= sprite_icon('chart')
= _("Monitoring")
diff --git a/app/views/projects/environments/_pin_button.html.haml b/app/views/projects/environments/_pin_button.html.haml
index 5c7bfc2b17b..ec3e7e20365 100644
--- a/app/views/projects/environments/_pin_button.html.haml
+++ b/app/views/projects/environments/_pin_button.html.haml
@@ -1,3 +1,3 @@
- if environment.auto_stop_at? && environment.available?
- = button_to cancel_auto_stop_project_environment_path(environment.project, environment), class: 'btn btn-secondary has-tooltip', title: _('Prevent environment from auto-stopping') do
+ = button_to cancel_auto_stop_project_environment_path(environment.project, environment), class: 'gl-button btn btn-secondary has-tooltip', title: _('Prevent environment from auto-stopping') do
= sprite_icon('thumbtack')
diff --git a/app/views/projects/environments/_terminal_button.html.haml b/app/views/projects/environments/_terminal_button.html.haml
index 38bc087664b..ab3363bbb07 100644
--- a/app/views/projects/environments/_terminal_button.html.haml
+++ b/app/views/projects/environments/_terminal_button.html.haml
@@ -1,3 +1,3 @@
- if environment.has_terminals? && can?(current_user, :admin_environment, @project)
- = link_to terminal_project_environment_path(@project, environment), class: 'btn terminal-button' do
+ = link_to terminal_project_environment_path(@project, environment), class: 'gl-button btn terminal-button' do
= sprite_icon('terminal')
diff --git a/app/views/projects/environments/empty_metrics.html.haml b/app/views/projects/environments/empty_metrics.html.haml
index 5642fb34da9..3ee51a318c6 100644
--- a/app/views/projects/environments/empty_metrics.html.haml
+++ b/app/views/projects/environments/empty_metrics.html.haml
@@ -11,4 +11,4 @@
%p.state-description
= s_('Metrics|Check out the CI/CD documentation on deploying to an environment')
.text-center
- = link_to s_("Environments|Learn about environments"), help_page_path('ci/environments/index.md'), class: 'btn btn-success'
+ = link_to s_("Environments|Learn about environments"), help_page_path('ci/environments/index.md'), class: 'gl-button btn btn-success'
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index 5b1556c9f52..0cb44bd03fb 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -3,6 +3,7 @@
- page_title _("Environments")
- add_page_specific_style 'page_bundles/xterm'
- add_page_specific_style 'page_bundles/environments'
+- add_page_specific_style 'page_bundles/ci_status'
#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)
@@ -27,8 +28,8 @@
rel: 'noopener noreferrer' }
= s_('Environments|Learn more about stopping environments')
.modal-footer
- = button_tag _('Cancel'), type: 'button', class: 'btn btn-cancel', data: { dismiss: 'modal' }
- = button_to stop_project_environment_path(@project, @environment), class: 'btn btn-danger has-tooltip', method: :post do
+ = button_tag _('Cancel'), type: 'button', class: 'gl-button btn btn-cancel', data: { dismiss: 'modal' }
+ = button_to stop_project_environment_path(@project, @environment), class: 'gl-button btn btn-danger has-tooltip', method: :post do
= s_('Environments|Stop environment')
- if can_destroy_environment?(@environment)
@@ -48,12 +49,12 @@
- if can?(current_user, :update_environment, @environment)
= link_to _('Edit'), edit_project_environment_path(@project, @environment), class: 'btn'
- if @environment.available? && can?(current_user, :stop_environment, @environment)
- = button_tag class: 'btn btn-danger', type: 'button', data: { toggle: 'modal',
+ = button_tag class: 'gl-button btn btn-danger', type: 'button', data: { toggle: 'modal',
target: '#stop-environment-modal' } do
= sprite_icon('stop')
= s_('Environments|Stop')
- if can_destroy_environment?(@environment)
- = button_tag class: 'btn btn-danger', type: 'button', data: { toggle: 'modal',
+ = button_tag class: 'gl-button btn btn-danger', type: 'button', data: { toggle: 'modal',
target: '#delete-environment-modal' } do
= s_('Environments|Delete')
@@ -66,7 +67,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/index.md"), class: "btn btn-success"
+ = link_to _("Read more"), help_page_path("ci/environments/index.md"), class: "gl-button 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 ed0bc0680d7..ee31985eaf0 100644
--- a/app/views/projects/environments/terminal.html.haml
+++ b/app/views/projects/environments/terminal.html.haml
@@ -13,7 +13,7 @@
.col-sm-6
.nav-controls
- if @environment.external_url.present?
- = link_to @environment.external_url, class: 'btn btn-default', target: '_blank', rel: 'noopener noreferrer nofollow' do
+ = link_to @environment.external_url, class: 'gl-button btn btn-default', target: '_blank', rel: 'noopener noreferrer nofollow' do
= sprite_icon('external-link')
= render 'projects/deployments/actions', deployment: @environment.last_deployment
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 e341831e17d..2627552058b 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
@@ -19,7 +19,7 @@
- if ref
- if generic_commit_status.ref
.icon-container
- = generic_commit_status.tags.any? ? icon('tag') : sprite_icon('fork', size: 10)
+ = generic_commit_status.tags.any? ? sprite_icon('tag', size: 10) : sprite_icon('fork', size: 10)
= link_to generic_commit_status.ref, project_commits_path(generic_commit_status.project, generic_commit_status.ref)
- else
.light none
@@ -30,7 +30,8 @@
= link_to generic_commit_status.short_sha, project_commit_path(generic_commit_status.project, generic_commit_status.sha), class: "commit-sha"
- if retried
- = icon('warning', class: 'text-warning has-tooltip', title: 'Status was retried.')
+ %span.has-tooltip{ title: _('Status was retried.') }
+ = sprite_icon('warning-solid', css_class: 'text-warning')
.label-container
- if generic_commit_status.tags.any?
diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml
index e40c36da29d..03ea623f4c6 100644
--- a/app/views/projects/hooks/index.html.haml
+++ b/app/views/projects/hooks/index.html.haml
@@ -9,6 +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 }
- = f.submit 'Add webhook', class: 'btn btn-success'
+ = f.submit 'Add webhook', class: 'gl-button btn btn-success'
= render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class
diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml
index 58981ca1556..3064b8bf873 100644
--- a/app/views/projects/imports/new.html.haml
+++ b/app/views/projects/imports/new.html.haml
@@ -16,4 +16,4 @@
= render "shared/import_form", f: f
.form-actions
- = f.submit 'Start import', class: "btn btn-success", tabindex: 4
+ = f.submit 'Start import', class: "gl-button btn btn-success"
diff --git a/app/views/projects/incidents/show.html.haml b/app/views/projects/incidents/show.html.haml
index b0ddc85df5d..4d4607e8e36 100644
--- a/app/views/projects/incidents/show.html.haml
+++ b/app/views/projects/incidents/show.html.haml
@@ -1 +1,6 @@
-= render template: 'projects/issues/show'
+- @content_class = "limit-container-width" unless fluid_layout
+- add_to_breadcrumbs _("Incidents"), project_incidents_path(@project)
+- breadcrumb_title @issue.to_reference
+- page_title "#{@issue.title} (#{@issue.to_reference})", _("Incidents")
+
+= render 'projects/issuable/show', issuable: @issue
diff --git a/app/views/projects/issuable/_show.html.haml b/app/views/projects/issuable/_show.html.haml
new file mode 100644
index 00000000000..48920c4e342
--- /dev/null
+++ b/app/views/projects/issuable/_show.html.haml
@@ -0,0 +1,10 @@
+- page_description issuable.description_html
+- page_card_attributes issuable.card_attributes
+- if issuable.relocation_target
+ - page_canonical_link issuable.relocation_target.present(current_user: current_user).web_url
+
+= render_if_exists "projects/issues/alert_blocked", issue: issuable, current_user: current_user
+= render "projects/issues/alert_moved_from_service_desk", issue: issuable
+
+= render 'shared/issue_type/details_header', issuable: issuable
+= render 'shared/issue_type/details_content', issuable: issuable
diff --git a/app/views/projects/issues/_design_management.html.haml b/app/views/projects/issues/_design_management.html.haml
index 6fc2f41b122..ad0605b10a8 100644
--- a/app/views/projects/issues/_design_management.html.haml
+++ b/app/views/projects/issues/_design_management.html.haml
@@ -6,6 +6,8 @@
- enable_lfs_message = s_("DesignManagement|To upload designs, you'll need to enable LFS and have admin enable hashed storage. %{requirements_link_start}More information%{requirements_link_end}").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end }
- if @project.design_management_enabled?
+ - add_page_startup_graphql_call('design_management/get_design_list', { fullPath: @project.full_path, iid: @issue.iid.to_s, atVersion: nil })
+ - add_page_startup_graphql_call('design_management/design_permissions', { fullPath: @project.full_path, iid: @issue.iid.to_s })
.js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
- else
.gl-border-solid.gl-border-1.gl-border-gray-100.gl-rounded-base.gl-mt-5.gl-p-3.gl-text-center
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index e1f1d8bb8f7..51130ae666c 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -5,8 +5,8 @@
- content_for :note_actions do
- if can?(current_user, :update_issue, @issue)
- = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
- = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
+ = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "gl-button btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
+ = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "gl-button btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
%section.issuable-discussion.js-vue-notes-event
#js-vue-notes{ data: { notes_data: notes_data(@issue).to_json,
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index 4f188ae273c..d9ad171a6cc 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -37,12 +37,15 @@
&nbsp;
= sprite_icon('calendar')
= issue.due_date.to_s(:medium)
+
+ = render_if_exists "projects/issues/issue_weight", issue: issue
+ = render_if_exists "projects/issues/health_status", issue: issue
+
- if issue.labels.any?
&nbsp;
- presented_labels_sorted_by_title(issue.labels, issue.project).each do |label|
= link_to_label(label, small: true)
- = render_if_exists "projects/issues/issue_weight", issue: issue
= render "projects/issues/issue_estimate", issue: issue
.issuable-meta
diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml
index fa08c39e407..ef602da72e5 100644
--- a/app/views/projects/issues/_issues.html.haml
+++ b/app/views/projects/issues/_issues.html.haml
@@ -11,7 +11,8 @@
'empty-state-meta': data_empty_state_meta.to_json,
'can-bulk-edit': @can_bulk_update.to_json,
'sort-key': @sort,
- type: type } }
+ type: type,
+ 'scoped-labels-available': scoped_labels_available?(@project).to_json } }
- 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') }
diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml
index cc6ca4aca4a..dbf6a1f1b94 100644
--- a/app/views/projects/issues/_nav_btns.html.haml
+++ b/app/views/projects/issues/_nav_btns.html.haml
@@ -8,22 +8,22 @@
.btn-group
- if show_export_button
- = render 'projects/issues/export_csv/button'
+ = render 'shared/issuable/csv_export/button', issuable_type: 'issues'
- if show_import_button
= render 'projects/issues/import_csv/button'
- if @can_bulk_update
- = button_tag _("Edit issues"), class: "btn btn-default gl-mr-3 js-bulk-update-toggle"
+ = button_tag _("Edit issues"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle"
- if show_new_issue_link?(@project)
= link_to _("New issue"), new_project_issue_path(@project,
issue: { assignee_id: finder.assignee.try(:id),
milestone_id: finder.milestones.first.try(:id) }),
- class: "btn btn-success",
+ class: "gl-button btn btn-success",
id: "new_issue_link"
- if show_export_button
- = render 'projects/issues/export_csv/modal'
+ = render 'shared/issuable/csv_export/modal', issuable_type: 'issues'
- if show_import_button
= render 'projects/issues/import_csv/modal'
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index aa95cecb5fe..34260899d94 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -29,7 +29,7 @@
- if can_create_merge_request
%li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', text: create_mr_text } }
.menu-item.text-nowrap
- = icon('check', class: 'icon')
+ = sprite_icon('check', css_class: 'icon')
- if can_create_confidential_merge_request?
= _('Create confidential merge request and branch')
- else
@@ -37,7 +37,7 @@
%li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: _('Create branch') } }
.menu-item
- = icon('check', class: 'icon')
+ = sprite_icon('check', css_class: 'icon')
= _('Create branch')
%li.divider.droplab-item-ignore
diff --git a/app/views/projects/issues/_service_desk_empty_state.html.haml b/app/views/projects/issues/_service_desk_empty_state.html.haml
index 4f004439f45..40abedea9d4 100644
--- a/app/views/projects/issues/_service_desk_empty_state.html.haml
+++ b/app/views/projects/issues/_service_desk_empty_state.html.haml
@@ -21,7 +21,7 @@
- if can_edit_project_settings && !service_desk_enabled
.text-center
- = link_to _("Turn on Service Desk"), edit_project_path(@project), class: 'btn btn-success'
+ = link_to _("Turn on Service Desk"), edit_project_path(@project), class: 'gl-button btn btn-success'
- else
.empty-state
.svg-content
diff --git a/app/views/projects/issues/_service_desk_info_content.html.haml b/app/views/projects/issues/_service_desk_info_content.html.haml
index 7fa2f3fab00..1eb427f4f7c 100644
--- a/app/views/projects/issues/_service_desk_info_content.html.haml
+++ b/app/views/projects/issues/_service_desk_info_content.html.haml
@@ -20,4 +20,4 @@
- if can_edit_project_settings && !service_desk_enabled
.gl-mt-3
- = link_to _("Turn on Service Desk"), edit_project_path(@project), class: 'btn btn-success'
+ = link_to _("Turn on Service Desk"), edit_project_path(@project), class: 'gl-button btn btn-success'
diff --git a/app/views/projects/issues/export_csv/_modal.html.haml b/app/views/projects/issues/export_csv/_modal.html.haml
deleted file mode 100644
index 6610af63445..00000000000
--- a/app/views/projects/issues/export_csv/_modal.html.haml
+++ /dev/null
@@ -1,22 +0,0 @@
-- if current_user
- .issues-export-modal.modal
- .modal-dialog
- .modal-content{ data: { qa_selector: 'export_issues_modal' } }
- .modal-header
- %h3
- = _('Export issues')
- .svg-content.import-export-svg-container
- = image_tag 'illustrations/export-import.svg', alt: _('Import/Export illustration'), class: 'illustration'
- %a.close{ href: '#', 'data-dismiss' => 'modal' }
- = sprite_icon('close', css_class: 'gl-icon')
- .modal-body
- - 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 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/import_csv/_modal.html.haml b/app/views/projects/issues/import_csv/_modal.html.haml
index fe4a4236896..e928a71b940 100644
--- a/app/views/projects/issues/import_csv/_modal.html.haml
+++ b/app/views/projects/issues/import_csv/_modal.html.haml
@@ -20,5 +20,5 @@
= _('It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.')
= _('The maximum file size allowed is %{size}.') % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) }
.modal-footer
- %button{ type: 'submit', class: 'btn btn-success', title: _('Import issues'), data: { track_label: "export_issues_csv", track_event: "click_button", track_value: ""} }
+ %button{ type: 'submit', class: 'gl-button btn btn-success', title: _('Import issues'), data: { track_label: "export_issues_csv", track_event: "click_button", track_value: ""} }
= _('Import issues')
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 7785093466b..c3949a83e3f 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -1,103 +1,6 @@
- @content_class = "limit-container-width" unless fluid_layout
- 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_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'
+- page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues")
-- can_update_issue = can?(current_user, :update_issue, @issue)
-- can_reopen_issue = can?(current_user, :reopen_issue, @issue)
-- can_report_spam = @issue.submittable_as_spam_by?(current_user)
-- can_create_issue = show_new_issue_link?(@project)
-- related_branches_path = related_branches_project_issue_path(@project, @issue)
-
-= render_if_exists "projects/issues/alert_blocked", issue: @issue, current_user: current_user
-= render "projects/issues/alert_moved_from_service_desk", issue: @issue
-
-.detail-page-header
- .detail-page-header-body
- .issuable-status-box.status-box.status-box-issue-closed{ class: issue_status_visibility(@issue, status_box: :closed) }
- = sprite_icon('mobile-issue-close', css_class: 'd-block d-sm-none')
- .d-none.d-sm-block
- = issue_closed_text(@issue, current_user)
- .issuable-status-box.status-box.status-box-open{ class: issue_status_visibility(@issue, status_box: :open) }
- = sprite_icon('issue-open-m', css_class: 'd-block d-sm-none')
- %span.d-none.d-sm-block Open
-
- .issuable-meta
- #js-issuable-header-warnings
- = issuable_meta(@issue, @project, "Issue")
-
- %a.btn.btn-default.float-right.d-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
- = sprite_icon('chevron-double-lg-left')
-
- .detail-page-header-actions.js-issuable-actions.js-issuable-buttons{ data: { "action": "close-reopen" } }
- .clearfix.issue-btn-group.dropdown
- %button.btn.btn-default.float-left.d-md-none.d-lg-none.d-xl-none{ type: "button", data: { toggle: "dropdown" } }
- Options
- = icon('caret-down')
- .dropdown-menu.dropdown-menu-right.d-lg-none.d-xl-none
- %ul
- - unless current_user == @issue.author
- %li= link_to 'Report abuse', new_abuse_report_path(user_id: @issue.author.id, ref_url: issue_url(@issue))
- - if can_update_issue
- %li= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close js-btn-issue-action #{issue_button_visibility(@issue, true)}", title: 'Close issue'
- - if can_reopen_issue
- %li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen js-btn-issue-action #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
- - if can_report_spam
- %li= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam'
- - if can_create_issue
- - if can_update_issue || can_report_spam
- %li.divider
- %li= link_to 'New issue', new_project_issue_path(@project), id: 'new_issue_link'
-
- = render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue, can_reopen: can_reopen_issue, warn_before_close: defined?(@issue.blocked?) && @issue.blocked?
-
- - if can_report_spam
- = link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'd-none d-sm-none d-md-block btn btn-grouped btn-spam', title: 'Submit as spam'
- - if can_create_issue
- = link_to new_project_issue_path(@project), class: 'd-none d-sm-none d-md-block btn btn-grouped btn-success btn-inverted', title: 'New issue', id: 'new_issue_link' do
- New issue
-
-.issue-details.issuable-details
- .detail-page-description.content-block
- #js-issuable-app{ data: { initial: issuable_initial_data(@issue).to_json} }
- .title-container
- %h2.title= markdown_field(@issue, :title)
- - if @issue.description.present?
- .description
- .md= markdown_field(@issue, :description)
-
- = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
-
- - if @issue.sentry_issue.present?
- #js-sentry-error-stack-trace{ data: error_details_data(@project, @issue.sentry_issue.sentry_issue_identifier) }
-
- = render 'projects/issues/design_management'
-
- = render_if_exists 'projects/issues/related_issues'
-
- #js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid)), project_namespace: @project.namespace.path, project_path: @project.path } }
-
- - if can?(current_user, :download_code, @project)
- - add_page_startup_api_call related_branches_path
- #related-branches{ data: { url: related_branches_path } }
- -# This element is filled in using JavaScript.
-
- .content-block.emoji-block.emoji-block-sticky
- .row.gl-m-0.gl-justify-content-space-between
- .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?
-
- = render 'projects/issues/discussion'
-
-= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @issue.assignees
+= render 'projects/issuable/show', issuable: @issue
diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml
index 0b4b4aafeee..a1960fc99cf 100644
--- a/app/views/projects/jobs/index.html.haml
+++ b/app/views/projects/jobs/index.html.haml
@@ -1,4 +1,5 @@
- page_title _("Jobs")
+- add_page_specific_style 'page_bundles/ci_status'
.top-area
- build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) }
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index d7a778088ee..44336b95e0f 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -1,7 +1,9 @@
- add_to_breadcrumbs _("Jobs"), project_jobs_path(@project)
- breadcrumb_title "##{@build.id}"
- page_title "#{@build.name} (##{@build.id})", _("Jobs")
+- add_page_specific_style 'page_bundles/build'
- add_page_specific_style 'page_bundles/xterm'
+- add_page_specific_style 'page_bundles/ci_status'
= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index 2699192adc9..357d4d193df 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -5,7 +5,7 @@
- labels_or_filters = @labels.exists? || @prioritized_labels.exists? || search.present? || subscribed.present?
- if labels_or_filters
- #promote-label-modal
+ #js-promote-label-modal
= render 'shared/labels/nav', labels_or_filters: labels_or_filters, can_admin_label: can_admin_label
.labels-container.gl-mt-3
@@ -17,7 +17,7 @@
-# Only show it in the first page
- hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1')
- .prioritized-labels{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] }
+ .prioritized-labels.gl-mb-7{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] }
%h5.gl-mt-3= _('Prioritized Labels')
.content-list.manage-labels-list.js-prioritized-labels{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } }
#js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" }
diff --git a/app/views/projects/logs/empty_logs.html.haml b/app/views/projects/logs/empty_logs.html.haml
index afae2d30f6e..5e3db401d79 100644
--- a/app/views/projects/logs/empty_logs.html.haml
+++ b/app/views/projects/logs/empty_logs.html.haml
@@ -11,4 +11,4 @@
%p.state-description.text-center
= s_('Logs|To see the logs, deploy your code to an environment.')
.text-center
- = link_to s_('Environments|Learn about environments'), help_page_path('ci/environments/index.md'), class: 'btn btn-success'
+ = link_to s_('Environments|Learn about environments'), help_page_path('ci/environments/index.md'), class: 'gl-button btn btn-success'
diff --git a/app/views/projects/mattermosts/_no_teams.html.haml b/app/views/projects/mattermosts/_no_teams.html.haml
index 0377cd6586e..00efea81f8f 100644
--- a/app/views/projects/mattermosts/_no_teams.html.haml
+++ b/app/views/projects/mattermosts/_no_teams.html.haml
@@ -9,8 +9,8 @@
To install this service,
= link_to "#{Gitlab.config.mattermost.host}/select_team", target: '__blank' do
join a team
- = icon('external-link')
+ = sprite_icon('external-link')
and try again.
%hr
.clearfix
- = link_to 'Go back', edit_project_service_path(@project, @service), class: 'btn btn-lg float-right'
+ = link_to 'Go back', edit_project_service_path(@project, @service), class: 'gl-button btn btn-lg float-right'
diff --git a/app/views/projects/mattermosts/_team_selection.html.haml b/app/views/projects/mattermosts/_team_selection.html.haml
index d0a7f89df31..ea04a55a77c 100644
--- a/app/views/projects/mattermosts/_team_selection.html.haml
+++ b/app/views/projects/mattermosts/_team_selection.html.haml
@@ -19,7 +19,7 @@
To create a team,
= link_to "#{Gitlab.config.mattermost.host}/create_team" do
use Mattermost's interface
- = icon('external-link')
+ = sprite_icon('external-link')
or ask your Mattermost system administrator.
%hr
%h4 Command trigger word
@@ -38,9 +38,9 @@
Reserved:
= link_to 'https://docs.mattermost.com/help/messaging/executing-commands.html#built-in-commands', target: '__blank' do
see list of built-in slash commands
- = icon('external-link')
+ = sprite_icon('external-link')
%hr
.clearfix
.float-right
- = link_to 'Cancel', edit_project_service_path(@project, @service), class: 'btn btn-lg'
- = f.submit 'Install', class: 'btn btn-success btn-lg'
+ = link_to 'Cancel', edit_project_service_path(@project, @service), class: 'gl-button btn btn-lg'
+ = f.submit 'Install', class: 'gl-button btn btn-success btn-lg'
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index b56e2c3f985..cd4ffa8602e 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -25,10 +25,10 @@
.detail-page-header-actions.js-issuable-actions
.clearfix.issue-btn-group.dropdown
- %button.btn.btn-default.float-left.d-md-none.d-lg-none.d-xl-none{ type: "button", data: { toggle: "dropdown" } }
+ %button.btn.btn-default.float-left.d-md-none{ type: "button", data: { toggle: "dropdown" } }
Options
= icon('caret-down')
- .dropdown-menu.dropdown-menu-right.d-lg-none.d-xl-none
+ .dropdown-menu.dropdown-menu-right
%ul
- if can_update_merge_request
%li= link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
@@ -41,10 +41,10 @@
- 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
+ - unless @merge_request.merged? || 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 gl-button btn-grouped js-issuable-edit qa-edit-button"
+ = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-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/_nav_btns.html.haml b/app/views/projects/merge_requests/_nav_btns.html.haml
index 2ef10365c18..473490c6c35 100644
--- a/app/views/projects/merge_requests/_nav_btns.html.haml
+++ b/app/views/projects/merge_requests/_nav_btns.html.haml
@@ -1,5 +1,10 @@
+.btn-group
+ = render 'shared/issuable/csv_export/button', issuable_type: 'merge-requests'
+
- if @can_bulk_update
- = button_tag "Edit merge requests", class: "btn gl-mr-3 js-bulk-update-toggle"
+ = button_tag "Edit merge requests", class: "gl-button btn gl-mr-3 js-bulk-update-toggle"
- if merge_project
- = link_to new_merge_request_path, class: "btn btn-success", title: "New merge request" do
+ = link_to new_merge_request_path, class: "gl-button btn btn-success", title: "New merge request" do
New merge request
+
+ = render 'shared/issuable/csv_export/modal', issuable_type: 'merge_requests'
diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
index 55c89f137c5..94c262d300e 100644
--- a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
+++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
@@ -21,4 +21,4 @@
%button.btn.btn-success.js-submit-button{ type: "button", "@click" => "commit()", ":disabled" => "!readyToCommit" }
%span {{commitButtonText}}
.col-6.text-right
- = link_to "Cancel", project_merge_request_path(@merge_request.project, @merge_request), class: "btn btn-cancel"
+ = link_to "Cancel", project_merge_request_path(@merge_request.project, @merge_request), class: "gl-button btn btn-cancel"
diff --git a/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml b/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml
index 3ca82adccf1..7294c5d321a 100644
--- a/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml
+++ b/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml
@@ -7,7 +7,4 @@
%button.btn.btn-sm.btn-close{ "@click" => "acceptDiscardConfirmation(file)" } Discard changes
%button.btn.btn-sm{ "@click" => "cancelDiscardConfirmation(file)" } Cancel
.editor-wrap{ ":class" => "classObject" }
- .loading
- .spinner.spinner-md
- .editor
- %pre{ "style" => "height: 350px" }
+ .editor{ "style" => "height: 350px", data: { 'editor-loading': true } }
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
index a7ffe825139..decdbce3fa7 100644
--- a/app/views/projects/merge_requests/conflicts/show.html.haml
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -1,7 +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"
.merge-request-details.issuable-details
diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml
index f0a68512326..2cb75d43d4b 100644
--- a/app/views/projects/merge_requests/creations/_new_compare.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml
@@ -4,7 +4,6 @@
= form_for [@project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form js-requires-input" } do |f|
- if params[:nav_source].present?
= hidden_field_tag(:nav_source, params[:nav_source])
- .hide.alert.alert-danger.mr-compare-errors
.js-merge-request-new-compare.row{ 'data-source-branch-url': project_new_merge_request_branch_from_path(@source_project), 'data-target-branch-url': project_new_merge_request_branch_to_path(@source_project) }
.col-lg-6
.card.card-new-merge-request
@@ -65,4 +64,4 @@
- if @merge_request.errors.any?
= form_errors(@merge_request)
- = f.submit 'Compare branches and continue', class: "btn btn-success mr-compare-btn"
+ = f.submit 'Compare branches and continue', class: "gl-button btn btn-success mr-compare-btn"
diff --git a/app/views/projects/merge_requests/creations/new.html.haml b/app/views/projects/merge_requests/creations/new.html.haml
index 4c968c8e8eb..0741b24a5a1 100644
--- a/app/views/projects/merge_requests/creations/new.html.haml
+++ b/app/views/projects/merge_requests/creations/new.html.haml
@@ -2,6 +2,7 @@
- breadcrumb_title _("New")
- page_title _("New Merge Request")
- add_page_specific_style 'page_bundles/pipelines'
+- add_page_specific_style 'page_bundles/ci_status'
- if @merge_request.can_be_created && !params[:change_branches]
= render 'new_submit'
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 1dbcd613ceb..6b506c38795 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -11,6 +11,7 @@
- add_page_specific_style 'page_bundles/merge_requests'
- add_page_specific_style 'page_bundles/pipelines'
- add_page_specific_style 'page_bundles/reports'
+- add_page_specific_style 'page_bundles/ci_status'
.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"
diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index 907af326ec5..a21f519da0e 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -22,7 +22,7 @@
.form-actions
- if @milestone.new_record?
= f.submit _('Create milestone'), class: 'btn-success btn', data: { qa_selector: 'create_milestone_button' }
- = link_to _('Cancel'), project_milestones_path(@project), class: 'btn btn-cancel'
+ = link_to _('Cancel'), project_milestones_path(@project), class: 'gl-button btn btn-cancel'
- else
= f.submit _('Save changes'), class: 'btn-success btn'
- = link_to _('Cancel'), project_milestone_path(@project, @milestone), class: 'btn btn-cancel'
+ = link_to _('Cancel'), project_milestone_path(@project, @milestone), class: 'gl-button btn btn-cancel'
diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml
index 2c52d2a5fbc..b964c8b1a93 100644
--- a/app/views/projects/milestones/index.html.haml
+++ b/app/views/projects/milestones/index.html.haml
@@ -8,7 +8,7 @@
= render 'shared/milestones/search_form'
= render 'shared/milestones_sort_dropdown'
- if can?(current_user, :admin_milestone, @project)
- = link_to new_project_milestone_path(@project), class: 'btn btn-success', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do
+ = link_to new_project_milestone_path(@project), class: 'gl-button btn btn-success', data: { qa_selector: "new_project_milestone_link" }, title: _('New milestone') do
= _('New milestone')
.milestones
diff --git a/app/views/projects/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml
index dae0fa958ba..88bd7da7fef 100644
--- a/app/views/projects/mirrors/_authentication_method.html.haml
+++ b/app/views/projects/mirrors/_authentication_method.html.haml
@@ -7,7 +7,7 @@
= f.select :auth_method,
options_for_select(auth_options, mirror.auth_method),
{}, { class: "form-control select-control js-mirror-auth-type qa-authentication-method" }
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200")
= f.hidden_field :auth_method, value: "password", class: "js-hidden-mirror-auth-type"
.form-group
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index d2847de6ece..5b074ff8a28 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -33,7 +33,7 @@
= link_to sprite_icon('question-o'), help_page_path('user/project/protected_branches'), target: '_blank'
.panel-footer
- = f.submit _('Mirror repository'), class: 'btn btn-success js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror
+ = f.submit _('Mirror repository'), class: 'gl-button btn btn-success js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror
- else
.gl-alert.gl-alert-info{ role: 'alert' }
= sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
@@ -72,6 +72,6 @@
- if mirror_settings_enabled
.btn-group.mirror-actions-group.float-right{ role: 'group' }
- 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')
+ = clipboard_button(text: mirror.ssh_public_key, class: 'gl-button 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.gl-button.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.btn-icon.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/_mirror_repos_form.html.haml b/app/views/projects/mirrors/_mirror_repos_form.html.haml
index dd794e03f48..215d0a59d1b 100644
--- a/app/views/projects/mirrors/_mirror_repos_form.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos_form.html.haml
@@ -2,6 +2,6 @@
= label_tag :mirror_direction, _('Mirror direction'), class: 'label-light'
.select-wrapper
= select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control select-control js-mirror-direction qa-mirror-direction', disabled: true
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200")
= render partial: "projects/mirrors/mirror_repos_push", locals: { f: f }
diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml
index 1690188f07a..4e3cd609d75 100644
--- a/app/views/projects/mirrors/_ssh_host_keys.html.haml
+++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml
@@ -14,7 +14,7 @@
%code= fp.fingerprint
- if verified_at
.form-text.text-muted.js-fingerprint-verification
- %i.fa.fa-check.fingerprint-verified
+ = sprite_icon('check', css_class: 'gl-text-green-500')
Verified by
- if verified_by
= link_to verified_by.name, user_path(verified_by)
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index d5099f80ea4..f2972a9617b 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -4,7 +4,7 @@
- header_title _("Projects"), dashboard_projects_path
- active_tab = local_assigns.fetch(:active_tab, 'blank')
-.project-edit-container.gl-mt-3
+.project-edit-container.gl-mt-5
.project-edit-errors
= render 'projects/errors'
diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml
index c44d3da23bb..65c4232b240 100644
--- a/app/views/projects/no_repo.html.haml
+++ b/app/views/projects/no_repo.html.haml
@@ -1,9 +1,10 @@
- breadcrumb_title _("Details")
- page_title _("Details")
-%h2
- %i.fa.fa-warning
- #{ _('No repository') }
+%h2.gl-display-flex
+ .gl-display-flex.gl-align-items-center.gl-justify-content-center
+ = sprite_icon('warning-solid', size: 24, css_class: 'gl-mr-2')
+ = _('No repository')
%p.slead
#{ _('The repository for this project does not exist.') }
@@ -11,6 +12,8 @@
#{ _('This means you can not push code until you create an empty repository or import existing one.') }
%hr
+= render_if_exists 'projects/invite_members_modal', project: @project
+
.no-repo-actions
= link_to project_repository_path(@project), method: :post, class: 'btn btn-primary' do
#{ _('Create empty repository') }
diff --git a/app/views/projects/pages/_ssl_limitations_warning.html.haml b/app/views/projects/pages/_ssl_limitations_warning.html.haml
index 7188e169824..1f2907d183e 100644
--- a/app/views/projects/pages/_ssl_limitations_warning.html.haml
+++ b/app/views/projects/pages/_ssl_limitations_warning.html.haml
@@ -1,5 +1,5 @@
.bs-callout.bs-callout-warning
- %i.fa.fa-warning
+ = sprite_icon("warning-solid", css_class: "gl-text-orange-600")
%strong= _("Warning:")
- pages_host = Gitlab.config.pages.host
= s_("GitLabPages|When using Pages under the general domain of a GitLab instance (%{pages_host}), you cannot use HTTPS with sub-subdomains. This means that if your username/groupname contains a dot it will not work. This is a limitation of the HTTP Over TLS protocol. HTTP pages will continue to work provided you don't redirect HTTP to HTTPS.").html_safe % { pages_host: pages_host }
diff --git a/app/views/projects/pages_domains/_dns.html.haml b/app/views/projects/pages_domains/_dns.html.haml
index 8e2a9c3bab4..dc8127ab068 100644
--- a/app/views/projects/pages_domains/_dns.html.haml
+++ b/app/views/projects/pages_domains/_dns.html.haml
@@ -30,4 +30,4 @@
= clipboard_button(target: '#domain_verification', class: 'btn-default d-none d-sm-block')
%p.form-text.text-muted
- link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership'))
- = _("To %{link_to_help} of your domain, add the above key to a TXT record within to your DNS configuration.").html_safe % { link_to_help: link_to_help }
+ = _("To %{link_to_help} of your domain, add the above key to a TXT record within your DNS configuration.").html_safe % { link_to_help: link_to_help }
diff --git a/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml b/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml
index a86637c36b3..9072312c100 100644
--- a/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml
+++ b/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml
@@ -6,7 +6,7 @@
.col-sm-10.offset-sm-2
.bs-callout.bs-callout-warning.mt-0
.row.align-items-center.mx-2
- = icon('warning', class: 'mr-2')
+ = sprite_icon('warning-solid', css_class: ' mr-2 gl-text-orange-600')
= _("Something went wrong while obtaining the Let's Encrypt certificate.")
.row.mx-0.mt-3
= link_to s_('GitLabPagesDomains|Retry'), retry_auto_ssl_project_pages_domain_path(@project, domain_presenter), class: "btn btn-sm btn-grouped btn-warning", method: :post
diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml
index 20ecf948447..54522a70f4a 100644
--- a/app/views/projects/pages_domains/show.html.haml
+++ b/app/views/projects/pages_domains/show.html.haml
@@ -6,7 +6,7 @@
- if verification_enabled && domain_presenter.unverified?
= content_for :flash_message do
- .alert.alert-warning
+ .gl-alert.gl-alert-warning
.container-fluid.container-limited
= _("This domain is not verified. You will need to verify ownership before access is enabled.")
diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml
index 1a8229350d9..ee0fe43e79c 100644
--- a/app/views/projects/pipeline_schedules/_form.html.haml
+++ b/app/views/projects/pipeline_schedules/_form.html.haml
@@ -39,5 +39,5 @@
= f.check_box :active, required: false, value: @schedule.active?
= f.label :active, _('Active'), class: 'gl-font-weight-normal'
.footer-block.row-content-block
- = f.submit _('Save pipeline schedule'), class: 'btn btn-success', tabindex: 3
+ = f.submit _('Save pipeline schedule'), class: 'btn btn-success'
= link_to _('Cancel'), pipeline_schedules_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/pipeline_schedules/edit.html.haml b/app/views/projects/pipeline_schedules/edit.html.haml
index d95fa6da903..29896500ea1 100644
--- a/app/views/projects/pipeline_schedules/edit.html.haml
+++ b/app/views/projects/pipeline_schedules/edit.html.haml
@@ -1,6 +1,7 @@
- add_to_breadcrumbs _("Schedules"), pipeline_schedules_path(@project)
- breadcrumb_title "##{@schedule.id}"
- page_title _("Edit"), @schedule.description, _("Pipeline Schedule")
+- add_page_specific_style 'page_bundles/pipeline_schedules'
%h3.page-title
= _("Edit Pipeline Schedule %{id}") % { id: @schedule.id }
diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml
index 91083cc0768..a52a6138402 100644
--- a/app/views/projects/pipeline_schedules/index.html.haml
+++ b/app/views/projects/pipeline_schedules/index.html.haml
@@ -1,8 +1,8 @@
- breadcrumb_title _("Schedules")
-
- page_title _("Pipeline Schedules")
+- add_page_specific_style 'page_bundles/pipeline_schedules'
-#pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules'), image_url: image_path('illustrations/pipeline_schedule_callout.svg') } }
+#pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules'), illustration_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/pipeline_schedules/new.html.haml b/app/views/projects/pipeline_schedules/new.html.haml
index cfdaf6d43bb..a2652304768 100644
--- a/app/views/projects/pipeline_schedules/new.html.haml
+++ b/app/views/projects/pipeline_schedules/new.html.haml
@@ -1,6 +1,7 @@
- breadcrumb_title "Schedules"
- @breadcrumb_link = namespace_project_pipeline_schedules_path(@project.namespace, @project)
- page_title _("New Pipeline Schedule")
+- add_page_specific_style 'page_bundles/pipeline_schedules'
- add_to_breadcrumbs("Pipelines", project_pipelines_path(@project))
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 6d3b3f815e4..f77f22cc555 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -42,7 +42,7 @@
toggle: "popover",
placement: "top",
html: "true",
- trigger: "focus",
+ triggers: "focus",
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>",
} }
@@ -57,12 +57,7 @@
.well-segment.branch-info
.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-md-inline") do
- %span.text-expander
- = sprite_icon('ellipsis_h', size: 12)
- %span.js-details-content.hide
- = link_to @pipeline.sha, project_commit_path(@project, @pipeline.sha), class: "commit-sha commit-hash-full"
+ = link_to commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha"
= clipboard_button(text: @pipeline.sha, title: _("Copy commit SHA"))
.well-segment.related-merge-request-info
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 40a52f76641..8955b568741 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -9,7 +9,7 @@
- if dag_pipeline_tab_enabled
%li.js-dag-tab-link
= link_to dag_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-dag', action: 'dag', toggle: 'tab' }, class: 'dag-tab' do
- = _('DAG')
+ = _('Needs')
%li.js-builds-tab-link
= link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do
= _('Jobs')
@@ -81,7 +81,7 @@
- if dag_pipeline_tab_enabled
#js-tab-dag.tab-pane
- #js-pipeline-dag-vue{ data: { pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} }
+ #js-pipeline-dag-vue{ data: { pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), about_dag_doc_path: help_page_path('ci/directed_acyclic_graph/index.md'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} }
#js-tab-tests.tab-pane
#js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json),
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index ca07f33136b..6aa1a564499 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -1,5 +1,6 @@
- page_title _('Pipelines')
- add_page_specific_style 'page_bundles/pipelines'
+- add_page_specific_style 'page_bundles/ci_status'
= 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 cb5401cd329..bc8e6a6d9cc 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -6,7 +6,7 @@
= s_('Pipeline|Run Pipeline')
%hr
-- if Feature.enabled?(:new_pipeline_form, @project)
+- if Feature.enabled?(:new_pipeline_form, @project, default_enabled: true)
#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),
@@ -48,7 +48,7 @@
= (s_("Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default.") % {settings_link: settings_link}).html_safe
.form-actions
- = f.submit s_('Pipeline|Run Pipeline'), class: 'btn btn-success js-variables-save-button', tabindex: 3
+ = f.submit s_('Pipeline|Run Pipeline'), class: 'btn btn-success js-variables-save-button'
= link_to _('Cancel'), project_pipelines_path(@project), class: 'btn btn-default float-right'
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index 34f7744f825..0b07fe9921e 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -4,9 +4,10 @@
- pipeline_has_errors = @pipeline.builds.empty? && @pipeline.yaml_errors.present?
- add_page_specific_style 'page_bundles/pipeline'
- add_page_specific_style 'page_bundles/reports'
+- add_page_specific_style 'page_bundles/ci_status'
.js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } }
- #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} }
+ #js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } }
- 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 4d4705c4ed5..171212b6a96 100644
--- a/app/views/projects/project_members/_team.html.haml
+++ b/app/views/projects/project_members/_team.html.haml
@@ -1,5 +1,7 @@
- project = local_assigns.fetch(:project)
- members = local_assigns.fetch(:members)
+- group = local_assigns.fetch(:group)
+- current_user_is_group_owner = group && group.has_owner?(current_user)
.card
.card-header.flex-project-members-panel
@@ -14,5 +16,9 @@
= 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' } }
- = render partial: 'shared/members/member', collection: members, as: :member
+ %ul.content-list.members-list{ data: { qa_selector: 'members_list', testid: 'members-table' } }
+ = render partial: 'shared/members/member',
+ collection: members, as: :member,
+ locals: { membership_source: project,
+ group: group,
+ current_user_is_group_owner: current_user_is_group_owner }
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index 9a1e997fce7..cad76d7aeac 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -1,5 +1,6 @@
- page_title _("Members")
- can_admin_project_members = can?(current_user, :admin_project_member, @project)
+- group = @project.group
.js-remove-member-modal
.row.gl-mt-3
@@ -32,12 +33,12 @@
- elsif @project.allowed_to_share_with_group?
.invite-group= render 'shared/members/invite_group', access_levels: ProjectGroupLink.access_options, default_access_level: ProjectGroupLink.default_access, submit_url: project_group_links_path(@project), group_link_field: 'link_group_id', group_access_field: 'link_group_access'
- = render 'shared/members/requests', membership_source: @project, requesters: @requesters
+ = render 'shared/members/requests', membership_source: @project, group: group, requesters: @requesters
.clearfix
%h5.member.existing-title
= _("Existing members and groups")
- if @group_links.any?
= render 'projects/project_members/groups', group_links: @group_links
- = render 'projects/project_members/team', project: @project, members: @project_members
+ = render 'projects/project_members/team', project: @project, group: group, members: @project_members
= paginate @project_members, theme: "gitlab"
diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml
index 74b6e981c00..1a3ba690184 100644
--- a/app/views/projects/runners/_runner.html.haml
+++ b/app/views/projects/runners/_runner.html.haml
@@ -37,8 +37,8 @@
- if runner.description.present?
%p.runner-description
= runner.description
- - if runner.tag_list.present?
+ - if runner.tags.present?
%p
- - runner.tag_list.sort.each do |tag|
+ - runner.tags.map(&:name).sort.each do |tag|
%span.badge.badge-primary
= tag
diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml
index 4cc67a8f5d8..e02e2cc784a 100644
--- a/app/views/projects/runners/_specific_runners.html.haml
+++ b/app/views/projects/runners/_specific_runners.html.haml
@@ -9,15 +9,18 @@
= render partial: 'ci/runner/how_to_setup_runner',
locals: { registration_token: @project.runners_token,
type: 'specific',
- reset_token_url: reset_registration_token_namespace_project_settings_ci_cd_path }
+ reset_token_url: reset_registration_token_namespace_project_settings_ci_cd_path,
+ project_path: @project.path_with_namespace,
+ group_path: '' }
- if @project_runners.any?
%h4.underlined-title= _('Runners activated for this project')
%ul.bordered-list.activated-specific-runners
= render partial: 'projects/runners/runner', collection: @project_runners, as: :runner
+ = paginate @project_runners, theme: "gitlab", param_name: "project_page", params: { expand_runners: true, anchor: 'js-runners-settings' }
- if @assignable_runners.any?
%h4.underlined-title= _('Available specific runners')
%ul.bordered-list.available-specific-runners
= render partial: 'projects/runners/runner', collection: @assignable_runners, as: :runner
- = paginate @assignable_runners, theme: "gitlab", :params => { :anchor => '#js-runners-settings' }
+ = paginate @assignable_runners, theme: "gitlab", param_name: "specific_page", :params => { :anchor => 'js-runners-settings'}
diff --git a/app/views/projects/services/prometheus/_metrics.html.haml b/app/views/projects/services/prometheus/_metrics.html.haml
index 732a084d476..4bafd4d06e0 100644
--- a/app/views/projects/services/prometheus/_metrics.html.haml
+++ b/app/views/projects/services/prometheus/_metrics.html.haml
@@ -25,7 +25,8 @@
.card.hidden.js-panel-missing-env-vars
.card-header
- = icon('caret-right lg fw', class: 'panel-toggle js-panel-toggle', 'aria-label' => 'Toggle panel')
+ = sprite_icon('chevron-lg-right', css_class: 'panel-toggle js-panel-toggle-right' )
+ = sprite_icon('chevron-lg-down', css_class: 'panel-toggle js-panel-toggle-down hidden' )
= s_('PrometheusService|Missing environment variable')
%span.badge.badge-pill.js-env-var-count 0
.card-body.hidden
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index 4793e685163..d247e73a5b4 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -72,7 +72,7 @@
%li
= _("For public projects, anyone can view pipelines and access job details (output logs and artifacts)")
%li
- = _("For internal projects, any logged in user can view pipelines and access job details (output logs and artifacts)")
+ = _("For internal projects, any logged in user except external users can view pipelines and access job details (output logs and artifacts)")
%li
= _("For private projects, any member (guest or higher) can view pipelines and access job details (output logs and artifacts)")
%p
@@ -120,6 +120,9 @@
pytest-cov (Python) -
%code ^TOTAL.+?(\d+\%)$
%li
+ Scoverage (Scala) -
+ %code Statement coverage[A-Za-z\.*]\s*:\s*([^%]+)
+ %li
phpunit --coverage-text --colors=never (PHP) -
%code ^\s*Lines:\s*\d+.\d+\%
%li
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 8e3be5fa086..f6ecb923100 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -33,7 +33,7 @@
= render_if_exists 'projects/settings/ci_cd/protected_environments', expanded: expanded
-%section.settings.no-animate#js-runners-settings{ class: ('expanded' if expanded), data: { qa_selector: 'runners_settings_content' } }
+%section.settings.no-animate#js-runners-settings{ class: ('expanded' if expanded || params[:expand_runners]), data: { qa_selector: 'runners_settings_content' } }
.settings-header
%h4
= _("Runners")
@@ -75,6 +75,8 @@
.settings-content
= render 'projects/registry/settings/index'
+= render_if_exists 'projects/settings/ci_cd/auto_rollback', expanded: expanded
+
- if can?(current_user, :create_freeze_period, @project)
%section.settings.no-animate#js-deploy-freeze-settings{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/projects/settings/operations/_alert_management.html.haml b/app/views/projects/settings/operations/_alert_management.html.haml
index 5c16a5e2758..9e76ad52ecb 100644
--- a/app/views/projects/settings/operations/_alert_management.html.haml
+++ b/app/views/projects/settings/operations/_alert_management.html.haml
@@ -1,5 +1,6 @@
- return unless can?(current_user, :admin_operations, @project)
- expanded = expanded_by_default?
+- add_page_specific_style 'page_bundles/alert_management_settings'
%section.settings.no-animate#js-alert-management-settings{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml
index 62b344b38f1..6ab8beff99f 100644
--- a/app/views/projects/settings/operations/_error_tracking.html.haml
+++ b/app/views/projects/settings/operations/_error_tracking.html.haml
@@ -1,4 +1,4 @@
-- return unless can?(current_user, :read_environment, @project)
+- return unless can?(current_user, :admin_operations, @project)
- setting = error_tracking_setting
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index 67bdcd0d9d6..f7c51e9ada9 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -1,5 +1,4 @@
- breadcrumb_title _("Details")
-- page_title _("Projects")
- @content_class = "limit-container-width" unless fluid_layout
= content_for :meta_tags do
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index d7231e758c7..7679e0714fe 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -1,5 +1,6 @@
- commit = @repository.commit(tag.dereferenced_target)
- release = @releases.find { |release| release.tag == tag.name }
+- commit_status = @tag_pipeline_statuses[tag.name] unless @tag_pipeline_statuses.nil?
%li.flex-row.allow-wrap.js-tag-list
.row-main-content
@@ -34,6 +35,12 @@
- if tag.has_signature?
= render partial: 'projects/commit/signature', object: tag.signature
+ - if commit_status
+ = render 'ci/status/icon', size: 24, status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-5'
+ - elsif @tag_pipeline_statuses && @tag_pipeline_statuses.any?
+ .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-5
+ %svg.s24
+
= render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name]
- if can?(current_user, :admin_tag, @project)
diff --git a/app/views/projects/terraform/index.html.haml b/app/views/projects/terraform/index.html.haml
new file mode 100644
index 00000000000..136e7ded224
--- /dev/null
+++ b/app/views/projects/terraform/index.html.haml
@@ -0,0 +1,4 @@
+- breadcrumb_title _('Terraform')
+- page_title _('Terraform')
+
+#js-terraform-list{ data: js_terraform_list_data(@project) }
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index dc9fb9e7792..cd6e85d60ed 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -13,7 +13,7 @@
= render 'shared/web_ide_button', blob: nil
- if show_xcode_link?(@project)
- .project-action-button.project-xcode.inline<
+ .project-action-button.project-xcode<
= render "projects/buttons/xcode_link"
= render 'projects/buttons/download', project: @project, ref: @ref
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index 4d8c357cee1..355277b7d41 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -1,5 +1,7 @@
- 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 })
+- add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" })
+- add_page_startup_graphql_call('repository/permissions', { projectPath: @project.full_path })
+- add_page_startup_graphql_call('repository/files', { nextPageCursor: "", pageSize: 100, 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/registrations/welcome.html.haml b/app/views/registrations/welcome/show.html.haml
index bebcc2152af..278c0ff7739 100644
--- a/app/views/registrations/welcome.html.haml
+++ b/app/views/registrations/welcome/show.html.haml
@@ -1,6 +1,6 @@
- page_title _('Your profile')
-.row.gl-flex-grow-1.gl-bg-gray-10
+.row.gl-flex-grow-1
.d-flex.gl-flex-direction-column.gl-align-items-center.gl-w-full.gl-p-5
.edit-profile.login-page.d-flex.flex-column.gl-align-items-center.pt-lg-3
= render_if_exists "registrations/welcome/progress_bar"
@@ -8,7 +8,7 @@
%p
.gl-text-center= html_escape(_('In order to personalize your experience with GitLab%{br_tag}we would like to know a bit more about you.')) % { br_tag: '<br/>'.html_safe }
- = form_for(current_user, url: users_sign_up_update_registration_path, html: { class: 'card gl-w-full! gl-p-5', 'aria-live' => 'assertive' }) do |f|
+ = form_for(current_user, url: users_sign_up_welcome_path, html: { class: 'card gl-w-full! gl-p-5', 'aria-live' => 'assertive' }) do |f|
.devise-errors
= render 'devise/shared/error_messages', resource: current_user
.row
diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml
index e7febd4638b..964a2a2772a 100644
--- a/app/views/search/_filter.html.haml
+++ b/app/views/search/_filter.html.haml
@@ -2,25 +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{ data: { testid: "group-filter" } }
+.dropdown.form-group.mb-lg-0.mx-lg-1.gl-p-0{ data: { testid: "group-filter" } }
%label.d-block{ for: "dashboard_search_group" }
= _("Group")
- %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"))
- = dropdown_filter(_("Search groups"))
- = dropdown_content
- = dropdown_loading
-
-.dropdown.project-filter.form-group.mb-lg-0.mx-lg-1
+ %input#js-search-group-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-group-data": @group.to_json } }
+.dropdown.form-group.mb-lg-0.mx-lg-1{ data: { testid: "project-filter" } }
%label.d-block{ for: "dashboard_search_project" }
= _("Project")
- %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' } }
+ %button.dropdown-menu-toggle.gl-display-inline-flex.js-search-project-dropdown.gl-mt-0{ type: "button", id: "dashboard_search_project", data: { toggle: "dropdown" } }
%span.dropdown-toggle-text.gl-flex-grow-1.str-truncated-100
= @project&.full_name || _("Any")
- if @project.present?
diff --git a/app/views/search/_form.html.haml b/app/views/search/_form.html.haml
index c8fa016662f..80973c2b273 100644
--- a/app/views/search/_form.html.haml
+++ b/app/views/search/_form.html.haml
@@ -17,4 +17,4 @@
- unless params[:snippets].eql? 'true'
= render 'filter'
.d-flex-center.flex-column.flex-lg-row
- = button_tag _("Search"), class: "btn btn-success btn-search form-control mt-lg-0 ml-lg-1 align-self-end"
+ = button_tag _("Search"), class: "gl-button btn btn-success btn-search form-control mt-lg-0 ml-lg-1 align-self-end"
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index 95c378bff7c..855112bdba2 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -1,38 +1,47 @@
- if @search_objects.to_a.empty?
- = render partial: "search/results/filters"
- = render partial: "search/results/empty"
- = render_if_exists 'shared/promotions/promote_advanced_search'
+ .gl-display-md-flex
+ - if %w(issues merge_requests).include?(@scope)
+ #js-search-sidebar.gl-display-flex.gl-flex-direction-column.col-md-3.gl-mr-4{ }
+ .gl-w-full
+ = render partial: "search/results/empty"
+ = render_if_exists 'shared/promotions/promote_advanced_search'
- else
- .row-content-block.d-md-flex.text-left.align-items-center
- - unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount)
- = search_entries_info(@search_objects, @scope, @search_term)
- - unless @show_snippets
- - if @project
- - link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1')
- - if @scope == 'blobs'
- = s_("SearchCodeResults|in")
- .mx-md-1
- = 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 }
- - elsif @group
- - 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 }
+ .search-results-status
+ .row-content-block.gl-display-flex
+ .gl-display-md-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1
+ - unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount)
+ = search_entries_info(@search_objects, @scope, @search_term)
+ - unless @show_snippets
+ - if @project
+ - link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1')
+ - if @scope == 'blobs'
+ = s_("SearchCodeResults|in")
+ .mx-md-1
+ = 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 }
+ - elsif @group
+ - 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 }
+ .gl-display-md-flex.gl-flex-direction-column
+ = render partial: 'search/sort_dropdown'
= render_if_exists 'shared/promotions/promote_advanced_search'
- = render partial: "search/results/filters"
- .results.gl-mt-3
- - if @scope == 'commits'
- %ul.content-list.commit-list
- = render partial: "search/results/commit", collection: @search_objects
- - else
- .search-results
- - if @scope == 'projects'
- .term
- = render 'shared/projects/list', projects: @search_objects, pipeline_status: false
- - else
- = render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects
+ .results.gl-display-md-flex.gl-mt-3
+ - if %w(issues merge_requests).include?(@scope)
+ #js-search-sidebar.gl-display-flex.gl-flex-direction-column.col-md-3.gl-mr-4{ }
+ .gl-w-full
+ - if @scope == 'commits'
+ %ul.content-list.commit-list
+ = render partial: "search/results/commit", collection: @search_objects
+ - else
+ .search-results
+ - if @scope == 'projects'
+ .term
+ = render 'shared/projects/list', projects: @search_objects, pipeline_status: false
+ - else
+ = render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects
- - if @scope != 'projects'
- = paginate_collection(@search_objects)
+ - if @scope != 'projects'
+ = paginate_collection(@search_objects)
diff --git a/app/views/search/_sort_dropdown.html.haml b/app/views/search/_sort_dropdown.html.haml
new file mode 100644
index 00000000000..085e2f348f7
--- /dev/null
+++ b/app/views/search/_sort_dropdown.html.haml
@@ -0,0 +1,16 @@
+- return unless ['issues', 'merge_requests'].include?(@scope)
+
+- sort_value = @sort
+- sort_title = search_sort_option_title(sort_value)
+
+.dropdown.gl-display-inline-block.gl-ml-3.filter-dropdown-container
+ .btn-group{ role: 'group' }
+ .btn-group{ role: 'group' }
+ %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
+ = sort_title
+ = icon('chevron-down')
+ %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
+ %li
+ = render_if_exists('search/sort_by_relevancy', sort_title: sort_title)
+ = sortable_item(sort_title_recently_created, page_filter_path(sort: sort_value_recently_created), sort_title)
+ = search_sort_direction_button(sort_value)
diff --git a/app/views/search/results/_blob_data.html.haml b/app/views/search/results/_blob_data.html.haml
index d873a15d051..16d640273b0 100644
--- a/app/views/search/results/_blob_data.html.haml
+++ b/app/views/search/results/_blob_data.html.haml
@@ -7,4 +7,4 @@
= search_blob_title(project, path)
- if blob.data
.file-content.code.term{ data: { qa_selector: 'file_text_content' } }
- = render 'shared/file_highlight', blob: blob, first_line_number: blob.startline, blob_link: blob_link
+ = render 'shared/file_highlight', blob: blob, first_line_number: blob.startline, blob_link: blob_link, highlight_line: blob.highlight_line
diff --git a/app/views/search/results/_empty.html.haml b/app/views/search/results/_empty.html.haml
index 3cd1c901f8e..0462c29f5c1 100644
--- a/app/views/search/results/_empty.html.haml
+++ b/app/views/search/results/_empty.html.haml
@@ -1,5 +1,5 @@
-.search_box
+.search_box.gl-my-8
.search_glyph
%h4
= sprite_icon('search', size: 24, css_class: 'gl-vertical-align-text-bottom')
- = search_entries_empty_message(@scope, @search_term)
+ = search_entries_empty_message(@scope, @search_term, @group, @project)
diff --git a/app/views/search/results/_filters.html.haml b/app/views/search/results/_filters.html.haml
deleted file mode 100644
index 632d3dfd58c..00000000000
--- a/app/views/search/results/_filters.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-.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/_issuable.html.haml b/app/views/search/results/_issuable.html.haml
new file mode 100644
index 00000000000..288ac53a954
--- /dev/null
+++ b/app/views/search/results/_issuable.html.haml
@@ -0,0 +1,10 @@
+%div{ class: 'search-result-row gl-pb-3! gl-mt-5 gl-mb-0!' }
+ %span.gl-display-flex.gl-align-items-center
+ %span.badge.badge-pill.gl-badge.sm{ class: "badge-#{issuable_state_to_badge_class(issuable)}" }= issuable_state_text(issuable)
+ = sprite_icon('eye-slash', css_class: 'gl-text-gray-500 gl-ml-2') if issuable.respond_to?(:confidential?) && issuable.confidential?
+ = link_to issuable_path(issuable), data: { track_event: 'click_text', track_label: "#{issuable.class.name.downcase}_title", track_property: 'search_result' }, class: 'gl-w-full' do
+ %span.term.str-truncated.gl-font-weight-bold.gl-ml-2= issuable.title
+ .gl-text-gray-500.gl-my-3
+ = sprintf(s_(' %{project_name}#%{issuable_iid} &middot; opened %{issuable_created} by %{author}'), { project_name: issuable.project.full_name, issuable_iid: issuable.iid, issuable_created: time_ago_with_tooltip(issuable.created_at, placement: 'bottom'), author: link_to_member(@project, issuable.author, avatar: false) }).html_safe
+ .description.term.col-sm-10.gl-px-0
+ = highlight_and_truncate_issuable(issuable, @search_term, @search_highlight)
diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml
index a101e60f297..6fb463b75fc 100644
--- a/app/views/search/results/_issue.html.haml
+++ b/app/views/search/results/_issue.html.haml
@@ -1,13 +1 @@
-%div{ class: 'search-result-row gl-pb-3! gl-mt-5 gl-mb-0!' }
- %span.gl-display-flex.gl-align-items-center
- - if issue.closed?
- %span.badge.badge-info.badge-pill.gl-badge.sm= _("Closed")
- - else
- %span.badge.badge-success.badge-pill.gl-badge.sm= _("Open")
- = sprite_icon('eye-slash', css_class: 'gl-text-gray-500 gl-ml-2') if issue.confidential?
- = link_to project_issue_path(issue.project, issue), data: { track_event: 'click_text', track_label: 'issue_title', track_property: 'search_result' }, class: 'gl-w-full' do
- %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
- .description.term.col-sm-10.gl-px-0
- = highlight_and_truncate_issue(issue, @search_term, @search_highlight)
+= render partial: 'search/results/issuable', object: issue
diff --git a/app/views/search/results/_merge_request.html.haml b/app/views/search/results/_merge_request.html.haml
index 3135ab9a17e..b2b067bcf68 100644
--- a/app/views/search/results/_merge_request.html.haml
+++ b/app/views/search/results/_merge_request.html.haml
@@ -1,14 +1 @@
-.search-result-row
- %h4
- = link_to project_merge_request_path(merge_request.target_project, merge_request), data: {track_event: 'click_text', track_label: 'merge_request_title', track_property: 'search_result'} do
- %span.term.str-truncated= merge_request.title
- - if merge_request.merged?
- %span.badge.badge-primary.gl-ml-2= _("Merged")
- - elsif merge_request.closed?
- %span.badge.badge-danger.gl-ml-2= _("Closed")
- .float-right= merge_request.to_reference
- - if merge_request.description.present?
- .description.term
- = search_md_sanitize(merge_request.description)
- %span.light
- #{merge_request.project.full_name}
+= render partial: 'search/results/issuable', object: merge_request
diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml
index 3040917dd6e..55161ce333b 100644
--- a/app/views/search/results/_wiki_blob.html.haml
+++ b/app/views/search/results/_wiki_blob.html.haml
@@ -1,4 +1,9 @@
- project = wiki_blob.project
- wiki_blob_link = project_wiki_path(project, wiki_blob.basename)
-= render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, path: wiki_blob.path, blob_link: wiki_blob_link }
+%div{ class: 'search-result-row gl-pb-3! gl-mt-5 gl-mb-0!' }
+ %span.gl-display-flex.gl-align-items-center
+ = link_to wiki_blob_link, data: { track_event: 'click_text', track_label: "wiki_title", track_property: 'search_result' }, class: 'gl-w-full' do
+ %span.term.str-truncated.gl-font-weight-bold= ::Gitlab::Git::Wiki::GollumSlug.canonicalize_filename(wiki_blob.path)
+ .description.term.col-sm-10.gl-px-0
+ = simple_search_highlight_and_truncate(wiki_blob.data, @search_term)
diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml
index 2ad29707c9f..a286693e2b6 100644
--- a/app/views/sent_notifications/unsubscribe.html.haml
+++ b/app/views/sent_notifications/unsubscribe.html.haml
@@ -15,5 +15,5 @@
%p
= link_to _('Unsubscribe'), unsubscribe_sent_notification_path(@sent_notification, force: true),
- class: 'btn btn-primary gl-mr-3'
- = link_to _('Cancel'), new_user_session_path, class: 'btn gl-mr-3'
+ class: 'gl-button btn btn-primary gl-mr-3'
+ = link_to _('Cancel'), new_user_session_path, class: 'gl-button btn gl-mr-3'
diff --git a/app/views/shared/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml
index 7be11b0fb81..7aaae3a88f3 100644
--- a/app/views/shared/_broadcast_message.html.haml
+++ b/app/views/shared/_broadcast_message.html.haml
@@ -8,5 +8,5 @@
= render_broadcast_message(message)
.gl-flex-grow-1.gl-flex-basis-0.gl-text-right
- if (message.notification? || message.dismissable?) && opts[:preview].blank?
- %button.broadcast-message-dismiss.js-dismiss-current-broadcast-notification.btn.btn-link.gl-button{ 'aria-label' => _('Close'), :type => 'button', data: { id: message.id, expire_date: message.ends_at.iso8601 } }
- = sprite_icon('close', size: 16, css_class: 'gl-icon gl-text-white gl-mx-3!')
+ %button.js-dismiss-current-broadcast-notification.btn.btn-link.gl-button{ 'aria-label' => _('Close'), :type => 'button', data: { id: message.id, expire_date: message.ends_at.iso8601 } }
+ = sprite_icon('close', size: 16, css_class: "gl-icon gl-mx-3! #{is_banner ? 'gl-text-white' : 'gl-text-gray-700'}")
diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml
index 7d328728332..b1f53e4d0f6 100644
--- a/app/views/shared/_file_highlight.html.haml
+++ b/app/views/shared/_file_highlight.html.haml
@@ -1,16 +1,17 @@
#blob-content.file-content.code.js-syntax-highlight
+ - offset = defined?(first_line_number) ? first_line_number : 1
.line-numbers
- if blob.data.present?
- link_icon = sprite_icon('link', size: 12)
- link = blob_link if defined?(blob_link)
- blob.data.each_line.each_with_index do |_, index|
- - offset = defined?(first_line_number) ? first_line_number : 1
- i = index + offset
-# We're not using `link_to` because it is too slow once we get to thousands of lines.
%a.diff-line-num{ href: "#{link}#L#{i}", id: "L#{i}", 'data-line-number' => i }
= link_icon
= i
- .blob-content{ data: { blob_id: blob.id, path: blob.path } }
+ - highlight = defined?(highlight_line) && highlight_line ? highlight_line - offset : nil
+ .blob-content{ data: { blob_id: blob.id, path: blob.path, highlight_line: highlight, qa_selector: 'file_content' } }
%pre.code.highlight
%code
= blob.present.highlight
diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml
index f21ec45eefb..352d51dbb8e 100644
--- a/app/views/shared/_issuable_meta_data.html.haml
+++ b/app/views/shared/_issuable_meta_data.html.haml
@@ -6,7 +6,7 @@
- if issuable_mr > 0
%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')
+ = sprite_icon('merge-request', css_class: "gl-vertical-align-middle")
= issuable_mr
- if upvotes > 0
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index 1dadb4384b9..4b09e8de896 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -34,10 +34,7 @@
label_title: label.title,
label_color: label.color,
label_text_color: label.text_color,
- group_name: label.project.group.name,
- target: '#promote-label-modal',
- container: 'body',
- toggle: 'modal' } }
+ group_name: label.project.group.name } }
= _('Promote to group label')
- if can?(current_user, :admin_label, label)
%li
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index 9c9ac5f7b2c..252f9c26f06 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -5,9 +5,9 @@
.label-name.gl-flex-shrink-0.gl-mt-2.gl-mr-3
= render_label(label, tooltip: false)
-.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
+.label-description.gl-overflow-hidden.gl-w-full
+ .gl-display-flex.gl-align-items-stretch.gl-flex-wrap.gl-mt-2
+ .gl-flex-basis-half.gl-flex-grow-1.gl-overflow-hidden.gl-mr-2
- if label.description.present?
= markdown_field(label, :description)
- elsif show_labels_full_path?(@project, @group)
diff --git a/app/views/shared/_ping_consent.html.haml b/app/views/shared/_ping_consent.html.haml
index ded9b55056a..d0f1e4d7221 100644
--- a/app/views/shared/_ping_consent.html.haml
+++ b/app/views/shared/_ping_consent.html.haml
@@ -1,12 +1,14 @@
- if session[:ask_for_usage_stats_consent]
- .ping-consent-message.alert.alert-warning.flex-alert
- - settings_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="alert-link">'.html_safe % { url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings') }
- - info_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="alert-link">'.html_safe % { url: help_page_path('user/admin_area/settings/usage_statistics.md') }
- .alert-message
- = s_('To help improve GitLab, we would like to periodically collect usage information. This can be changed at any time in %{settings_link_start}Settings%{link_end}. %{info_link_start}More Information%{link_end}').html_safe % { settings_link_start: settings_link_start, info_link_start: info_link_start, link_end: '</a>'.html_safe }
- .alert-link-group
+ .ping-consent-message.gl-alert.gl-alert-info
+ = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
+ = sprite_icon('close', css_class: 'gl-icon')
+ .gl-alert-body
+ - docs_link = link_to _('collect usage information'), help_page_path('user/admin_area/settings/usage_statistics.md'), class: 'gl-link'
+ - settings_link = link_to _('your settings'), metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), class: 'gl-link'
+ = s_('To help improve GitLab, we would like to periodically %{docs_link}. This can be changed at any time in %{settings_link}.').html_safe % { docs_link: docs_link, settings_link: settings_link }
+ .gl-alert-actions.gl-mt-3
- send_usage_data_path = admin_application_settings_path(application_setting: { version_check_enabled: 1, usage_ping_enabled: 1 })
- not_now_path = admin_application_settings_path(application_setting: { version_check_enabled: 0, usage_ping_enabled: 0 })
- = link_to _("Send usage data"), send_usage_data_path, 'data-url' => admin_application_settings_path, method: :put, 'data-check-enabled': true, 'data-ping-enabled': true, class: 'alert-link js-usage-consent-action'
- |
- = link_to _('Not now'), not_now_path, 'data-url' => admin_application_settings_path, method: :put, 'data-check-enabled': false, 'data-ping-enabled': false, class: 'hide-ping-consent-message alert-link js-usage-consent-action'
+ = link_to _("Send usage data"), send_usage_data_path, 'data-url' => admin_application_settings_path, method: :put, 'data-check-enabled': true, 'data-ping-enabled': true, class: 'js-usage-consent-action alert-link btn gl-button btn-info'
+ = link_to _("Don't send usage data"), not_now_path, 'data-url' => admin_application_settings_path, method: :put, 'data-check-enabled': false, 'data-ping-enabled': false, class: 'js-usage-consent-action alert-link btn gl-button btn-default gl-ml-2'
diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml
index 54bd4ba04a0..70b72f74ab3 100644
--- a/app/views/shared/_remote_mirror_update_button.html.haml
+++ b/app/views/shared/_remote_mirror_update_button.html.haml
@@ -1,6 +1,6 @@
- if remote_mirror.update_in_progress?
- %button.btn.disabled{ type: 'button', data: { toggle: 'tooltip', container: 'body', qa_selector: 'updating_button' }, title: _('Updating') }
- = icon("refresh spin")
+ %button.btn.btn-icon.gl-button.disabled{ type: 'button', data: { toggle: 'tooltip', container: 'body', qa_selector: 'updating_button' }, title: _('Updating') }
+ = sprite_icon("retry", css_class: "spin")
- elsif remote_mirror.enabled?
- = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn qa-update-now-button rspec-update-now-button", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do
- = icon("refresh")
+ = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn btn-icon gl-button qa-update-now-button rspec-update-now-button", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do
+ = sprite_icon("retry")
diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml
index 820a6cbd15d..f206a2152c2 100644
--- a/app/views/shared/access_tokens/_form.html.haml
+++ b/app/views/shared/access_tokens/_form.html.haml
@@ -13,7 +13,7 @@
.row
.form-group.col-md-6
= f.label :name, _('Name'), class: 'label-bold'
- = f.text_field :name, class: 'form-control', required: true, data: { qa_selector: 'access_token_name_field' }
+ = f.text_field :name, class: 'form-control gl-form-input', required: true, data: { qa_selector: 'access_token_name_field' }
.row
.form-group.col-md-6
@@ -23,8 +23,7 @@
= render_if_exists 'personal_access_tokens/callout_max_personal_access_token_lifetime'
.js-access-tokens-expires-at
- %expires-at-field
- = f.text_field :expires_at, class: 'datepicker form-control gl-datepicker-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', inputmode: 'none', data: { qa_selector: 'expiry_date_field' }
+ = f.text_field :expires_at, class: 'datepicker gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off'
.form-group
= f.label :scopes, _('Scopes'), class: 'label-bold'
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index c3137120034..ce48691166b 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -15,10 +15,9 @@
%script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal, show_sorting_dropdown: false
%script#js-board-promotion{ type: "text/x-template" }= render_if_exists "shared/promotions/promote_issue_board"
+= render 'shared/issuable/search_bar', type: :boards, board: board
#board-app.boards-app.position-relative{ "v-cloak" => "true", data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" }
- = render 'shared/issuable/search_bar', type: :boards, board: board
-
- - if Feature.enabled?(:boards_with_swimlanes, current_board_parent) || Feature.enabled?(:graphql_board_lists, current_board_parent)
+ - if Feature.enabled?(:boards_with_swimlanes, current_board_parent, default_enabled: true) || Feature.enabled?(:graphql_board_lists, current_board_parent)
%board-content{ "v-cloak" => "true",
"ref" => "board_content",
":lists" => "state.lists",
diff --git a/app/views/shared/boards/components/sidebar/_assignee.html.haml b/app/views/shared/boards/components/sidebar/_assignee.html.haml
index af6a519a967..e22a7807b3b 100644
--- a/app/views/shared/boards/components/sidebar/_assignee.html.haml
+++ b/app/views/shared/boards/components/sidebar/_assignee.html.haml
@@ -23,7 +23,7 @@
%button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: 'button', ref: 'assigneeDropdown', data: board_sidebar_user_data,
":data-issuable-id" => "issue.iid" }
= dropdown_options[:title]
- = icon("chevron-down")
+ = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
.dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
= dropdown_title("Assign to")
= dropdown_filter("Search users")
diff --git a/app/views/shared/boards/components/sidebar/_due_date.html.haml b/app/views/shared/boards/components/sidebar/_due_date.html.haml
index d8ed3b13bf1..ab4d22ac03d 100644
--- a/app/views/shared/boards/components/sidebar/_due_date.html.haml
+++ b/app/views/shared/boards/components/sidebar/_due_date.html.haml
@@ -24,7 +24,7 @@
%button.dropdown-menu-toggle.js-due-date-select.js-issue-boards-due-date{ type: 'button',
data: { toggle: 'dropdown', field_name: "issue[due_date]", ability_name: "issue" } }
%span.dropdown-toggle-text= _("Due date")
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
.dropdown-menu.dropdown-menu-due-date
= dropdown_title(_('Due date'))
= dropdown_content do
diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml
index 61f3ebcdba4..5af52d4de23 100644
--- a/app/views/shared/boards/components/sidebar/_labels.html.haml
+++ b/app/views/shared/boards/components/sidebar/_labels.html.haml
@@ -27,7 +27,7 @@
data: label_dropdown_data(@project, namespace_path: @namespace_path, field_name: "issue[label_names][]") }
%span.dropdown-toggle-text
{{ labelDropdownTitle }}
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
.dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height
= render partial: "shared/issuable/label_page_default"
- if can?(current_user, :admin_label, current_board_parent)
diff --git a/app/views/shared/boards/components/sidebar/_milestone.html.haml b/app/views/shared/boards/components/sidebar/_milestone.html.haml
index 2c894e9b1b3..6143f1d5afe 100644
--- a/app/views/shared/boards/components/sidebar/_milestone.html.haml
+++ b/app/views/shared/boards/components/sidebar/_milestone.html.haml
@@ -21,7 +21,7 @@
":data-issuable-id" => "issue.iid",
":data-project-id" => "issue.project_id" }
= _("Milestone")
- = icon("chevron-down")
+ = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
.dropdown-menu.dropdown-select.dropdown-menu-selectable
= dropdown_title(_("Assign milestone"))
= dropdown_filter(_("Search milestones"))
diff --git a/app/views/shared/form_elements/_apply_template_warning.html.haml b/app/views/shared/form_elements/_apply_template_warning.html.haml
index b1edfba6df4..73be0c741dc 100644
--- a/app/views/shared/form_elements/_apply_template_warning.html.haml
+++ b/app/views/shared/form_elements/_apply_template_warning.html.haml
@@ -1,6 +1,5 @@
-.form-group.row.js-template-warning.mb-0.hidden.js-issuable-template-warning{ :class => ("gl-mb-5!" if issuable.supports_issue_type? && can?(current_user, :admin_issue, @project)) }
- .offset-sm-2.col-sm-10
-
+.form-group.row.js-template-warning.hidden.js-issuable-template-warning
+ .col-sm-12
.warning_message.mb-0{ role: 'alert' }
%btn.js-close-btn.js-dismiss-btn.close{ type: "button", "aria-hidden": true, "aria-label": _("Close") }
= sprite_icon("close")
diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index 413df29da77..7f4aed5d1f7 100644
--- a/app/views/shared/form_elements/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -14,6 +14,9 @@
- if model.is_a?(Issuable)
= render 'shared/issuable/form/template_selector', issuable: model
+
+ = render 'shared/form_elements/apply_template_warning', issuable: model
+
= render layout: 'shared/md_preview', locals: { url: preview_url, referenced_users: true } do
= render 'shared/zen', f: form, attr: :description,
classes: 'note-textarea qa-issuable-form-description rspec-issuable-form-description',
diff --git a/app/views/shared/groups/_empty_state.html.haml b/app/views/shared/groups/_empty_state.html.haml
index f6b3a49eacb..1d3bc1d6959 100644
--- a/app/views/shared/groups/_empty_state.html.haml
+++ b/app/views/shared/groups/_empty_state.html.haml
@@ -1,8 +1,13 @@
-.group-empty-state.row.align-items-center.justify-content-center
- .icon.text-center.order-md-2
+.row.gl-align-items-center.gl-justify-content-center
+ .order-md-2
= custom_icon("icon_empty_groups")
- .text-content.m-0.order-md-1
+ .text-content.order-md-1{ class: 'gl-m-0!' }
%h4= s_("GroupsEmptyState|A group is a collection of several projects.")
%p= s_("GroupsEmptyState|If you organize your projects under a group, it works like a folder.")
%p= s_("GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group.")
+ - if invite_group_members?(@group)
+ = link_to _('Invite your team'),
+ group_group_members_path(@group),
+ class: 'gl-button btn btn-success-secondary',
+ data: { track_event: 'click_invite_team_group_empty_state', track_label: 'invite_team_group_empty_state' }
diff --git a/app/views/shared/groups/_search_form.html.haml b/app/views/shared/groups/_search_form.html.haml
index 49b812baefc..a574394694d 100644
--- a/app/views/shared/groups/_search_form.html.haml
+++ b/app/views/shared/groups/_search_form.html.haml
@@ -1,2 +1,2 @@
= form_tag request.path, method: :get, class: "group-filter-form js-group-filter-form", id: 'group-filter-form' do |f|
- = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter qa-groups-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"
+ = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter qa-groups-filter', spellcheck: false, id: 'group-filter-form-field'
diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml
index 8365bc6f863..3453db9f209 100644
--- a/app/views/shared/issuable/_close_reopen_button.html.haml
+++ b/app/views/shared/issuable/_close_reopen_button.html.haml
@@ -21,5 +21,6 @@
- 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-md-block btn btn-grouped btn-close-color', title: _('Report abuse')
+ - unless issuable.is_a?(MergeRequest) && issuable.merged?
+ = link_to _('Report abuse'), new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)),
+ class: 'd-none d-md-block btn btn-grouped btn-close-color', title: _('Report abuse')
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 df441e6d0af..48d1e146629 100644
--- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
+++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
@@ -21,7 +21,7 @@
data: { text: _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, url: close_issuable_path(issuable),
button_class: "#{button_class} btn-close", toggle_class: "#{toggle_class} btn-close-color" } }
%button.btn.btn-transparent
- = icon('check', class: 'icon')
+ = sprite_icon('check', css_class: 'icon')
.description
%strong.title
= _('Close')
@@ -31,7 +31,7 @@
data: { text: _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, url: reopen_issuable_path(issuable),
button_class: "#{button_class} btn-reopen", toggle_class: "#{toggle_class} btn-reopen-color" } }
%button.btn.btn-transparent
- = icon('check', class: 'icon')
+ = sprite_icon('check', css_class: 'icon')
.description
%strong.title
= _('Reopen')
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 728b527f499..c0aba0eef7f 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -20,8 +20,6 @@
= render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?)
#js-suggestions{ data: { project_path: @project.full_path } }
-= render 'shared/form_elements/apply_template_warning', issuable: issuable
-
= render 'shared/issuable/form/type_selector', issuable: issuable, form: form
= render 'shared/form_elements/description', model: issuable, form: form, project: project
diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml
index 535af522c1a..08883bb3372 100644
--- a/app/views/shared/issuable/_label_dropdown.html.haml
+++ b/app/views/shared/issuable/_label_dropdown.html.haml
@@ -26,7 +26,7 @@
- apply_is_default_styles = (selected.nil? || selected.empty?) && !no_default_styles
%span.dropdown-toggle-text{ class: ("is-default" if apply_is_default_styles) }
= multi_label_name(selected, label_name)
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
.dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height
= render partial: "shared/issuable/label_page_default", locals: { title: dropdown_title, show_footer: show_footer, show_create: show_create }
- if show_create && project && can?(current_user, :admin_label, project)
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index ae79d5e3c3e..00b235809ed 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -96,6 +96,7 @@
%li.filter-dropdown-item
%button.btn.btn-link.js-data-value{ type: 'button' }
{{title}}
+ = render_if_exists 'shared/issuable/filter_iteration', type: type
#js-dropdown-release.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
@@ -181,7 +182,7 @@
= render 'shared/issuable/board_create_list_dropdown', board: board
- if @project
#js-add-issues-btn.gl-ml-3{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
- - if current_user && Feature.enabled?(:boards_with_swimlanes, @group)
+ - if current_user && Feature.enabled?(:boards_with_swimlanes, @group, default_enabled: true)
#js-board-epics-swimlanes-toggle
#js-toggle-focus-btn
- elsif is_not_boards_modal_or_productivity_analytics && show_sorting_dropdown
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 458703ebc5f..1f20c1a30aa 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -96,24 +96,14 @@
%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')
+ = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
.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 } }
+ .js-sidebar-labels{ data: sidebar_labels_data(issuable_sidebar, @project) }
= render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar
diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml
index 81dbecb430b..f60be3f3e4a 100644
--- a/app/views/shared/issuable/_sort_dropdown.html.haml
+++ b/app/views/shared/issuable/_sort_dropdown.html.haml
@@ -7,7 +7,7 @@
.btn-group{ role: 'group' }
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
= sort_title
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
%li
= sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority), sort_title)
diff --git a/app/views/projects/issues/export_csv/_button.html.haml b/app/views/shared/issuable/csv_export/_button.html.haml
index e5710fcdb60..3584c9c1ed5 100644
--- a/app/views/projects/issues/export_csv/_button.html.haml
+++ b/app/views/shared/issuable/csv_export/_button.html.haml
@@ -1,4 +1,4 @@
- if current_user
%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' } }
+ data: { toggle: 'modal', target: ".#{issuable_type}-export-modal", qa_selector: 'export_as_csv_button' } }
= sprite_icon('export')
diff --git a/app/views/shared/issuable/csv_export/_modal.html.haml b/app/views/shared/issuable/csv_export/_modal.html.haml
new file mode 100644
index 00000000000..4a4c6b90cd9
--- /dev/null
+++ b/app/views/shared/issuable/csv_export/_modal.html.haml
@@ -0,0 +1,29 @@
+- class_name = "#{issuable_type.dasherize}-export-modal"
+- if current_user
+ .modal.issuable-export-modal{ class: class_name }
+ .modal-dialog
+ .modal-content{ data: { qa_selector: "export_issuable_modal" } }
+ .modal-header
+ %h3
+ = _("Export %{issuable_type}" % { issuable_type: issuable_type.humanize(capitalize: false) })
+ .svg-content.import-export-svg-container
+ = image_tag 'illustrations/export-import.svg', role: "presentation", class: 'illustration'
+ %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
+ = sprite_icon('close', css_class: 'gl-icon')
+ .modal-body
+ - issuable_count = issuables_count_for_state(issuable_type.to_sym, params[:state])
+ - unless issuable_count == -1 # The count timed out
+ .modal-subheader
+ = sprite_icon('check', css_class: 'gl-icon gl-color-green-400')
+ %strong.gl-ml-3
+ - if issuable_type.eql?('merge_requests')
+ = n_("%{count} merge request selected", "%{count} merge requests selected", issuable_count) % { count: issuable_count }
+ - else
+ = n_("%{count} issue selected", "%{count} issues selected", issuable_count) % { count: issuable_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
+ - if issuable_type.eql?('merge_requests')
+ = link_to _("Export merge requests"), export_csv_project_merge_requests_path(@project, request.query_parameters), method: :post, class: 'btn gl-button btn-success', data: { track_label: "export_merge_requests_csv", track_event: "click_button", track_value: "" }
+ - else
+ = link_to _('Export issues'), export_csv_project_issues_path(@project, request.query_parameters), method: :post, class: 'btn gl-button btn-success', data: { track_label: "export_issues_csv", track_event: "click_button", track_value: "", qa_selector: "export_issues_button" }
diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml
index 3347966f39a..5d64c15d9f9 100644
--- a/app/views/shared/issuable/form/_type_selector.html.haml
+++ b/app/views/shared/issuable/form/_type_selector.html.haml
@@ -8,7 +8,7 @@
%button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.dropdown-toggle-text.is-default
= issuable.issue_type.capitalize || _("Select type")
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
.dropdown-menu.dropdown-menu-selectable.dropdown-select
.dropdown-title.gl-display-flex
%span.gl-ml-auto
diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml
new file mode 100644
index 00000000000..7c1ec332ba4
--- /dev/null
+++ b/app/views/shared/issue_type/_details_content.html.haml
@@ -0,0 +1,31 @@
+- related_branches_path = related_branches_project_issue_path(@project, issuable)
+
+.issue-details.issuable-details
+ .detail-page-description.content-block
+ #js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json} }
+ .title-container
+ %h2.title= markdown_field(issuable, :title)
+ - if issuable.description.present?
+ .description
+ .md= markdown_field(issuable, :description)
+
+ = edited_time_ago_with_tooltip(issuable, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
+
+ = render 'shared/issue_type/sentry_stack_trace', issuable: issuable
+
+ = render 'projects/issues/design_management'
+
+ = render_if_exists 'projects/issues/related_issues'
+
+ #js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: issuable.iid)), project_namespace: @project.namespace.path, project_path: @project.path } }
+
+ - if can?(current_user, :download_code, @project)
+ - add_page_startup_api_call related_branches_path
+ #related-branches{ data: { url: related_branches_path } }
+ -# This element is filled in using JavaScript.
+
+ = render 'shared/issue_type/emoji_block', issuable: issuable
+
+ = render 'projects/issues/discussion'
+
+= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @issue.assignees
diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml
new file mode 100644
index 00000000000..ea4df288839
--- /dev/null
+++ b/app/views/shared/issue_type/_details_header.html.haml
@@ -0,0 +1,55 @@
+- can_update_issue = can?(current_user, :update_issue, issuable)
+- can_reopen_issue = can?(current_user, :reopen_issue, issuable)
+- can_report_spam = issuable.submittable_as_spam_by?(current_user)
+- can_create_issue = show_new_issue_link?(@project)
+- display_issuable_type = issuable_display_type(issuable)
+- new_issuable_params = ({ issuable_template: 'incident', issue: { issue_type: 'incident' } } if issuable.incident?)
+
+.detail-page-header
+ .detail-page-header-body
+ .issuable-status-box.status-box.status-box-issue-closed{ class: issue_status_visibility(issuable, status_box: :closed) }
+ = sprite_icon('mobile-issue-close', css_class: 'gl-display-block gl-display-sm-none!')
+ .gl-display-none.gl-display-sm-block!
+ = issue_closed_text(issuable, current_user)
+ .issuable-status-box.status-box.status-box-open{ class: issue_status_visibility(issuable, status_box: :open) }
+ = sprite_icon('issue-open-m', css_class: 'gl-display-block gl-display-sm-none!')
+ %span.gl-display-none.gl-display-sm-block!
+ = _('Open')
+
+ .issuable-meta
+ #js-issuable-header-warnings
+ = issuable_meta(issuable, @project, display_issuable_type)
+
+ %a.btn.gl-button.btn-default.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
+ = sprite_icon('chevron-double-lg-left')
+
+ - if Feature.enabled?(:vue_issue_header, @project, default_enabled: true)
+ .js-issue-header-actions{ data: issue_header_actions_data(@project, issuable, current_user) }
+ - else
+ .detail-page-header-actions.js-issuable-actions.js-issuable-buttons{ data: { "action": "close-reopen" } }
+ .clearfix.issue-btn-group.dropdown
+ %button.btn.gl-button.btn-default.float-left.gl-display-md-none{ type: "button", data: { toggle: "dropdown" } }
+ = _('Options')
+ = icon('caret-down')
+ .dropdown-menu.dropdown-menu-right
+ %ul
+ - unless current_user == issuable.author
+ %li= link_to _('Report abuse'), new_abuse_report_path(user_id: issuable.author.id, ref_url: issue_url(issuable))
+ - if can_update_issue
+ %li= link_to _('Close %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, issue_path(issuable, issue: { state_event: :close }, format: 'json'), class: "btn-close js-btn-issue-action #{issue_button_visibility(issuable, true)}", title: _('Close %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, data: { endpoint: close_reopen_issuable_path(issuable) }
+ - if can_reopen_issue
+ %li= link_to _('Reopen %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, issue_path(issuable, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen js-btn-issue-action #{issue_button_visibility(issuable, false)}", title: _('Reopen %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, data: { endpoint: close_reopen_issuable_path(issuable) }
+ - if can_report_spam
+ %li= link_to _('Submit as spam'), mark_as_spam_project_issue_path(@project, issuable), method: :post, class: 'btn-spam', title: 'Submit as spam'
+ - if can_create_issue
+ - if can_update_issue || can_report_spam
+ %li.divider
+ %li= link_to _('New %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, new_project_issue_path(@project, new_issuable_params), id: 'new_%{display_issuable_type}_link' % { display_issuable_type: display_issuable_type }
+
+ = render 'shared/issuable/close_reopen_button', issuable: issuable, can_update: can_update_issue, can_reopen: can_reopen_issue, warn_before_close: defined?(issuable.blocked?) && issuable.blocked?
+
+ - if can_report_spam
+ = link_to _('Submit as spam'), mark_as_spam_project_issue_path(@project, issuable), method: :post, class: 'gl-display-none gl-display-md-block gl-button btn btn-grouped btn-spam', title: 'Submit as spam'
+ - if can_create_issue
+ = link_to new_project_issue_path(@project, new_issuable_params), class: 'gl-display-none gl-display-md-block gl-button btn btn-grouped btn-success btn-inverted', title: _('New %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, id: 'new_%{display_issuable_type}_link' % { display_issuable_type: display_issuable_type } do
+ = _('New %{display_issuable_type}') % { display_issuable_type: display_issuable_type }
diff --git a/app/views/shared/issue_type/_emoji_block.html.haml b/app/views/shared/issue_type/_emoji_block.html.haml
new file mode 100644
index 00000000000..42d149b2ab3
--- /dev/null
+++ b/app/views/shared/issue_type/_emoji_block.html.haml
@@ -0,0 +1,9 @@
+.content-block.emoji-block.emoji-block-sticky
+ .row.gl-m-0.gl-justify-content-space-between
+ .js-noteable-awards
+ = render 'award_emoji/awards_block', awardable: issuable, inline: true
+ .new-branch-col
+ = render_if_exists "projects/issues/timeline_toggle", issuable: issuable
+ #js-vue-sort-issue-discussions
+ #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(issuable), notes_filters: UserPreference.notes_filters.to_json } }
+ = render 'new_branch' if show_new_branch_button?
diff --git a/app/views/shared/issue_type/_sentry_stack_trace.html.haml b/app/views/shared/issue_type/_sentry_stack_trace.html.haml
new file mode 100644
index 00000000000..40b29a74b53
--- /dev/null
+++ b/app/views/shared/issue_type/_sentry_stack_trace.html.haml
@@ -0,0 +1,4 @@
+- return unless issuable.sentry_issue.present?
+- add_page_specific_style 'page_bundles/error_tracking_details'
+
+#js-sentry-error-stack-trace{ data: error_details_data(@project, issuable.sentry_issue.sentry_issue_identifier) }
diff --git a/app/views/shared/members/_filter_2fa_dropdown.html.haml b/app/views/shared/members/_filter_2fa_dropdown.html.haml
index a2bc5e9ecdf..8187a9bde15 100644
--- a/app/views/shared/members/_filter_2fa_dropdown.html.haml
+++ b/app/views/shared/members/_filter_2fa_dropdown.html.haml
@@ -1,7 +1,7 @@
- filter = params[:two_factor] || 'everyone'
- filter_options = { 'everyone' => _('Everyone'), 'enabled' => _('Enabled'), 'disabled' => _('Disabled') }
-.dropdown.inline.member-filter-2fa-dropdown
- = dropdown_toggle(filter_options[filter], { toggle: 'dropdown' })
+.dropdown.inline.member-filter-2fa-dropdown{ data: { testid: 'member-filter-2fa-dropdown' } }
+ = dropdown_toggle(filter_options[filter], { toggle: 'dropdown', testid: 'dropdown-toggle' })
%ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
%li.dropdown-header
= _("Filter by two-factor authentication")
diff --git a/app/views/shared/members/_invite_group.html.haml b/app/views/shared/members/_invite_group.html.haml
index a87a4c6a45c..5e3a6918ab2 100644
--- a/app/views/shared/members/_invite_group.html.haml
+++ b/app/views/shared/members/_invite_group.html.haml
@@ -13,7 +13,7 @@
= label_tag group_access_field, _("Max access level"), class: "label-bold"
.select-wrapper
= select_tag group_access_field, options_for_select(access_levels, default_access_level), data: { qa_selector: 'group_access_field' }, class: "form-control select-control"
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200")
.form-text.text-muted.gl-mb-3
- permissions_docs_path = help_page_path('user/permissions')
- link_start = %q{<a href="%{url}">}.html_safe % { url: permissions_docs_path }
diff --git a/app/views/shared/members/_invite_member.html.haml b/app/views/shared/members/_invite_member.html.haml
index 5f9046b3dcb..59b0600e2dd 100644
--- a/app/views/shared/members/_invite_member.html.haml
+++ b/app/views/shared/members/_invite_member.html.haml
@@ -5,7 +5,7 @@
- import_path = local_assigns[:import_path]
.row
.col-sm-12
- = form_tag submit_url, class: 'invite-users-form', method: :post do
+ = form_tag submit_url, class: 'invite-users-form', data: { testid: 'invite-users-form' }, method: :post do
.form-group
= label_tag :user_ids, _("GitLab member or Email address"), class: "label-bold"
= users_select_tag(:user_ids, multiple: true, class: 'input-clamp qa-member-select-field', scope: :all, email_user: true, placeholder: 'Search for members to update or invite')
@@ -13,7 +13,7 @@
= label_tag :access_level, _("Choose a role permission"), class: "label-bold"
.select-wrapper
= select_tag :access_level, options_for_select(access_levels, default_access_level), class: "form-control project-access-select select-control"
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200")
.form-text.text-muted.gl-mb-3
- permissions_docs_path = help_page_path('user/permissions')
- link_start = %q{<a href="%{url}">}.html_safe % { url: permissions_docs_path }
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index 164d38986ec..e294936f82c 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -2,6 +2,9 @@
- show_controls = local_assigns.fetch(:show_controls, true)
- force_mobile_view = local_assigns.fetch(:force_mobile_view, false)
- member = local_assigns.fetch(:member)
+- current_user_is_group_owner = local_assigns.fetch(:current_user_is_group_owner, false)
+- membership_source = local_assigns.fetch(:membership_source)
+- group = local_assigns.fetch(:group)
- user = local_assigns.fetch(:user, member.user)
- source = member.source
- override = member.try(:override)
@@ -25,13 +28,13 @@
= render 'shared/members/its_you_badge', user: user, current_user: current_user
- = render_if_exists 'shared/members/ee/license_badge', user: user, group: @group
+ = render_if_exists 'shared/members/ee/license_badge', user: user, group: group, current_user_is_group_owner: current_user_is_group_owner
= render 'shared/members/blocked_badge', user: user
= render 'shared/members/two_factor_auth_badge', user: user
- - if source.instance_of?(Group) && source != @group
+ - if source.instance_of?(Group) && source != membership_source
&middot;
= link_to source.full_name, source, class: "gl-display-inline-block inline-link"
@@ -57,10 +60,9 @@
= link_to member.created_by.name, user_path(member.created_by)
= time_ago_with_tooltip(member.created_at)
- if show_roles
- - current_resource = @project || @group
.controls.member-controls.align-items-center
= render_if_exists 'shared/members/ee/ldap_tag', can_override: member.can_override?
- - if show_controls && member.source == current_resource
+ - if show_controls && member.source == membership_source
- if member.can_resend_invite?
= link_to sprite_icon('paper-airplane'), polymorphic_path([:resend_invite, member]),
@@ -88,7 +90,7 @@
class: ("is-active" if member.access_level == role_id),
data: { id: role_id, el_id: dom_id(member), qa_selector: "#{role.downcase}_access_level_link" }
= render_if_exists 'shared/members/ee/revert_ldap_group_sync_option',
- group: @group,
+ group: group,
member: member,
can_override: member.can_override?
.clearable-input.member-form-control{ class: [("d-sm-inline-block" unless force_mobile_view)] }
@@ -105,12 +107,12 @@
- if member.can_approve?
= link_to polymorphic_path([:approve_access_request, member]),
method: :post,
- class: "btn btn-success align-self-center m-0 mb-2 #{'mb-sm-0 ml-sm-2' unless force_mobile_view}",
+ class: "btn btn-success btn-icon gl-button align-self-center m-0 mb-2 #{'mb-sm-0 ml-sm-2' unless force_mobile_view}",
title: _('Grant access') do
%span{ class: ('d-block d-sm-none' unless force_mobile_view) }
= _('Grant access')
- unless force_mobile_view
- = icon('check inverse', class: 'd-none d-sm-block')
+ = sprite_icon('check', css_class: 'd-none d-sm-block')
- if member.can_remove?
- if current_user == user
@@ -125,8 +127,8 @@
= _("Delete")
- unless force_mobile_view
= sprite_icon('remove', css_class: 'd-none d-sm-block gl-icon')
- = render_if_exists 'shared/members/ee/override_member_buttons', group: @group, member: member, user: user, action: :edit, can_override: member.can_override?
+ = render_if_exists 'shared/members/ee/override_member_buttons', group: group, member: member, user: user, action: :edit, can_override: member.can_override?
- else
%span.member-access-text.user-access-role= member.human_access
-= render_if_exists 'shared/members/ee/override_member_buttons', group: @group, member: member, user: user, action: :confirm, can_override: member.can_override?
+= render_if_exists 'shared/members/ee/override_member_buttons', group: group, member: member, user: user, action: :confirm, can_override: member.can_override?
diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml
index e1e7aa36a78..3aa43ed1922 100644
--- a/app/views/shared/members/_requests.html.haml
+++ b/app/views/shared/members/_requests.html.haml
@@ -1,6 +1,8 @@
- membership_source = local_assigns.fetch(:membership_source)
- requesters = local_assigns.fetch(:requesters)
- force_mobile_view = local_assigns.fetch(:force_mobile_view, false)
+- group = local_assigns.fetch(:group)
+- current_user_is_group_owner = group && group.has_owner?(current_user)
- return if requesters.empty?
@@ -10,4 +12,9 @@
%strong= membership_source.name
%span.badge.badge-pill= requesters.size
%ul.content-list.members-list
- = render partial: 'shared/members/member', collection: requesters, as: :member, locals: { force_mobile_view: force_mobile_view }
+ = render partial: 'shared/members/member',
+ collection: requesters, as: :member,
+ locals: { membership_source: membership_source,
+ group: group,
+ force_mobile_view: force_mobile_view,
+ current_user_is_group_owner: current_user_is_group_owner }
diff --git a/app/views/shared/members/_search_field.html.haml b/app/views/shared/members/_search_field.html.haml
index e70cb063324..b1e3134f7aa 100644
--- a/app/views/shared/members/_search_field.html.haml
+++ b/app/views/shared/members/_search_field.html.haml
@@ -2,5 +2,5 @@
.search-control-wrap.gl-relative
= search_field_tag name, params[name], { placeholder: _('Search'), class: 'form-control', spellcheck: false }
- %button.user-search-btn.border-left.gl-display-flex.gl-align-items-center.gl-justify-content-center{ type: 'submit', 'aria': { label: _('Submit search') } }
+ %button.user-search-btn.border-left.gl-display-flex.gl-align-items-center.gl-justify-content-center{ type: 'submit', 'aria': { label: _('Submit search') }, data: { testid: 'user-search-submit' } }
= sprite_icon('search')
diff --git a/app/views/shared/members/_sort_dropdown.html.haml b/app/views/shared/members/_sort_dropdown.html.haml
index 606d3bcdfa8..682e3a0433b 100644
--- a/app/views/shared/members/_sort_dropdown.html.haml
+++ b/app/views/shared/members/_sort_dropdown.html.haml
@@ -1,5 +1,5 @@
-.dropdown.inline.qa-user-sort-dropdown
- = dropdown_toggle(member_sort_options_hash[@sort], { toggle: 'dropdown' })
+.dropdown.inline.qa-user-sort-dropdown{ data: { testid: 'user-sort-dropdown' } }
+ = dropdown_toggle(member_sort_options_hash[@sort], { toggle: 'dropdown', testid: 'dropdown-toggle' })
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
%li.dropdown-header
= _("Sort by")
@@ -8,12 +8,12 @@
= link_to filter_group_project_member_path(sort: value), class: ("is-active" if @sort == value) do
= title
%li.divider
- %li{ data: { 'qa-selector': 'filter-members-with-inherited-permissions' } }
+ %li{ data: { testid: 'filter-members-with-inherited-permissions' } }
= link_to filter_group_project_member_path(with_inherited_permissions: nil), class: ("is-active" unless params[:with_inherited_permissions].present?) do
= _("Show all members")
- %li{ data: { 'qa-selector': 'filter-members-with-inherited-permissions' } }
+ %li{ data: { testid: 'filter-members-with-inherited-permissions' } }
= link_to filter_group_project_member_path(with_inherited_permissions: 'exclude'), class: ("is-active" if params[:with_inherited_permissions] == 'exclude') do
= _("Show only direct members")
- %li{ data: { 'qa-selector': 'filter-members-with-inherited-permissions' } }
+ %li{ data: { testid: 'filter-members-with-inherited-permissions' } }
= link_to filter_group_project_member_path(with_inherited_permissions: 'only'), class: ("is-active" if params[:with_inherited_permissions] == 'only') do
= _("Show only inherited members")
diff --git a/app/views/shared/milestones/_delete_button.html.haml b/app/views/shared/milestones/_delete_button.html.haml
index 7a813e110c4..09c783a0b24 100644
--- a/app/views/shared/milestones/_delete_button.html.haml
+++ b/app/views/shared/milestones/_delete_button.html.haml
@@ -1,6 +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: { milestone_id: @milestone.id,
+%button.js-delete-milestone-button.btn.gl-button.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,
diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml
index ea90b674b34..93da319fce7 100644
--- a/app/views/shared/milestones/_header.html.haml
+++ b/app/views/shared/milestones/_header.html.haml
@@ -11,10 +11,10 @@
.milestone-buttons
- if can?(current_user, :admin_milestone, @group || @project)
- = link_to _('Edit'), edit_milestone_path(milestone), class: 'btn btn-grouped'
+ = link_to _('Edit'), edit_milestone_path(milestone), class: 'btn gl-button btn-grouped'
- if milestone.project_milestone? && milestone.project.group
- %button.js-promote-project-milestone-button.btn.btn-grouped{ data: { toggle: 'modal',
+ %button.js-promote-project-milestone-button.btn.gl-button.btn-grouped{ data: { toggle: 'modal',
target: '#promote-milestone-modal',
milestone_title: milestone.title,
group_name: milestone.project.group.name,
@@ -26,11 +26,11 @@
#promote-milestone-modal
- if milestone.active?
- = link_to _('Close milestone'), update_milestone_path(milestone, { state_event: :close }), method: :put, class: 'btn btn-grouped btn-close'
+ = link_to _('Close milestone'), update_milestone_path(milestone, { state_event: :close }), method: :put, class: 'btn gl-button btn-grouped btn-close'
- else
- = link_to _('Reopen milestone'), update_milestone_path(milestone, { state_event: :activate }), method: :put, class: 'btn btn-grouped btn-reopen'
+ = link_to _('Reopen milestone'), update_milestone_path(milestone, { state_event: :activate }), method: :put, class: 'btn gl-button btn-grouped btn-reopen'
= render 'shared/milestones/delete_button'
- %button.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ type: 'button' }
= sprite_icon('chevron-double-lg-left')
diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml
index 3b4d29ca7b0..a419e749f35 100644
--- a/app/views/shared/milestones/_labels_tab.html.haml
+++ b/app/views/shared/milestones/_labels_tab.html.haml
@@ -7,8 +7,8 @@
%span.prepend-description-left
= markdown_field(label, :description)
- .float-right.d-none.d-lg-block.d-xl-block
- = link_to milestones_issues_path(options.merge(state: 'opened')), class: 'btn btn-transparent btn-action' do
+ .float-right.d-none.d-lg-block
+ = link_to milestones_issues_path(options.merge(state: 'opened')), class: 'btn gl-button btn-default-tertiary btn-action' do
- pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), _('open issue')
- = link_to milestones_issues_path(options.merge(state: 'closed')), class: 'btn btn-transparent btn-action' do
+ = link_to milestones_issues_path(options.merge(state: 'closed')), class: 'btn gl-button btn-default-tertiary btn-action' do
- pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), _('closed issue')
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index f28aa406784..1597a011a45 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -46,7 +46,7 @@
.milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end
- if @project # if in milestones list on project level
- if can_admin_group_milestones?
- %button.js-promote-project-milestone-button.btn.btn-blank.btn-sm.btn-grouped.has-tooltip{ title: s_('Milestones|Promote to Group Milestone'),
+ %button.js-promote-project-milestone-button.btn.gl-button.btn-default-tertiary.btn-sm.btn-grouped.has-tooltip{ title: s_('Milestones|Promote to Group Milestone'),
disabled: true,
type: 'button',
data: { url: promote_project_milestone_path(milestone.project, milestone),
@@ -59,6 +59,6 @@
- if can?(current_user, :admin_milestone, milestone)
- if milestone.closed?
- = link_to s_('Milestones|Reopen Milestone'), milestone_path(milestone, milestone: { state_event: :activate }), method: :put, class: "btn btn-sm btn-grouped btn-reopen"
+ = link_to s_('Milestones|Reopen Milestone'), milestone_path(milestone, milestone: { state_event: :activate }), method: :put, class: "btn gl-button btn-sm btn-grouped btn-reopen"
- else
- = link_to s_('Milestones|Close Milestone'), milestone_path(milestone, milestone: { state_event: :close }), method: :put, class: "btn btn-sm btn-grouped btn-close"
+ = link_to s_('Milestones|Close Milestone'), milestone_path(milestone, milestone: { state_event: :close }), method: :put, class: "btn gl-button btn-warning-secondary btn-sm btn-grouped btn-close"
diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml
index e151e55d0d2..45af4b51b27 100644
--- a/app/views/shared/notes/_comment_button.html.haml
+++ b/app/views/shared/notes/_comment_button.html.haml
@@ -10,7 +10,7 @@
%ul#resolvable-comment-menu.dropdown-menu.dropdown-open-top{ data: { dropdown: true } }
%li#comment.droplab-item-selected{ data: { value: '', 'submit-text' => _('Comment'), 'close-text' => _("Comment & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Comment & reopen %{noteable_name}") % { noteable_name: noteable_name } } }
%button.btn.btn-transparent
- = icon('check', class: 'icon')
+ = sprite_icon('check', css_class: 'icon')
.description
%strong= _("Comment")
%p
@@ -20,7 +20,7 @@
%li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => _('Start thread'), 'close-text' => _("Start thread & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Start thread & reopen %{noteable_name}") % { noteable_name: noteable_name } } }
%button.btn.btn-transparent
- = icon('check', class: 'icon')
+ = sprite_icon('check', css_class: 'icon')
.description
%strong= _("Start thread")
%p
diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
index 3703cca2290..a03e8446f5d 100644
--- a/app/views/shared/notes/_hints.html.haml
+++ b/app/views/shared/notes/_hints.html.haml
@@ -23,13 +23,20 @@
= sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom')
%span.uploading-error-message
-# Populated by app/assets/javascripts/dropzone_input.js
- %button.retry-uploading-link{ type: 'button' }= _("Try again")
- or
- %button.attach-new-file.markdown-selector{ type: 'button' }= _("attach a new file")
+ %button.btn.gl-button.btn-link.gl-vertical-align-baseline.retry-uploading-link
+ %span.gl-button-text
+ = _("Try again")
+ = _("or")
+ %button.btn.gl-button.btn-link.attach-new-file.markdown-selector.gl-vertical-align-baseline
+ %span.gl-button-text
+ = _("attach a new file")
+ = _(".")
- %button.btn.markdown-selector.button-attach-file.btn-link{ type: 'button' }
+ %button.btn.gl-button.btn-link.button-attach-file.markdown-selector.button-attach-file.gl-vertical-align-text-bottom
= sprite_icon('media')
- %span.text-attach-file<>
+ %span.gl-button-text
= _("Attach a file")
- %button.btn.btn-default.btn-sm.hide.button-cancel-uploading-files{ type: 'button' }= _("Cancel")
+ %button.btn.gl-button.btn-link.button-cancel-uploading-files.gl-vertical-align-baseline.hide
+ %span.gl-button-text
+ = _("Cancel")
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index 97ed2852871..f1352be28e3 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -72,7 +72,7 @@
= image_tag note.attachment.url, class: 'note-image-attach'
.attachment
= link_to note.attachment.url, target: '_blank' do
- = icon('paperclip')
+ = sprite_icon('paperclip')
= note.attachment_identifier
= link_to delete_attachment_project_note_path(note.project, note),
title: _('Delete this attachment'), method: :delete, remote: true, data: { confirm: _('Are you sure you want to remove the attachment?') }, class: 'danger js-note-attachment-delete' do
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index 9baa340376b..1b03225d48d 100644
--- a/app/views/shared/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -19,7 +19,7 @@
= render "shared/notes/form", view: diff_view, supports_autocomplete: autocomplete
- elsif !current_user
.disabled-comment.text-center.gl-mt-3
- - link_to_register = link_to(_("register"), new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), class: 'js-register-link')
+ - link_to_register = link_to(_("register"), new_user_registration_path(redirect_to_referer: 'yes'), class: 'js-register-link')
- link_to_sign_in = link_to(_("sign in"), new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link')
= _("Please %{link_to_register} or %{link_to_sign_in} to comment").html_safe % { link_to_register: link_to_register, link_to_sign_in: link_to_sign_in }
- elsif discussion_locked
diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml
index 51b7da7dee8..946e3c67dcf 100644
--- a/app/views/shared/notifications/_custom_notifications.html.haml
+++ b/app/views/shared/notifications/_custom_notifications.html.haml
@@ -30,4 +30,5 @@
%label.form-check-label{ for: field_id }
%strong
= notification_event_name(event)
- .fa.custom-notification-event-loading.spinner
+ %span.spinner.is-loading.gl-vertical-align-middle.gl-display-none
+ = sprite_icon('check', css_class: 'is-done gl-display-none gl-vertical-align-middle gl-text-green-600')
diff --git a/app/views/shared/notifications/_new_button.html.haml b/app/views/shared/notifications/_new_button.html.haml
index fbcfec5fd96..14f4b04ef78 100644
--- a/app/views/shared/notifications/_new_button.html.haml
+++ b/app/views/shared/notifications/_new_button.html.haml
@@ -1,4 +1,5 @@
- btn_class = local_assigns.fetch(:btn_class, '')
+- dropdown_container_class = local_assigns.fetch(:dropdown_container_class, '')
- emails_disabled = local_assigns.fetch(:emails_disabled, false)
- if notification_setting
@@ -8,8 +9,8 @@
- else
- button_title = _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }
- .js-notification-dropdown.notification-dropdown.home-panel-action-button.gl-mt-3.gl-mr-3.dropdown.inline
- = form_for notification_setting, remote: true, html: { class: "inline notification-form no-label" } do |f|
+ .js-notification-dropdown.notification-dropdown.home-panel-action-button.gl-mt-3.dropdown.inline{ class: dropdown_container_class }
+ = form_for notification_setting, remote: true, html: { class: "notification-form no-label" } do |f|
= hidden_setting_source_input(notification_setting)
= hidden_field_tag "hide_label", true
= f.hidden_field :level, class: "notification_setting_level"
diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml
index 7b76d6d789b..e96a9152c80 100644
--- a/app/views/shared/projects/_search_form.html.haml
+++ b/app/views/shared/projects/_search_form.html.haml
@@ -7,7 +7,6 @@
class: "project-filter-form-field form-control #{form_field_classes}",
spellcheck: false,
id: 'project-filter-form-field',
- tabindex: "2",
autofocus: local_assigns[:autofocus]
- if local_assigns[:icon]
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index 9c60201412c..c5234f14090 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -78,6 +78,18 @@
%strong= s_('Webhooks|Deployment events')
%p.text-muted.ml-1
= s_('Webhooks|This URL is triggered when a deployment starts, finishes, fails, or is canceled')
+ %li
+ = form.check_box :feature_flag_events, class: 'form-check-input'
+ = form.label :feature_flag_events, class: 'list-label form-check-label ml-1' do
+ %strong= s_('Webhooks|Feature Flag events')
+ %p.text-muted.ml-1
+ = s_('Webhooks|This URL is triggered when a feature flag is turned on or off')
+ %li
+ = form.check_box :releases_events, class: 'form-check-input'
+ = form.label :releases_events, class: 'list-label form-check-label ml-1' do
+ %strong= s_('Webhooks|Releases events')
+ %p.text-muted.ml-1
+ = s_('Webhooks|This URL is triggered when a release is created/updated')
.form-group
= form.label :enable_ssl_verification, s_('Webhooks|SSL verification'), class: 'label-bold checkbox'
.form-check
diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml
index 893661755ab..c0ed7b4c6f2 100644
--- a/app/views/shared/wikis/_sidebar.html.haml
+++ b/app/views/shared/wikis/_sidebar.html.haml
@@ -1,7 +1,7 @@
%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" } }
.sidebar-container
.block.wiki-sidebar-header.gl-mb-3.w-100
- %a.gutter-toggle.float-right.d-block.d-sm-block.d-md-none.js-sidebar-wiki-toggle{ href: "#" }
+ %a.gutter-toggle.float-right.d-block.d-md-none.js-sidebar-wiki-toggle{ href: "#" }
= sprite_icon('chevron-double-lg-right', css_class: 'gl-icon')
- if @wiki.container.is_a?(Project)
diff --git a/app/views/sherlock/transactions/_file_samples.html.haml b/app/views/sherlock/transactions/_file_samples.html.haml
index 5b3448605f2..110eb42f9ea 100644
--- a/app/views/sherlock/transactions/_file_samples.html.haml
+++ b/app/views/sherlock/transactions/_file_samples.html.haml
@@ -21,4 +21,4 @@
%td
= link_to(t('sherlock.view'),
sherlock_transaction_file_sample_path(@transaction, sample),
- class: 'btn btn-sm')
+ class: 'gl-button btn btn-sm')
diff --git a/app/views/sherlock/transactions/_queries.html.haml b/app/views/sherlock/transactions/_queries.html.haml
index 5e224f3aa0e..afe23d61bcd 100644
--- a/app/views/sherlock/transactions/_queries.html.haml
+++ b/app/views/sherlock/transactions/_queries.html.haml
@@ -21,4 +21,4 @@
%td
= link_to(t('sherlock.view'),
sherlock_transaction_query_path(@transaction, query),
- class: 'btn btn-sm')
+ class: 'gl-button btn btn-sm')
diff --git a/app/views/sherlock/transactions/index.html.haml b/app/views/sherlock/transactions/index.html.haml
index 1e16c88571e..a2be6cae1e0 100644
--- a/app/views/sherlock/transactions/index.html.haml
+++ b/app/views/sherlock/transactions/index.html.haml
@@ -4,7 +4,7 @@
.row-content-block
.float-right
= link_to(destroy_all_sherlock_transactions_path,
- class: 'btn btn-danger',
+ class: 'gl-button btn btn-danger',
method: :delete) do
= sprite_icon('remove')
= t('sherlock.delete_all_transactions')
@@ -37,5 +37,5 @@
%td
= time_ago_with_tooltip trans.finished_at
%td
- = link_to(sherlock_transaction_path(trans), class: 'btn btn-sm') do
+ = link_to(sherlock_transaction_path(trans), class: 'gl-button btn btn-sm') do
= t('sherlock.view')
diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml
index 310d9946663..2e94bbe4baf 100644
--- a/app/views/snippets/notes/_actions.html.haml
+++ b/app/views/snippets/notes/_actions.html.haml
@@ -8,7 +8,7 @@
- if note_editable
.note-actions-item
- = button_tag title: _('Edit comment'), class: 'note-action-button js-note-edit has-tooltip btn btn-transparent', data: { container: 'body', qa_selector: 'edit_comment_button' } do
+ = button_tag title: _('Edit comment'), class: 'note-action-button js-note-edit has-tooltip gl-button btn btn-transparent', data: { container: 'body', qa_selector: 'edit_comment_button' } do
%span.link-highlight
= custom_icon('icon_pencil')
diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml
index 77a6ff5455e..beb4cf4a6aa 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -1,3 +1,9 @@
+- add_page_startup_graphql_call('snippet/snippet', { ids: [@snippet.to_global_id.uri] })
+- add_page_startup_graphql_call('snippet/snippet_blob_content', { ids: [@snippet.to_global_id.uri], rich: false, paths: [@snippet.file_name] })
+- if @snippet.project_id?
+ - add_page_startup_graphql_call('snippet/project_permissions', { fullPath: @snippet.project_id })
+- else
+ - add_page_startup_graphql_call('snippet/user_permissions')
- @hide_top_links = true
- @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout
- add_to_breadcrumbs _("Snippets"), dashboard_snippets_path
diff --git a/app/views/users/_groups.html.haml b/app/views/users/_groups.html.haml
deleted file mode 100644
index 6d7a52c7688..00000000000
--- a/app/views/users/_groups.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-.clearfix
- - groups.each do |group|
- = link_to group, class: 'profile-groups-avatars inline', title: group.name do
- .avatar-container.rect-avatar.s40
- = group_icon(group, class: 'avatar group-avatar s40')
diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml
index 294af53e35b..1367d80cf54 100644
--- a/app/views/users/_overview.html.haml
+++ b/app/views/users/_overview.html.haml
@@ -1,12 +1,14 @@
- 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
- .user-calendar.d-none.d-sm-block{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: Time.zone.utc_offset } }
- %h4.center.light
- .spinner.spinner-md
- .user-calendar-activities.d-none.d-sm-block
+.row.d-none.d-sm-flex
+ .col-12.calendar-block.gl-my-3
+ .user-calendar.light{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: Time.zone.utc_offset } }
+ .spinner.spinner-md.gl-my-8
+ .user-calendar-error.invisible
+ = _('There was an error loading users activity calendar.')
+ %a.js-retry-load{ href: '#' }
+ = s_('UserProfile|Retry')
+ .user-calendar-activities
.row
%div{ class: activity_pane_class }
- if can?(current_user, :read_cross_project)
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 2746a139dd0..ee037a7d66a 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -4,6 +4,7 @@
- page_title @user.blocked? ? s_('UserProfile|Blocked user') : @user.name
- page_description @user.bio_html
- header_title @user.name, user_path(@user)
+- page_itemtype 'http://schema.org/Person'
- link_classes = "flex-grow-1 mx-1 "
= content_for :meta_tags do
@@ -35,7 +36,7 @@
.profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] }
.avatar-holder
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
- = image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: ''
+ = image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: '', itemprop: 'image'
- if @user.blocked?
.user-info
@@ -44,25 +45,27 @@
= render "users/profile_basic_info"
- else
.user-info
- .cover-title
+ .cover-title{ itemprop: 'name' }
= @user.name
+ - if @user&.status && user_status_set_to_busy?(@user.status)
+ %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle= s_("UserProfile|(Busy)")
- - if @user.status
+ - if show_status_emoji?(@user.status)
.cover-status
= emoji_icon(@user.status.emoji)
= markdown_field(@user.status, :message)
= render "users/profile_basic_info"
.cover-desc.cgray.mb-1.mb-sm-2
- unless @user.location.blank?
- .profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mb-1.mb-sm-0
+ .profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mb-1.mb-sm-0{ itemprop: 'address', itemscope: true, itemtype: 'https://schema.org/PostalAddress' }
= sprite_icon('location', css_class: 'vertical-align-sub fgray')
- %span.vertical-align-middle
+ %span.vertical-align-middle{ itemprop: 'addressLocality' }
= @user.location
- unless work_information(@user).blank?
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline
= sprite_icon('work', css_class: 'vertical-align-middle fgray')
%span.vertical-align-middle
- = work_information(@user)
+ = work_information(@user, with_schema_markup: true)
.cover-desc.cgray.mb-1.mb-sm-2
- unless @user.skype.blank?
.profile-link-holder.middle-dot-divider
@@ -80,10 +83,10 @@
.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'
+ = link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'me noopener noreferrer nofollow', itemprop: 'url'
- unless @user.public_email.blank?
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0
- = link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link'
+ = link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link', itemprop: 'email'
- if @user.bio.present?
.cover-desc.cgray
.profile-user-bio
diff --git a/app/views/users/terms/index.html.haml b/app/views/users/terms/index.html.haml
index 175fde1c862..da8b73fd4fd 100644
--- a/app/views/users/terms/index.html.haml
+++ b/app/views/users/terms/index.html.haml
@@ -6,13 +6,13 @@
.card-footer.footer-block.clearfix
- if can?(current_user, :accept_terms, @term)
.float-right
- = button_to accept_term_path(@term, redirect_params), class: 'btn btn-success gl-ml-3', data: { qa_selector: 'accept_terms_button' } do
+ = button_to accept_term_path(@term, redirect_params), class: 'gl-button btn btn-success gl-ml-3', data: { qa_selector: 'accept_terms_button' } do
= _('Accept terms')
- else
.float-right
- = link_to root_path, class: 'btn btn-success gl-ml-3' do
+ = link_to root_path, class: 'gl-button btn btn-success gl-ml-3' do
= _('Continue')
- if can?(current_user, :decline_terms, @term)
.float-right
- = button_to decline_term_path(@term, redirect_params), class: 'btn btn-default gl-ml-3' do
+ = button_to decline_term_path(@term, redirect_params), class: 'gl-button btn btn-default gl-ml-3' do
= _('Decline and sign out')
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 30b89f37562..6f080a97f7a 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -97,7 +97,15 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent: true
+ :tags: []
+- :name: container_repository:container_expiration_policies_cleanup_container_repository
+ :feature_category: :container_registry
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
:tags: []
- :name: container_repository:delete_container_repository
:feature_category: :container_registry
@@ -371,6 +379,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: cronjob:schedule_merge_request_cleanup_refs
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:schedule_migrate_external_diffs
:feature_category: :source_code_management
:has_external_dependencies:
@@ -435,6 +451,14 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: dependency_proxy:purge_dependency_proxy_cache
+ :feature_category: :dependency_proxy
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: deployment:deployments_drop_older_deployments
:feature_category: :continuous_delivery
:has_external_dependencies:
@@ -819,6 +843,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: jira_connect:jira_connect_sync_project
+ :feature_category: :integrations
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: jira_importer:jira_import_advance_stage
:feature_category: :importers
:has_external_dependencies:
@@ -1312,6 +1344,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: bulk_import
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: chat_notification
:feature_category: :chatops
:has_external_dependencies: true
@@ -1409,6 +1449,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: destroy_pages_deployments
+ :feature_category: :pages
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: detect_repository_languages
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1839,6 +1887,14 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: propagate_integration_inherit_descendant
+ :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:
diff --git a/app/workers/analytics/instance_statistics/counter_job_worker.rb b/app/workers/analytics/instance_statistics/counter_job_worker.rb
index 062b5ccc207..7fc715419b8 100644
--- a/app/workers/analytics/instance_statistics/counter_job_worker.rb
+++ b/app/workers/analytics/instance_statistics/counter_job_worker.rb
@@ -11,18 +11,24 @@ module Analytics
idempotent!
def perform(measurement_identifier, min_id, max_id, recorded_at)
- query_scope = ::Analytics::InstanceStatistics::Measurement::IDENTIFIER_QUERY_MAPPING[measurement_identifier].call
+ query_scope = ::Analytics::InstanceStatistics::Measurement.identifier_query_mapping[measurement_identifier].call
count = if min_id.nil? || max_id.nil? # table is empty
0
else
- Gitlab::Database::BatchCount.batch_count(query_scope, start: min_id, finish: max_id)
+ counter(query_scope, min_id, max_id)
end
return if count == Gitlab::Database::BatchCounter::FALLBACK
InstanceStatistics::Measurement.insert_all([{ recorded_at: recorded_at, count: count, identifier: measurement_identifier }])
end
+
+ private
+
+ def counter(query_scope, min_id, max_id)
+ Gitlab::Database::BatchCount.batch_count(query_scope, start: min_id, finish: max_id)
+ end
end
end
end
diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb
index 74a12dbff77..70c4ad53726 100644
--- a/app/workers/background_migration_worker.rb
+++ b/app/workers/background_migration_worker.rb
@@ -24,10 +24,14 @@ 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.
# 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.
+ # lease on the class before giving up. See MR for more discussion.
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45298#note_434304956
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)
+ attempts_left = lease_attempts - 1
+ should_perform, ttl = perform_and_ttl(class_name, attempts_left)
+
+ break if should_perform.nil?
if should_perform
Gitlab::BackgroundMigration.perform(class_name, arguments)
@@ -37,32 +41,41 @@ class BackgroundMigrationWorker # rubocop:disable Scalability/IdempotentWorker
# we'll reschedule the job in such a way that it is picked up again around
# the time the lease expires.
self.class
- .perform_in(ttl || self.class.minimum_interval, class_name, arguments)
+ .perform_in(ttl || self.class.minimum_interval, class_name, arguments, attempts_left)
end
end
end
- def perform_and_ttl(class_name)
- if always_perform?
- # In test environments `perform_in` will run right away. This can then
- # lead to stack level errors in the above `#perform`. To work around this
- # we'll just perform the migration right away in the test environment.
- [true, nil]
- else
- lease = lease_for(class_name)
- perform = !!lease.try_obtain
-
- # If we managed to acquire the lease but the DB is not healthy, then we
- # want to simply reschedule our job and try again _after_ the lease
- # expires.
- if perform && !healthy_database?
- database_unhealthy_counter.increment
-
- perform = false
- end
+ def perform_and_ttl(class_name, attempts_left)
+ # In test environments `perform_in` will run right away. This can then
+ # lead to stack level errors in the above `#perform`. To work around this
+ # we'll just perform the migration right away in the test environment.
+ return [true, nil] if always_perform?
+
+ lease = lease_for(class_name)
+ lease_obtained = !!lease.try_obtain
+ healthy_db = healthy_database?
+ perform = lease_obtained && healthy_db
+
+ database_unhealthy_counter.increment if lease_obtained && !healthy_db
- [perform, lease.ttl]
+ # When the DB is unhealthy or the lease can't be obtained after several tries,
+ # then give up on the job and log a warning. Otherwise we could end up in
+ # an infinite rescheduling loop. Jobs can be tracked in the database with the
+ # use of Gitlab::Database::BackgroundMigrationJob
+ if !perform && attempts_left < 0
+ msg = if !lease_obtained
+ 'Job could not get an exclusive lease after several tries. Giving up.'
+ else
+ 'Database was unhealthy after several tries. Giving up.'
+ end
+
+ Sidekiq.logger.warn(class: class_name, message: msg, job_id: jid)
+
+ return [nil, nil]
end
+
+ [perform, lease.ttl]
end
def lease_for(class_name)
diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb
index d7a5fcf4f18..af2305528ce 100644
--- a/app/workers/build_finished_worker.rb
+++ b/app/workers/build_finished_worker.rb
@@ -33,6 +33,11 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker
BuildCoverageWorker.new.perform(build.id)
Ci::BuildReportResultWorker.new.perform(build.id)
+ # TODO: As per https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/194, it may be
+ # best to avoid creating more workers that we have no intention of calling async.
+ # Change the previous worker calls on top to also just call the service directly.
+ Ci::TestCasesService.new.execute(build)
+
# We execute these async as these are independent operations.
BuildHooksWorker.perform_async(build.id)
ExpirePipelineCacheWorker.perform_async(build.pipeline_id) if build.pipeline.cacheable?
diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb
new file mode 100644
index 00000000000..7828d046036
--- /dev/null
+++ b/app/workers/bulk_import_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ feature_category :importers
+
+ sidekiq_options retry: false, dead: false
+
+ worker_has_external_dependencies!
+
+ def perform(bulk_import_id)
+ BulkImports::Importers::GroupsImporter.new(bulk_import_id).execute
+ end
+end
diff --git a/app/workers/ci/build_trace_chunk_flush_worker.rb b/app/workers/ci/build_trace_chunk_flush_worker.rb
index 89400247a7b..a63b12c0d03 100644
--- a/app/workers/ci/build_trace_chunk_flush_worker.rb
+++ b/app/workers/ci/build_trace_chunk_flush_worker.rb
@@ -5,6 +5,8 @@ module Ci
include ApplicationWorker
include PipelineBackgroundQueue
+ deduplicate :until_executed
+
idempotent!
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/workers/ci/delete_objects_worker.rb b/app/workers/ci/delete_objects_worker.rb
index e34be33b438..d845ad61358 100644
--- a/app/workers/ci/delete_objects_worker.rb
+++ b/app/workers/ci/delete_objects_worker.rb
@@ -14,18 +14,16 @@ module Ci
def remaining_work_count(*args)
@remaining_work_count ||= service
- .remaining_batches_count(max_batch_count: remaining_capacity)
+ .remaining_batches_count(max_batch_count: max_running_jobs)
end
def max_running_jobs
- if ::Feature.enabled?(:ci_delete_objects_low_concurrency)
- 2
- elsif ::Feature.enabled?(:ci_delete_objects_medium_concurrency)
+ if ::Feature.enabled?(:ci_delete_objects_medium_concurrency)
20
elsif ::Feature.enabled?(:ci_delete_objects_high_concurrency)
50
else
- 0
+ 2
end
end
diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb
index 80cc296fff5..1cac2858156 100644
--- a/app/workers/cleanup_container_repository_worker.rb
+++ b/app/workers/cleanup_container_repository_worker.rb
@@ -1,10 +1,13 @@
# frozen_string_literal: true
-class CleanupContainerRepositoryWorker # rubocop:disable Scalability/IdempotentWorker
+class CleanupContainerRepositoryWorker
include ApplicationWorker
queue_namespace :container_repository
feature_category :container_registry
+ urgency :low
+ worker_resource_boundary :unknown
+ idempotent!
loggable_arguments 2
attr_reader :container_repository, :current_user
diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
index 30dec5159a2..d101ef100d8 100644
--- a/app/workers/concerns/application_worker.rb
+++ b/app/workers/concerns/application_worker.rb
@@ -19,7 +19,7 @@ module ApplicationWorker
def structured_payload(payload = {})
context = Labkit::Context.current.to_h.merge(
- 'class' => self.class,
+ 'class' => self.class.name,
'job_status' => 'running',
'queue' => self.class.queue,
'jid' => jid
diff --git a/app/workers/concerns/limited_capacity/worker.rb b/app/workers/concerns/limited_capacity/worker.rb
index c0d6bfff2f5..b5a97e49300 100644
--- a/app/workers/concerns/limited_capacity/worker.rb
+++ b/app/workers/concerns/limited_capacity/worker.rb
@@ -67,6 +67,7 @@ module LimitedCapacity
return unless has_capacity?
job_tracker.register(jid)
+ report_running_jobs_metrics
perform_work(*args)
rescue => exception
raise
@@ -108,11 +109,15 @@ module LimitedCapacity
end
def report_prometheus_metrics(*args)
- running_jobs_gauge.set(prometheus_labels, running_jobs_count)
+ report_running_jobs_metrics
remaining_work_gauge.set(prometheus_labels, remaining_work_count(*args))
max_running_jobs_gauge.set(prometheus_labels, max_running_jobs)
end
+ def report_running_jobs_metrics
+ running_jobs_gauge.set(prometheus_labels, running_jobs_count)
+ end
+
def required_jobs_count(*args)
[
remaining_work_count(*args),
diff --git a/app/workers/concerns/reenqueuer.rb b/app/workers/concerns/reenqueuer.rb
index bf6f6546c03..6f399b6d90b 100644
--- a/app/workers/concerns/reenqueuer.rb
+++ b/app/workers/concerns/reenqueuer.rb
@@ -13,7 +13,7 @@
# - `#lease_timeout`
#
# The worker spec should include `it_behaves_like 'reenqueuer'` and
-# `it_behaves_like 'it is rate limited to 1 call per'`.
+# `it_behaves_like '#perform is rate limited to 1 call per'`.
#
# Optionally override `#minimum_duration` to adjust the rate limit.
#
diff --git a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
new file mode 100644
index 00000000000..8c3c2e9e103
--- /dev/null
+++ b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+module ContainerExpirationPolicies
+ class CleanupContainerRepositoryWorker
+ include ApplicationWorker
+ include LimitedCapacity::Worker
+ include Gitlab::Utils::StrongMemoize
+
+ queue_namespace :container_repository
+ feature_category :container_registry
+ urgency :low
+ worker_resource_boundary :unknown
+ idempotent!
+
+ def perform_work
+ return unless throttling_enabled?
+ return unless container_repository
+
+ log_extra_metadata_on_done(:container_repository_id, container_repository.id)
+
+ unless allowed_to_run?(container_repository)
+ container_repository.cleanup_unscheduled!
+ log_extra_metadata_on_done(:cleanup_status, :skipped)
+ return
+ end
+
+ result = ContainerExpirationPolicies::CleanupService.new(container_repository)
+ .execute
+ log_extra_metadata_on_done(:cleanup_status, result.payload[:cleanup_status])
+ end
+
+ def remaining_work_count
+ cleanup_scheduled_count = ContainerRepository.cleanup_scheduled.count
+ cleanup_unfinished_count = ContainerRepository.cleanup_unfinished.count
+ total_count = cleanup_scheduled_count + cleanup_unfinished_count
+
+ log_info(
+ cleanup_scheduled_count: cleanup_scheduled_count,
+ cleanup_unfinished_count: cleanup_unfinished_count,
+ cleanup_total_count: total_count
+ )
+
+ total_count
+ end
+
+ def max_running_jobs
+ return 0 unless throttling_enabled?
+
+ ::Gitlab::CurrentSettings.current_application_settings.container_registry_expiration_policies_worker_capacity
+ end
+
+ private
+
+ def allowed_to_run?(container_repository)
+ return false unless policy&.enabled && policy&.next_run_at
+
+ Time.zone.now + max_cleanup_execution_time.seconds < policy.next_run_at
+ end
+
+ def throttling_enabled?
+ Feature.enabled?(:container_registry_expiration_policies_throttling)
+ end
+
+ def max_cleanup_execution_time
+ ::Gitlab::CurrentSettings.current_application_settings.container_registry_delete_tags_service_timeout
+ end
+
+ def policy
+ project.container_expiration_policy
+ end
+
+ def project
+ container_repository&.project
+ end
+
+ def container_repository
+ strong_memoize(:container_repository) do
+ ContainerRepository.transaction do
+ # rubocop: disable CodeReuse/ActiveRecord
+ # We need a lock to prevent two workers from picking up the same row
+ container_repository = ContainerRepository.waiting_for_cleanup
+ .order(:expiration_policy_cleanup_status, :expiration_policy_started_at)
+ .limit(1)
+ .lock('FOR UPDATE SKIP LOCKED')
+ .first
+ # rubocop: enable CodeReuse/ActiveRecord
+ container_repository&.tap(&:cleanup_ongoing!)
+ end
+ end
+ end
+
+ def log_info(extra_structure)
+ logger.info(structured_payload(extra_structure))
+ end
+ end
+end
diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb
index 61ba27f00d2..43dbea027f2 100644
--- a/app/workers/container_expiration_policy_worker.rb
+++ b/app/workers/container_expiration_policy_worker.rb
@@ -3,20 +3,79 @@
class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
include CronjobQueue
+ include ExclusiveLeaseGuard
feature_category :container_registry
+ InvalidPolicyError = Class.new(StandardError)
+
+ BATCH_SIZE = 1000.freeze
+
def perform
- 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)
+ throttling_enabled? ? perform_throttled : perform_unthrottled
+ end
+
+ private
+
+ def perform_unthrottled
+ with_runnable_policy(preloaded: true) do |policy|
+ with_context(project: policy.project,
+ user: policy.project.owner) do |project:, user:|
+ ContainerExpirationPolicyService.new(project, user)
+ .execute(policy)
+ end
+ end
+ end
+
+ def perform_throttled
+ try_obtain_lease do
+ with_runnable_policy do |policy|
+ ContainerExpirationPolicy.transaction do
+ policy.schedule_next_run!
+ ContainerRepository.for_project_id(policy.id)
+ .each_batch do |relation|
+ relation.update_all(expiration_policy_cleanup_status: :cleanup_scheduled)
+ end
end
end
+
+ ContainerExpirationPolicies::CleanupContainerRepositoryWorker.perform_with_capacity
end
end
+
+ # TODO : remove the preload option when cleaning FF container_registry_expiration_policies_throttling
+ def with_runnable_policy(preloaded: false)
+ ContainerExpirationPolicy.runnable_schedules.each_batch(of: BATCH_SIZE) do |policies|
+ # rubocop: disable CodeReuse/ActiveRecord
+ cte = Gitlab::SQL::CTE.new(:batched_policies, policies.limit(BATCH_SIZE))
+ # rubocop: enable CodeReuse/ActiveRecord
+ scope = cte.apply_to(ContainerExpirationPolicy.all).with_container_repositories
+
+ scope = scope.preloaded if preloaded
+
+ scope.each do |policy|
+ if policy.valid?
+ yield policy
+ else
+ disable_invalid_policy!(policy)
+ end
+ end
+ end
+ end
+
+ def disable_invalid_policy!(policy)
+ policy.disable!
+ Gitlab::ErrorTracking.log_exception(
+ ::ContainerExpirationPolicyWorker::InvalidPolicyError.new,
+ container_expiration_policy_id: policy.id
+ )
+ end
+
+ def throttling_enabled?
+ Feature.enabled?(:container_registry_expiration_policies_throttling)
+ end
+
+ def lease_timeout
+ 5.hours
+ end
end
diff --git a/app/workers/destroy_pages_deployments_worker.rb b/app/workers/destroy_pages_deployments_worker.rb
new file mode 100644
index 00000000000..32b539325c9
--- /dev/null
+++ b/app/workers/destroy_pages_deployments_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class DestroyPagesDeploymentsWorker
+ include ApplicationWorker
+
+ idempotent!
+
+ loggable_arguments 0, 1
+ sidekiq_options retry: 3
+ feature_category :pages
+
+ def perform(project_id, last_deployment_id = nil)
+ project = Project.find_by_id(project_id)
+
+ return unless project
+
+ ::Pages::DestroyDeploymentsService.new(project, last_deployment_id).execute
+ end
+end
diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb
index 9071e4b8a1b..e1dcb16bafb 100644
--- a/app/workers/git_garbage_collect_worker.rb
+++ b/app/workers/git_garbage_collect_worker.rb
@@ -27,15 +27,15 @@ class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker
task = task.to_sym
- if task == :gc
+ if gc?(task)
::Projects::GitDeduplicationService.new(project).execute
cleanup_orphan_lfs_file_references(project)
end
- gitaly_call(task, project.repository.raw_repository)
+ gitaly_call(task, project)
# Refresh the branch cache in case garbage collection caused a ref lookup to fail
- flush_ref_caches(project) if task == :gc
+ flush_ref_caches(project) if gc?(task)
update_repository_statistics(project) if task != :pack_refs
@@ -48,6 +48,10 @@ class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker
private
+ def gc?(task)
+ task == :gc || task == :prune
+ end
+
def try_obtain_lease(key)
::Gitlab::ExclusiveLease.new(key, timeout: LEASE_TIMEOUT).try_obtain
end
@@ -64,8 +68,9 @@ class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker
::Gitlab::ExclusiveLease.get_uuid(key)
end
- ## `repository` has to be a Gitlab::Git::Repository
- def gitaly_call(task, repository)
+ def gitaly_call(task, project)
+ repository = project.repository.raw_repository
+
client = if task == :pack_refs
Gitlab::GitalyClient::RefService.new(repository)
else
@@ -73,8 +78,8 @@ class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker
end
case task
- when :gc
- client.garbage_collect(bitmaps_enabled?)
+ when :prune, :gc
+ client.garbage_collect(bitmaps_enabled?, prune: task == :prune)
when :full_repack
client.repack_full(bitmaps_enabled?)
when :incremental_repack
diff --git a/app/workers/jira_connect/sync_branch_worker.rb b/app/workers/jira_connect/sync_branch_worker.rb
index 8c3416478fd..4c1c987353d 100644
--- a/app/workers/jira_connect/sync_branch_worker.rb
+++ b/app/workers/jira_connect/sync_branch_worker.rb
@@ -8,7 +8,7 @@ module JiraConnect
feature_category :integrations
loggable_arguments 1, 2
- def perform(project_id, branch_name, commit_shas)
+ def perform(project_id, branch_name, commit_shas, update_sequence_id = nil)
project = Project.find_by_id(project_id)
return unless project
@@ -16,7 +16,7 @@ module JiraConnect
branches = [project.repository.find_branch(branch_name)] if branch_name.present?
commits = project.commits_by(oids: commit_shas) if commit_shas.present?
- JiraConnect::SyncService.new(project).execute(commits: commits, branches: branches)
+ JiraConnect::SyncService.new(project).execute(commits: commits, branches: branches, update_sequence_id: update_sequence_id)
end
end
end
diff --git a/app/workers/jira_connect/sync_merge_request_worker.rb b/app/workers/jira_connect/sync_merge_request_worker.rb
index b78bb8dfe16..f45ab38f35d 100644
--- a/app/workers/jira_connect/sync_merge_request_worker.rb
+++ b/app/workers/jira_connect/sync_merge_request_worker.rb
@@ -7,12 +7,12 @@ module JiraConnect
queue_namespace :jira_connect
feature_category :integrations
- def perform(merge_request_id)
+ def perform(merge_request_id, update_sequence_id = nil)
merge_request = MergeRequest.find_by_id(merge_request_id)
return unless merge_request && merge_request.project
- JiraConnect::SyncService.new(merge_request.project).execute(merge_requests: [merge_request])
+ JiraConnect::SyncService.new(merge_request.project).execute(merge_requests: [merge_request], update_sequence_id: update_sequence_id)
end
end
end
diff --git a/app/workers/jira_connect/sync_project_worker.rb b/app/workers/jira_connect/sync_project_worker.rb
new file mode 100644
index 00000000000..4d52705f207
--- /dev/null
+++ b/app/workers/jira_connect/sync_project_worker.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module JiraConnect
+ class SyncProjectWorker
+ include ApplicationWorker
+
+ queue_namespace :jira_connect
+ feature_category :integrations
+ idempotent!
+ worker_has_external_dependencies!
+
+ MERGE_REQUEST_LIMIT = 400
+
+ def perform(project_id, update_sequence_id)
+ project = Project.find_by_id(project_id)
+
+ return if project.nil?
+
+ JiraConnect::SyncService.new(project).execute(merge_requests: merge_requests_to_sync(project), update_sequence_id: update_sequence_id)
+ end
+
+ private
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def merge_requests_to_sync(project)
+ project.merge_requests.with_jira_issue_keys.preload(:author).limit(MERGE_REQUEST_LIMIT).order(id: :desc)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 0b224b88e4d..9fe7dd31e68 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -20,7 +20,7 @@ class PostReceive # rubocop:disable Scalability/IdempotentWorker
changes = Base64.decode64(changes) unless changes.include?(' ')
# Use Sidekiq.logger so arguments can be correlated with execution
# time and thread ID's.
- Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS']
+ Sidekiq.logger.info "changes: #{changes.inspect}" if SidekiqLogArguments.enabled?
post_received = Gitlab::GitPostReceive.new(container, identifier, changes, push_options)
if repo_type.wiki?
diff --git a/app/workers/propagate_integration_inherit_descendant_worker.rb b/app/workers/propagate_integration_inherit_descendant_worker.rb
new file mode 100644
index 00000000000..d589619818c
--- /dev/null
+++ b/app/workers/propagate_integration_inherit_descendant_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class PropagateIntegrationInheritDescendantWorker
+ 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 = Service.inherited_descendants_from_self_or_ancestors_from(integration).where(id: min_id..max_id)
+
+ BulkUpdateIntegrationService.new(integration, batch).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
index ef3132202f6..40d67c6d3bf 100644
--- a/app/workers/propagate_integration_inherit_worker.rb
+++ b/app/workers/propagate_integration_inherit_worker.rb
@@ -11,9 +11,9 @@ class PropagateIntegrationInheritWorker
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)
+ batch = Service.where(id: min_id..max_id).by_type(integration.type).inherit_from_id(integration.id)
- BulkUpdateIntegrationService.new(integration, services).execute
+ BulkUpdateIntegrationService.new(integration, batch).execute
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/purge_dependency_proxy_cache_worker.rb b/app/workers/purge_dependency_proxy_cache_worker.rb
new file mode 100644
index 00000000000..594cdd3ed11
--- /dev/null
+++ b/app/workers/purge_dependency_proxy_cache_worker.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class PurgeDependencyProxyCacheWorker
+ include ApplicationWorker
+ include Gitlab::Allowable
+ idempotent!
+
+ queue_namespace :dependency_proxy
+ feature_category :dependency_proxy
+
+ def perform(current_user_id, group_id)
+ @current_user = User.find_by_id(current_user_id)
+ @group = Group.find_by_id(group_id)
+
+ return unless valid?
+
+ @group.dependency_proxy_blobs.destroy_all # rubocop:disable Cop/DestroyAll
+ end
+
+ private
+
+ def valid?
+ return unless @group
+
+ can?(@current_user, :admin_group, @group) && @group.dependency_proxy_feature_available?
+ end
+end
diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb
index f56a6cd9fa2..35844fdf297 100644
--- a/app/workers/remove_expired_members_worker.rb
+++ b/app/workers/remove_expired_members_worker.rb
@@ -7,11 +7,19 @@ class RemoveExpiredMembersWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :authentication_and_authorization
worker_resource_boundary :cpu
+ # rubocop: disable CodeReuse/ActiveRecord
def perform
- Member.expired.find_each do |member|
+ Member.expired.preload(:user).find_each do |member|
Members::DestroyService.new.execute(member, skip_authorization: true)
+
+ expired_user = member.user
+
+ if expired_user.project_bot?
+ Users::DestroyService.new(nil).execute(expired_user, skip_authorization: true)
+ end
rescue => ex
logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}")
end
end
+ # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/repository_cleanup_worker.rb b/app/workers/repository_cleanup_worker.rb
index 33b7223dd95..03c9add6afb 100644
--- a/app/workers/repository_cleanup_worker.rb
+++ b/app/workers/repository_cleanup_worker.rb
@@ -27,8 +27,9 @@ class RepositoryCleanupWorker # rubocop:disable Scalability/IdempotentWorker
project = Project.find(project_id)
user = User.find(user_id)
- # Ensure the file is removed
- project.bfg_object_map.remove!
+ # Ensure the file is removed and the repository is made read-write again
+ Projects::CleanupService.cleanup_after(project)
+
notification_service.repository_cleanup_failure(project, user, error)
end
diff --git a/app/workers/schedule_merge_request_cleanup_refs_worker.rb b/app/workers/schedule_merge_request_cleanup_refs_worker.rb
new file mode 100644
index 00000000000..17cabba4278
--- /dev/null
+++ b/app/workers/schedule_merge_request_cleanup_refs_worker.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class ScheduleMergeRequestCleanupRefsWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ feature_category :source_code_management
+ idempotent!
+
+ # Based on existing data, MergeRequestCleanupRefsWorker can run 3 jobs per
+ # second. This means that 180 jobs can be performed but since there are some
+ # spikes from time time, it's better to give it some allowance.
+ LIMIT = 180
+ DELAY = 10.seconds
+ BATCH_SIZE = 30
+
+ def perform
+ return if Gitlab::Database.read_only?
+
+ ids = MergeRequest::CleanupSchedule.scheduled_merge_request_ids(LIMIT).map { |id| [id] }
+
+ MergeRequestCleanupRefsWorker.bulk_perform_in(DELAY, ids, batch_size: BATCH_SIZE) # rubocop:disable Scalability/BulkPerformWithContext
+
+ log_extra_metadata_on_done(:merge_requests_count, ids.size)
+ end
+end