summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-04-20 23:50:22 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-04-20 23:50:22 +0000
commit9dc93a4519d9d5d7be48ff274127136236a3adb3 (patch)
tree70467ae3692a0e35e5ea56bcb803eb512a10bedb /app
parent4b0f34b6d759d6299322b3a54453e930c6121ff0 (diff)
downloadgitlab-ce-9dc93a4519d9d5d7be48ff274127136236a3adb3.tar.gz
Add latest changes from gitlab-org/gitlab@13-11-stable-eev13.11.0-rc43
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/learn_gitlab/issue_created.svg65
-rw-r--r--app/assets/images/learn_gitlab/section_deploy.svg16
-rw-r--r--app/assets/images/learn_gitlab/section_plan.svg1
-rw-r--r--app/assets/images/learn_gitlab/section_workspace.svg1
-rw-r--r--app/assets/javascripts/access_tokens/index.js16
-rw-r--r--app/assets/javascripts/activities.js33
-rw-r--r--app/assets/javascripts/admin/statistics_panel/constants.js2
-rw-r--r--app/assets/javascripts/admin/users/components/users_table.vue2
-rw-r--r--app/assets/javascripts/admin/users/constants.js2
-rw-r--r--app/assets/javascripts/admin/users/new.js55
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_empty_state.vue2
-rw-r--r--app/assets/javascripts/alert_management/list.js4
-rw-r--r--app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue18
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue19
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue430
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue123
-rw-r--r--app/assets/javascripts/alerts_settings/constants.js94
-rw-r--r--app/assets/javascripts/alerts_settings/index.js40
-rw-r--r--app/assets/javascripts/alerts_settings/utils/error_messages.js8
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/charts_config.js2
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue2
-rw-r--r--app/assets/javascripts/api.js30
-rw-r--r--app/assets/javascripts/api/user_api.js3
-rw-r--r--app/assets/javascripts/awards_handler.js2
-rw-r--r--app/assets/javascripts/badges/components/badge.vue7
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_item.vue14
-rw-r--r--app/assets/javascripts/batch_comments/mixins/resolved_status.js16
-rw-r--r--app/assets/javascripts/behaviors/deprecated_remove_row_behavior.js15
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js54
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js594
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js65
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js3
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js20
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue8
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js29
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js70
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js21
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue6
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js3
-rw-r--r--app/assets/javascripts/blob/components/blob_content.vue7
-rw-r--r--app/assets/javascripts/blob/file_template_selector.js26
-rw-r--r--app/assets/javascripts/blob/stl_viewer.js4
-rw-r--r--app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue1
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js4
-rw-r--r--app/assets/javascripts/boards/boards_util.js33
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column.vue56
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_form.vue119
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_trigger.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_blocked_icon.vue192
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue28
-rw-r--r--app/assets/javascripts/boards/components/board_card_loading_skeleton.vue26
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue29
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue96
-rw-r--r--app/assets/javascripts/boards/components/board_extra_actions.vue57
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue33
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_list_deprecated.vue7
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue1
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue15
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue_deprecated.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue8
-rw-r--r--app/assets/javascripts/boards/components/config_toggle.vue12
-rw-r--r--app/assets/javascripts/boards/components/filtered_search.vue54
-rw-r--r--app/assets/javascripts/boards/components/modal/empty_state.vue84
-rw-r--r--app/assets/javascripts/boards/components/modal/filters.js27
-rw-r--r--app/assets/javascripts/boards/components/modal/footer.vue80
-rw-r--r--app/assets/javascripts/boards/components/modal/header.vue80
-rw-r--r--app/assets/javascripts/boards/components/modal/index.vue151
-rw-r--r--app/assets/javascripts/boards/components/modal/list.vue141
-rw-r--r--app/assets/javascripts/boards/components/modal/lists_dropdown.vue49
-rw-r--r--app/assets/javascripts/boards/components/modal/tabs.vue42
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_editable_item.vue4
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue7
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue12
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue19
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue18
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue25
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue (renamed from app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue)48
-rw-r--r--app/assets/javascripts/boards/components/toggle_focus.vue4
-rw-r--r--app/assets/javascripts/boards/config_toggle.js5
-rw-r--r--app/assets/javascripts/boards/constants.js29
-rw-r--r--app/assets/javascripts/boards/ee_functions.js1
-rw-r--r--app/assets/javascripts/boards/filtered_search.js25
-rw-r--r--app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql16
-rw-r--r--app/assets/javascripts/boards/graphql/issue.fragment.graphql4
-rw-r--r--app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql2
-rw-r--r--app/assets/javascripts/boards/index.js71
-rw-r--r--app/assets/javascripts/boards/mixins/board_new_issue.js6
-rw-r--r--app/assets/javascripts/boards/mixins/modal_footer.js1
-rw-r--r--app/assets/javascripts/boards/mixins/modal_mixins.js12
-rw-r--r--app/assets/javascripts/boards/stores/actions.js306
-rw-r--r--app/assets/javascripts/boards/stores/getters.js12
-rw-r--r--app/assets/javascripts/boards/stores/modal_store.js95
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js15
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js70
-rw-r--r--app/assets/javascripts/branches/branch_sort_dropdown.js25
-rw-r--r--app/assets/javascripts/branches/components/sort_dropdown.vue88
-rw-r--r--app/assets/javascripts/branches/divergence_graph.js8
-rw-r--r--app/assets/javascripts/captcha/apollo_captcha_link.js37
-rw-r--r--app/assets/javascripts/ci_lint/components/ci_lint.vue6
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue10
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue16
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue2
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue7
-rw-r--r--app/assets/javascripts/ci_variable_list/index.js9
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue13
-rw-r--r--app/assets/javascripts/clusters/constants.js1
-rw-r--r--app/assets/javascripts/clusters/forms/show/index.js5
-rw-r--r--app/assets/javascripts/clusters/services/application_state_machine.js4
-rw-r--r--app/assets/javascripts/clusters_list/components/node_error_help_text.vue2
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js4
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue33
-rw-r--r--app/assets/javascripts/commits.js2
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue18
-rw-r--r--app/assets/javascripts/content_editor/constants.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js38
-rw-r--r--app/assets/javascripts/content_editor/index.js2
-rw-r--r--app/assets/javascripts/content_editor/services/create_editor.js60
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js73
-rw-r--r--app/assets/javascripts/contributors/components/contributors.vue11
-rw-r--r--app/assets/javascripts/contributors/index.js9
-rw-r--r--app/assets/javascripts/contributors/stores/index.js6
-rw-r--r--app/assets/javascripts/contributors/stores/state.js4
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js30
-rw-r--r--app/assets/javascripts/delete_label_modal.js16
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue28
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue35
-rw-r--r--app/assets/javascripts/deploy_freeze/store/actions.js47
-rw-r--r--app/assets/javascripts/deploy_freeze/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/deploy_freeze/store/mutations.js11
-rw-r--r--app/assets/javascripts/deploy_freeze/store/state.js2
-rw-r--r--app/assets/javascripts/deploy_tokens/components/revoke_button.vue81
-rw-r--r--app/assets/javascripts/deploy_tokens/init_revoke_button.js26
-rw-r--r--app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js23
-rw-r--r--app/assets/javascripts/design_management/components/delete_button.vue2
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue7
-rw-r--r--app/assets/javascripts/design_management/components/design_scaler.vue16
-rw-r--r--app/assets/javascripts/design_management/components/list/item.vue3
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/design_navigation.vue24
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/index.vue8
-rw-r--r--app/assets/javascripts/design_management/components/upload/button.vue3
-rw-r--r--app/assets/javascripts/design_management/index.js4
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue5
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue7
-rw-r--r--app/assets/javascripts/diffs/components/app.vue73
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue61
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue52
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue18
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue36
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue40
-rw-r--r--app/assets/javascripts/diffs/components/image_diff_overlay.vue1
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue7
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_table_row.vue14
-rw-r--r--app/assets/javascripts/diffs/index.js4
-rw-r--r--app/assets/javascripts/diffs/store/actions.js93
-rw-r--r--app/assets/javascripts/diffs/store/getters.js12
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js6
-rw-r--r--app/assets/javascripts/diffs/store/modules/index.js6
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js9
-rw-r--r--app/assets/javascripts/diffs/store/utils.js17
-rw-r--r--app/assets/javascripts/diffs/utils/interoperability.js49
-rw-r--r--app/assets/javascripts/droplab/drop_lab.js15
-rw-r--r--app/assets/javascripts/droplab/hook_button.js2
-rw-r--r--app/assets/javascripts/editor/extensions/editor_lite_extension_base.js76
-rw-r--r--app/assets/javascripts/emoji/awards_app/index.js43
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/actions.js51
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/index.js20
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/mutation_types.js6
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/mutations.js23
-rw-r--r--app/assets/javascripts/emoji/components/category.vue2
-rw-r--r--app/assets/javascripts/emoji/components/picker.vue5
-rw-r--r--app/assets/javascripts/ensure_data.js56
-rw-r--r--app/assets/javascripts/environments/components/enable_review_app_modal.vue19
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue1
-rw-r--r--app/assets/javascripts/environments/index.js1
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_actions.vue2
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue6
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/getters.js4
-rw-r--r--app/assets/javascripts/experimentation/components/experiment.vue15
-rw-r--r--app/assets/javascripts/experimentation/constants.js2
-rw-r--r--app/assets/javascripts/experimentation/utils.js18
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue8
-rw-r--r--app/assets/javascripts/feature_flags/components/form.vue19
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy.vue1
-rw-r--r--app/assets/javascripts/feature_flags/components/user_lists_table.vue7
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_popover.vue1
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_emoji.js8
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js8
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js7
-rw-r--r--app/assets/javascripts/flash.js74
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js31
-rw-r--r--app/assets/javascripts/grafana_integration/components/grafana_integration.vue4
-rw-r--r--app/assets/javascripts/graphql_shared/constants.js2
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql11
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql5
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_search.query.graphql4
-rw-r--r--app/assets/javascripts/group.js6
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue14
-rw-r--r--app/assets/javascripts/header.js4
-rw-r--r--app/assets/javascripts/ide/components/cannot_push_code_alert.vue40
-rw-r--r--app/assets/javascripts/ide/components/ide.vue13
-rw-r--r--app/assets/javascripts/ide/components/ide_status_mr.vue2
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue1
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/list.vue4
-rw-r--r--app/assets/javascripts/ide/components/nav_dropdown_button.vue4
-rw-r--r--app/assets/javascripts/ide/components/nav_form.vue2
-rw-r--r--app/assets/javascripts/ide/constants.js2
-rw-r--r--app/assets/javascripts/ide/index.js1
-rw-r--r--app/assets/javascripts/ide/lib/editor_options.js2
-rw-r--r--app/assets/javascripts/ide/lib/languages/README.md10
-rw-r--r--app/assets/javascripts/ide/messages.js12
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js6
-rw-r--r--app/assets/javascripts/ide/stores/getters.js38
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/import_entities/components/import_status.vue27
-rw-r--r--app/assets/javascripts/import_entities/constants.js58
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue2
-rw-r--r--app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue5
-rw-r--r--app/assets/javascripts/integrations/edit/components/dynamic_field.vue11
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue54
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue147
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_upgrade_cta.vue51
-rw-r--r--app/assets/javascripts/integrations/edit/components/trigger_fields.vue4
-rw-r--r--app/assets/javascripts/integrations/edit/index.js9
-rw-r--r--app/assets/javascripts/integrations/index/components/integrations_list.vue59
-rw-r--r--app/assets/javascripts/integrations/index/components/integrations_table.vue95
-rw-r--r--app/assets/javascripts/integrations/index/index.js23
-rw-r--r--app/assets/javascripts/invite_member/components/invite_member_modal.vue4
-rw-r--r--app/assets/javascripts/invite_member/components/invite_member_trigger.vue8
-rw-r--r--app/assets/javascripts/invite_member/init_invite_member_modal.js12
-rw-r--r--app/assets/javascripts/invite_member/init_invite_member_trigger.js6
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue19
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue71
-rw-r--r--app/assets/javascripts/invite_members/constants.js2
-rw-r--r--app/assets/javascripts/issuable/components/csv_export_modal.vue16
-rw-r--r--app/assets/javascripts/issuable/components/csv_import_export_buttons.vue31
-rw-r--r--app/assets/javascripts/issuable/components/issuable_by_email.vue6
-rw-r--r--app/assets/javascripts/issuable/init_csv_import_export_buttons.js11
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js4
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar.js23
-rw-r--r--app/assets/javascripts/issuable_index.js2
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_item.vue6
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_list_root.vue30
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_tabs.vue13
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_body.vue5
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_edit_form.vue8
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_show_root.vue6
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_title.vue7
-rw-r--r--app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql16
-rw-r--r--app/assets/javascripts/issuable_type_selector/components/info_popover.vue41
-rw-r--r--app/assets/javascripts/issuable_type_selector/index.js16
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue2
-rw-r--r--app/assets/javascripts/issue_show/components/edit_actions.vue12
-rw-r--r--app/assets/javascripts/issue_show/components/header_actions.vue25
-rw-r--r--app/assets/javascripts/issue_show/components/title.vue7
-rw-r--r--app/assets/javascripts/issues_list/components/issuables_list_app.vue3
-rw-r--r--app/assets/javascripts/issues_list/components/issues_list_app.vue363
-rw-r--r--app/assets/javascripts/issues_list/constants.js188
-rw-r--r--app/assets/javascripts/issues_list/index.js39
-rw-r--r--app/assets/javascripts/jira_connect/api.js24
-rw-r--r--app/assets/javascripts/jira_connect/components/app.vue64
-rw-r--r--app/assets/javascripts/jira_connect/components/group_item_name.vue34
-rw-r--r--app/assets/javascripts/jira_connect/components/groups_list.vue96
-rw-r--r--app/assets/javascripts/jira_connect/components/groups_list_item.vue41
-rw-r--r--app/assets/javascripts/jira_connect/components/subscriptions_list.vue109
-rw-r--r--app/assets/javascripts/jira_connect/index.js54
-rw-r--r--app/assets/javascripts/jira_connect/utils.js40
-rw-r--r--app/assets/javascripts/jobs/components/commit_block.vue9
-rw-r--r--app/assets/javascripts/jobs/components/job_container_item.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue20
-rw-r--r--app/assets/javascripts/jobs/components/manual_variables_form.vue22
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue3
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_job_details_container.vue4
-rw-r--r--app/assets/javascripts/jobs/components/stages_dropdown.vue14
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql52
-rw-r--r--app/assets/javascripts/jobs/components/table/index.js33
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table.vue67
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue85
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue66
-rw-r--r--app/assets/javascripts/labels_select.js33
-rw-r--r--app/assets/javascripts/lib/graphql.js2
-rw-r--r--app/assets/javascripts/lib/utils/color_utils.js12
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js13
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js76
-rw-r--r--app/assets/javascripts/lib/utils/forms.js94
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js4
-rw-r--r--app/assets/javascripts/lib/utils/webpack.js6
-rw-r--r--app/assets/javascripts/main.js55
-rw-r--r--app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue7
-rw-r--r--app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue1
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue7
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_member_button.vue28
-rw-r--r--app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue7
-rw-r--r--app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue10
-rw-r--r--app/assets/javascripts/members/components/app.vue14
-rw-r--r--app/assets/javascripts/members/components/avatars/user_avatar.vue3
-rw-r--r--app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue10
-rw-r--r--app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue7
-rw-r--r--app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue10
-rw-r--r--app/assets/javascripts/members/components/modals/leave_modal.vue23
-rw-r--r--app/assets/javascripts/members/components/modals/remove_group_link_modal.vue19
-rw-r--r--app/assets/javascripts/members/components/table/expiration_datepicker.vue7
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue13
-rw-r--r--app/assets/javascripts/members/components/table/members_table_cell.vue3
-rw-r--r--app/assets/javascripts/members/components/table/role_dropdown.vue7
-rw-r--r--app/assets/javascripts/members/index.js32
-rw-r--r--app/assets/javascripts/members/store/index.js1
-rw-r--r--app/assets/javascripts/members/store/state.js6
-rw-r--r--app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue32
-rw-r--r--app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue25
-rw-r--r--app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue17
-rw-r--r--app/assets/javascripts/merge_conflicts/constants.js1
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue129
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_service.js16
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_store.js432
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js92
-rw-r--r--app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js7
-rw-r--r--app/assets/javascripts/merge_conflicts/store/actions.js5
-rw-r--r--app/assets/javascripts/merge_conflicts/store/getters.js2
-rw-r--r--app/assets/javascripts/merge_request/components/status_box.vue2
-rw-r--r--app/assets/javascripts/merge_request_tabs.js2
-rw-r--r--app/assets/javascripts/milestone_select.js12
-rw-r--r--app/assets/javascripts/mini_pipeline_graph_dropdown.js113
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue7
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue7
-rw-r--r--app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/links_section.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/refresh_button.vue8
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js8
-rw-r--r--app/assets/javascripts/mr_notes/stores/actions.js35
-rw-r--r--app/assets/javascripts/mr_notes/stores/modules/index.js4
-rw-r--r--app/assets/javascripts/mr_notes/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/mr_notes/stores/mutations.js6
-rw-r--r--app/assets/javascripts/namespaces/cascading_settings/components/lock_popovers.vue77
-rw-r--r--app/assets/javascripts/namespaces/cascading_settings/index.js15
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue11
-rw-r--r--app/assets/javascripts/notes.js31
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue128
-rw-r--r--app/assets/javascripts/notes/components/discussion_navigator.vue13
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue7
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue29
-rw-r--r--app/assets/javascripts/notes/components/note_attachment.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue10
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue26
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue7
-rw-r--r--app/assets/javascripts/notes/components/sort_discussion.vue11
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js17
-rw-r--r--app/assets/javascripts/notes/stores/getters.js27
-rw-r--r--app/assets/javascripts/operation_settings/components/metrics_settings.vue4
-rw-r--r--app/assets/javascripts/packages/list/components/package_search.vue30
-rw-r--r--app/assets/javascripts/packages/list/components/package_title.vue10
-rw-r--r--app/assets/javascripts/packages/list/components/packages_list_app.vue63
-rw-r--r--app/assets/javascripts/packages/list/constants.js4
-rw-r--r--app/assets/javascripts/packages/list/packages_list_app_bundle.js8
-rw-r--r--app/assets/javascripts/packages/list/utils.js4
-rw-r--r--app/assets/javascripts/packages/shared/components/package_icon_and_name.vue17
-rw-r--r--app/assets/javascripts/packages/shared/components/package_list_row.vue22
-rw-r--r--app/assets/javascripts/packages/shared/constants.js1
-rw-r--r--app/assets/javascripts/packages/shared/utils.js16
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue17
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue45
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue53
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js33
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue4
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/utils.js29
-rw-r--r--app/assets/javascripts/pager.js20
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/admin.js13
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/components/signup_checkbox.vue50
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue417
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/index.js26
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/gitpod.js24
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js31
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/utils.js21
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/index.js6
-rw-r--r--app/assets/javascripts/pages/admin/groups/new/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/labels/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/labels/index/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/labels/new/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/runners/index/index.js (renamed from app/assets/javascripts/pages/admin/runners/index.js)0
-rw-r--r--app/assets/javascripts/pages/admin/runners/show/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/services/edit/index.js6
-rw-r--r--app/assets/javascripts/pages/admin/spam_logs/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue4
-rw-r--r--app/assets/javascripts/pages/admin/users/new/index.js52
-rw-r--r--app/assets/javascripts/pages/dashboard/activity/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js35
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js5
-rw-r--r--app/assets/javascripts/pages/groups/labels/index/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js11
-rw-r--r--app/assets/javascripts/pages/groups/new/group_path_validator.js9
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js6
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/index.js5
-rw-r--r--app/assets/javascripts/pages/groups/settings/integrations/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/repository/show/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/show/index.js135
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js23
-rw-r--r--app/assets/javascripts/pages/projects/branches/index/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue7
-rw-r--r--app/assets/javascripts/pages/projects/hooks/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/issues/form.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js14
-rw-r--r--app/assets/javascripts/pages/projects/jobs/index/index.js34
-rw-r--r--app/assets/javascripts/pages/projects/jobs/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/labels/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue64
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue6
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue2
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue52
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue43
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js50
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js14
-rw-r--r--app/assets/javascripts/pages/projects/packages/infrastructure_registry/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pages/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/path_locks/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue2
-rw-r--r--app/assets/javascripts/pages/projects/project.js17
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/settings/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/settings/integrations/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/settings/operations/show/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue7
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue87
-rw-r--r--app/assets/javascripts/pages/projects/tags/index/index.js2
-rw-r--r--app/assets/javascripts/pages/shared/mount_runner_instructions.js9
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue253
-rw-r--r--app/assets/javascripts/pages/shared/wikis/index.js23
-rw-r--r--app/assets/javascripts/pages/shared/wikis/wikis.js75
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js2
-rw-r--r--app/assets/javascripts/performance/constants.js2
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue98
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue19
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue4
-rw-r--r--app/assets/javascripts/performance_bar/components/request_warning.vue4
-rw-r--r--app/assets/javascripts/performance_bar/constants.js17
-rw-r--r--app/assets/javascripts/performance_bar/index.js32
-rw-r--r--app/assets/javascripts/performance_bar/stores/performance_bar_store.js13
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue42
-rw-r--r--app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js11
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue76
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue65
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue21
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue15
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue34
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue59
-rw-r--r--app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue12
-rw-r--r--app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue30
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue75
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue66
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js11
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql9
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/resolvers.js24
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js21
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue90
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue9
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue15
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/constants.js9
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue72
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue56
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue85
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue37
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue112
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue19
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue67
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/utils.js16
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/api.js2
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue26
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue8
-rw-r--r--app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue (renamed from app/assets/javascripts/pipelines/components/graph/action_component.vue)2
-rw-r--r--app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue (renamed from app/assets/javascripts/pipelines/components/graph/job_name_component.vue)0
-rw-r--r--app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue90
-rw-r--r--app/assets/javascripts/pipelines/components/parsing_utils.js26
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue9
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue218
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue30
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue39
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue190
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue28
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue143
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue38
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue269
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue74
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/unwrapping_utils.js48
-rw-r--r--app/assets/javascripts/pipelines/constants.js3
-rw-r--r--app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql5
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql1
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql13
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines_mixin.js2
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js40
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_dag.js7
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_graph.js13
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_header.js8
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_notification.js29
-rw-r--r--app/assets/javascripts/pipelines/pipeline_shared_client.js11
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js6
-rw-r--r--app/assets/javascripts/pipelines/utils.js8
-rw-r--r--app/assets/javascripts/projects/commit/components/commit_comments_button.vue42
-rw-r--r--app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue107
-rw-r--r--app/assets/javascripts/projects/commit/components/form_modal.vue8
-rw-r--r--app/assets/javascripts/projects/commit/components/form_trigger.vue35
-rw-r--r--app/assets/javascripts/projects/commit/constants.js2
-rw-r--r--app/assets/javascripts/projects/commit/index.js8
-rw-r--r--app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js1
-rw-r--r--app/assets/javascripts/projects/commit/init_cherry_pick_commit_trigger.js20
-rw-r--r--app/assets/javascripts/projects/commit/init_commit_comments_button.js18
-rw-r--r--app/assets/javascripts/projects/commit/init_commit_options_dropdown.js35
-rw-r--r--app/assets/javascripts/projects/commit/init_revert_commit_trigger.js20
-rw-r--r--app/assets/javascripts/projects/commit/store/actions.js4
-rw-r--r--app/assets/javascripts/projects/commit_box/info/index.js16
-rw-r--r--app/assets/javascripts/projects/commit_box/info/load_branches.js3
-rw-r--r--app/assets/javascripts/projects/compare/components/app_legacy.vue27
-rw-r--r--app/assets/javascripts/projects/compare/components/repo_dropdown.vue9
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_dropdown.vue59
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue13
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue32
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue3
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/constants.js1
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app.vue36
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue6
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue4
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/index.js6
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue30
-rw-r--r--app/assets/javascripts/registry/settings/components/settings_form.vue14
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue14
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue4
-rw-r--r--app/assets/javascripts/releases/components/app_show.vue56
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue6
-rw-r--r--app/assets/javascripts/releases/components/release_block_header.vue7
-rw-r--r--app/assets/javascripts/releases/components/release_block_milestone_info.vue2
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination_graphql.vue4
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination_rest.vue4
-rw-r--r--app/assets/javascripts/releases/components/releases_sort.vue4
-rw-r--r--app/assets/javascripts/releases/components/tag_field.vue2
-rw-r--r--app/assets/javascripts/releases/components/tag_field_existing.vue2
-rw-r--r--app/assets/javascripts/releases/components/tag_field_new.vue6
-rw-r--r--app/assets/javascripts/releases/mount_edit.js4
-rw-r--r--app/assets/javascripts/releases/mount_index.js8
-rw-r--r--app/assets/javascripts/releases/mount_new.js4
-rw-r--r--app/assets/javascripts/releases/mount_show.js28
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js (renamed from app/assets/javascripts/releases/stores/modules/detail/actions.js)4
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/getters.js (renamed from app/assets/javascripts/releases/stores/modules/detail/getters.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/index.js (renamed from app/assets/javascripts/releases/stores/modules/detail/index.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js (renamed from app/assets/javascripts/releases/stores/modules/detail/mutation_types.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutations.js (renamed from app/assets/javascripts/releases/stores/modules/detail/mutations.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/state.js (renamed from app/assets/javascripts/releases/stores/modules/detail/state.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/actions.js (renamed from app/assets/javascripts/releases/stores/modules/list/actions.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/index.js (renamed from app/assets/javascripts/releases/stores/modules/list/index.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/mutation_types.js (renamed from app/assets/javascripts/releases/stores/modules/list/mutation_types.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/mutations.js (renamed from app/assets/javascripts/releases/stores/modules/list/mutations.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/state.js (renamed from app/assets/javascripts/releases/stores/modules/list/state.js)0
-rw-r--r--app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue1
-rw-r--r--app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue3
-rw-r--r--app/assets/javascripts/reports/components/grouped_issues_list.vue4
-rw-r--r--app/assets/javascripts/reports/components/issues_list.vue4
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue23
-rw-r--r--app/assets/javascripts/reports/components/summary_row.vue2
-rw-r--r--app/assets/javascripts/reports/constants.js2
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/components/modal.vue21
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue18
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue31
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/actions.js2
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/mutations.js19
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/state.js14
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/utils.js9
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue100
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue53
-rw-r--r--app/assets/javascripts/repository/components/directory_download_links.vue12
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue22
-rw-r--r--app/assets/javascripts/repository/components/tree_action_link.vue28
-rw-r--r--app/assets/javascripts/repository/components/upload_blob_modal.vue8
-rw-r--r--app/assets/javascripts/repository/index.js42
-rw-r--r--app/assets/javascripts/repository/pages/blob.vue22
-rw-r--r--app/assets/javascripts/repository/queries/blob_info.query.graphql30
-rw-r--r--app/assets/javascripts/repository/router.js20
-rw-r--r--app/assets/javascripts/runner/runner_details/constants.js3
-rw-r--r--app/assets/javascripts/runner/runner_details/index.js23
-rw-r--r--app/assets/javascripts/runner/runner_details/runner_details_app.vue20
-rw-r--r--app/assets/javascripts/search/sidebar/components/app.vue4
-rw-r--r--app/assets/javascripts/search/sort/components/app.vue1
-rw-r--r--app/assets/javascripts/search/topbar/components/app.vue6
-rw-r--r--app/assets/javascripts/search/topbar/components/searchable_dropdown.vue8
-rw-r--r--app/assets/javascripts/search_settings/constants.js2
-rw-r--r--app/assets/javascripts/security_configuration/components/manage_sast.vue2
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue65
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue25
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue37
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue296
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue51
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue39
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue17
-rw-r--r--app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue35
-rw-r--r--app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue203
-rw-r--r--app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue41
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue38
-rw-r--r--app/assets/javascripts/sidebar/constants.js9
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js105
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql10
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql8
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql8
-rw-r--r--app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql9
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue54
-rw-r--r--app/assets/javascripts/snippets/components/embed_dropdown.vue1
-rw-r--r--app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql2
-rw-r--r--app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql3
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/index.js4
-rw-r--r--app/assets/javascripts/tags/components/sort_dropdown.vue77
-rw-r--r--app/assets/javascripts/tags/index.js24
-rw-r--r--app/assets/javascripts/tooltips/index.js5
-rw-r--r--app/assets/javascripts/tracking.js34
-rw-r--r--app/assets/javascripts/user_lists/components/user_list.vue1
-rw-r--r--app/assets/javascripts/users_select/index.js11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue38
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue57
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue56
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js3
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue7
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue5
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue23
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue24
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/constants.js11
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/index.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/alert_details_table.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/clone_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/delete_label_modal.vue81
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_modal.vue146
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue105
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue133
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_toggle_vuex.vue49
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/help_popover.vue17
-rw-r--r--app/assets/javascripts/vue_shared/components/lib/utils/props_utils.js35
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/modal_copy_button.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue70
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js21
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_modal.vue90
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/registry_search.vue45
-rw-r--r--app/assets/javascripts/vue_shared/components/remove_member_modal.vue45
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql8
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql14
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue246
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue249
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue88
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/components/url_sync.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/user_date.vue (renamed from app/assets/javascripts/admin/users/components/user_date.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue25
-rw-r--r--app/assets/javascripts/vue_shared/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js36
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue2
-rw-r--r--app/assets/javascripts/whats_new/components/app.vue58
-rw-r--r--app/assets/javascripts/whats_new/index.js6
-rw-r--r--app/assets/javascripts/whats_new/store/actions.js10
-rw-r--r--app/assets/javascripts/whats_new/utils/notification.js13
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss1
-rw-r--r--app/assets/stylesheets/application_dark.scss57
-rw-r--r--app/assets/stylesheets/components/feature_highlight.scss9
-rw-r--r--app/assets/stylesheets/fontawesome_custom.scss43
-rw-r--r--app/assets/stylesheets/framework/awards.scss43
-rw-r--r--app/assets/stylesheets/framework/ci_variable_list.scss23
-rw-r--r--app/assets/stylesheets/framework/common.scss10
-rw-r--r--app/assets/stylesheets/framework/diffs.scss4
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss14
-rw-r--r--app/assets/stylesheets/framework/editor-lite.scss50
-rw-r--r--app/assets/stylesheets/framework/emojis.scss13
-rw-r--r--app/assets/stylesheets/framework/filters.scss19
-rw-r--r--app/assets/stylesheets/framework/header.scss12
-rw-r--r--app/assets/stylesheets/framework/modal.scss8
-rw-r--r--app/assets/stylesheets/framework/page_header.scss25
-rw-r--r--app/assets/stylesheets/framework/responsive_tables.scss2
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss2
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss13
-rw-r--r--app/assets/stylesheets/framework/spinner.scss4
-rw-r--r--app/assets/stylesheets/framework/typography.scss80
-rw-r--r--app/assets/stylesheets/framework/variables.scss19
-rw-r--r--app/assets/stylesheets/highlight/common.scss28
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/none.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss4
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss4
-rw-r--r--app/assets/stylesheets/lazy_bundles/select2_overrides.scss56
-rw-r--r--app/assets/stylesheets/page_bundles/alert_management_settings.scss1
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss117
-rw-r--r--app/assets/stylesheets/page_bundles/build.scss38
-rw-r--r--app/assets/stylesheets/page_bundles/ci_status.scss14
-rw-r--r--app/assets/stylesheets/page_bundles/jira_connect.scss59
-rw-r--r--app/assets/stylesheets/page_bundles/learn_gitlab.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss33
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline.scss18
-rw-r--r--app/assets/stylesheets/pages/commits.scss8
-rw-r--r--app/assets/stylesheets/pages/editor.scss2
-rw-r--r--app/assets/stylesheets/pages/events.scss19
-rw-r--r--app/assets/stylesheets/pages/groups.scss15
-rw-r--r--app/assets/stylesheets/pages/help.scss7
-rw-r--r--app/assets/stylesheets/pages/issuable.scss45
-rw-r--r--app/assets/stylesheets/pages/issues.scss5
-rw-r--r--app/assets/stylesheets/pages/labels.scss3
-rw-r--r--app/assets/stylesheets/pages/login.scss3
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss4
-rw-r--r--app/assets/stylesheets/pages/note_form.scss1
-rw-r--r--app/assets/stylesheets/pages/notes.scss20
-rw-r--r--app/assets/stylesheets/pages/notifications.scss4
-rw-r--r--app/assets/stylesheets/pages/projects.scss11
-rw-r--r--app/assets/stylesheets/pages/runners.scss56
-rw-r--r--app/assets/stylesheets/pages/settings.scss4
-rw-r--r--app/assets/stylesheets/pages/tree.scss2
-rw-r--r--app/assets/stylesheets/performance_bar.scss2
-rw-r--r--app/assets/stylesheets/print.scss8
-rw-r--r--app/assets/stylesheets/themes/_dark.scss3
-rw-r--r--app/assets/stylesheets/themes/theme_light.scss46
-rw-r--r--app/controllers/admin/application_settings_controller.rb10
-rw-r--r--app/controllers/admin/clusters/integrations_controller.rb11
-rw-r--r--app/controllers/admin/dev_ops_report_controller.rb8
-rw-r--r--app/controllers/admin/groups_controller.rb11
-rw-r--r--app/controllers/admin/services_controller.rb8
-rw-r--r--app/controllers/admin/users_controller.rb6
-rw-r--r--app/controllers/application_controller.rb5
-rw-r--r--app/controllers/boards/issues_controller.rb6
-rw-r--r--app/controllers/chaos_controller.rb6
-rw-r--r--app/controllers/clusters/clusters_controller.rb6
-rw-r--r--app/controllers/clusters/integrations_controller.rb34
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb2
-rw-r--r--app/controllers/concerns/creates_commit.rb24
-rw-r--r--app/controllers/concerns/enforces_admin_authentication.rb2
-rw-r--r--app/controllers/concerns/labels_as_hash.rb2
-rw-r--r--app/controllers/concerns/membership_actions.rb11
-rw-r--r--app/controllers/concerns/milestone_actions.rb2
-rw-r--r--app/controllers/concerns/redis_tracking.rb15
-rw-r--r--app/controllers/concerns/renders_commits.rb3
-rw-r--r--app/controllers/concerns/runner_setup_scripts.rb4
-rw-r--r--app/controllers/concerns/service_params.rb3
-rw-r--r--app/controllers/concerns/sessionless_authentication.rb2
-rw-r--r--app/controllers/customers_dot/proxy_controller.rb21
-rw-r--r--app/controllers/graphql_controller.rb23
-rw-r--r--app/controllers/groups/boards_controller.rb2
-rw-r--r--app/controllers/groups/clusters/integrations_controller.rb18
-rw-r--r--app/controllers/groups/email_campaigns_controller.rb22
-rw-r--r--app/controllers/groups/labels_controller.rb3
-rw-r--r--app/controllers/groups/settings/applications_controller.rb75
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb10
-rw-r--r--app/controllers/groups/settings/integrations_controller.rb2
-rw-r--r--app/controllers/groups/settings/packages_and_registries_controller.rb1
-rw-r--r--app/controllers/groups/settings/repository_controller.rb1
-rw-r--r--app/controllers/groups/shared_projects_controller.rb14
-rw-r--r--app/controllers/groups/variables_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb33
-rw-r--r--app/controllers/ide_controller.rb16
-rw-r--r--app/controllers/import/gitlab_projects_controller.rb6
-rw-r--r--app/controllers/import/manifest_controller.rb6
-rw-r--r--app/controllers/invites_controller.rb26
-rw-r--r--app/controllers/ldap/omniauth_callbacks_controller.rb2
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb4
-rw-r--r--app/controllers/profiles/notifications_controller.rb23
-rw-r--r--app/controllers/projects/blame_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.rb2
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb1
-rw-r--r--app/controllers/projects/clusters/integrations_controller.rb15
-rw-r--r--app/controllers/projects/commit_controller.rb39
-rw-r--r--app/controllers/projects/commits_controller.rb8
-rw-r--r--app/controllers/projects/compare_controller.rb2
-rw-r--r--app/controllers/projects/forks_controller.rb16
-rw-r--r--app/controllers/projects/issues_controller.rb34
-rw-r--r--app/controllers/projects/jobs_controller.rb5
-rw-r--r--app/controllers/projects/labels_controller.rb11
-rw-r--r--app/controllers/projects/logs_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests/content_controller.rb11
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb17
-rw-r--r--app/controllers/projects/merge_requests_controller.rb48
-rw-r--r--app/controllers/projects/network_controller.rb5
-rw-r--r--app/controllers/projects/notes_controller.rb6
-rw-r--r--app/controllers/projects/packages/infrastructure_registry_controller.rb9
-rw-r--r--app/controllers/projects/pipelines_controller.rb43
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb15
-rw-r--r--app/controllers/projects/releases_controller.rb1
-rw-r--r--app/controllers/projects/services_controller.rb2
-rw-r--r--app/controllers/projects/settings/access_tokens_controller.rb9
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb3
-rw-r--r--app/controllers/projects/settings/operations_controller.rb1
-rw-r--r--app/controllers/projects/settings/repository_controller.rb1
-rw-r--r--app/controllers/projects/tags_controller.rb3
-rw-r--r--app/controllers/projects_controller.rb12
-rw-r--r--app/controllers/registrations/experience_levels_controller.rb2
-rw-r--r--app/controllers/registrations/welcome_controller.rb2
-rw-r--r--app/controllers/registrations_controller.rb6
-rw-r--r--app/controllers/root_controller.rb5
-rw-r--r--app/controllers/search_controller.rb6
-rw-r--r--app/controllers/whats_new_controller.rb12
-rw-r--r--app/experiments/application_experiment.rb2
-rw-r--r--app/experiments/members/invite_email_experiment.rb77
-rw-r--r--app/experiments/strategy/round_robin.rb78
-rw-r--r--app/finders/alert_management/http_integrations_finder.rb2
-rw-r--r--app/finders/applications_finder.rb2
-rw-r--r--app/finders/award_emojis_finder.rb3
-rw-r--r--app/finders/branches_finder.rb3
-rw-r--r--app/finders/ci/commit_statuses_finder.rb4
-rw-r--r--app/finders/ci/daily_build_group_report_results_finder.rb6
-rw-r--r--app/finders/ci/pipelines_finder.rb2
-rw-r--r--app/finders/ci/variables_finder.rb14
-rw-r--r--app/finders/concerns/finder_with_group_hierarchy.rb74
-rw-r--r--app/finders/concerns/merged_at_filter.rb20
-rw-r--r--app/finders/concerns/packages/finder_helper.rb18
-rw-r--r--app/finders/context_commits_finder.rb18
-rw-r--r--app/finders/deployments_finder.rb4
-rw-r--r--app/finders/environments_by_deployments_finder.rb67
-rw-r--r--app/finders/environments_finder.rb67
-rw-r--r--app/finders/git_refs_finder.rb12
-rw-r--r--app/finders/group_members_finder.rb54
-rw-r--r--app/finders/group_projects_finder.rb3
-rw-r--r--app/finders/issuable_finder.rb17
-rw-r--r--app/finders/issues_finder.rb10
-rw-r--r--app/finders/labels_finder.rb80
-rw-r--r--app/finders/merge_request/metrics_finder.rb4
-rw-r--r--app/finders/merge_requests/by_approvals_finder.rb8
-rw-r--r--app/finders/merge_requests/oldest_per_commit_finder.rb19
-rw-r--r--app/finders/metrics/dashboards/annotations_finder.rb3
-rw-r--r--app/finders/metrics/users_starred_dashboards_finder.rb4
-rw-r--r--app/finders/namespaces/projects_finder.rb3
-rw-r--r--app/finders/notes_finder.rb12
-rw-r--r--app/finders/packages/conan/package_file_finder.rb3
-rw-r--r--app/finders/packages/debian/distributions_finder.rb6
-rw-r--r--app/finders/packages/go/package_finder.rb29
-rw-r--r--app/finders/packages/go/version_finder.rb3
-rw-r--r--app/finders/packages/group_packages_finder.rb3
-rw-r--r--app/finders/packages/maven/package_finder.rb37
-rw-r--r--app/finders/packages/package_file_finder.rb4
-rw-r--r--app/finders/packages/packages_finder.rb3
-rw-r--r--app/finders/pending_todos_finder.rb15
-rw-r--r--app/finders/projects/export_job_finder.rb4
-rw-r--r--app/finders/projects/prometheus/alerts_finder.rb4
-rw-r--r--app/finders/projects_finder.rb5
-rw-r--r--app/finders/prometheus_metrics_finder.rb4
-rw-r--r--app/finders/protected_branches_finder.rb4
-rw-r--r--app/finders/releases_finder.rb3
-rw-r--r--app/finders/repositories/branch_names_finder.rb24
-rw-r--r--app/finders/repositories/changelog_tag_finder.rb82
-rw-r--r--app/finders/repositories/previous_tag_finder.rb57
-rw-r--r--app/finders/tags_finder.rb3
-rw-r--r--app/finders/user_group_notification_settings_finder.rb17
-rw-r--r--app/finders/users_star_projects_finder.rb4
-rw-r--r--app/graphql/gitlab_schema.rb13
-rw-r--r--app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb2
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/create.rb2
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/update.rb2
-rw-r--r--app/graphql/mutations/base_mutation.rb29
-rw-r--r--app/graphql/mutations/boards/issues/issue_move_list.rb15
-rw-r--r--app/graphql/mutations/ci/ci_cd_settings_update.rb12
-rw-r--r--app/graphql/mutations/concerns/mutations/assignable.rb46
-rw-r--r--app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb41
-rw-r--r--app/graphql/mutations/concerns/mutations/spam_protection.rb67
-rw-r--r--app/graphql/mutations/container_repositories/destroy_tags.rb2
-rw-r--r--app/graphql/mutations/issues/move.rb2
-rw-r--r--app/graphql/mutations/issues/set_assignees.rb13
-rw-r--r--app/graphql/mutations/merge_requests/accept.rb3
-rw-r--r--app/graphql/mutations/merge_requests/set_assignees.rb2
-rw-r--r--app/graphql/mutations/release_asset_links/delete.rb40
-rw-r--r--app/graphql/mutations/snippets/create.rb13
-rw-r--r--app/graphql/mutations/snippets/update.rb13
-rw-r--r--app/graphql/queries/pipelines/get_pipeline_details.query.graphql1
-rw-r--r--app/graphql/resolvers/alert_management/http_integrations_resolver.rb30
-rw-r--r--app/graphql/resolvers/alert_management/integrations_resolver.rb43
-rw-r--r--app/graphql/resolvers/base_resolver.rb24
-rw-r--r--app/graphql/resolvers/blobs_resolver.rb37
-rw-r--r--app/graphql/resolvers/board_lists_resolver.rb18
-rw-r--r--app/graphql/resolvers/board_resolver.rb2
-rw-r--r--app/graphql/resolvers/ci/config_resolver.rb6
-rw-r--r--app/graphql/resolvers/ci/jobs_resolver.rb13
-rw-r--r--app/graphql/resolvers/ci/pipeline_stages_resolver.rb2
-rw-r--r--app/graphql/resolvers/ci/runner_platforms_resolver.rb3
-rw-r--r--app/graphql/resolvers/ci/runner_setup_resolver.rb43
-rw-r--r--app/graphql/resolvers/ci/test_report_summary_resolver.rb17
-rw-r--r--app/graphql/resolvers/ci/test_suite_resolver.rb40
-rw-r--r--app/graphql/resolvers/concerns/board_issue_filterable.rb13
-rw-r--r--app/graphql/resolvers/concerns/issue_resolver_arguments.rb33
-rw-r--r--app/graphql/resolvers/concerns/looks_ahead.rb15
-rw-r--r--app/graphql/resolvers/concerns/manual_authorization.rb11
-rw-r--r--app/graphql/resolvers/concerns/resolves_merge_requests.rb3
-rw-r--r--app/graphql/resolvers/concerns/resolves_snippets.rb2
-rw-r--r--app/graphql/resolvers/echo_resolver.rb6
-rw-r--r--app/graphql/resolvers/environments_resolver.rb2
-rw-r--r--app/graphql/resolvers/group_members_resolver.rb6
-rw-r--r--app/graphql/resolvers/group_merge_requests_resolver.rb2
-rw-r--r--app/graphql/resolvers/group_milestones_resolver.rb28
-rw-r--r--app/graphql/resolvers/issues_resolver.rb3
-rw-r--r--app/graphql/resolvers/members_resolver.rb6
-rw-r--r--app/graphql/resolvers/merge_request_resolver.rb8
-rw-r--r--app/graphql/resolvers/merge_requests_resolver.rb39
-rw-r--r--app/graphql/resolvers/metrics/dashboard_resolver.rb9
-rw-r--r--app/graphql/resolvers/milestones_resolver.rb4
-rw-r--r--app/graphql/resolvers/package_details_resolver.rb10
-rw-r--r--app/graphql/resolvers/project_jobs_resolver.rb41
-rw-r--r--app/graphql/resolvers/project_pipeline_resolver.rb2
-rw-r--r--app/graphql/resolvers/projects/services_resolver.rb12
-rw-r--r--app/graphql/resolvers/repository_branch_names_resolver.rb17
-rw-r--r--app/graphql/resolvers/snippets/blobs_resolver.rb3
-rw-r--r--app/graphql/resolvers/timelog_resolver.rb112
-rw-r--r--app/graphql/resolvers/user_merge_requests_resolver_base.rb25
-rw-r--r--app/graphql/resolvers/user_starred_projects_resolver.rb6
-rw-r--r--app/graphql/resolvers/users/snippets_resolver.rb7
-rw-r--r--app/graphql/types/base_argument.rb4
-rw-r--r--app/graphql/types/base_enum.rb27
-rw-r--r--app/graphql/types/base_field.rb41
-rw-r--r--app/graphql/types/base_interface.rb6
-rw-r--r--app/graphql/types/base_object.rb8
-rw-r--r--app/graphql/types/base_union.rb3
-rw-r--r--app/graphql/types/board_type.rb6
-rw-r--r--app/graphql/types/boards/assignee_wildcard_id_enum.rb13
-rw-r--r--app/graphql/types/boards/board_issuable_input_base_type.rb20
-rw-r--r--app/graphql/types/boards/board_issue_input_base_type.rb17
-rw-r--r--app/graphql/types/boards/board_issue_input_type.rb13
-rw-r--r--app/graphql/types/boards/negated_board_issue_input_type.rb10
-rw-r--r--app/graphql/types/ci/job_status_enum.rb15
-rw-r--r--app/graphql/types/ci/job_type.rb99
-rw-r--r--app/graphql/types/ci/pipeline_config_source_enum.rb3
-rw-r--r--app/graphql/types/ci/pipeline_status_enum.rb4
-rw-r--r--app/graphql/types/ci/pipeline_type.rb45
-rw-r--r--app/graphql/types/ci/recent_failures_type.rb20
-rw-r--r--app/graphql/types/ci/stage_type.rb35
-rw-r--r--app/graphql/types/ci/test_case_status_enum.rb15
-rw-r--r--app/graphql/types/ci/test_case_type.rb41
-rw-r--r--app/graphql/types/ci/test_report_summary_type.rb19
-rw-r--r--app/graphql/types/ci/test_report_total_type.rb33
-rw-r--r--app/graphql/types/ci/test_suite_summary_type.rb41
-rw-r--r--app/graphql/types/ci/test_suite_type.rb41
-rw-r--r--app/graphql/types/concerns/find_closest.rb11
-rw-r--r--app/graphql/types/concerns/gitlab_style_deprecations.rb18
-rw-r--r--app/graphql/types/global_id_type.rb13
-rw-r--r--app/graphql/types/group_type.rb69
-rw-r--r--app/graphql/types/issue_type.rb3
-rw-r--r--app/graphql/types/issues/negated_issue_filter_input_type.rb27
-rw-r--r--app/graphql/types/jira_users_mapping_input_type.rb12
-rw-r--r--app/graphql/types/merge_request_review_state_enum.rb11
-rw-r--r--app/graphql/types/merge_request_state_enum.rb2
-rw-r--r--app/graphql/types/merge_request_type.rb7
-rw-r--r--app/graphql/types/merge_requests/reviewer_type.rb26
-rw-r--r--app/graphql/types/milestone_type.rb3
-rw-r--r--app/graphql/types/mutation_operation_mode_enum.rb8
-rw-r--r--app/graphql/types/mutation_type.rb1
-rw-r--r--app/graphql/types/packages/conan/file_metadatum_type.rb22
-rw-r--r--app/graphql/types/packages/conan/metadatum_file_type_enum.rb16
-rw-r--r--app/graphql/types/packages/conan/metadatum_type.rb22
-rw-r--r--app/graphql/types/packages/file_metadata_type.rb27
-rw-r--r--app/graphql/types/packages/metadata_type.rb4
-rw-r--r--app/graphql/types/packages/package_details_type.rb20
-rw-r--r--app/graphql/types/packages/package_file_type.rb36
-rw-r--r--app/graphql/types/packages/package_type.rb47
-rw-r--r--app/graphql/types/packages/package_without_versions_type.rb44
-rw-r--r--app/graphql/types/project_type.rb6
-rw-r--r--app/graphql/types/query_type.rb34
-rw-r--r--app/graphql/types/repository/blob_type.rb40
-rw-r--r--app/graphql/types/repository_type.rb5
-rw-r--r--app/graphql/types/sort_enum.rb32
-rw-r--r--app/graphql/types/timelog_type.rb42
-rw-r--r--app/graphql/types/user_merge_request_interaction_type.rb47
-rw-r--r--app/graphql/types/user_type.rb75
-rw-r--r--app/helpers/analytics/unique_visits_helper.rb2
-rw-r--r--app/helpers/appearances_helper.rb1
-rw-r--r--app/helpers/application_helper.rb14
-rw-r--r--app/helpers/application_settings_helper.rb4
-rw-r--r--app/helpers/avatars_helper.rb6
-rw-r--r--app/helpers/blob_helper.rb16
-rw-r--r--app/helpers/boards_helper.rb11
-rw-r--r--app/helpers/button_helper.rb4
-rw-r--r--app/helpers/ci/jobs_helper.rb15
-rw-r--r--app/helpers/ci/pipeline_editor_helper.rb18
-rw-r--r--app/helpers/ci/pipelines_helper.rb40
-rw-r--r--app/helpers/ci/runners_helper.rb31
-rw-r--r--app/helpers/clusters_helper.rb2
-rw-r--r--app/helpers/commits_helper.rb24
-rw-r--r--app/helpers/diff_helper.rb20
-rw-r--r--app/helpers/dropdowns_helper.rb4
-rw-r--r--app/helpers/events_helper.rb2
-rw-r--r--app/helpers/git_helper.rb3
-rw-r--r--app/helpers/gitlab_routing_helper.rb2
-rw-r--r--app/helpers/graph_helper.rb2
-rw-r--r--app/helpers/groups_helper.rb62
-rw-r--r--app/helpers/ide_helper.rb2
-rw-r--r--app/helpers/in_product_marketing_helper.rb19
-rw-r--r--app/helpers/issuables_helper.rb9
-rw-r--r--app/helpers/issues_helper.rb31
-rw-r--r--app/helpers/jira_connect_helper.rb16
-rw-r--r--app/helpers/learn_gitlab_helper.rb5
-rw-r--r--app/helpers/merge_requests_helper.rb32
-rw-r--r--app/helpers/namespaces_helper.rb27
-rw-r--r--app/helpers/nav_helper.rb6
-rw-r--r--app/helpers/packages_helper.rb3
-rw-r--r--app/helpers/page_layout_helper.rb11
-rw-r--r--app/helpers/preferences_helper.rb2
-rw-r--r--app/helpers/profiles_helper.rb14
-rw-r--r--app/helpers/projects_helper.rb35
-rw-r--r--app/helpers/search_helper.rb2
-rw-r--r--app/helpers/services_helper.rb50
-rw-r--r--app/helpers/sidebars_helper.rb45
-rw-r--r--app/helpers/snippets_helper.rb14
-rw-r--r--app/helpers/sorting_helper.rb2
-rw-r--r--app/helpers/submodule_helper.rb3
-rw-r--r--app/helpers/tab_helper.rb61
-rw-r--r--app/helpers/timeboxes_helper.rb11
-rw-r--r--app/helpers/todos_helper.rb20
-rw-r--r--app/helpers/tracking_helper.rb4
-rw-r--r--app/helpers/tree_helper.rb2
-rw-r--r--app/helpers/user_callouts_helper.rb13
-rw-r--r--app/helpers/whats_new_helper.rb12
-rw-r--r--app/helpers/wiki_helper.rb4
-rw-r--r--app/helpers/workhorse_helper.rb12
-rw-r--r--app/mailers/emails/in_product_marketing.rb7
-rw-r--r--app/mailers/emails/profile.rb24
-rw-r--r--app/mailers/notify.rb4
-rw-r--r--app/models/ability.rb3
-rw-r--r--app/models/application_record.rb10
-rw-r--r--app/models/application_setting.rb11
-rw-r--r--app/models/application_setting_implementation.rb13
-rw-r--r--app/models/audit_event_archived.rb10
-rw-r--r--app/models/blob.rb1
-rw-r--r--app/models/blob_viewer/dependency_manager.rb4
-rw-r--r--app/models/board.rb8
-rw-r--r--app/models/bulk_imports/entity.rb19
-rw-r--r--app/models/bulk_imports/stage.rb65
-rw-r--r--app/models/bulk_imports/tracker.rb25
-rw-r--r--app/models/ci/build.rb53
-rw-r--r--app/models/ci/build_dependencies.rb3
-rw-r--r--app/models/ci/build_trace_chunk.rb4
-rw-r--r--app/models/ci/build_trace_chunks/redis.rb2
-rw-r--r--app/models/ci/group.rb7
-rw-r--r--app/models/ci/job_artifact.rb8
-rw-r--r--app/models/ci/pipeline.rb61
-rw-r--r--app/models/ci/pipeline_artifact.rb2
-rw-r--r--app/models/ci/pipeline_schedule.rb2
-rw-r--r--app/models/ci/processable.rb8
-rw-r--r--app/models/ci/runner.rb2
-rw-r--r--app/models/ci/stage.rb11
-rw-r--r--app/models/ci/test_case.rb35
-rw-r--r--app/models/ci/test_case_failure.rb29
-rw-r--r--app/models/ci/unit_test.rb46
-rw-r--r--app/models/ci/unit_test_failure.rb29
-rw-r--r--app/models/clusters/agent_token.rb30
-rw-r--r--app/models/clusters/applications/prometheus.rb39
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/cluster.rb16
-rw-r--r--app/models/clusters/clusters_hierarchy.rb2
-rw-r--r--app/models/clusters/concerns/application_status.rb8
-rw-r--r--app/models/clusters/concerns/application_version.rb6
-rw-r--r--app/models/clusters/concerns/prometheus_client.rb50
-rw-r--r--app/models/clusters/integrations/prometheus.rb21
-rw-r--r--app/models/commit.rb11
-rw-r--r--app/models/commit_status.rb33
-rw-r--r--app/models/concerns/avatarable.rb1
-rw-r--r--app/models/concerns/boards/listable.rb1
-rw-r--r--app/models/concerns/bulk_member_access_load.rb26
-rw-r--r--app/models/concerns/cache_markdown_field.rb4
-rw-r--r--app/models/concerns/cascading_namespace_setting_attribute.rb241
-rw-r--r--app/models/concerns/ci/artifactable.rb7
-rw-r--r--app/models/concerns/ci/has_status.rb13
-rw-r--r--app/models/concerns/counter_attribute.rb2
-rw-r--r--app/models/concerns/deprecated_assignee.rb2
-rw-r--r--app/models/concerns/enums/ci/commit_status.rb2
-rw-r--r--app/models/concerns/enums/ci/pipeline.rb4
-rw-r--r--app/models/concerns/has_repository.rb9
-rw-r--r--app/models/concerns/has_timelogs_report.rb20
-rw-r--r--app/models/concerns/integration.rb4
-rw-r--r--app/models/concerns/issuable.rb14
-rw-r--r--app/models/concerns/loaded_in_group_list.rb4
-rw-r--r--app/models/concerns/milestoneable.rb4
-rw-r--r--app/models/concerns/milestoneish.rb6
-rw-r--r--app/models/concerns/object_storable.rb10
-rw-r--r--app/models/concerns/participable.rb35
-rw-r--r--app/models/concerns/protected_ref.rb2
-rw-r--r--app/models/concerns/safe_url.rb4
-rw-r--r--app/models/concerns/sidebars/container_with_html_options.rb42
-rw-r--r--app/models/concerns/sidebars/has_active_routes.rb16
-rw-r--r--app/models/concerns/sidebars/has_hint.rb16
-rw-r--r--app/models/concerns/sidebars/has_icon.rb27
-rw-r--r--app/models/concerns/sidebars/has_pill.rb21
-rw-r--r--app/models/concerns/sidebars/positionable_list.rb37
-rw-r--r--app/models/concerns/sidebars/renderable.rb12
-rw-r--r--app/models/concerns/sortable.rb1
-rw-r--r--app/models/concerns/subscribable.rb32
-rw-r--r--app/models/concerns/taskable.rb3
-rw-r--r--app/models/concerns/token_authenticatable_strategies/encrypted.rb17
-rw-r--r--app/models/concerns/token_authenticatable_strategies/encryption_helper.rb26
-rw-r--r--app/models/concerns/vulnerability_finding_helpers.rb7
-rw-r--r--app/models/concerns/vulnerability_finding_signature_helpers.rb7
-rw-r--r--app/models/deploy_key.rb2
-rw-r--r--app/models/deployment.rb33
-rw-r--r--app/models/design_management/design_action.rb4
-rw-r--r--app/models/design_management/design_at_version.rb3
-rw-r--r--app/models/design_management/repository.rb2
-rw-r--r--app/models/design_management/version.rb4
-rw-r--r--app/models/environment.rb12
-rw-r--r--app/models/experiment.rb17
-rw-r--r--app/models/external_issue.rb3
-rw-r--r--app/models/gpg_key.rb4
-rw-r--r--app/models/group.rb62
-rw-r--r--app/models/internal_id.rb35
-rw-r--r--app/models/issue.rb24
-rw-r--r--app/models/key.rb2
-rw-r--r--app/models/list.rb1
-rw-r--r--app/models/member.rb15
-rw-r--r--app/models/members/group_member.rb4
-rw-r--r--app/models/members/last_group_owner_assigner.rb46
-rw-r--r--app/models/members/project_member.rb2
-rw-r--r--app/models/merge_request.rb39
-rw-r--r--app/models/milestone.rb4
-rw-r--r--app/models/namespace.rb41
-rw-r--r--app/models/namespace/admin_note.rb7
-rw-r--r--app/models/namespace/traversal_hierarchy.rb26
-rw-r--r--app/models/namespace_setting.rb14
-rw-r--r--app/models/namespaces/traversal/linear.rb93
-rw-r--r--app/models/namespaces/traversal/recursive.rb15
-rw-r--r--app/models/note.rb19
-rw-r--r--app/models/notification_setting.rb4
-rw-r--r--app/models/packages/debian/file_entry.rb44
-rw-r--r--app/models/packages/debian/file_metadatum.rb2
-rw-r--r--app/models/packages/dependency.rb4
-rw-r--r--app/models/packages/go/module_version.rb4
-rw-r--r--app/models/packages/maven/metadatum.rb1
-rw-r--r--app/models/packages/package.rb26
-rw-r--r--app/models/packages/tag.rb2
-rw-r--r--app/models/pages/lookup_path.rb6
-rw-r--r--app/models/pages_deployment.rb2
-rw-r--r--app/models/preloaders/labels_preloader.rb34
-rw-r--r--app/models/preloaders/user_max_access_level_in_projects_preloader.rb25
-rw-r--r--app/models/project.rb69
-rw-r--r--app/models/project_feature.rb3
-rw-r--r--app/models/project_feature_usage.rb25
-rw-r--r--app/models/project_services/asana_service.rb24
-rw-r--r--app/models/project_services/assembla_service.rb2
-rw-r--r--app/models/project_services/bamboo_service.rb41
-rw-r--r--app/models/project_services/chat_message/merge_message.rb2
-rw-r--r--app/models/project_services/chat_notification_service.rb8
-rw-r--r--app/models/project_services/ci_service.rb2
-rw-r--r--app/models/project_services/custom_issue_tracker_service.rb6
-rw-r--r--app/models/project_services/datadog_service.rb4
-rw-r--r--app/models/project_services/discord_service.rb18
-rw-r--r--app/models/project_services/drone_ci_service.rb12
-rw-r--r--app/models/project_services/emails_on_push_service.rb30
-rw-r--r--app/models/project_services/external_wiki_service.rb16
-rw-r--r--app/models/project_services/hipchat_service.rb2
-rw-r--r--app/models/project_services/irker_service.rb2
-rw-r--r--app/models/project_services/issue_tracker_service.rb6
-rw-r--r--app/models/project_services/jenkins_service.rb40
-rw-r--r--app/models/project_services/jira_service.rb112
-rw-r--r--app/models/project_services/jira_tracker_data.rb21
-rw-r--r--app/models/project_services/mattermost_service.rb19
-rw-r--r--app/models/project_services/mattermost_slash_commands_service.rb2
-rw-r--r--app/models/project_services/microsoft_teams_service.rb14
-rw-r--r--app/models/project_services/mock_ci_service.rb7
-rw-r--r--app/models/project_services/pipelines_email_service.rb8
-rw-r--r--app/models/project_services/pushover_service.rb2
-rw-r--r--app/models/project_services/redmine_service.rb8
-rw-r--r--app/models/project_services/slack_service.rb4
-rw-r--r--app/models/project_services/teamcity_service.rb38
-rw-r--r--app/models/project_services/youtrack_service.rb4
-rw-r--r--app/models/project_team.rb4
-rw-r--r--app/models/protected_branch.rb2
-rw-r--r--app/models/raw_usage_data.rb4
-rw-r--r--app/models/release.rb2
-rw-r--r--app/models/release_highlight.rb23
-rw-r--r--app/models/remote_mirror.rb39
-rw-r--r--app/models/repository.rb16
-rw-r--r--app/models/sent_notification.rb2
-rw-r--r--app/models/service.rb12
-rw-r--r--app/models/sidebars/context.rb21
-rw-r--r--app/models/sidebars/menu.rb82
-rw-r--r--app/models/sidebars/menu_item.rb21
-rw-r--r--app/models/sidebars/panel.rb75
-rw-r--r--app/models/sidebars/projects/context.rb11
-rw-r--r--app/models/sidebars/projects/menus/learn_gitlab/menu.rb41
-rw-r--r--app/models/sidebars/projects/menus/project_overview/menu.rb45
-rw-r--r--app/models/sidebars/projects/menus/project_overview/menu_items/activity.rb35
-rw-r--r--app/models/sidebars/projects/menus/project_overview/menu_items/details.rb36
-rw-r--r--app/models/sidebars/projects/menus/project_overview/menu_items/releases.rb40
-rw-r--r--app/models/sidebars/projects/menus/repository/menu.rb59
-rw-r--r--app/models/sidebars/projects/menus/repository/menu_items/branches.rb35
-rw-r--r--app/models/sidebars/projects/menus/repository/menu_items/commits.rb35
-rw-r--r--app/models/sidebars/projects/menus/repository/menu_items/compare.rb28
-rw-r--r--app/models/sidebars/projects/menus/repository/menu_items/contributors.rb28
-rw-r--r--app/models/sidebars/projects/menus/repository/menu_items/files.rb28
-rw-r--r--app/models/sidebars/projects/menus/repository/menu_items/graphs.rb28
-rw-r--r--app/models/sidebars/projects/menus/repository/menu_items/tags.rb28
-rw-r--r--app/models/sidebars/projects/menus/scope/menu.rb21
-rw-r--r--app/models/sidebars/projects/panel.rb26
-rw-r--r--app/models/timelog.rb4
-rw-r--r--app/models/todo.rb18
-rw-r--r--app/models/user.rb76
-rw-r--r--app/models/user_callout.rb10
-rw-r--r--app/models/user_detail.rb2
-rw-r--r--app/models/users/in_product_marketing_email.rb49
-rw-r--r--app/models/users/merge_request_interaction.rb44
-rw-r--r--app/models/wiki.rb59
-rw-r--r--app/policies/base_policy.rb2
-rw-r--r--app/policies/group_member_policy.rb2
-rw-r--r--app/policies/group_policy.rb20
-rw-r--r--app/policies/note_policy.rb2
-rw-r--r--app/policies/packages/conan/file_metadatum_policy.rb8
-rw-r--r--app/policies/packages/conan/metadatum_policy.rb8
-rw-r--r--app/policies/packages/package_file_policy.rb6
-rw-r--r--app/policies/project_policy.rb27
-rw-r--r--app/policies/timelog_policy.rb5
-rw-r--r--app/presenters/ci/build_runner_presenter.rb18
-rw-r--r--app/presenters/ci/pipeline_presenter.rb4
-rw-r--r--app/presenters/clusters/cluster_presenter.rb20
-rw-r--r--app/presenters/clusters/integration_presenter.rb11
-rw-r--r--app/presenters/commit_status_presenter.rb4
-rw-r--r--app/presenters/dev_ops_report/metric_presenter.rb2
-rw-r--r--app/presenters/packages/detail/package_presenter.rb1
-rw-r--r--app/presenters/packages/npm/package_presenter.rb4
-rw-r--r--app/presenters/packages/nuget/packages_metadata_presenter.rb2
-rw-r--r--app/presenters/project_presenter.rb16
-rw-r--r--app/presenters/projects/settings/deploy_keys_presenter.rb29
-rw-r--r--app/presenters/search_service_presenter.rb6
-rw-r--r--app/presenters/user_presenter.rb6
-rw-r--r--app/serializers/README.md2
-rw-r--r--app/serializers/admin/user_entity.rb2
-rw-r--r--app/serializers/build_artifact_entity.rb10
-rw-r--r--app/serializers/build_details_entity.rb8
-rw-r--r--app/serializers/ci/group_variable_entity.rb1
-rw-r--r--app/serializers/diff_file_entity.rb17
-rw-r--r--app/serializers/discussion_entity.rb8
-rw-r--r--app/serializers/environment_serializer.rb54
-rw-r--r--app/serializers/fork_namespace_entity.rb2
-rw-r--r--app/serializers/member_entity.rb2
-rw-r--r--app/serializers/member_serializer.rb6
-rw-r--r--app/serializers/merge_request_poll_cached_widget_entity.rb46
-rw-r--r--app/serializers/merge_request_poll_widget_entity.rb47
-rw-r--r--app/serializers/merge_request_widget_entity.rb2
-rw-r--r--app/serializers/merge_requests/pipeline_entity.rb4
-rw-r--r--app/serializers/note_entity.rb3
-rw-r--r--app/serializers/pipeline_details_entity.rb2
-rw-r--r--app/serializers/pipeline_serializer.rb1
-rw-r--r--app/serializers/runner_entity.rb2
-rw-r--r--app/serializers/service_field_entity.rb29
-rw-r--r--app/services/application_settings/base_service.rb4
-rw-r--r--app/services/authorized_project_update/find_records_due_for_refresh_service.rb92
-rw-r--r--app/services/base_container_service.rb4
-rw-r--r--app/services/base_service.rb4
-rw-r--r--app/services/boards/base_item_move_service.rb20
-rw-r--r--app/services/boards/base_service.rb4
-rw-r--r--app/services/boards/destroy_service.rb8
-rw-r--r--app/services/boards/issues/move_service.rb23
-rw-r--r--app/services/boards/lists/base_update_service.rb58
-rw-r--r--app/services/boards/lists/list_service.rb10
-rw-r--r--app/services/boards/lists/update_service.rb45
-rw-r--r--app/services/ci/abort_pipelines_service.rb32
-rw-r--r--app/services/ci/abort_project_pipelines_service.rb25
-rw-r--r--app/services/ci/after_requeue_job_service.rb28
-rw-r--r--app/services/ci/cancel_user_pipelines_service.rb18
-rw-r--r--app/services/ci/create_job_artifacts_service.rb172
-rw-r--r--app/services/ci/create_pipeline_service.rb3
-rw-r--r--app/services/ci/create_web_ide_terminal_service.rb3
-rw-r--r--app/services/ci/destroy_expired_job_artifacts_service.rb56
-rw-r--r--app/services/ci/disable_user_pipeline_schedules_service.rb11
-rw-r--r--app/services/ci/drop_pipeline_service.rb37
-rw-r--r--app/services/ci/generate_coverage_reports_service.rb8
-rw-r--r--app/services/ci/job_artifacts/create_service.rb174
-rw-r--r--app/services/ci/job_artifacts/destroy_all_expired_service.rb58
-rw-r--r--app/services/ci/job_artifacts/destroy_batch_service.rb74
-rw-r--r--app/services/ci/job_artifacts_destroy_batch_service.rb72
-rw-r--r--app/services/ci/pipeline_artifacts/destroy_all_expired_service.rb (renamed from app/services/ci/pipeline_artifacts/destroy_expired_artifacts_service.rb)2
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb8
-rw-r--r--app/services/ci/pipeline_trigger_service.rb23
-rw-r--r--app/services/ci/play_bridge_service.rb4
-rw-r--r--app/services/ci/play_build_service.rb8
-rw-r--r--app/services/ci/process_build_service.rb25
-rw-r--r--app/services/ci/process_pipeline_service.rb2
-rw-r--r--app/services/ci/register_job_service.rb30
-rw-r--r--app/services/ci/retry_build_service.rb18
-rw-r--r--app/services/ci/retry_pipeline_service.rb2
-rw-r--r--app/services/ci/stop_environments_service.rb2
-rw-r--r--app/services/ci/test_failure_history_service.rb48
-rw-r--r--app/services/clusters/create_service.rb3
-rw-r--r--app/services/clusters/destroy_service.rb3
-rw-r--r--app/services/clusters/integrations/create_service.rb43
-rw-r--r--app/services/clusters/update_service.rb3
-rw-r--r--app/services/concerns/integrations/project_test_data.rb2
-rw-r--r--app/services/concerns/suggestible.rb2
-rw-r--r--app/services/deployments/link_merge_requests_service.rb28
-rw-r--r--app/services/draft_notes/base_service.rb4
-rw-r--r--app/services/git/wiki_push_service.rb4
-rw-r--r--app/services/git/wiki_push_service/change.rb4
-rw-r--r--app/services/groups/base_service.rb16
-rw-r--r--app/services/groups/count_service.rb49
-rw-r--r--app/services/groups/create_service.rb9
-rw-r--r--app/services/groups/group_links/create_service.rb2
-rw-r--r--app/services/groups/merge_requests_count_service.rb22
-rw-r--r--app/services/groups/nested_create_service.rb3
-rw-r--r--app/services/groups/open_issues_count_service.rb41
-rw-r--r--app/services/groups/update_service.rb12
-rw-r--r--app/services/issuable/bulk_update_service.rb17
-rw-r--r--app/services/issuable/destroy_service.rb34
-rw-r--r--app/services/issuable/process_assignees.rb9
-rw-r--r--app/services/issuable_base_service.rb74
-rw-r--r--app/services/issuable_links/create_service.rb8
-rw-r--r--app/services/issuable_links/destroy_service.rb8
-rw-r--r--app/services/issuable_links/list_service.rb3
-rw-r--r--app/services/issue_rebalancing_service.rb2
-rw-r--r--app/services/issues/after_create_service.rb13
-rw-r--r--app/services/issues/base_service.rb2
-rw-r--r--app/services/issues/create_service.rb8
-rw-r--r--app/services/issues/update_service.rb18
-rw-r--r--app/services/jira_connect_subscriptions/base_service.rb4
-rw-r--r--app/services/keys/base_service.rb3
-rw-r--r--app/services/keys/create_service.rb3
-rw-r--r--app/services/keys/expiry_notification_service.rb42
-rw-r--r--app/services/mattermost/create_team_service.rb3
-rw-r--r--app/services/members/create_service.rb111
-rw-r--r--app/services/members/invite_service.rb92
-rw-r--r--app/services/merge_requests/add_context_service.rb4
-rw-r--r--app/services/merge_requests/after_create_service.rb12
-rw-r--r--app/services/merge_requests/base_service.rb16
-rw-r--r--app/services/merge_requests/build_service.rb38
-rw-r--r--app/services/merge_requests/create_service.rb12
-rw-r--r--app/services/merge_requests/handle_assignees_change_service.rb43
-rw-r--r--app/services/merge_requests/merge_to_ref_service.rb8
-rw-r--r--app/services/merge_requests/migrate_external_diffs_service.rb2
-rw-r--r--app/services/merge_requests/push_options_handler_service.rb7
-rw-r--r--app/services/merge_requests/refresh_service.rb9
-rw-r--r--app/services/merge_requests/resolve_todos_service.rb28
-rw-r--r--app/services/merge_requests/retarget_chain_service.rb2
-rw-r--r--app/services/merge_requests/update_assignees_service.rb64
-rw-r--r--app/services/merge_requests/update_service.rb77
-rw-r--r--app/services/metrics/dashboard/annotations/create_service.rb3
-rw-r--r--app/services/metrics/dashboard/annotations/delete_service.rb3
-rw-r--r--app/services/metrics/dashboard/grafana_metric_embed_service.rb6
-rw-r--r--app/services/metrics/dashboard/panel_preview_service.rb4
-rw-r--r--app/services/metrics/users_starred_dashboards/create_service.rb4
-rw-r--r--app/services/metrics/users_starred_dashboards/delete_service.rb4
-rw-r--r--app/services/milestones/base_service.rb4
-rw-r--r--app/services/milestones/find_or_create_service.rb4
-rw-r--r--app/services/milestones/merge_requests_count_service.rb17
-rw-r--r--app/services/milestones/transfer_service.rb9
-rw-r--r--app/services/namespace_settings/update_service.rb13
-rw-r--r--app/services/namespaces/in_product_marketing_emails_service.rb47
-rw-r--r--app/services/notes/create_service.rb25
-rw-r--r--app/services/notification_recipients/builder/base.rb44
-rw-r--r--app/services/notification_recipients/builder/request_review.rb4
-rw-r--r--app/services/notification_service.rb16
-rw-r--r--app/services/packages/composer/composer_json_service.rb3
-rw-r--r--app/services/packages/composer/version_parser_service.rb3
-rw-r--r--app/services/packages/debian/create_distribution_service.rb3
-rw-r--r--app/services/packages/debian/extract_changes_metadata_service.rb112
-rw-r--r--app/services/packages/debian/extract_metadata_service.rb21
-rw-r--r--app/services/packages/debian/parse_debian822_service.rb2
-rw-r--r--app/services/packages/debian/process_changes_service.rb102
-rw-r--r--app/services/packages/debian/update_distribution_service.rb3
-rw-r--r--app/services/packages/go/create_package_service.rb70
-rw-r--r--app/services/packages/go/sync_packages_service.rb24
-rw-r--r--app/services/packages/maven/find_or_create_package_service.rb3
-rw-r--r--app/services/packages/maven/metadata/sync_service.rb15
-rw-r--r--app/services/packages/nuget/create_dependency_service.rb4
-rw-r--r--app/services/packages/rubygems/create_dependencies_service.rb44
-rw-r--r--app/services/packages/rubygems/create_gemspec_service.rb42
-rw-r--r--app/services/packages/rubygems/metadata_extraction_service.rb56
-rw-r--r--app/services/packages/rubygems/process_gem_service.rb124
-rw-r--r--app/services/pages/delete_service.rb2
-rw-r--r--app/services/pages/migrate_from_legacy_storage_service.rb43
-rw-r--r--app/services/pages/migrate_legacy_storage_to_deployment_service.rb17
-rw-r--r--app/services/pages/zip_directory_service.rb4
-rw-r--r--app/services/pod_logs/kubernetes_service.rb2
-rw-r--r--app/services/post_receive_service.rb2
-rw-r--r--app/services/projects/alerting/notify_service.rb2
-rw-r--r--app/services/projects/branches_by_mode_service.rb3
-rw-r--r--app/services/projects/create_from_template_service.rb3
-rw-r--r--app/services/projects/create_service.rb17
-rw-r--r--app/services/projects/destroy_service.rb4
-rw-r--r--app/services/projects/download_service.rb3
-rw-r--r--app/services/projects/gitlab_projects_import_service.rb4
-rw-r--r--app/services/projects/update_pages_configuration_service.rb2
-rw-r--r--app/services/projects/update_pages_service.rb15
-rw-r--r--app/services/projects/update_remote_mirror_service.rb14
-rw-r--r--app/services/prometheus/create_default_alerts_service.rb2
-rw-r--r--app/services/prometheus/proxy_service.rb4
-rw-r--r--app/services/prometheus/proxy_variable_substitution_service.rb3
-rw-r--r--app/services/releases/base_service.rb4
-rw-r--r--app/services/repositories/changelog_service.rb10
-rw-r--r--app/services/resource_access_tokens/create_service.rb2
-rw-r--r--app/services/resource_access_tokens/revoke_service.rb12
-rw-r--r--app/services/resource_events/base_synthetic_notes_builder_service.rb4
-rw-r--r--app/services/resource_events/change_labels_service.rb3
-rw-r--r--app/services/resource_events/change_state_service.rb3
-rw-r--r--app/services/search/global_service.rb3
-rw-r--r--app/services/search/project_service.rb4
-rw-r--r--app/services/snippets/create_service.rb2
-rw-r--r--app/services/snippets/update_service.rb2
-rw-r--r--app/services/spam/spam_action_service.rb32
-rw-r--r--app/services/spam/spam_params.rb8
-rw-r--r--app/services/submit_usage_ping_service.rb8
-rw-r--r--app/services/system_hooks_service.rb24
-rw-r--r--app/services/system_note_service.rb2
-rw-r--r--app/services/system_notes/alert_management_service.rb2
-rw-r--r--app/services/system_notes/commit_service.rb2
-rw-r--r--app/services/task_list_toggle_service.rb8
-rw-r--r--app/services/todo_service.rb25
-rw-r--r--app/services/todos/destroy/base_service.rb2
-rw-r--r--app/services/todos/destroy/destroyed_issuable_service.rb46
-rw-r--r--app/services/todos/destroy/entity_leave_service.rb15
-rw-r--r--app/services/todos/destroy/private_features_service.rb2
-rw-r--r--app/services/two_factor/base_service.rb3
-rw-r--r--app/services/upload_service.rb10
-rw-r--r--app/services/user_agent_detail_service.rb3
-rw-r--r--app/services/user_preferences/update_service.rb20
-rw-r--r--app/services/users/activity_service.rb2
-rw-r--r--app/services/users/batch_status_cleaner_service.rb2
-rw-r--r--app/services/users/refresh_authorized_projects_service.rb71
-rw-r--r--app/services/users/respond_to_terms_service.rb3
-rw-r--r--app/services/users/set_status_service.rb3
-rw-r--r--app/services/users/update_canonical_email_service.rb2
-rw-r--r--app/services/users/update_todo_count_cache_service.rb34
-rw-r--r--app/uploaders/object_storage.rb1
-rw-r--r--app/validators/json_schema_validator.rb24
-rw-r--r--app/validators/json_schemas/application_setting_kroki_formats.json1
-rw-r--r--app/validators/json_schemas/build_metadata_secrets.json1
-rw-r--r--app/validators/json_schemas/build_report_result_data.json1
-rw-r--r--app/validators/json_schemas/build_report_result_data_tests.json1
-rw-r--r--app/validators/json_schemas/codeclimate.json1
-rw-r--r--app/validators/json_schemas/daily_build_group_report_result_data.json1
-rw-r--r--app/validators/json_schemas/debian_fields.json1
-rw-r--r--app/validators/json_schemas/git_trailers.json1
-rw-r--r--app/validators/json_schemas/http_integration_payload_attribute_mapping.json1
-rw-r--r--app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json33
-rw-r--r--app/validators/json_schemas/security_scan_info.json28
-rw-r--r--app/validators/json_schemas/vulnerability_finding_details.json294
-rw-r--r--app/views/admin/abuse_reports/_abuse_report.html.haml8
-rw-r--r--app/views/admin/abuse_reports/index.html.haml11
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml2
-rw-r--r--app/views/admin/application_settings/_diff_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_eks.html.haml2
-rw-r--r--app/views/admin/application_settings/_external_authorization_service_form.html.haml2
-rw-r--r--app/views/admin/application_settings/_gitpod.html.haml2
-rw-r--r--app/views/admin/application_settings/_ip_limits.html.haml22
-rw-r--r--app/views/admin/application_settings/_kroki.html.haml2
-rw-r--r--app/views/admin/application_settings/_pages.html.haml2
-rw-r--r--app/views/admin/application_settings/_performance.html.haml4
-rw-r--r--app/views/admin/application_settings/_performance_bar.html.haml6
-rw-r--r--app/views/admin/application_settings/_plantuml.html.haml2
-rw-r--r--app/views/admin/application_settings/_realtime.html.haml11
-rw-r--r--app/views/admin/application_settings/_registry.html.haml4
-rw-r--r--app/views/admin/application_settings/_repository_check.html.haml33
-rw-r--r--app/views/admin/application_settings/_signin.html.haml11
-rw-r--r--app/views/admin/application_settings/_signup.html.haml98
-rw-r--r--app/views/admin/application_settings/_snowplow.html.haml2
-rw-r--r--app/views/admin/application_settings/_sourcegraph.html.haml2
-rw-r--r--app/views/admin/application_settings/_terminal.html.haml2
-rw-r--r--app/views/admin/application_settings/_terms.html.haml2
-rw-r--r--app/views/admin/application_settings/_third_party_offers.html.haml2
-rw-r--r--app/views/admin/application_settings/_usage.html.haml2
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml2
-rw-r--r--app/views/admin/application_settings/ci/_header.html.haml4
-rw-r--r--app/views/admin/application_settings/ci_cd.html.haml19
-rw-r--r--app/views/admin/application_settings/general.html.haml2
-rw-r--r--app/views/admin/application_settings/integrations.html.haml8
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml4
-rw-r--r--app/views/admin/broadcast_messages/index.html.haml21
-rw-r--r--app/views/admin/dashboard/index.html.haml30
-rw-r--r--app/views/admin/deploy_keys/new.html.haml2
-rw-r--r--app/views/admin/dev_ops_report/_callout.html.haml2
-rw-r--r--app/views/admin/groups/_form.html.haml2
-rw-r--r--app/views/admin/groups/show.html.haml5
-rw-r--r--app/views/admin/hook_logs/_index.html.haml18
-rw-r--r--app/views/admin/labels/_label.html.haml4
-rw-r--r--app/views/admin/labels/index.html.haml2
-rw-r--r--app/views/admin/projects/_projects.html.haml4
-rw-r--r--app/views/admin/projects/index.html.haml10
-rw-r--r--app/views/admin/runners/_runner.html.haml43
-rw-r--r--app/views/admin/runners/index.html.haml13
-rw-r--r--app/views/admin/runners/show.html.haml39
-rw-r--r--app/views/admin/serverless/domains/index.html.haml2
-rw-r--r--app/views/admin/services/_form.html.haml4
-rw-r--r--app/views/admin/services/_service_templates_deprecated_alert.html.haml8
-rw-r--r--app/views/admin/services/index.html.haml28
-rw-r--r--app/views/admin/spam_logs/_spam_log.html.haml20
-rw-r--r--app/views/admin/users/_admin_notes.html.haml2
-rw-r--r--app/views/admin/users/_head.html.haml14
-rw-r--r--app/views/admin/users/_user.html.haml12
-rw-r--r--app/views/authentication/_authenticate.html.haml4
-rw-r--r--app/views/authentication/_register.html.haml4
-rw-r--r--app/views/award_emoji/_awards_block.html.haml43
-rw-r--r--app/views/ci/group_variables/_index.html.haml2
-rw-r--r--app/views/ci/group_variables/_variable_header.html.haml2
-rw-r--r--app/views/ci/runner/_how_to_setup_runner.html.haml2
-rw-r--r--app/views/ci/status/_dropdown_graph_badge.html.haml20
-rw-r--r--app/views/ci/variables/_content.html.haml2
-rw-r--r--app/views/ci/variables/_index.html.haml6
-rw-r--r--app/views/ci/variables/_url_query_variable_row.html.haml2
-rw-r--r--app/views/ci/variables/_variable_row.html.haml8
-rw-r--r--app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml2
-rw-r--r--app/views/clusters/clusters/_gitlab_integration_form.html.haml4
-rw-r--r--app/views/clusters/clusters/_health.html.haml2
-rw-r--r--app/views/clusters/clusters/_integrations.html.haml19
-rw-r--r--app/views/clusters/clusters/_integrations_tab.html.haml6
-rw-r--r--app/views/clusters/clusters/aws/_new.html.haml4
-rw-r--r--app/views/clusters/clusters/show.html.haml1
-rw-r--r--app/views/dashboard/_projects_head.html.haml4
-rw-r--r--app/views/dashboard/merge_requests.html.haml4
-rw-r--r--app/views/dashboard/projects/index.html.haml2
-rw-r--r--app/views/dashboard/todos/index.html.haml4
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_account.html.haml12
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_account.text.erb10
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_secondary.text.erb6
-rw-r--r--app/views/devise/mailer/password_change.html.haml9
-rw-r--r--app/views/devise/mailer/password_change.text.erb8
-rw-r--r--app/views/devise/mailer/unlock_instructions.html.haml8
-rw-r--r--app/views/devise/passwords/edit.html.haml16
-rw-r--r--app/views/devise/passwords/new.html.haml4
-rw-r--r--app/views/devise/registrations/edit.html.erb12
-rw-r--r--app/views/devise/sessions/_new_crowd.html.haml10
-rw-r--r--app/views/devise/sessions/_new_ldap.html.haml6
-rw-r--r--app/views/devise/sessions/new.html.haml2
-rw-r--r--app/views/devise/sessions/two_factor.html.haml10
-rw-r--r--app/views/devise/shared/_email_opted_in.html.haml7
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml4
-rw-r--r--app/views/devise/shared/_sign_in_link.html.haml6
-rw-r--r--app/views/devise/shared/_tabs_ldap.html.haml4
-rw-r--r--app/views/devise/unlocks/new.html.haml6
-rw-r--r--app/views/doorkeeper/applications/edit.html.haml3
-rw-r--r--app/views/doorkeeper/applications/index.html.haml89
-rw-r--r--app/views/doorkeeper/applications/show.html.haml40
-rw-r--r--app/views/doorkeeper/authorizations/new.html.haml4
-rw-r--r--app/views/errors/_footer.html.haml3
-rw-r--r--app/views/events/event/_note.html.haml2
-rw-r--r--app/views/events/event/_push.html.haml4
-rw-r--r--app/views/groups/_activities.html.haml2
-rw-r--r--app/views/groups/_create_chat_team.html.haml4
-rw-r--r--app/views/groups/_group_admin_settings.html.haml12
-rw-r--r--app/views/groups/_home_panel.html.haml2
-rw-r--r--app/views/groups/_new_group_fields.html.haml5
-rw-r--r--app/views/groups/activity.html.haml2
-rw-r--r--app/views/groups/edit.html.haml5
-rw-r--r--app/views/groups/group_members/index.html.haml8
-rw-r--r--app/views/groups/issues.html.haml45
-rw-r--r--app/views/groups/labels/edit.html.haml2
-rw-r--r--app/views/groups/labels/index.html.haml2
-rw-r--r--app/views/groups/labels/new.html.haml2
-rw-r--r--app/views/groups/merge_requests.html.haml4
-rw-r--r--app/views/groups/milestones/_form.html.haml14
-rw-r--r--app/views/groups/milestones/edit.html.haml2
-rw-r--r--app/views/groups/runners/_group_runners.html.haml2
-rw-r--r--app/views/groups/runners/_index.html.haml19
-rw-r--r--app/views/groups/runners/_runner.html.haml45
-rw-r--r--app/views/groups/runners/edit.html.haml11
-rw-r--r--app/views/groups/settings/_advanced.html.haml8
-rw-r--r--app/views/groups/settings/_general.html.haml4
-rw-r--r--app/views/groups/settings/_permanent_deletion.html.haml3
-rw-r--r--app/views/groups/settings/_permissions.html.haml31
-rw-r--r--app/views/groups/settings/_project_access_token_creation.html.haml10
-rw-r--r--app/views/groups/settings/_remove_button.html.haml7
-rw-r--r--app/views/groups/settings/applications/edit.html.haml5
-rw-r--r--app/views/groups/settings/applications/index.html.haml8
-rw-r--r--app/views/groups/settings/applications/show.html.haml9
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml2
-rw-r--r--app/views/groups/settings/integrations/index.html.haml8
-rw-r--r--app/views/groups/settings/packages_and_registries/index.html.haml2
-rw-r--r--app/views/groups/settings/repository/show.html.haml2
-rw-r--r--app/views/help/index.html.haml27
-rw-r--r--app/views/help/instance_configuration.html.haml6
-rw-r--r--app/views/help/instance_configuration/_gitlab_ci.html.haml14
-rw-r--r--app/views/help/instance_configuration/_gitlab_pages.html.haml20
-rw-r--r--app/views/import/fogbugz/new_user_map.html.haml2
-rw-r--r--app/views/import/shared/_new_project_form.html.haml2
-rw-r--r--app/views/invites/show.html.haml2
-rw-r--r--app/views/jira_connect/subscriptions/index.html.haml25
-rw-r--r--app/views/jira_connect/users/show.html.haml4
-rw-r--r--app/views/kaminari/gitlab/_page.html.haml2
-rw-r--r--app/views/layouts/_flash.html.haml2
-rw-r--r--app/views/layouts/_head.html.haml2
-rw-r--r--app/views/layouts/_page.html.haml1
-rw-r--r--app/views/layouts/_search.html.haml2
-rw-r--r--app/views/layouts/errors.html.haml6
-rw-r--r--app/views/layouts/group_settings.html.haml1
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml2
-rw-r--r--app/views/layouts/header/_default.html.haml16
-rw-r--r--app/views/layouts/header/_new_dropdown.html.haml (renamed from app/views/layouts/header/_new_dropdown.haml)5
-rw-r--r--app/views/layouts/header/_new_repo_experiment.html.haml7
-rw-r--r--app/views/layouts/header/_read_only_banner.html.haml2
-rw-r--r--app/views/layouts/header/_service_templates_deprecation_callout.html.haml21
-rw-r--r--app/views/layouts/header/_whats_new_dropdown_item.html.haml11
-rw-r--r--app/views/layouts/nav/_breadcrumbs.html.haml4
-rw-r--r--app/views/layouts/nav/_combined_menu.html.haml3
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml6
-rw-r--r--app/views/layouts/nav/projects_dropdown/_show.html.haml23
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml21
-rw-r--r--app/views/layouts/nav/sidebar/_profile.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml472
-rw-r--r--app/views/layouts/nav/sidebar/_project_menus.html.haml380
-rw-r--r--app/views/layouts/nav/sidebar/_project_packages_link.html.haml8
-rw-r--r--app/views/layouts/project_settings.html.haml2
-rw-r--r--app/views/notify/_successful_pipeline.text.erb2
-rw-r--r--app/views/notify/closed_merge_request_email.html.haml2
-rw-r--r--app/views/notify/closed_merge_request_email.text.haml4
-rw-r--r--app/views/notify/merge_request_status_email.html.haml2
-rw-r--r--app/views/notify/merge_request_status_email.text.haml4
-rw-r--r--app/views/notify/merge_request_unmergeable_email.html.haml2
-rw-r--r--app/views/notify/merge_request_unmergeable_email.text.haml4
-rw-r--r--app/views/notify/merge_when_pipeline_succeeds_email.text.haml4
-rw-r--r--app/views/notify/merged_merge_request_email.html.haml2
-rw-r--r--app/views/notify/merged_merge_request_email.text.haml4
-rw-r--r--app/views/notify/new_mention_in_merge_request_email.html.haml2
-rw-r--r--app/views/notify/new_mention_in_merge_request_email.text.erb2
-rw-r--r--app/views/notify/new_review_email.html.haml2
-rw-r--r--app/views/notify/pipeline_failed_email.text.erb2
-rw-r--r--app/views/notify/push_to_merge_request_email.text.haml2
-rw-r--r--app/views/notify/reassigned_merge_request_email.text.erb2
-rw-r--r--app/views/notify/resolved_all_discussions_email.html.haml2
-rw-r--r--app/views/notify/resolved_all_discussions_email.text.erb2
-rw-r--r--app/views/notify/ssh_key_expired_email.html.haml13
-rw-r--r--app/views/notify/ssh_key_expired_email.text.erb9
-rw-r--r--app/views/notify/ssh_key_expiring_soon.text.erb9
-rw-r--r--app/views/notify/ssh_key_expiring_soon_email.html.haml13
-rw-r--r--app/views/notify/unknown_sign_in_email.html.haml8
-rw-r--r--app/views/profiles/accounts/show.html.haml4
-rw-r--r--app/views/profiles/chat_names/_chat_name.html.haml2
-rw-r--r--app/views/profiles/chat_names/new.html.haml2
-rw-r--r--app/views/profiles/emails/index.html.haml2
-rw-r--r--app/views/profiles/gpg_keys/_form.html.haml4
-rw-r--r--app/views/profiles/gpg_keys/_key.html.haml4
-rw-r--r--app/views/profiles/gpg_keys/index.html.haml4
-rw-r--r--app/views/profiles/keys/_form.html.haml7
-rw-r--r--app/views/profiles/keys/_key.html.haml16
-rw-r--r--app/views/profiles/keys/index.html.haml2
-rw-r--r--app/views/profiles/passwords/edit.html.haml2
-rw-r--r--app/views/profiles/passwords/new.html.haml2
-rw-r--r--app/views/profiles/show.html.haml6
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml2
-rw-r--r--app/views/projects/_commit_button.html.haml2
-rw-r--r--app/views/projects/_customize_workflow.html.haml2
-rw-r--r--app/views/projects/_files.html.haml1
-rw-r--r--app/views/projects/_fork_suggestion.html.haml2
-rw-r--r--app/views/projects/_home_panel.html.haml9
-rw-r--r--app/views/projects/_invite_members.html.haml2
-rw-r--r--app/views/projects/_merge_request_settings.html.haml3
-rw-r--r--app/views/projects/_merge_request_target_project_settings.html.haml23
-rw-r--r--app/views/projects/_new_project_fields.html.haml4
-rw-r--r--app/views/projects/_readme.html.haml2
-rw-r--r--app/views/projects/_service_desk_settings.html.haml2
-rw-r--r--app/views/projects/_wiki.html.haml2
-rw-r--r--app/views/projects/blob/_blob.html.haml11
-rw-r--r--app/views/projects/blob/_header.html.haml2
-rw-r--r--app/views/projects/blob/_new_dir.html.haml4
-rw-r--r--app/views/projects/blob/_template_selectors.html.haml2
-rw-r--r--app/views/projects/blob/_upload.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_empty.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_loading_auxiliary.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_stl.html.haml4
-rw-r--r--app/views/projects/branches/_branch.html.haml8
-rw-r--r--app/views/projects/branches/_delete_protected_modal.html.haml2
-rw-r--r--app/views/projects/branches/index.html.haml20
-rw-r--r--app/views/projects/branches/new.html.haml4
-rw-r--r--app/views/projects/buttons/_remove_tag.html.haml2
-rw-r--r--app/views/projects/ci/pipeline_editor/show.html.haml13
-rw-r--r--app/views/projects/cleanup/_show.html.haml4
-rw-r--r--app/views/projects/commit/_commit_box.html.haml42
-rw-r--r--app/views/projects/commits/_commits.html.haml4
-rw-r--r--app/views/projects/default_branch/_show.html.haml2
-rw-r--r--app/views/projects/deploy_keys/edit.html.haml4
-rw-r--r--app/views/projects/diffs/_diffs.html.haml4
-rw-r--r--app/views/projects/diffs/_file.html.haml6
-rw-r--r--app/views/projects/diffs/_file_header.html.haml3
-rw-r--r--app/views/projects/diffs/viewers/_collapsed.html.haml2
-rw-r--r--app/views/projects/edit.html.haml6
-rw-r--r--app/views/projects/empty.html.haml1
-rw-r--r--app/views/projects/environments/_form.html.haml2
-rw-r--r--app/views/projects/environments/empty_metrics.html.haml2
-rw-r--r--app/views/projects/environments/index.html.haml3
-rw-r--r--app/views/projects/environments/show.html.haml2
-rw-r--r--app/views/projects/forks/_fork_button.html.haml2
-rw-r--r--app/views/projects/forks/index.html.haml4
-rw-r--r--app/views/projects/graphs/show.html.haml2
-rw-r--r--app/views/projects/hooks/edit.html.haml2
-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/issuable/_show.html.haml3
-rw-r--r--app/views/projects/issues/_new_branch.html.haml15
-rw-r--r--app/views/projects/issues/index.html.haml31
-rw-r--r--app/views/projects/issues/show.html.haml3
-rw-r--r--app/views/projects/jobs/_table.html.haml4
-rw-r--r--app/views/projects/jobs/index.html.haml19
-rw-r--r--app/views/projects/labels/index.html.haml2
-rw-r--r--app/views/projects/logs/empty_logs.html.haml2
-rw-r--r--app/views/projects/merge_requests/_awards_block.html.haml2
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml4
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml2
-rw-r--r--app/views/projects/merge_requests/creations/_new_compare.html.haml4
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml4
-rw-r--r--app/views/projects/merge_requests/creations/new.html.haml4
-rw-r--r--app/views/projects/merge_requests/edit.html.haml4
-rw-r--r--app/views/projects/merge_requests/index.html.haml2
-rw-r--r--app/views/projects/merge_requests/invalid.html.haml4
-rw-r--r--app/views/projects/merge_requests/show.html.haml40
-rw-r--r--app/views/projects/network/show.html.haml2
-rw-r--r--app/views/projects/packages/infrastructure_registry/index.html.haml10
-rw-r--r--app/views/projects/pages/_pages_settings.html.haml8
-rw-r--r--app/views/projects/pages/_ssl_limitations_warning.html.haml6
-rw-r--r--app/views/projects/pages/_use.html.haml5
-rw-r--r--app/views/projects/pages/show.html.haml4
-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/pipeline_schedules/_form.html.haml10
-rw-r--r--app/views/projects/pipeline_schedules/index.html.haml2
-rw-r--r--app/views/projects/pipelines/_stage.html.haml5
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml4
-rw-r--r--app/views/projects/pipelines/charts.html.haml2
-rw-r--r--app/views/projects/pipelines/index.html.haml6
-rw-r--r--app/views/projects/pipelines/new.html.haml8
-rw-r--r--app/views/projects/pipelines/show.html.haml3
-rw-r--r--app/views/projects/project_members/index.html.haml8
-rw-r--r--app/views/projects/protected_branches/shared/_branches_list.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_create_protected_branch.html.haml4
-rw-r--r--app/views/projects/protected_tags/shared/_create_protected_tag.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_protected_tag.html.haml2
-rw-r--r--app/views/projects/runners/_group_runners.html.haml2
-rw-r--r--app/views/projects/runners/_runner.html.haml66
-rw-r--r--app/views/projects/runners/_shared_runners.html.haml2
-rw-r--r--app/views/projects/runners/edit.html.haml11
-rw-r--r--app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml35
-rw-r--r--app/views/projects/services/mattermost_slash_commands/_help.html.haml10
-rw-r--r--app/views/projects/services/prometheus/_configuration_banner.html.haml2
-rw-r--r--app/views/projects/services/prometheus/_custom_metrics.html.haml2
-rw-r--r--app/views/projects/services/slack/_help.haml16
-rw-r--r--app/views/projects/services/slack_slash_commands/_help.html.haml4
-rw-r--r--app/views/projects/settings/_archive.html.haml2
-rw-r--r--app/views/projects/settings/_general.html.haml4
-rw-r--r--app/views/projects/settings/access_tokens/index.html.haml29
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml2
-rw-r--r--app/views/projects/settings/integrations/show.html.haml2
-rw-r--r--app/views/projects/settings/operations/_alert_management.html.haml10
-rw-r--r--app/views/projects/settings/operations/_configuration_banner.html.haml2
-rw-r--r--app/views/projects/settings/operations/_error_tracking.html.haml4
-rw-r--r--app/views/projects/settings/operations/_prometheus.html.haml4
-rw-r--r--app/views/projects/settings/operations/_tracing.html.haml15
-rw-r--r--app/views/projects/settings/operations/show.html.haml2
-rw-r--r--app/views/projects/settings/repository/show.html.haml2
-rw-r--r--app/views/projects/show.html.haml2
-rw-r--r--app/views/projects/snippets/index.html.haml2
-rw-r--r--app/views/projects/stage/_stage.html.haml6
-rw-r--r--app/views/projects/starrers/_starrer.html.haml2
-rw-r--r--app/views/projects/tags/index.html.haml31
-rw-r--r--app/views/projects/tags/new.html.haml4
-rw-r--r--app/views/projects/tags/releases/edit.html.haml2
-rw-r--r--app/views/projects/tracings/_tracing_button.html.haml2
-rw-r--r--app/views/projects/tracings/show.html.haml4
-rw-r--r--app/views/projects/tree/show.html.haml2
-rw-r--r--app/views/projects/triggers/_form.html.haml2
-rw-r--r--app/views/projects/triggers/_index.html.haml32
-rw-r--r--app/views/projects/triggers/_trigger.html.haml4
-rw-r--r--app/views/registrations/welcome/show.html.haml11
-rw-r--r--app/views/search/results/_empty.html.haml2
-rw-r--r--app/views/search/results/_note.html.haml2
-rw-r--r--app/views/shared/_clone_panel.html.haml10
-rw-r--r--app/views/shared/_confirm_fork_modal.html.haml4
-rw-r--r--app/views/shared/_confirm_modal.html.haml2
-rw-r--r--app/views/shared/_delete_label_modal.html.haml20
-rw-r--r--app/views/shared/_file_highlight.html.haml4
-rw-r--r--app/views/shared/_file_picker_button.html.haml4
-rw-r--r--app/views/shared/_flash_user_callout.html.haml2
-rw-r--r--app/views/shared/_issuable_meta_data.html.haml3
-rw-r--r--app/views/shared/_issues.html.haml3
-rw-r--r--app/views/shared/_label.html.haml24
-rw-r--r--app/views/shared/_milestone_expired.html.haml2
-rw-r--r--app/views/shared/_mini_pipeline_graph.html.haml17
-rw-r--r--app/views/shared/_mobile_clone_panel.html.haml4
-rw-r--r--app/views/shared/_recaptcha_form.html.haml2
-rw-r--r--app/views/shared/_search_settings.html.haml7
-rw-r--r--app/views/shared/access_tokens/_form.html.haml4
-rw-r--r--app/views/shared/access_tokens/_table.html.haml2
-rw-r--r--app/views/shared/admin/_admin_note.html.haml7
-rw-r--r--app/views/shared/admin/_admin_note_form.html.haml6
-rw-r--r--app/views/shared/blob/_markdown_buttons.html.haml2
-rw-r--r--app/views/shared/boards/_show.html.haml22
-rw-r--r--app/views/shared/boards/components/_sidebar.html.haml2
-rw-r--r--app/views/shared/deploy_tokens/_form.html.haml10
-rw-r--r--app/views/shared/deploy_tokens/_revoke_modal.html.haml15
-rw-r--r--app/views/shared/deploy_tokens/_table.html.haml5
-rw-r--r--app/views/shared/doorkeeper/applications/_delete_form.html.haml (renamed from app/views/doorkeeper/applications/_delete_form.html.haml)6
-rw-r--r--app/views/shared/doorkeeper/applications/_form.html.haml (renamed from app/views/doorkeeper/applications/_form.html.haml)6
-rw-r--r--app/views/shared/doorkeeper/applications/_index.html.haml88
-rw-r--r--app/views/shared/doorkeeper/applications/_show.html.haml39
-rw-r--r--app/views/shared/empty_states/_issues.html.haml4
-rw-r--r--app/views/shared/empty_states/_profile_tabs.html.haml2
-rw-r--r--app/views/shared/empty_states/_wikis.html.haml4
-rw-r--r--app/views/shared/form_elements/_description.html.haml7
-rw-r--r--app/views/shared/hook_logs/_content.html.haml4
-rw-r--r--app/views/shared/integrations/_index.html.haml28
-rw-r--r--app/views/shared/issuable/_approved_by_dropdown.html.haml4
-rw-r--r--app/views/shared/issuable/_board_create_list_dropdown.html.haml2
-rw-r--r--app/views/shared/issuable/_bulk_update_sidebar.html.haml2
-rw-r--r--app/views/shared/issuable/_form.html.haml28
-rw-r--r--app/views/shared/issuable/_invite_members_trigger.html.haml8
-rw-r--r--app/views/shared/issuable/_nav.html.haml2
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml72
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml46
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml12
-rw-r--r--app/views/shared/issuable/_sort_dropdown.html.haml2
-rw-r--r--app/views/shared/issuable/_user_dropdown_item.html.haml2
-rw-r--r--app/views/shared/issuable/form/_type_selector.html.haml44
-rw-r--r--app/views/shared/issue_type/_details_content.html.haml3
-rw-r--r--app/views/shared/issue_type/_details_header.html.haml4
-rw-r--r--app/views/shared/issue_type/_emoji_block.html.haml4
-rw-r--r--app/views/shared/members/_member.html.haml4
-rw-r--r--app/views/shared/milestones/_milestone.html.haml10
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml2
-rw-r--r--app/views/shared/milestones/_tabs.html.haml2
-rw-r--r--app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml14
-rw-r--r--app/views/shared/namespaces/cascading_settings/_lock_popovers.html.haml1
-rw-r--r--app/views/shared/namespaces/cascading_settings/_setting_label.html.haml21
-rw-r--r--app/views/shared/nav/_scope_menu.html.haml6
-rw-r--r--app/views/shared/nav/_sidebar.html.haml14
-rw-r--r--app/views/shared/nav/_sidebar_menu.html.haml27
-rw-r--r--app/views/shared/nav/_sidebar_menu_item.html.haml8
-rw-r--r--app/views/shared/notes/_comment_button.html.haml8
-rw-r--r--app/views/shared/notes/_note.html.haml2
-rw-r--r--app/views/shared/projects/_project.html.haml4
-rw-r--r--app/views/shared/projects/_search_bar.html.haml2
-rw-r--r--app/views/shared/projects/_sort_dropdown.html.haml2
-rw-r--r--app/views/shared/projects/protected_branches/_update_protected_branch.html.haml2
-rw-r--r--app/views/shared/promotions/_promote_servicedesk.html.haml2
-rw-r--r--app/views/shared/runners/_form.html.haml2
-rw-r--r--app/views/shared/runners/_runner_type_alert.html.haml20
-rw-r--r--app/views/shared/runners/_runner_type_badge.html.haml10
-rw-r--r--app/views/shared/runners/show.html.haml15
-rw-r--r--app/views/shared/tokens/_scopes_form.html.haml2
-rw-r--r--app/views/shared/web_hooks/_hook.html.haml6
-rw-r--r--app/views/shared/web_hooks/_test_button.html.haml9
-rw-r--r--app/views/shared/wikis/_form.html.haml79
-rw-r--r--app/views/shared/wikis/history.html.haml2
-rw-r--r--app/views/shared/wikis/pages.html.haml2
-rw-r--r--app/views/sherlock/queries/_general.html.haml4
-rw-r--r--app/views/users/show.html.haml9
-rw-r--r--app/views/users/terms/index.html.haml4
-rw-r--r--app/workers/all_queues.yml128
-rw-r--r--app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb39
-rw-r--r--app/workers/build_finished_worker.rb4
-rw-r--r--app/workers/build_hooks_worker.rb1
-rw-r--r--app/workers/bulk_import_worker.rb13
-rw-r--r--app/workers/bulk_imports/entity_worker.rb49
-rw-r--r--app/workers/bulk_imports/pipeline_worker.rb70
-rw-r--r--app/workers/chaos/kill_worker.rb4
-rw-r--r--app/workers/ci/drop_pipeline_worker.rb16
-rw-r--r--app/workers/ci/initial_pipeline_process_worker.rb22
-rw-r--r--app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb21
-rw-r--r--app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb2
-rw-r--r--app/workers/concerns/application_worker.rb2
-rw-r--r--app/workers/concerns/cronjob_queue.rb2
-rw-r--r--app/workers/concerns/each_shard_worker.rb8
-rw-r--r--app/workers/concerns/reactive_cacheable_worker.rb8
-rw-r--r--app/workers/concerns/worker_attributes.rb33
-rw-r--r--app/workers/container_expiration_policy_worker.rb22
-rw-r--r--app/workers/database/batched_background_migration_worker.rb57
-rw-r--r--app/workers/delete_stored_files_worker.rb4
-rw-r--r--app/workers/emails_on_push_worker.rb8
-rw-r--r--app/workers/expire_build_artifacts_worker.rb2
-rw-r--r--app/workers/expire_job_cache_worker.rb2
-rw-r--r--app/workers/expire_pipeline_cache_worker.rb2
-rw-r--r--app/workers/irker_worker.rb3
-rw-r--r--app/workers/merge_requests/assignees_change_worker.rb26
-rw-r--r--app/workers/merge_requests/create_pipeline_worker.rb28
-rw-r--r--app/workers/merge_requests/handle_assignees_change_worker.rb22
-rw-r--r--app/workers/merge_requests/resolve_todos_worker.rb18
-rw-r--r--app/workers/namespaces/in_product_marketing_emails_worker.rb21
-rw-r--r--app/workers/new_issue_worker.rb5
-rw-r--r--app/workers/object_storage/migrate_uploads_worker.rb3
-rw-r--r--app/workers/packages/go/sync_packages_worker.rb33
-rw-r--r--app/workers/packages/rubygems/extraction_worker.rb27
-rw-r--r--app/workers/pages_update_configuration_worker.rb2
-rw-r--r--app/workers/projects/post_creation_worker.rb34
-rw-r--r--app/workers/remove_expired_members_worker.rb21
-rw-r--r--app/workers/ssh_keys/expired_notification_worker.rb25
-rw-r--r--app/workers/ssh_keys/expiring_soon_notification_worker.rb25
-rw-r--r--app/workers/todos_destroyer/destroyed_issuable_worker.rb14
-rw-r--r--app/workers/update_highest_role_worker.rb2
1939 files changed, 26536 insertions, 13001 deletions
diff --git a/app/assets/images/learn_gitlab/issue_created.svg b/app/assets/images/learn_gitlab/issue_created.svg
new file mode 100644
index 00000000000..01652b97fc0
--- /dev/null
+++ b/app/assets/images/learn_gitlab/issue_created.svg
@@ -0,0 +1,65 @@
+<svg width="81" height="48" viewBox="0 0 81 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M42.9799 11.6386C42.9688 11.7501 42.955 11.8786 42.9384 12.0222C42.8865 12.4687 42.8257 12.9142 42.756 13.3582C42.7493 13.3954 42.7501 13.4335 42.7584 13.4704C42.7667 13.5072 42.7822 13.542 42.8041 13.5728C42.826 13.6035 42.8538 13.6296 42.8859 13.6495C42.918 13.6693 42.9538 13.6826 42.9911 13.6884C43.0284 13.6943 43.0664 13.6926 43.1031 13.6835C43.1397 13.6745 43.1742 13.6582 43.2045 13.6356C43.2347 13.613 43.2602 13.5847 43.2793 13.5521C43.2985 13.5196 43.3109 13.4835 43.316 13.4461C43.3915 12.9637 43.4531 12.506 43.5015 12.0874C43.5221 11.9092 43.5408 11.7308 43.5577 11.5522C43.5609 11.5151 43.5568 11.4777 43.5456 11.4422C43.5343 11.4067 43.5162 11.3738 43.4923 11.3453C43.4684 11.3168 43.439 11.2933 43.406 11.2761C43.373 11.2589 43.3369 11.2484 43.2998 11.2452C43.2627 11.242 43.2253 11.2461 43.1898 11.2573C43.1543 11.2685 43.1214 11.2866 43.0929 11.3106C43.0644 11.3345 43.0409 11.3638 43.0237 11.3969C43.0065 11.4299 42.996 11.466 42.9928 11.5031C42.9909 11.5263 42.9866 11.572 42.9799 11.6386ZM41.9287 16.9968C41.9171 17.0322 41.9127 17.0695 41.9156 17.1066C41.9185 17.1438 41.9287 17.1799 41.9456 17.2131C41.9624 17.2463 41.9857 17.2758 42.014 17.3C42.0423 17.3242 42.0751 17.3426 42.1105 17.3542C42.1459 17.3657 42.1832 17.3701 42.2203 17.3672C42.2574 17.3643 42.2936 17.3541 42.3268 17.3373C42.36 17.3204 42.3895 17.2971 42.4137 17.2688C42.4379 17.2405 42.4563 17.2078 42.4678 17.1724C42.6521 16.606 42.8172 15.985 42.9645 15.321C42.9734 15.2843 42.975 15.2462 42.9691 15.2089C42.9631 15.1716 42.9498 15.1359 42.9299 15.1039C42.91 15.0718 42.8839 15.044 42.8531 15.0222C42.8223 15.0004 42.7874 14.9849 42.7506 14.9767C42.7137 14.9686 42.6756 14.9678 42.6385 14.9746C42.6013 14.9813 42.5659 14.9954 42.5343 15.016C42.5027 15.0367 42.4755 15.0634 42.4543 15.0947C42.4332 15.1259 42.4185 15.1611 42.4111 15.1981C42.2675 15.8456 42.1069 16.4491 41.9287 16.9968ZM40.0489 19.874C40.0136 19.8862 39.981 19.9053 39.9531 19.9302C39.9252 19.955 39.9025 19.9852 39.8863 20.0189C39.8701 20.0526 39.8607 20.0891 39.8587 20.1265C39.8566 20.1638 39.862 20.2012 39.8745 20.2364C39.887 20.2716 39.9063 20.3041 39.9313 20.3318C39.9564 20.3596 39.9867 20.3821 40.0205 20.3981C40.0543 20.4141 40.0909 20.4232 40.1283 20.425C40.1656 20.4267 40.2029 20.4211 40.2381 20.4084C40.8012 20.209 41.2854 19.713 41.7083 18.9671C41.7454 18.9017 41.755 18.8242 41.735 18.7517C41.715 18.6792 41.667 18.6177 41.6016 18.5806C41.5362 18.5435 41.4587 18.5339 41.3862 18.554C41.3137 18.574 41.2522 18.622 41.2151 18.6874C40.8532 19.3257 40.4601 19.7283 40.0489 19.874ZM36.3662 20.7087C36.3319 20.7231 36.3007 20.7442 36.2746 20.7706C36.2484 20.7971 36.2277 20.8285 36.2136 20.863C36.1996 20.8974 36.1925 20.9343 36.1927 20.9716C36.1929 21.0088 36.2004 21.0456 36.2149 21.0799C36.2293 21.1142 36.2504 21.1454 36.2769 21.1715C36.3033 21.1977 36.3347 21.2184 36.3692 21.2324C36.4037 21.2465 36.4406 21.2536 36.4778 21.2534C36.515 21.2532 36.5518 21.2456 36.5861 21.2312C37.1757 20.9829 37.7714 20.792 38.3357 20.6679C38.4091 20.6517 38.4731 20.607 38.5136 20.5437C38.5541 20.4803 38.5677 20.4035 38.5516 20.3301C38.5354 20.2566 38.4907 20.1926 38.4274 20.1521C38.364 20.1117 38.2872 20.098 38.2138 20.1142C37.6153 20.2459 36.9871 20.4473 36.3662 20.7087ZM33.1143 22.7955C33.0607 22.8475 33.0298 22.9186 33.0283 22.9932C33.0267 23.0678 33.0547 23.1401 33.1061 23.1942C33.1576 23.2483 33.2282 23.28 33.3029 23.2823C33.3775 23.2846 33.45 23.2574 33.5047 23.2066C33.9359 22.7967 34.4242 22.42 34.9553 22.0829C35.0187 22.0426 35.0636 21.9788 35.08 21.9054C35.0964 21.832 35.083 21.7551 35.0427 21.6916C35.0024 21.6282 34.9385 21.5833 34.8651 21.5669C34.7918 21.5505 34.7149 21.5639 34.6514 21.6042C34.0901 21.9605 33.5729 22.3598 33.1143 22.7955ZM31.0777 26.1259C31.0686 26.162 31.0667 26.1995 31.0721 26.2364C31.0776 26.2732 31.0902 26.3086 31.1093 26.3406C31.1284 26.3725 31.1536 26.4004 31.1835 26.4226C31.2134 26.4448 31.2474 26.4609 31.2835 26.47C31.3196 26.4791 31.3571 26.481 31.3939 26.4755C31.4308 26.4701 31.4662 26.4575 31.4981 26.4384C31.5301 26.4193 31.558 26.394 31.5802 26.3642C31.6024 26.3343 31.6185 26.3003 31.6276 26.2642C31.7727 25.6871 32.0133 25.1347 32.3419 24.611C32.3799 24.5474 32.3915 24.4715 32.3742 24.3994C32.3569 24.3274 32.3121 24.265 32.2493 24.2256C32.1866 24.1862 32.1109 24.1729 32.0385 24.1886C31.9661 24.2043 31.9027 24.2478 31.8619 24.3096C31.5023 24.8826 31.2379 25.4896 31.0777 26.1259ZM31.0276 29.9893C31.0322 30.0262 31.0441 30.0619 31.0626 30.0943C31.081 30.1266 31.1056 30.155 31.135 30.1778C31.1644 30.2007 31.1981 30.2175 31.234 30.2273C31.2699 30.2371 31.3074 30.2398 31.3443 30.2352C31.3813 30.2305 31.4169 30.2186 31.4493 30.2002C31.4816 30.1818 31.51 30.1571 31.5328 30.1277C31.5557 30.0983 31.5725 30.0647 31.5823 30.0288C31.5922 29.9929 31.5948 29.9554 31.5902 29.9184C31.5208 29.3685 31.4806 28.7587 31.4683 28.0633C31.467 27.9881 31.4358 27.9165 31.3817 27.8643C31.3276 27.8121 31.255 27.7835 31.1798 27.7848C31.1046 27.7861 31.0331 27.8173 30.9808 27.8714C30.9286 27.9255 30.9 27.9981 30.9014 28.0733C30.914 28.7882 30.9556 29.418 31.0276 29.9893ZM32.2028 33.6691C32.238 33.7355 32.2981 33.7853 32.37 33.8074C32.4418 33.8295 32.5195 33.8222 32.586 33.7871C32.6524 33.7519 32.7022 33.6918 32.7243 33.6199C32.7465 33.5481 32.7392 33.4704 32.704 33.4039C32.3876 32.8064 32.1554 32.271 31.9768 31.7139C31.9539 31.6423 31.9034 31.5828 31.8365 31.5484C31.7697 31.514 31.6919 31.5075 31.6203 31.5305C31.5487 31.5535 31.4892 31.6039 31.4548 31.6708C31.4204 31.7376 31.414 31.8154 31.4369 31.887C31.6265 32.4779 31.8719 33.0435 32.2028 33.6691ZM33.6326 36.0823C33.8058 36.3634 33.9778 36.6453 34.1485 36.9279C34.1874 36.9923 34.2502 37.0386 34.3233 37.0567C34.3963 37.0747 34.4734 37.063 34.5378 37.0241C34.6022 36.9853 34.6485 36.9224 34.6665 36.8494C34.6846 36.7764 34.6729 36.6992 34.634 36.6348C34.4623 36.351 34.2895 36.0678 34.1155 35.7854C34.1306 35.81 33.7462 35.1858 33.6457 35.0219C33.6264 34.9897 33.601 34.9617 33.5709 34.9394C33.5407 34.9171 33.5065 34.901 33.4701 34.892C33.4337 34.8831 33.3958 34.8814 33.3588 34.8872C33.3217 34.8929 33.2862 34.906 33.2542 34.9256C33.2222 34.9451 33.1945 34.9709 33.1725 35.0013C33.1506 35.0317 33.1349 35.0661 33.1263 35.1026C33.1177 35.1391 33.1165 35.177 33.1227 35.2139C33.1289 35.2509 33.1423 35.2863 33.1623 35.3181C33.2632 35.4825 33.6481 36.1076 33.6326 36.0825V36.0823ZM35.3062 40.3242C35.3067 40.3615 35.3145 40.3982 35.3292 40.4324C35.3439 40.4666 35.3652 40.4976 35.3918 40.5236C35.4185 40.5496 35.45 40.57 35.4846 40.5838C35.5192 40.5976 35.5561 40.6045 35.5933 40.604C35.6306 40.6035 35.6673 40.5957 35.7015 40.581C35.7357 40.5663 35.7667 40.545 35.7927 40.5184C35.8187 40.4917 35.8392 40.4602 35.853 40.4256C35.8668 40.391 35.8736 40.3541 35.8731 40.3169C35.8648 39.6518 35.7475 38.9926 35.526 38.3655C35.4998 38.2962 35.4474 38.2399 35.3802 38.2087C35.313 38.1775 35.2363 38.1738 35.1664 38.1985C35.0966 38.2232 35.0392 38.2743 35.0065 38.3407C34.9738 38.4072 34.9684 38.4839 34.9915 38.5543C35.1924 39.1231 35.2987 39.721 35.3062 40.3242ZM34.27 43.7311C34.2299 43.7937 34.216 43.8695 34.2313 43.9422C34.2466 44.015 34.2898 44.0788 34.3517 44.12C34.4135 44.1612 34.4891 44.1764 34.5621 44.1624C34.6351 44.1484 34.6997 44.1063 34.7419 44.0452C35.109 43.4961 35.3947 42.8967 35.59 42.2658C35.6121 42.1939 35.6048 42.1162 35.5696 42.0497C35.5344 41.9833 35.4743 41.9335 35.4024 41.9114C35.3305 41.8893 35.2528 41.8967 35.1864 41.9319C35.1199 41.9671 35.0702 42.0272 35.0481 42.0991C34.8689 42.6778 34.6068 43.2275 34.27 43.7311Z" fill="#FDE5D8"/>
+<path d="M60.8446 31.6803L53.1713 38.2926M49.7697 32.4009L59.3328 29.0618L49.7697 32.4009Z" stroke="#FDE5D8" stroke-linecap="round"/>
+<path d="M60.2631 30.4887C60.3987 30.4104 60.4451 30.237 60.3668 30.1014C60.2886 29.9659 60.1152 29.9194 59.9796 29.9977L54.1288 33.3756C53.9932 33.4539 53.9468 33.6273 54.0251 33.7629C54.1033 33.8984 54.2767 33.9449 54.4123 33.8666L60.2631 30.4887Z" fill="#FDE5D8"/>
+<path d="M63.2421 30.9507L61.2578 27.5138C61.1535 27.3331 60.9223 27.2711 60.7415 27.3755L59.9327 27.8425C59.752 27.9468 59.69 28.178 59.7944 28.3588L61.7786 31.7956C61.883 31.9764 62.1142 32.0383 62.2949 31.9339L63.1037 31.467C63.2845 31.3626 63.3464 31.1314 63.2421 30.9507Z" fill="white" stroke="#FDE5D8"/>
+<path d="M69.8124 20.1746L73.8754 27.2119L63.7936 33.0326L59.7306 25.9953L69.8124 20.1746Z" stroke="#FDE5D8"/>
+<path d="M68.6454 29.795L68.3599 32.8374C68.35 32.9411 68.2766 33.0767 68.1935 33.1419L64.4623 36.0762C64.2994 36.2045 64.2327 36.1536 64.3133 35.9633L65.268 33.7089L65.5559 31.5788" stroke="#FDE5D8"/>
+<path d="M64.9604 23.4123L62.1829 22.1385C62.088 22.0951 61.934 22.0911 61.836 22.1302L57.4292 23.8944C57.2367 23.9715 57.2473 24.0546 57.4525 24.08L59.8823 24.3805L61.8709 25.1962" stroke="#FDE5D8"/>
+<path d="M69.8126 20.1746L76.321 20.7344C76.6326 20.7612 76.778 21.0118 76.645 21.2955L73.8758 27.2118" stroke="#FDE5D8"/>
+<path d="M62.0503 27.3839C62.1285 27.5195 62.3012 27.5663 62.4359 27.4886C62.5705 27.4108 62.6163 27.2379 62.538 27.1023C62.4597 26.9667 62.2871 26.9198 62.1524 26.9976C62.0177 27.0754 61.972 27.2483 62.0503 27.3839Z" fill="#FC8A51"/>
+<path d="M62.6173 28.3658C62.6955 28.5014 62.8682 28.5483 63.0028 28.4705C63.1375 28.3928 63.1832 28.2198 63.105 28.0842C63.0267 27.9487 62.8541 27.9018 62.7194 27.9795C62.5847 28.0573 62.539 28.2302 62.6173 28.3658Z" fill="#FC8A51"/>
+<path d="M63.1841 29.3476C63.2624 29.4832 63.435 29.5301 63.5697 29.4523C63.7044 29.3746 63.7501 29.2016 63.6718 29.066C63.5935 28.9305 63.4209 28.8836 63.2862 28.9613C63.1516 29.0391 63.1058 29.212 63.1841 29.3476Z" fill="#FC8A51"/>
+<path d="M63.7511 30.3297C63.8294 30.4653 64.002 30.5121 64.1367 30.4344C64.2714 30.3566 64.3171 30.1837 64.2388 30.0481C64.1605 29.9125 63.9879 29.8656 63.8532 29.9434C63.7186 30.0212 63.6728 30.1941 63.7511 30.3297Z" fill="#FC8A51"/>
+<path d="M65.9899 27.0731C66.5378 28.0221 67.7464 28.3501 68.6894 27.8057C69.6324 27.2612 69.9527 26.0505 69.4048 25.1015C68.8568 24.1524 67.6482 23.8244 66.7052 24.3689C65.7622 24.9133 65.4419 26.124 65.9899 27.0731Z" stroke="#FDE5D8"/>
+<path d="M66.8032 26.6036C67.0902 27.1008 67.7233 27.2726 68.2173 26.9874C68.7113 26.7022 68.879 26.068 68.592 25.5709C68.305 25.0738 67.6719 24.902 67.1779 25.1872C66.684 25.4723 66.5162 26.1065 66.8032 26.6036Z" stroke="#FDE5D8"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M18.5279 7.40988C18.4991 7.72887 18.5432 8.13101 18.7163 8.57152C18.7364 8.61656 18.7731 8.65211 18.8188 8.6708C18.8645 8.68948 18.9156 8.68987 18.9615 8.67188C19.0075 8.65389 19.0447 8.61891 19.0656 8.57418C19.0864 8.52945 19.0892 8.47842 19.0734 8.43167C18.924 8.05108 18.8863 7.70752 18.91 7.44408C18.9119 7.42254 18.9137 7.40893 18.9145 7.40421C18.9193 7.37911 18.9189 7.35331 18.9136 7.32834C18.9082 7.30336 18.8979 7.2797 18.8833 7.25876C18.8687 7.23781 18.85 7.22 18.8284 7.20637C18.8068 7.19273 18.7827 7.18355 18.7575 7.17936C18.7323 7.17517 18.7065 7.17605 18.6817 7.18196C18.6568 7.18787 18.6334 7.19868 18.6128 7.21376C18.5922 7.22885 18.5748 7.2479 18.5616 7.26979C18.5485 7.29169 18.5398 7.31599 18.5362 7.34128C18.5326 7.36404 18.5298 7.38692 18.5279 7.40988ZM20.6986 10.3736C20.7914 10.4189 20.9061 10.3855 20.9547 10.2991C21.0033 10.213 20.9675 10.1064 20.8746 10.061C20.5219 9.88963 20.2211 9.69933 19.9686 9.49278C19.9299 9.46178 19.8811 9.44626 19.8316 9.44923C19.7822 9.4522 19.7355 9.47344 19.7008 9.50884C19.6845 9.52574 19.6719 9.5459 19.6638 9.568C19.6557 9.59009 19.6524 9.61364 19.654 9.63711C19.6556 9.66058 19.6622 9.68344 19.6732 9.70422C19.6842 9.725 19.6995 9.74323 19.718 9.75772C19.9936 9.98298 20.3195 10.1892 20.6988 10.3738L20.6986 10.3736ZM23.1801 11.5188C23.2048 11.5264 23.2308 11.5289 23.2565 11.5263C23.2822 11.5238 23.3072 11.5161 23.3299 11.5038C23.3527 11.4915 23.3727 11.4748 23.389 11.4546C23.4052 11.4345 23.4172 11.4113 23.4244 11.3865C23.4399 11.3362 23.435 11.2819 23.4107 11.2352C23.3865 11.1886 23.3448 11.1533 23.2948 11.1371C22.9212 11.0216 22.5486 10.903 22.177 10.7814C22.1524 10.7735 22.1264 10.7705 22.1007 10.7727C22.0749 10.7749 22.0498 10.7822 22.0269 10.7941C22.004 10.8061 21.9837 10.8225 21.9672 10.8425C21.9507 10.8624 21.9383 10.8854 21.9308 10.9101C21.9146 10.9602 21.9187 11.0146 21.9423 11.0616C21.966 11.1087 22.0072 11.1445 22.057 11.1614C22.3837 11.269 22.606 11.3391 23.1801 11.5188ZM25.4353 12.2611C25.5204 12.3076 25.6285 12.2796 25.6772 12.1987C25.6887 12.1795 25.6962 12.158 25.6991 12.1358C25.7021 12.1135 25.7004 12.0909 25.6943 12.0692C25.6882 12.0476 25.6777 12.0275 25.6635 12.0101C25.6493 11.9927 25.6318 11.9784 25.6118 11.968C25.3295 11.8132 25.0086 11.676 24.6165 11.5381C24.5246 11.5059 24.4228 11.5507 24.389 11.6382C24.3549 11.7259 24.4022 11.823 24.4938 11.8554C24.8688 11.9871 25.1723 12.1169 25.4353 12.2611ZM27.2083 14.2457C27.247 14.3391 27.3614 14.3859 27.4632 14.3504C27.5653 14.3149 27.6163 14.2106 27.5776 14.1172C27.4169 13.7302 27.2274 13.3999 27.0036 13.1147C26.94 13.0336 26.8166 13.0151 26.7279 13.0735C26.6393 13.1317 26.6193 13.2447 26.6829 13.3258C26.8859 13.5843 27.0594 13.887 27.2083 14.2457ZM27.7057 16.8237C27.7091 16.9282 27.813 17.0108 27.9378 17.008C28.0625 17.005 28.1607 16.9178 28.1575 16.8133C28.1437 16.3968 28.1065 16.0185 28.04 15.6568C28.0209 15.5534 27.9054 15.4824 27.782 15.4984C27.6588 15.5143 27.5742 15.6113 27.5932 15.7146C27.6567 16.0595 27.6925 16.4224 27.7057 16.8237Z" fill="#EEEEEE"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M27.6172 18.4687C27.6047 18.7996 27.5957 19.1305 27.5903 19.4616C27.589 19.5661 27.6652 19.6521 27.7608 19.6536C27.8564 19.6551 27.9352 19.5718 27.9365 19.4675C27.9403 19.1902 27.9471 18.9608 27.9634 18.4827L27.9683 18.3378C27.9717 18.2334 27.897 18.1458 27.8016 18.142C27.706 18.1382 27.6257 18.2195 27.6221 18.324L27.6172 18.4687ZM27.7678 22.1483C27.7905 22.2513 27.895 22.3168 28.0014 22.2951C28.1076 22.2732 28.1754 22.1721 28.1529 22.0691C28.0741 21.71 28.0187 21.3434 27.9834 20.9609C27.9736 20.8562 27.878 20.7788 27.7697 20.7882C27.6614 20.7977 27.5815 20.8903 27.5911 20.995C27.6279 21.3922 27.6856 21.7737 27.7678 22.1483ZM28.7232 24.4772C28.7792 24.563 28.9033 24.5925 29.0005 24.543C29.0976 24.4937 29.1309 24.3841 29.0751 24.2983C28.8775 23.9966 28.7013 23.6814 28.5479 23.3551C28.505 23.2634 28.3863 23.22 28.2827 23.2578C28.1792 23.2956 28.1297 23.4004 28.1728 23.4919C28.333 23.8328 28.5169 24.1621 28.7232 24.4772ZM29.633 25.9325C29.8392 26.1713 30.063 26.3944 30.3025 26.5998C30.3398 26.6315 30.3877 26.6478 30.4366 26.6455C30.4855 26.6432 30.5317 26.6224 30.5658 26.5873C30.5821 26.5704 30.5948 26.5503 30.603 26.5282C30.6113 26.5062 30.6149 26.4827 30.6136 26.4592C30.6124 26.4357 30.6063 26.4127 30.5958 26.3916C30.5853 26.3706 30.5706 26.3519 30.5525 26.3368C30.3268 26.1431 30.1158 25.9329 29.9214 25.7078C29.8848 25.6652 29.8485 25.6222 29.8125 25.579C29.7807 25.5418 29.7359 25.5182 29.6872 25.5129C29.6386 25.5075 29.5897 25.5209 29.5506 25.5502C29.5315 25.5641 29.5156 25.5818 29.5036 25.6021C29.4917 25.6224 29.4841 25.645 29.4812 25.6684C29.4784 25.6918 29.4804 25.7155 29.4871 25.7381C29.4939 25.7607 29.5052 25.7816 29.5204 25.7997C29.5574 25.8439 29.5948 25.8883 29.633 25.9325ZM32.6235 28.1392C32.7142 28.1849 32.8223 28.1424 32.8647 28.0445C32.907 27.9463 32.8677 27.8299 32.777 27.7841C32.4527 27.6215 32.1381 27.4403 31.8347 27.2414C31.7491 27.1851 31.6375 27.2142 31.5853 27.3066C31.5331 27.3988 31.5602 27.5194 31.6458 27.5757C31.9605 27.7823 32.287 27.9704 32.6235 28.1392Z" fill="#E5E5E5"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M35.2883 29.0967C35.3928 29.1234 35.4977 29.054 35.5225 28.9418C35.5472 28.8293 35.4828 28.7165 35.3783 28.6898C35.0044 28.5945 34.6348 28.4834 34.2703 28.3567C34.1683 28.3212 34.0587 28.3812 34.0254 28.4912C33.9923 28.601 34.0483 28.7189 34.1505 28.7545C34.5248 28.8846 34.9044 28.9987 35.2883 29.0965V29.0967ZM37.7828 29.48C37.8875 29.4875 37.9775 29.3959 37.9841 29.2753C37.9907 29.1545 37.9111 29.0508 37.8066 29.0432C37.4321 29.0161 37.0589 28.9735 36.6879 28.9155C36.584 28.8992 36.4885 28.9831 36.4744 29.1028C36.4604 29.2226 36.5329 29.3327 36.6369 29.349C37.0169 29.4085 37.3993 29.4522 37.783 29.48H37.7828ZM40.464 29.2169C40.568 29.2065 40.6417 29.1305 40.6286 29.0468C40.6158 28.9635 40.5211 28.9041 40.4174 28.9145C40.0551 28.9508 39.6817 28.975 39.3005 28.9867C39.1958 28.9899 39.1144 29.0608 39.1185 29.1449C39.1225 29.229 39.2105 29.2946 39.3151 29.2912C39.6988 29.2795 40.082 29.2548 40.464 29.2169ZM43.156 28.7148C43.2562 28.6732 43.3026 28.5606 43.2599 28.4631C43.2385 28.4159 43.1995 28.379 43.1513 28.3601C43.1031 28.3412 43.0494 28.3419 43.0016 28.362C42.6464 28.5079 42.2817 28.6294 41.9099 28.7254C41.8852 28.7315 41.862 28.7424 41.8416 28.7575C41.8212 28.7727 41.804 28.7918 41.7911 28.8136C41.7781 28.8355 41.7696 28.8597 41.7662 28.8849C41.7627 28.9101 41.7643 28.9357 41.7708 28.9603C41.7991 29.0627 41.9071 29.1234 42.0121 29.0958C42.4018 28.9951 42.784 28.8678 43.1562 28.7148H43.156ZM45.326 26.9135C45.3812 26.8313 45.354 26.7234 45.2656 26.6721C45.1767 26.6211 45.0601 26.6461 45.0049 26.7283C44.8241 26.9974 44.5845 27.2449 44.2899 27.4691C44.2709 27.4827 44.255 27.5003 44.2433 27.5205C44.2316 27.5407 44.2242 27.5632 44.2218 27.5864C44.2193 27.6097 44.2218 27.6332 44.229 27.6554C44.2362 27.6777 44.2481 27.6981 44.2638 27.7155C44.3299 27.7903 44.449 27.8011 44.5299 27.7397C44.8555 27.4921 45.1225 27.216 45.326 26.9135ZM46.8046 25.0747C46.8407 25.0429 46.8628 24.998 46.866 24.9499C46.8692 24.9018 46.8532 24.8544 46.8216 24.8181C46.8059 24.8001 46.7869 24.7854 46.7655 24.7748C46.7441 24.7642 46.7209 24.758 46.6971 24.7564C46.6733 24.7548 46.6494 24.7579 46.6268 24.7656C46.6043 24.7733 46.5834 24.7853 46.5655 24.8011C46.2859 25.0458 46.0227 25.3086 45.7775 25.5878C45.7458 25.6241 45.7297 25.6716 45.7329 25.7197C45.736 25.7678 45.7581 25.8127 45.7943 25.8446C45.8122 25.8604 45.833 25.8724 45.8556 25.8801C45.8781 25.8878 45.902 25.891 45.9257 25.8894C45.9495 25.8878 45.9728 25.8816 45.9941 25.871C46.0155 25.8604 46.0345 25.8458 46.0502 25.8278C46.2849 25.5606 46.5368 25.309 46.8044 25.0747H46.8046ZM49.036 23.5975C49.126 23.5489 49.1602 23.4354 49.1125 23.3439C49.1015 23.3222 49.0862 23.303 49.0676 23.2873C49.049 23.2716 49.0275 23.2598 49.0043 23.2526C48.9811 23.2453 48.9566 23.2428 48.9324 23.2451C48.9082 23.2474 48.8847 23.2545 48.8633 23.266C48.5322 23.4443 48.2091 23.6372 47.8952 23.8441C47.8099 23.9004 47.7856 24.0167 47.8409 24.1036C47.8965 24.1905 48.0106 24.2151 48.096 24.1588C48.4009 23.9579 48.7145 23.7706 49.036 23.5975ZM51.454 22.4735C51.5579 22.4415 51.6148 22.3367 51.581 22.239C51.5471 22.1411 51.4354 22.0878 51.3315 22.1195C50.9508 22.236 50.5742 22.3654 50.2024 22.5077C50.1011 22.5464 50.0523 22.6551 50.0935 22.7501C50.1347 22.8454 50.2504 22.8911 50.3517 22.8524C50.7147 22.7135 51.0823 22.5872 51.454 22.4735Z" fill="#EEEEEE"/>
+<path d="M27.7796 18.3307C28.3014 18.3307 28.7244 17.9076 28.7244 17.3858C28.7244 16.864 28.3014 16.4409 27.7796 16.4409C27.2577 16.4409 26.8347 16.864 26.8347 17.3858C26.8347 17.9076 27.2577 18.3307 27.7796 18.3307Z" fill="white" stroke="#EEEEEE"/>
+<path d="M45.3543 27.4016C45.8761 27.4016 46.2992 26.9786 46.2992 26.4567C46.2992 25.9349 45.8761 25.5118 45.3543 25.5118C44.8325 25.5118 44.4094 25.9349 44.4094 26.4567C44.4094 26.9786 44.8325 27.4016 45.3543 27.4016Z" fill="white" stroke="#EEEEEE"/>
+<path d="M4.16876 10.9607C4.16876 10.9607 0.867338 17.1969 1.90104 20.9764C2.93474 24.756 5.11364 27.0237 9.08214 29.2914C13.0506 31.5591 15.2125 28.3465 20.5984 30.4253C25.9842 32.504 26.0787 38.5513 26.0787 38.5513" stroke="#B5A7DD" stroke-width="0.4" stroke-linecap="round" stroke-dasharray="8 10"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.8898 47.6221C49.8898 39.5857 43.375 33.0709 35.3386 33.0709C27.3022 33.0709 20.7874 39.5857 20.7874 47.6221" fill="white"/>
+<path d="M49.8898 47.6221C49.8898 39.5857 43.375 33.0709 35.3386 33.0709C27.3022 33.0709 20.7874 39.5857 20.7874 47.6221" stroke="#EEEEEE" stroke-linecap="round"/>
+<path d="M41.1969 43.8425C42.8668 43.8425 44.2205 42.4888 44.2205 40.8189C44.2205 39.149 42.8668 37.7953 41.1969 37.7953C39.527 37.7953 38.1732 39.149 38.1732 40.8189C38.1732 42.4888 39.527 43.8425 41.1969 43.8425Z" stroke="#EEEEEE"/>
+<path d="M28.8189 40.441C29.7061 40.441 30.4252 39.7218 30.4252 38.8347C30.4252 37.9476 29.7061 37.2284 28.8189 37.2284C27.9318 37.2284 27.2126 37.9476 27.2126 38.8347C27.2126 39.7218 27.9318 40.441 28.8189 40.441Z" stroke="#EEEEEE"/>
+<path d="M24.9449 44.9764C25.4667 44.9764 25.8898 44.5534 25.8898 44.0316C25.8898 43.5097 25.4667 43.0867 24.9449 43.0867C24.423 43.0867 24 43.5097 24 44.0316C24 44.5534 24.423 44.9764 24.9449 44.9764Z" stroke="#EEEEEE"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M45.703 3.43286L46.1389 3.54966C46.346 2.7768 47.1318 2.3159 47.8939 2.5201L48.0123 2.078C47.0096 1.80933 45.9757 2.416 45.7032 3.43291L45.703 3.43286Z" fill="#FC8A51"/>
+<path d="M47.9471 2.61347C48.1478 2.66723 48.3546 2.54588 48.4091 2.34244C48.4636 2.139 48.3452 1.93051 48.1445 1.87675C47.9439 1.823 47.7371 1.94434 47.6826 2.14778C47.6281 2.35122 47.7465 2.55972 47.9471 2.61347Z" fill="#FC8A51"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M45.412 3.35511L44.9761 3.23831C45.1832 2.46545 44.7332 1.67342 43.9711 1.46921L44.0896 1.02711C45.0922 1.29577 45.6843 2.33814 45.4119 3.35506L45.412 3.35511Z" fill="#FC8A51"/>
+<path d="M43.8787 1.52354C43.6781 1.46979 43.5597 1.26129 43.6142 1.05785C43.6687 0.854411 43.8755 0.733068 44.0761 0.786823C44.2768 0.840578 44.3952 1.04908 44.3407 1.25252C44.2862 1.45596 44.0794 1.5773 43.8787 1.52354Z" fill="#FC8A51"/>
+<path d="M47.8422 7.79545L46.7544 7.50399C46.6536 7.47698 46.55 7.5368 46.523 7.63762C46.496 7.73843 46.5558 7.84205 46.6566 7.86907L47.7444 8.16052C47.8452 8.18754 47.9488 8.12771 47.9758 8.0269C48.0028 7.92608 47.943 7.82246 47.8422 7.79545Z" fill="#FC8A51"/>
+<path d="M47.4759 9.27595L46.5007 8.7129C46.4103 8.66071 46.2947 8.69168 46.2425 8.78207C46.1904 8.87245 46.2213 8.98803 46.3117 9.04021L47.287 9.60327C47.3773 9.65545 47.4929 9.62448 47.5451 9.5341C47.5973 9.44371 47.5663 9.32813 47.4759 9.27595Z" fill="#FC8A51"/>
+<path d="M48.1795 6.35876L47.0534 6.35876C46.949 6.35876 46.8644 6.44337 46.8644 6.54774C46.8644 6.65211 46.949 6.73671 47.0534 6.73671L48.1795 6.73671C48.2839 6.73671 48.3685 6.6521 48.3685 6.54774C48.3685 6.44337 48.2839 6.35876 48.1795 6.35876Z" fill="#FC8A51"/>
+<path d="M41.3783 6.06356L42.4661 6.35502C42.5669 6.38203 42.6267 6.48565 42.5997 6.58647C42.5727 6.68728 42.469 6.74711 42.3682 6.72009L41.2805 6.42863C41.1797 6.40162 41.1198 6.298 41.1469 6.19719C41.1739 6.09637 41.2775 6.03655 41.3783 6.06356Z" fill="#FC8A51"/>
+<path d="M40.9549 7.52856L42.081 7.52856C42.1854 7.52856 42.27 7.61317 42.27 7.71754C42.27 7.82191 42.1854 7.90651 42.081 7.90651L40.9549 7.90651C40.8506 7.90651 40.7659 7.8219 40.7659 7.71754C40.7659 7.61317 40.8505 7.52856 40.9549 7.52856Z" fill="#FC8A51"/>
+<path d="M41.8041 4.65032L42.7794 5.21337C42.8698 5.26556 42.9007 5.38113 42.8485 5.47152C42.7964 5.5619 42.6808 5.59287 42.5904 5.54069L41.6151 4.97763C41.5248 4.92545 41.4938 4.80987 41.546 4.71949C41.5982 4.6291 41.7137 4.59813 41.8041 4.65032Z" fill="#FC8A51"/>
+<path d="M47.3515 6.28652C47.7063 4.96254 46.9206 3.60168 45.5967 3.24693C44.2728 2.89219 42.912 3.6779 42.5572 5.00187L42.1051 6.68907C41.7504 8.01304 42.536 9.3739 43.86 9.72865C45.1839 10.0834 46.5447 9.29768 46.8995 7.97371L47.3515 6.28652Z" stroke="#FC8A51"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M42.7135 5.00037L47.0721 6.16826L46.9254 6.71587L42.5668 5.54798L42.7135 5.00037Z" fill="#FC8A51"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M17.4662 2.50359L17.1869 2.57842C17.1557 2.46241 17.1019 2.35369 17.0286 2.25847C16.9554 2.16325 16.8641 2.0834 16.7599 2.02348C16.6558 1.96355 16.5409 1.92474 16.4217 1.90924C16.3026 1.89374 16.1816 1.90187 16.0656 1.93316L15.9909 1.6546C16.634 1.48229 17.2944 1.86252 17.4662 2.50359Z" fill="#EEEEEE"/>
+<path d="M16.0312 1.99224C15.9025 2.02671 15.7704 1.95069 15.736 1.82246C15.7017 1.69423 15.7781 1.56233 15.9067 1.52786C16.0354 1.4934 16.1675 1.56941 16.2019 1.69764C16.2362 1.82587 16.1598 1.95777 16.0312 1.99224Z" fill="#EEEEEE"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M17.6526 2.45366L17.9318 2.37883C17.9009 2.26274 17.8931 2.14169 17.909 2.02259C17.9248 1.9035 17.964 1.78869 18.0242 1.68473C18.0844 1.58076 18.1645 1.48969 18.2599 1.4167C18.3554 1.3437 18.4642 1.29023 18.5804 1.25932L18.5057 0.980773C17.8626 1.15308 17.4808 1.81259 17.6526 2.45366Z" fill="#EEEEEE"/>
+<path d="M18.6393 1.29344C18.7679 1.25898 18.8444 1.12708 18.81 0.998847C18.7756 0.870613 18.6435 0.794601 18.5149 0.82907C18.3862 0.863538 18.3098 0.995433 18.3442 1.12367C18.3785 1.2519 18.5107 1.32791 18.6393 1.29344Z" fill="#EEEEEE"/>
+<path d="M16.1505 5.24482L16.7167 5.0931C16.8175 5.06609 16.9211 5.12591 16.9481 5.22673C16.9752 5.32754 16.9153 5.43116 16.8145 5.45817L16.2483 5.60989C16.1475 5.63691 16.0439 5.57708 16.0168 5.47627C15.9898 5.37546 16.0497 5.27183 16.1505 5.24482Z" fill="#EEEEEE"/>
+<path d="M16.3578 6.1703L16.8655 5.8772C16.9559 5.82502 17.0714 5.85599 17.1236 5.94637C17.1758 6.03676 17.1448 6.15233 17.0545 6.20452L16.5468 6.49762C16.4564 6.5498 16.3408 6.51883 16.2886 6.42845C16.2365 6.33806 16.2674 6.22249 16.3578 6.1703Z" fill="#EEEEEE"/>
+<path d="M15.9573 4.35278L16.5435 4.35278C16.6479 4.35278 16.7325 4.43739 16.7325 4.54176C16.7325 4.64613 16.6479 4.73074 16.5435 4.73074L15.9573 4.73074C15.853 4.73074 15.7684 4.64613 15.7684 4.54176C15.7684 4.43739 15.853 4.35278 15.9573 4.35278Z" fill="#EEEEEE"/>
+<path d="M20.1626 4.16985L19.5964 4.32157C19.4956 4.34859 19.4357 4.45221 19.4628 4.55302C19.4898 4.65383 19.5934 4.71366 19.6942 4.68665L20.2604 4.53493C20.3612 4.50791 20.4211 4.40429 20.3941 4.30348C20.367 4.20267 20.2634 4.14284 20.1626 4.16985Z" fill="#EEEEEE"/>
+<path d="M20.4451 5.07507L19.8589 5.07507C19.7545 5.07507 19.6699 5.15968 19.6699 5.26405C19.6699 5.36842 19.7545 5.45303 19.8589 5.45303L20.4451 5.45303C20.5494 5.45303 20.634 5.36842 20.634 5.26405C20.634 5.15968 20.5494 5.07507 20.4451 5.07507Z" fill="#EEEEEE"/>
+<path d="M19.8834 3.30076L19.3757 3.59387C19.2853 3.64605 19.2543 3.76163 19.3065 3.85201C19.3587 3.9424 19.4743 3.97337 19.5647 3.92118L20.0723 3.62808C20.1627 3.5759 20.1937 3.46032 20.1415 3.36993C20.0893 3.27955 19.9737 3.24858 19.8834 3.30076Z" fill="#EEEEEE"/>
+<path d="M17.5341 2.38566L17.5343 2.38561C18.3829 2.15821 19.2552 2.66184 19.4826 3.51048L19.7541 4.52356C19.9815 5.3722 19.4778 6.24449 18.6292 6.47189L18.629 6.47193C17.7804 6.69933 16.9081 6.19571 16.6807 5.34707L16.4092 4.33398C16.1819 3.48534 16.6855 2.61305 17.5341 2.38566Z" stroke="#EEEEEE"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M19.3759 3.48474L16.582 4.23337L16.6798 4.59844L19.4737 3.84982L19.3759 3.48474Z" fill="#EEEEEE"/>
+<path d="M1.98426 23.0551C2.87139 23.0551 3.59056 22.336 3.59056 21.4488C3.59056 20.5617 2.87139 19.8425 1.98426 19.8425C1.09712 19.8425 0.37796 20.5617 0.37796 21.4488C0.37796 22.336 1.09712 23.0551 1.98426 23.0551Z" fill="white" stroke="#B5A7DD" stroke-width="0.4"/>
+<path d="M32.7874 24.9449C33.4658 24.9449 34.0158 24.3949 34.0158 23.7165C34.0158 23.0381 33.4658 22.4882 32.7874 22.4882C32.109 22.4882 31.5591 23.0381 31.5591 23.7165C31.5591 24.3949 32.109 24.9449 32.7874 24.9449Z" fill="white"/>
+<path d="M10.1102 31.9371C11.4148 31.9371 12.4724 30.8795 12.4724 29.5749C12.4724 28.2702 11.4148 27.2126 10.1102 27.2126C8.80561 27.2126 7.74802 28.2702 7.74802 29.5749C7.74802 30.8795 8.80561 31.9371 10.1102 31.9371Z" fill="white" stroke="#6B4FBB"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.77635 29.759L9.44262 29.4251C9.38944 29.3719 9.31732 29.3421 9.24211 29.3421C9.16691 29.3421 9.09479 29.3719 9.04161 29.4251C8.98843 29.4783 8.95856 29.5504 8.95856 29.6256C8.95856 29.7008 8.98843 29.773 9.04161 29.8261L9.57566 30.3602H9.57585V30.3604C9.68734 30.4719 9.86592 30.4713 9.97629 30.3609L11.1801 29.1572C11.2328 29.1038 11.2623 29.0319 11.2622 28.9569C11.2621 28.8819 11.2324 28.8101 11.1795 28.7569C11.1533 28.7305 11.1222 28.7095 11.0879 28.6952C11.0535 28.6808 11.0167 28.6734 10.9795 28.6733C10.9423 28.6733 10.9055 28.6805 10.8711 28.6948C10.8368 28.709 10.8055 28.7298 10.7792 28.7561L9.77635 29.759Z" fill="#FC8A51"/>
+<path d="M32.7874 24.7559C33.4658 24.7559 34.0158 24.2059 34.0158 23.5275C34.0158 22.8491 33.4658 22.2992 32.7874 22.2992C32.109 22.2992 31.5591 22.8491 31.5591 23.5275C31.5591 24.2059 32.109 24.7559 32.7874 24.7559Z" fill="white" stroke="#FC8A51"/>
+<path d="M4.53541 11.3386C5.16162 11.3386 5.66926 10.831 5.66926 10.2048C5.66926 9.57857 5.16162 9.07092 4.53541 9.07092C3.9092 9.07092 3.40155 9.57857 3.40155 10.2048C3.40155 10.831 3.9092 11.3386 4.53541 11.3386Z" fill="white" stroke="#B5A7DD" stroke-width="0.4"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M35.9339 27.1363C37.5787 25.6725 38.0151 23.182 36.8527 21.1688C35.5261 18.8708 32.6187 18.0654 30.3591 19.3701C28.0993 20.6748 27.343 23.5953 28.6698 25.8932C29.8483 27.9342 32.2732 28.7978 34.3841 28.0513L36.4437 31.619C36.563 31.8259 36.7596 31.9769 36.9903 32.0389C37.2209 32.1008 37.4667 32.0686 37.6736 31.9493C37.8802 31.8297 38.0309 31.6329 38.0926 31.4023C38.1542 31.1716 38.1218 30.9259 38.0024 30.7191L35.9339 27.1363ZM34.34 26.2653C35.8248 25.4081 36.3218 23.4889 35.4499 21.9788C34.5782 20.4686 32.6676 19.9395 31.1828 20.7967C29.6978 21.6541 29.2008 23.5733 30.0726 25.0834C30.9445 26.5934 32.8551 27.1227 34.34 26.2653Z" fill="white" stroke="#B5A7DD" stroke-width="0.5"/>
+</g>
+<defs>
+<clipPath id="clip0">
+<rect width="80.315" height="48" fill="white"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/app/assets/images/learn_gitlab/section_deploy.svg b/app/assets/images/learn_gitlab/section_deploy.svg
new file mode 100644
index 00000000000..187956a66db
--- /dev/null
+++ b/app/assets/images/learn_gitlab/section_deploy.svg
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="35px" height="34px" viewBox="0 0 35 34" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 64 (93537) - https://sketch.com -->
+ <title>Group</title>
+ <desc>Created with Sketch.</desc>
+ <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+ <g id="Artboard" transform="translate(-463.000000, -393.000000)">
+ <g id="Group" transform="translate(463.000000, 393.000000)">
+ <path d="M19.3557,25.2338 C17.7369,25.9233 16.1619,26.1062 14.5861,25.6013 C13.996,26.033 13.2711,26.2395 12.5422,26.1834 C11.8132,26.1273 11.1284,25.8124 10.6114,25.2955 L8.75506,23.4392 C8.19927,22.8833 7.87849,22.1351 7.8591,21.3493 C7.83972,20.5635 8.12323,19.8003 8.65094,19.2177 C8.34425,17.8947 8.46763,16.5564 8.93356,15.1901 L6.183,14.1287 C5.81926,13.9884 5.49427,13.7633 5.23503,13.472 C4.9758,13.1808 4.7898,12.832 4.69251,12.4544 C4.59521,12.0769 4.58942,11.6816 4.67562,11.3013 C4.76182,10.9211 4.93753,10.5669 5.18813,10.2682 L8.79006,5.9759 C9.11841,5.5846 9.56087,5.3057 10.0555,5.1782 C10.5502,5.0508 11.0724,5.0811 11.5489,5.265 L15.0686,6.6234 C15.4064,6.2747 15.7529,5.9234 16.1073,5.5691 C19.6177,2.05856 24.1782,0.20312 29.7892,0.00275 C29.9524,-0.00304 30.1159,0.00032 30.2787,0.01281 C32.6881,0.19656 34.4919,2.29918 34.3077,4.7085 C33.8986,10.0569 31.957,14.4687 28.4824,17.9429 C28.2408,18.1846 27.9986,18.4257 27.7557,18.6661 L29.1763,22.3463 C29.3602,22.8229 29.3905,23.345 29.263,23.8397 C29.1355,24.3344 28.8566,24.7768 28.4653,25.1052 L24.1734,28.7071 C23.8748,28.9578 23.5206,29.1336 23.1403,29.2198 C22.76,29.3061 22.3646,29.3003 21.987,29.203 C21.6094,29.1057 21.2605,28.9197 20.9692,28.6604 C20.678,28.4011 20.4528,28.0761 20.3125,27.7122 L19.3557,25.2338 Z M9.32819,13.455 L13.8808,8.0291 L10.8962,6.8772 C10.7601,6.8247 10.611,6.816 10.4697,6.8524 C10.3284,6.8888 10.2021,6.9685 10.1082,7.0802 L6.50631,11.3725 C6.43473,11.4578 6.38455,11.559 6.35993,11.6677 C6.33532,11.7763 6.33699,11.8893 6.3648,11.9971 C6.39261,12.105 6.44576,12.2046 6.51983,12.2878 C6.59391,12.371 6.68676,12.4353 6.79069,12.4754 L9.32819,13.455 Z M20.9858,24.5675 L21.9654,27.105 C22.0055,27.2089 22.0699,27.3017 22.1531,27.3757 C22.2363,27.4498 22.336,27.5029 22.4438,27.5306 C22.5517,27.5584 22.6646,27.56 22.7732,27.5354 C22.8818,27.5107 22.983,27.4605 23.0683,27.3889 L27.3602,23.7874 C27.4721,23.6936 27.5518,23.5672 27.5883,23.4259 C27.6248,23.2845 27.6162,23.1353 27.5636,22.9991 L26.4117,20.0144 L20.9858,24.5675 Z M12.3714,22.1057 C16.2,25.9347 19.3999,24.55 27.2442,16.7056 C30.4161,13.5337 32.1845,9.5162 32.5621,4.5751 C32.5903,4.2067 32.5404,3.83648 32.4156,3.4887 C32.2909,3.14091 32.0942,2.82338 31.8383,2.55685 C31.5825,2.29033 31.2732,2.08082 30.9308,1.94203 C30.5884,1.80323 30.2205,1.73829 29.8513,1.75143 C24.6687,1.9365 20.5312,3.62 17.3449,6.8063 C9.89431,14.2569 8.41381,18.1481 12.3714,22.1057 Z" id="Shape" fill="#6E49CB"></path>
+ <path d="M22.9861,11.6834 C23.148,11.8489 23.341,11.9806 23.5541,12.071 C23.7672,12.1613 23.9962,12.2085 24.2276,12.2098236 C24.4591,12.211 24.6885,12.1664 24.9026,12.0784 C25.1166,11.9904 25.3111,11.8608 25.4748,11.6971 C25.6384,11.5334 25.768,11.3389 25.856,11.1248 C25.9439,10.9107 25.9885,10.6813 25.9872276,10.4499 C25.9859,10.2184 25.9387,9.9895 25.8483,9.7764 C25.7579,9.5633 25.6262,9.3703 25.4606,9.2085 C25.1325,8.8803 24.6873,8.6959 24.2232,8.6959 C23.759,8.6959 23.3139,8.8803 22.9857,9.2085 C22.6575,9.5367 22.4731,9.9818 22.4731,10.446 C22.4731,10.9101 22.6575,11.3552 22.9857,11.6834 L22.9861,11.6834 Z M21.748,12.9211 C21.423,12.5961 21.1651,12.2102 20.9892,11.7855 C20.8133,11.3608 20.7228,10.9056 20.7228,10.446 C20.7228,9.9863 20.8133,9.5311 20.9892,9.1064 C21.1651,8.6817 21.423,8.2958 21.748,7.9708 C22.0731,7.6458 22.4589,7.3879 22.8836,7.212 C23.3083,7.0361 23.7635,6.9456 24.2232,6.9456 C24.6829,6.9456 25.138,7.0361 25.5627,7.212 C25.9874,7.3879 26.3733,7.6458 26.6983,7.9708 C27.3482,8.6285 27.7113,9.5166 27.708615,10.4412 C27.7058,11.3658 27.3373,12.2517 26.6836,12.9055 C26.0298,13.5593 25.1439,13.9278 24.2194,13.9307161 C23.2948,13.9335 22.4067,13.5704 21.7489,12.9207 L21.748,12.9211 Z" id="Shape" fill="#C2B7E6" fill-rule="nonzero"></path>
+ <path d="M6.58996,23.1303 C6.754,23.2943 6.84615,23.5169 6.84615,23.7489 C6.84615,23.9809 6.754,24.2034 6.58996,24.3675 L1.64009,29.3165 C1.5594,29.4001 1.46287,29.4668 1.35614,29.5127 C1.2494,29.5586 1.13459,29.5828 1.01841,29.5838391 C0.902228,29.5849 0.787001,29.5628 0.679451,29.5188 C0.571902,29.4749 0.474183,29.4099 0.391998,29.3278 C0.309813,29.2457 0.244808,29.148 0.200774,29.0405 C0.15674,28.933 0.13456,28.8177 0.135497628,28.7016 C0.136497,28.5854 0.160594,28.4706 0.206414,28.3638 C0.252233,28.257 0.318858,28.1604 0.4024,28.0797 L5.35228,23.1298 C5.43353,23.0485 5.53001,22.984 5.63619,22.9401 C5.74237,22.8961 5.85618,22.8734 5.97112,22.8734 C6.08606,22.8734 6.19987,22.8961 6.30605,22.9401 C6.41223,22.984 6.50871,23.0485 6.58996,23.1298 L6.58996,23.1303 Z M10.9208,27.4611 C11.0848,27.6252 11.177,27.8477 11.177,28.0797 C11.177,28.3117 11.0848,28.5342 10.9208,28.6983 L7.20859,32.4105 C7.12735,32.4918 7.0309,32.5562 6.92474,32.6002 C6.81859,32.6442 6.70481,32.6669 6.5899,32.6669 C6.47499,32.6669 6.3612,32.6443 6.25503,32.6004 C6.14886,32.5564 6.05239,32.492 5.97112,32.4107 C5.88985,32.3295 5.82538,32.233 5.78139,32.1269 C5.7374,32.0207 5.71471999,31.9069 5.71471999,31.792 C5.71471999,31.6771 5.73731,31.5633 5.78127,31.4572 C5.82522,31.351 5.88966,31.2545 5.9709,31.1733 L9.68353,27.4611 C9.84761,27.297 10.0701,27.2049 10.3022,27.2049 C10.5342,27.2049 10.7567,27.297 10.9208,27.4611 L10.9208,27.4611 Z" id="Shape" fill="#E0DBF2"></path>
+ <path d="M8.75534,25.2954 C8.91937,25.4595 9.01152,25.682 9.01152,25.914 C9.01152,26.1461 8.91937,26.3686 8.75534,26.5327 L1.94959,33.3389 C1.78546,33.503 1.56286,33.5952 1.33074,33.5952 C1.09863,33.5952 0.876027,33.503 0.7119,33.3389 C0.547772,33.1747 0.455566,32.9521 0.455566,32.72 C0.455566,32.4879 0.547772,32.2653 0.7119,32.1012 L7.51809,25.2959 C7.68217,25.1318 7.90469,25.0397 8.13671,25.0397 C8.36873,25.0397 8.59125,25.1318 8.75534,25.2959 L8.75534,25.2954 Z" id="Path" fill="#C2B7E6"></path>
+ </g>
+ </g>
+ </g>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/learn_gitlab/section_plan.svg b/app/assets/images/learn_gitlab/section_plan.svg
new file mode 100644
index 00000000000..2348e837e5f
--- /dev/null
+++ b/app/assets/images/learn_gitlab/section_plan.svg
@@ -0,0 +1 @@
+<svg width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.343 6c.82 0 1.516.145 1.968.34.198.085.315.165.375.218v31.441a1.45 1.45 0 01-.375.218c-.452.195-1.148.34-1.968.34-.82 0-1.516-.145-1.968-.34A1.45 1.45 0 011 37.999V6.558c.06-.053.177-.133.375-.218C1.827 6.145 2.523 6 3.343 6z" fill="#EFEDF8" stroke="#6E49CB" stroke-width="2"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.686 6.41l21.724 6.692c2.085.642 2.122 1.774.095 2.523L6.686 23.69V6.412z" fill="#6E49CB"/></svg> \ No newline at end of file
diff --git a/app/assets/images/learn_gitlab/section_workspace.svg b/app/assets/images/learn_gitlab/section_workspace.svg
new file mode 100644
index 00000000000..5cb7fd36ddd
--- /dev/null
+++ b/app/assets/images/learn_gitlab/section_workspace.svg
@@ -0,0 +1 @@
+<svg width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M14.544 6.44C14.816 5.58 15.581 5 16.442 5h1.116c.861 0 1.626.58 1.898 1.44l1.05 3.32c.744.238 1.456.55 2.13.93l2.974-1.566c.77-.405 1.7-.247 2.309.394l.79.832c.608.64.76 1.62.374 2.43l-1.486 3.131c.359.71.656 1.46.882 2.243l3.153 1.106c.817.287 1.368 1.091 1.368 1.998v1.176c0 .906-.55 1.71-1.368 1.998l-3.153 1.106a12.936 12.936 0 01-.882 2.242l1.486 3.131c.385.81.234 1.79-.374 2.43l-.79.832c-.609.641-1.539.8-2.309.395l-2.973-1.566c-.675.379-1.387.692-2.13.93l-1.051 3.32c-.272.86-1.037 1.44-1.898 1.44h-1.116c-.861 0-1.626-.58-1.898-1.44l-1.05-3.32a11.604 11.604 0 01-2.13-.93L8.39 34.569c-.77.406-1.7.247-2.309-.395l-.79-.831a2.189 2.189 0 01-.374-2.43l1.487-3.131c-.36-.71-.657-1.46-.883-2.243l-3.153-1.106C1.55 24.145 1 23.34 1 22.434v-1.176c0-.906.55-1.711 1.368-1.998l3.153-1.106c.226-.783.523-1.533.883-2.243l-1.487-3.13a2.19 2.19 0 01.374-2.431l.79-.832c.609-.64 1.539-.8 2.309-.394l2.973 1.565a11.599 11.599 0 012.13-.93l1.051-3.32zM17 30.269c4.418 0 8-3.771 8-8.423s-3.582-8.423-8-8.423-8 3.771-8 8.423 3.582 8.423 8 8.423z" fill="#EFEDF8" stroke="#6E49CB" stroke-width="2"/><path d="M17 27.11c2.762 0 5-2.357 5-5.264 0-2.908-2.238-5.265-5-5.265-2.76 0-5 2.357-5 5.265 0 2.907 2.24 5.264 5 5.264z" stroke="#6E49CB" stroke-linecap="round"/></svg> \ No newline at end of file
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index 43d56295f78..7f5f0403de6 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -1,20 +1,10 @@
import Vue from 'vue';
import createFlash from '~/flash';
+import { parseRailsFormFields } from '~/lib/utils/forms';
import { __ } from '~/locale';
import ExpiresAtField from './components/expires_at_field.vue';
-const getInputAttrs = (el) => {
- const input = el.querySelector('input');
-
- return {
- id: input.id,
- name: input.name,
- value: input.value,
- placeholder: input.placeholder,
- };
-};
-
export const initExpiresAtField = () => {
const el = document.querySelector('.js-access-tokens-expires-at');
@@ -22,7 +12,7 @@ export const initExpiresAtField = () => {
return null;
}
- const inputAttrs = getInputAttrs(el);
+ const { expiresAt: inputAttrs } = parseRailsFormFields(el);
return new Vue({
el,
@@ -43,7 +33,7 @@ export const initProjectsField = () => {
return null;
}
- const inputAttrs = getInputAttrs(el);
+ const { projects: inputAttrs } = parseRailsFormFields(el);
if (window.gon.features.personalAccessTokensScopedToProjects) {
return new Promise((resolve) => {
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index 5064d9ee2d2..b671d038ce8 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -2,14 +2,20 @@
import $ from 'jquery';
import Cookies from 'js-cookie';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
import { localTimeAgo } from './lib/utils/datetime_utility';
import Pager from './pager';
export default class Activities {
- constructor(container = '') {
- this.container = container;
+ constructor(containerSelector = '') {
+ this.containerSelector = containerSelector;
+ this.containerEl = this.containerSelector
+ ? document.querySelector(this.containerSelector)
+ : undefined;
+ this.$contentList = $('.content_list');
- Pager.init(20, true, false, (data) => data, this.updateTooltips, this.container);
+ this.loadActivities();
$('.event-filter-link').on('click', (e) => {
e.preventDefault();
@@ -18,13 +24,30 @@ export default class Activities {
});
}
+ loadActivities() {
+ Pager.init({
+ limit: 20,
+ preload: true,
+ prepareData: (data) => data,
+ successCallback: () => this.updateTooltips(),
+ errorCallback: () =>
+ createFlash({
+ message: s__(
+ 'Activity|An error occured while retrieving activity. Reload the page to try again.',
+ ),
+ parent: this.containerEl,
+ }),
+ container: this.containerSelector,
+ });
+ }
+
updateTooltips() {
localTimeAgo($('.js-timeago', '.content_list'));
}
reloadActivities() {
- $('.content_list').html('');
- Pager.init(20, true, false, (data) => data, this.updateTooltips, this.container);
+ this.$contentList.html('');
+ this.loadActivities();
}
toggleFilter(sender) {
diff --git a/app/assets/javascripts/admin/statistics_panel/constants.js b/app/assets/javascripts/admin/statistics_panel/constants.js
index 2dce19a3894..de413b2e7f0 100644
--- a/app/assets/javascripts/admin/statistics_panel/constants.js
+++ b/app/assets/javascripts/admin/statistics_panel/constants.js
@@ -3,7 +3,7 @@ import { s__ } from '~/locale';
const statisticsLabels = {
forks: s__('AdminStatistics|Forks'),
issues: s__('AdminStatistics|Issues'),
- mergeRequests: s__('AdminStatistics|Merge Requests'),
+ mergeRequests: s__('AdminStatistics|Merge requests'),
notes: s__('AdminStatistics|Notes'),
snippets: s__('AdminStatistics|Snippets'),
sshKeys: s__('AdminStatistics|SSH Keys'),
diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue
index 8962068601c..8b41a063abc 100644
--- a/app/assets/javascripts/admin/users/components/users_table.vue
+++ b/app/assets/javascripts/admin/users/components/users_table.vue
@@ -1,9 +1,9 @@
<script>
import { GlTable } from '@gitlab/ui';
import { __ } from '~/locale';
+import UserDate from '~/vue_shared/components/user_date.vue';
import UserActions from './user_actions.vue';
import UserAvatar from './user_avatar.vue';
-import UserDate from './user_date.vue';
const DEFAULT_TH_CLASSES =
'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!';
diff --git a/app/assets/javascripts/admin/users/constants.js b/app/assets/javascripts/admin/users/constants.js
index 8ea1bd3ca7a..c55edefe607 100644
--- a/app/assets/javascripts/admin/users/constants.js
+++ b/app/assets/javascripts/admin/users/constants.js
@@ -2,8 +2,6 @@ import { s__, __ } from '~/locale';
export const USER_AVATAR_SIZE = 32;
-export const SHORT_DATE_FORMAT = 'd mmm, yyyy';
-
export const LENGTH_OF_USER_NOTE_TOOLTIP = 100;
export const I18N_USER_ACTIONS = {
diff --git a/app/assets/javascripts/admin/users/new.js b/app/assets/javascripts/admin/users/new.js
new file mode 100644
index 00000000000..33565bfc14f
--- /dev/null
+++ b/app/assets/javascripts/admin/users/new.js
@@ -0,0 +1,55 @@
+const DATA_ATTR_REGEX_PATTERN = 'data-user-internal-regex-pattern';
+const DATA_ATTR_REGEX_OPTIONS = 'data-user-internal-regex-options';
+export const ID_USER_EXTERNAL = 'user_external';
+export const ID_WARNING = 'warning_external_automatically_set';
+export const ID_USER_EMAIL = 'user_email';
+
+const getAttributeValue = (attr) => document.querySelector(`[${attr}]`)?.getAttribute(attr);
+
+const getRegexPattern = () => getAttributeValue(DATA_ATTR_REGEX_PATTERN);
+
+const getRegexOptions = () => getAttributeValue(DATA_ATTR_REGEX_OPTIONS);
+
+export const setupInternalUserRegexHandler = () => {
+ const regexPattern = getRegexPattern();
+
+ if (!regexPattern) {
+ return;
+ }
+
+ const regexOptions = getRegexOptions();
+ const elExternal = document.getElementById(ID_USER_EXTERNAL);
+ const elWarningMessage = document.getElementById(ID_WARNING);
+ const elUserEmail = document.getElementById(ID_USER_EMAIL);
+
+ const isEmailInternal = (email) => {
+ const regex = new RegExp(regexPattern, regexOptions);
+ return regex.test(email);
+ };
+
+ const setExternalCheckbox = (email) => {
+ const isChecked = elExternal.checked;
+
+ if (isEmailInternal(email)) {
+ if (isChecked) {
+ elExternal.checked = false;
+ elWarningMessage.classList.remove('hidden');
+ }
+ } else if (!isChecked) {
+ elExternal.checked = true;
+ elWarningMessage.classList.add('hidden');
+ }
+ };
+
+ const setupListeners = () => {
+ elUserEmail.addEventListener('input', (event) => {
+ setExternalCheckbox(event.target.value);
+ });
+
+ elExternal.addEventListener('change', () => {
+ elWarningMessage.classList.add('hidden');
+ });
+ };
+
+ setupListeners();
+};
diff --git a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue
index 9b0e5090a75..77c14d9f812 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue
@@ -43,7 +43,7 @@ export default {
</gl-link>
</div>
<div v-if="userCanEnableAlertManagement" class="gl-display-block center gl-pt-4">
- <gl-button category="primary" variant="success" :href="enableAlertManagementPath">
+ <gl-button category="primary" variant="confirm" :href="enableAlertManagementPath">
{{ $options.i18n.emptyState.buttonText }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js
index f5eac26431f..b23f8a8eba4 100644
--- a/app/assets/javascripts/alert_management/list.js
+++ b/app/assets/javascripts/alert_management/list.js
@@ -5,6 +5,7 @@ import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants';
import AlertManagementList from './components/alert_management_list_wrapper.vue';
+import alertsHelpUrlQuery from './graphql/queries/alert_help_url.query.graphql';
Vue.use(VueApollo);
@@ -41,7 +42,8 @@ export default () => {
),
});
- apolloProvider.clients.defaultClient.cache.writeData({
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: alertsHelpUrlQuery,
data: {
alertsHelpUrl,
},
diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
index 07b2e59671e..5171588eb64 100644
--- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
+++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
@@ -118,17 +118,17 @@ export default {
<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">
+ <h5 id="gitlabFieldsHeader" class="gl-display-table-cell gl-pb-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">
+ <h5 class="gl-display-table-cell gl-pb-3 gl-pr-3">&nbsp;</h5>
+ <h5 id="parsedFieldsHeader" class="gl-display-table-cell gl-pb-3 gl-pr-3">
{{ $options.i18n.columns.payloadKeyTitle }}
</h5>
<h5
v-if="hasFallbackColumn"
id="fallbackFieldsHeader"
- class="gl-display-table-cell gl-py-3 gl-pr-3"
+ class="gl-display-table-cell gl-pb-3 gl-pr-3"
>
{{ $options.i18n.columns.fallbackKeyTitle }}
<gl-icon
@@ -140,11 +140,7 @@ export default {
</h5>
</div>
- <div
- v-for="(gitlabField, index) in mappingData"
- :key="gitlabField.name"
- class="gl-display-table-row"
- >
+ <div v-for="gitlabField in mappingData" :key="gitlabField.name" class="gl-display-table-row">
<div class="gl-display-table-cell gl-py-3 gl-pr-3 gl-w-30p gl-vertical-align-middle">
<gl-form-input
aria-labelledby="gitlabFieldsHeader"
@@ -153,8 +149,8 @@ export default {
/>
</div>
- <div class="gl-display-table-cell gl-py-3 gl-pr-3">
- <div class="right-arrow" :class="{ 'gl-vertical-align-middle': index === 0 }">
+ <div class="gl-display-table-cell gl-pr-3 gl-vertical-align-middle">
+ <div class="right-arrow">
<i class="right-arrow-head"></i>
</div>
</div>
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 a5e17d80f86..ef29fc5e8b4 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
@@ -21,8 +21,10 @@ import {
import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
export const i18n = {
+ deleteIntegration: s__('AlertSettings|Delete integration'),
+ editIntegration: s__('AlertSettings|Edit integration'),
title: s__('AlertsIntegrations|Current integrations'),
- emptyState: s__('AlertsIntegrations|No integrations have been added yet'),
+ emptyState: s__('AlertsIntegrations|No integrations have been added yet.'),
status: {
enabled: {
name: __('Enabled'),
@@ -139,7 +141,7 @@ export default {
<template>
<div class="incident-management-list">
- <h5 class="gl-font-lg">{{ $options.i18n.title }}</h5>
+ <h5 class="gl-font-lg gl-mt-5">{{ $options.i18n.title }}</h5>
<gl-table
class="integration-list"
:items="integrations"
@@ -174,11 +176,16 @@ export default {
<template #cell(actions)="{ item }">
<gl-button-group class="gl-ml-3">
- <gl-button icon="settings" @click="editIntegration(item)" />
+ <gl-button
+ icon="settings"
+ :aria-label="$options.i18n.editIntegration"
+ @click="editIntegration(item)"
+ />
<gl-button
v-gl-modal.deleteIntegration
:disabled="item.type === $options.typeSet.prometheus"
icon="remove"
+ :aria-label="$options.i18n.deleteIntegration"
@click="setIntegrationToDelete(item)"
/>
</gl-button-group>
@@ -198,15 +205,15 @@ export default {
</gl-table>
<gl-modal
modal-id="deleteIntegration"
- :title="s__('AlertSettings|Delete integration')"
- :ok-title="s__('AlertSettings|Delete integration')"
+ :title="$options.i18n.deleteIntegration"
+ :ok-title="$options.i18n.deleteIntegration"
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.',
+ 'AlertsIntegrations|If you delete the %{integrationName} integration, alerts are no longer sent from this endpoint. This action cannot be undone.',
)
"
>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
index 5d9513e5b53..a5f7b84446f 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
@@ -14,7 +14,7 @@ import {
GlTab,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
-import { isEmpty, omit } from 'lodash';
+import { isEqual, isEmpty, omit } from 'lodash';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import {
integrationTypes,
@@ -24,8 +24,9 @@ import {
JSON_VALIDATE_DELAY,
targetPrometheusUrlPlaceholder,
typeSet,
- viewCredentialsTabIndex,
i18n,
+ tabIndices,
+ testAlertModalId,
} from '../constants';
import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
import parseSamplePayloadQuery from '../graphql/queries/parse_sample_payload.query.graphql';
@@ -40,6 +41,10 @@ export default {
typeSet,
integrationSteps,
i18n,
+ primaryProps: { text: i18n.integrationFormSteps.testPayload.savedAndTest },
+ secondaryProps: { text: i18n.integrationFormSteps.testPayload.proceedWithoutSave },
+ cancelProps: { text: i18n.integrationFormSteps.testPayload.cancel },
+ testAlertModalId,
components: {
ClipboardButton,
GlButton,
@@ -60,11 +65,8 @@ export default {
GlModal: GlModalDirective,
},
inject: {
- generic: {
- default: {},
- },
- prometheus: {
- default: {},
+ alertsUsageUrl: {
+ default: '#',
},
multiIntegrations: {
default: false,
@@ -87,6 +89,11 @@ export default {
required: false,
default: null,
},
+ tabIndex: {
+ type: Number,
+ required: false,
+ default: tabIndices.configureDetails,
+ },
},
apollo: {
currentIntegration: {
@@ -96,11 +103,10 @@ export default {
data() {
return {
integrationTypesOptions: Object.values(integrationTypes),
- selectedIntegration: integrationTypes.none.value,
- active: false,
samplePayload: {
json: null,
error: null,
+ loading: false,
},
testPayload: {
json: null,
@@ -108,18 +114,32 @@ export default {
},
resetPayloadAndMappingConfirmed: false,
mapping: [],
- parsingPayload: false,
+ integrationForm: {
+ active: false,
+ type: integrationTypes.none.value,
+ name: '',
+ token: '',
+ url: '',
+ apiUrl: '',
+ },
+ activeTabIndex: this.tabIndex,
currentIntegration: null,
parsedPayload: [],
- activeTabIndex: 0,
+ validationState: {
+ name: true,
+ apiUrl: true,
+ },
};
},
computed: {
isPrometheus() {
- return this.selectedIntegration === this.$options.typeSet.prometheus;
+ return this.integrationForm.type === typeSet.prometheus;
},
isHttp() {
- return this.selectedIntegration === this.$options.typeSet.http;
+ return this.integrationForm.type === typeSet.http;
+ },
+ isNone() {
+ return !this.isHttp && !this.isPrometheus;
},
isCreating() {
return !this.currentIntegration;
@@ -130,29 +150,6 @@ export default {
isTestPayloadValid() {
return this.testPayload.error === null;
},
- selectedIntegrationType() {
- switch (this.selectedIntegration) {
- case typeSet.http:
- return this.generic;
- case typeSet.prometheus:
- return this.prometheus;
- default:
- return {};
- }
- },
- integrationForm() {
- return {
- name: this.currentIntegration?.name || '',
- active: this.currentIntegration?.active || false,
- token:
- this.currentIntegration?.token ||
- (this.selectedIntegrationType !== this.generic ? this.selectedIntegrationType.token : ''),
- url:
- this.currentIntegration?.url ||
- (this.selectedIntegrationType !== this.generic ? this.selectedIntegrationType.url : ''),
- apiUrl: this.currentIntegration?.apiUrl || '',
- };
- },
testAlertPayload() {
return {
data: this.testPayload.json,
@@ -170,13 +167,7 @@ export default {
return this.hasSamplePayload && !this.resetPayloadAndMappingConfirmed;
},
canParseSamplePayload() {
- return !this.active || !this.isSampePayloadValid || !this.samplePayload.json;
- },
- isResetAuthKeyDisabled() {
- return !this.active && !this.integrationForm.token !== '';
- },
- isPayloadEditDisabled() {
- return !this.active || this.canEditPayload;
+ return this.isSampePayloadValid && this.samplePayload.json;
},
isSelectDisabled() {
return this.currentIntegration !== null || !this.canAddIntegration;
@@ -186,30 +177,105 @@ export default {
? i18n.integrationFormSteps.setupCredentials.prometheusHelp
: i18n.integrationFormSteps.setupCredentials.help;
},
+ isFormValid() {
+ return (
+ Object.values(this.validationState).every(Boolean) &&
+ !this.isNone &&
+ this.isSampePayloadValid
+ );
+ },
+ isFormDirty() {
+ const { type, active, name, apiUrl, payloadAlertFields = [], payloadAttributeMappings = [] } =
+ this.currentIntegration || {};
+ const {
+ name: formName,
+ apiUrl: formApiUrl,
+ active: formActive,
+ type: formType,
+ } = this.integrationForm;
+
+ const isDirty =
+ type !== formType ||
+ active !== formActive ||
+ name !== formName ||
+ apiUrl !== formApiUrl ||
+ !isEqual(this.parsedPayload, payloadAlertFields) ||
+ !isEqual(this.mapping, this.getCleanMapping(payloadAttributeMappings));
+
+ return isDirty;
+ },
+ canSubmitForm() {
+ return this.isFormValid && this.isFormDirty;
+ },
+ dataForSave() {
+ const { name, apiUrl, active } = this.integrationForm;
+ const customMappingVariables = {
+ payloadAttributeMappings: this.mapping,
+ payloadExample: this.samplePayload.json || '{}',
+ };
+
+ const variables = this.isHttp
+ ? { name, active, ...customMappingVariables }
+ : { apiUrl, active };
+
+ return { type: this.integrationForm.type, variables };
+ },
+ testAlertModal() {
+ return this.isFormDirty ? testAlertModalId : null;
+ },
+ prometheusUrlInvalidFeedback() {
+ const { blankUrlError, invalidUrlError } = i18n.integrationFormSteps.prometheusFormUrl;
+ return this.integrationForm.apiUrl?.length ? invalidUrlError : blankUrlError;
+ },
},
watch: {
+ tabIndex(val) {
+ this.activeTabIndex = val;
+ },
currentIntegration(val) {
if (val === null) {
this.reset();
return;
}
- const { type, active, payloadExample, payloadAlertFields, payloadAttributeMappings } = val;
- this.selectedIntegration = type;
- this.active = active;
- if (type === typeSet.http && this.showMappingBuilder) {
+ this.resetPayloadAndMapping();
+ const {
+ name,
+ type,
+ active,
+ url,
+ apiUrl,
+ token,
+ payloadExample,
+ payloadAlertFields,
+ payloadAttributeMappings,
+ } = val;
+ this.integrationForm = { type, name, active, url, apiUrl, token };
+
+ if (this.showMappingBuilder) {
+ this.resetPayloadAndMappingConfirmed = false;
this.parsedPayload = payloadAlertFields;
- this.samplePayload.json = this.isValidNonEmptyJSON(payloadExample) ? payloadExample : null;
- const mapping = payloadAttributeMappings.map((mappingItem) =>
- omit(mappingItem, '__typename'),
- );
- this.updateMapping(mapping);
+ this.samplePayload.json = this.getPrettifiedPayload(payloadExample);
+ this.updateMapping(this.getCleanMapping(payloadAttributeMappings));
}
- this.activeTabIndex = viewCredentialsTabIndex;
this.$el.scrollIntoView({ block: 'center' });
},
},
methods: {
+ getCleanMapping(mapping) {
+ return mapping.map((mappingItem) => omit(mappingItem, '__typename'));
+ },
+ validateName() {
+ this.validationState.name = Boolean(this.integrationForm.name?.length);
+ },
+ validateApiUrl() {
+ try {
+ const parsedUrl = new URL(this.integrationForm.apiUrl);
+ this.validationState.apiUrl = ['http:', 'https:'].includes(parsedUrl.protocol);
+ } catch (e) {
+ this.validationState.apiUrl = false;
+ }
+ },
isValidNonEmptyJSON(JSONString) {
if (JSONString) {
let parsed;
@@ -222,29 +288,37 @@ export default {
}
return false;
},
+ getPrettifiedPayload(payload) {
+ return this.isValidNonEmptyJSON(payload)
+ ? JSON.stringify(JSON.parse(payload), null, '\t')
+ : null;
+ },
+ triggerValidation() {
+ if (this.isHttp) {
+ this.validationState.apiUrl = true;
+ this.validateName();
+ if (!this.validationState.name) {
+ this.$refs.integrationName.$el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }
+ } else if (this.isPrometheus) {
+ this.validationState.name = true;
+ this.validateApiUrl();
+ }
+ },
sendTestAlert() {
this.$emit('test-alert-payload', this.testAlertPayload);
},
- submit() {
- const { name, apiUrl } = this.integrationForm;
- const customMappingVariables = {
- payloadAttributeMappings: this.mapping,
- payloadExample: this.samplePayload.json || '{}',
- };
-
- const variables =
- this.selectedIntegration === typeSet.http
- ? { name, active: this.active, ...customMappingVariables }
- : { apiUrl, active: this.active };
-
- const integrationPayload = { type: this.selectedIntegration, variables };
+ saveAndSendTestAlert() {
+ this.$emit('save-and-test-alert-payload', this.dataForSave, this.testAlertPayload);
+ },
+ submit(testAfterSubmit = false) {
+ this.triggerValidation();
- if (this.currentIntegration) {
- return this.$emit('update-integration', integrationPayload);
+ if (!this.isFormValid) {
+ return;
}
-
- this.reset();
- return this.$emit('create-new-integration', integrationPayload);
+ const event = this.currentIntegration ? 'update-integration' : 'create-new-integration';
+ this.$emit(event, this.dataForSave, testAfterSubmit);
},
reset() {
this.resetFormValues();
@@ -252,14 +326,14 @@ export default {
this.$emit('clear-current-integration', { type: this.currentIntegration?.type });
},
resetFormValues() {
- this.selectedIntegration = integrationTypes.none.value;
+ this.integrationForm.type = integrationTypes.none.value;
this.integrationForm.name = '';
+ this.integrationForm.active = false;
this.integrationForm.apiUrl = '';
this.samplePayload = {
json: null,
error: null,
};
- this.active = false;
},
resetAuthKey() {
if (!this.currentIntegration) {
@@ -267,7 +341,7 @@ export default {
}
this.$emit('reset-token', {
- type: this.selectedIntegration,
+ type: this.integrationForm.type,
variables: { id: this.currentIntegration.id },
});
},
@@ -285,8 +359,8 @@ export default {
payload.error = JSON.stringify(e.message);
}
},
- parseMapping() {
- this.parsingPayload = true;
+ parseSamplePayload() {
+ this.samplePayload.loading = true;
return this.$apollo
.query({
@@ -303,7 +377,7 @@ export default {
this.resetPayloadAndMappingConfirmed = false;
this.$toast.show(
- this.$options.i18n.integrationFormSteps.setSamplePayload.payloadParsedSucessMsg,
+ this.$options.i18n.integrationFormSteps.mapFields.payloadParsedSucessMsg,
);
},
)
@@ -311,7 +385,7 @@ export default {
this.samplePayload.error = message;
})
.finally(() => {
- this.parsingPayload = false;
+ this.samplePayload.loading = false;
});
},
updateMapping(mapping) {
@@ -338,7 +412,7 @@ export default {
<template>
<gl-form class="gl-mt-6" @submit.prevent="submit" @reset.prevent="reset">
<gl-tabs v-model="activeTabIndex">
- <gl-tab :title="$options.i18n.integrationTabs.configureDetails">
+ <gl-tab :title="$options.i18n.integrationTabs.configureDetails" class="gl-mt-3">
<gl-form-group
v-if="isCreating"
id="integration-type"
@@ -351,7 +425,7 @@ export default {
label-for="integration-type"
>
<gl-form-select
- v-model="selectedIntegration"
+ v-model="integrationForm.type"
:disabled="isSelectDisabled"
class="gl-max-w-full"
:options="integrationTypesOptions"
@@ -369,7 +443,6 @@ export default {
<div class="gl-mt-3">
<gl-form-group
v-if="isHttp"
- id="name-integration"
:label="
getLabelWithStepNumber(
$options.integrationSteps.nameIntegration,
@@ -377,67 +450,82 @@ export default {
)
"
label-for="name-integration"
+ :invalid-feedback="$options.i18n.integrationFormSteps.nameIntegration.error"
+ :state="validationState.name"
>
<gl-form-input
+ id="name-integration"
+ ref="integrationName"
v-model="integrationForm.name"
type="text"
:placeholder="$options.i18n.integrationFormSteps.nameIntegration.placeholder"
+ @input="validateName"
/>
</gl-form-group>
- <gl-toggle
- v-model="active"
- :is-loading="loading"
- :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle"
- class="gl-my-4 gl-font-weight-normal"
- />
-
- <div v-if="isPrometheus" class="gl-my-4">
- <span class="gl-font-weight-bold">
- {{
- getLabelWithStepNumber(
- $options.integrationSteps.setPrometheusApiUrl,
- $options.i18n.integrationFormSteps.prometheusFormUrl.label,
- )
- }}
- </span>
+ <gl-form-group
+ v-if="!isNone"
+ :label="
+ getLabelWithStepNumber(
+ isHttp
+ ? $options.integrationSteps.enableHttpIntegration
+ : $options.integrationSteps.enablePrometheusIntegration,
+ $options.i18n.integrationFormSteps.enableIntegration.label,
+ )
+ "
+ >
+ <span>{{ $options.i18n.integrationFormSteps.enableIntegration.help }}</span>
+
+ <gl-toggle
+ id="enable-integration"
+ v-model="integrationForm.active"
+ :is-loading="loading"
+ :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle"
+ class="gl-mt-4 gl-font-weight-normal"
+ />
+ </gl-form-group>
+ <gl-form-group
+ v-if="isPrometheus"
+ class="gl-my-4"
+ :label="$options.i18n.integrationFormSteps.prometheusFormUrl.label"
+ label-for="api-url"
+ :invalid-feedback="prometheusUrlInvalidFeedback"
+ :state="validationState.apiUrl"
+ >
<gl-form-input
- id="integration-apiUrl"
+ id="api-url"
v-model="integrationForm.apiUrl"
type="text"
:placeholder="$options.placeholders.prometheus"
+ @input="validateApiUrl"
/>
-
<span class="gl-text-gray-400">
{{ $options.i18n.integrationFormSteps.prometheusFormUrl.help }}
</span>
- </div>
+ </gl-form-group>
<template v-if="showMappingBuilder">
<gl-form-group
data-testid="sample-payload-section"
:label="
getLabelWithStepNumber(
- $options.integrationSteps.setSamplePayload,
- $options.i18n.integrationFormSteps.setSamplePayload.label,
+ $options.integrationSteps.customizeMapping,
+ $options.i18n.integrationFormSteps.mapFields.label,
)
"
label-for="sample-payload"
class="gl-mb-0!"
:invalid-feedback="samplePayload.error"
>
- <alert-settings-form-help-block
- :message="$options.i18n.integrationFormSteps.setSamplePayload.testPayloadHelpHttp"
- :link="generic.alertsUsageUrl"
- />
+ <span>{{ $options.i18n.integrationFormSteps.mapFields.help }}</span>
<gl-form-textarea
id="sample-payload"
- v-model.trim="samplePayload.json"
- :disabled="isPayloadEditDisabled"
+ v-model="samplePayload.json"
+ :disabled="canEditPayload"
:state="isSampePayloadValid"
- :placeholder="$options.i18n.integrationFormSteps.setSamplePayload.placeholder"
+ :placeholder="$options.i18n.integrationFormSteps.mapFields.placeholder"
class="gl-my-3"
:debounce="$options.JSON_VALIDATE_DELAY"
rows="6"
@@ -450,71 +538,76 @@ export default {
v-if="canEditPayload"
v-gl-modal.resetPayloadModal
data-testid="payload-action-btn"
- :disabled="!active"
+ :disabled="!integrationForm.active"
class="gl-mt-3"
>
- {{ $options.i18n.integrationFormSteps.setSamplePayload.editPayload }}
+ {{ $options.i18n.integrationFormSteps.mapFields.editPayload }}
</gl-button>
<gl-button
v-else
data-testid="payload-action-btn"
:class="{ 'gl-mt-3': samplePayload.error }"
- :disabled="canParseSamplePayload"
- :loading="parsingPayload"
- @click="parseMapping"
+ :disabled="!canParseSamplePayload"
+ :loading="samplePayload.loading"
+ @click="parseSamplePayload"
>
- {{ $options.i18n.integrationFormSteps.setSamplePayload.parsePayload }}
+ {{ $options.i18n.integrationFormSteps.mapFields.parsePayload }}
</gl-button>
<gl-modal
modal-id="resetPayloadModal"
- :title="$options.i18n.integrationFormSteps.setSamplePayload.resetHeader"
- :ok-title="$options.i18n.integrationFormSteps.setSamplePayload.resetOk"
+ :title="$options.i18n.integrationFormSteps.mapFields.resetHeader"
+ :ok-title="$options.i18n.integrationFormSteps.mapFields.resetOk"
ok-variant="danger"
- @ok="resetPayloadAndMapping"
+ @ok="resetPayloadAndMappingConfirmed = true"
>
- {{ $options.i18n.integrationFormSteps.setSamplePayload.resetBody }}
+ {{ $options.i18n.integrationFormSteps.mapFields.resetBody }}
</gl-modal>
- <gl-form-group
- id="mapping-builder"
- class="gl-mt-5"
- :label="
- getLabelWithStepNumber(
- $options.integrationSteps.customizeMapping,
- $options.i18n.integrationFormSteps.mapFields.label,
- )
- "
- label-for="mapping-builder"
- >
- <span>{{ $options.i18n.integrationFormSteps.mapFields.intro }}</span>
+ <div class="gl-mt-5">
+ <span>{{ $options.i18n.integrationFormSteps.mapFields.mapIntro }}</span>
<mapping-builder
:parsed-payload="parsedPayload"
:saved-mapping="mapping"
:alert-fields="alertFields"
@onMappingUpdate="updateMapping"
/>
- </gl-form-group>
+ </div>
</template>
</div>
-
<div class="gl-display-flex gl-justify-content-start gl-py-3">
<gl-button
- type="submit"
+ :disabled="!canSubmitForm"
variant="confirm"
class="js-no-auto-disable"
data-testid="integration-form-submit"
+ @click="submit(false)"
>
{{ $options.i18n.saveIntegration }}
</gl-button>
+ <gl-button
+ :disabled="!canSubmitForm"
+ variant="confirm"
+ category="secondary"
+ class="gl-ml-3 js-no-auto-disable"
+ data-testid="integration-form-test-and-submit"
+ @click="submit(true)"
+ >
+ {{ $options.i18n.saveAndTestIntegration }}
+ </gl-button>
+
<gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{
$options.i18n.cancelAndClose
}}</gl-button>
</div>
</gl-tab>
- <gl-tab :title="$options.i18n.integrationTabs.viewCredentials" :disabled="isCreating">
+ <gl-tab
+ :title="$options.i18n.integrationTabs.viewCredentials"
+ :disabled="isCreating"
+ class="gl-mt-3"
+ >
<alert-settings-form-help-block
:message="viewCredentialsHelpMsg"
link="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html"
@@ -559,13 +652,15 @@ export default {
</div>
</gl-form-group>
- <gl-button v-gl-modal.authKeyModal :disabled="isResetAuthKeyDisabled" variant="danger">
- {{ $options.i18n.integrationFormSteps.setupCredentials.reset }}
- </gl-button>
+ <div class="gl-display-flex gl-justify-content-start gl-py-3">
+ <gl-button v-gl-modal.authKeyModal variant="danger">
+ {{ $options.i18n.integrationFormSteps.setupCredentials.reset }}
+ </gl-button>
- <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{
- $options.i18n.cancelAndClose
- }}</gl-button>
+ <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">
+ {{ $options.i18n.cancelAndClose }}
+ </gl-button>
+ </div>
<gl-modal
modal-id="authKeyModal"
@@ -578,18 +673,22 @@ export default {
</gl-modal>
</gl-tab>
- <gl-tab :title="$options.i18n.integrationTabs.sendTestAlert" :disabled="isCreating">
+ <gl-tab
+ :title="$options.i18n.integrationTabs.sendTestAlert"
+ :disabled="isCreating"
+ class="gl-mt-3"
+ >
<gl-form-group id="test-integration" :invalid-feedback="testPayload.error">
<alert-settings-form-help-block
- :message="$options.i18n.integrationFormSteps.setSamplePayload.testPayloadHelp"
- :link="generic.alertsUsageUrl"
+ :message="$options.i18n.integrationFormSteps.testPayload.help"
+ :link="alertsUsageUrl"
/>
<gl-form-textarea
id="test-payload"
- v-model.trim="testPayload.json"
+ v-model="testPayload.json"
:state="isTestPayloadValid"
- :placeholder="$options.i18n.integrationFormSteps.setSamplePayload.placeholder"
+ :placeholder="$options.i18n.integrationFormSteps.testPayload.placeholder"
class="gl-my-3"
:debounce="$options.JSON_VALIDATE_DELAY"
rows="6"
@@ -597,20 +696,35 @@ export default {
@input="validateJson(false)"
/>
</gl-form-group>
+ <div class="gl-display-flex gl-justify-content-start gl-py-3">
+ <gl-button
+ v-gl-modal="testAlertModal"
+ :disabled="!isTestPayloadValid"
+ :loading="loading"
+ data-testid="send-test-alert"
+ variant="confirm"
+ class="js-no-auto-disable"
+ @click="isFormDirty ? null : sendTestAlert()"
+ >
+ {{ $options.i18n.send }}
+ </gl-button>
- <gl-button
- :disabled="!isTestPayloadValid"
- data-testid="send-test-alert"
- variant="confirm"
- class="js-no-auto-disable"
- @click="sendTestAlert"
- >
- {{ $options.i18n.send }}
- </gl-button>
+ <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">
+ {{ $options.i18n.cancelAndClose }}
+ </gl-button>
+ </div>
- <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{
- $options.i18n.cancelAndClose
- }}</gl-button>
+ <gl-modal
+ :modal-id="$options.testAlertModalId"
+ :title="$options.i18n.integrationFormSteps.testPayload.modalTitle"
+ :action-primary="$options.primaryProps"
+ :action-secondary="$options.secondaryProps"
+ :action-cancel="$options.cancelProps"
+ @primary="saveAndSendTestAlert"
+ @secondary="sendTestAlert"
+ >
+ {{ $options.i18n.integrationFormSteps.testPayload.modalBody }}
+ </gl-modal>
</gl-tab>
</gl-tabs>
</gl-form>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
index 3ffb652e61b..f51c8d7e9f7 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
@@ -1,11 +1,11 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlAlert } from '@gitlab/ui';
import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql';
import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql';
import createFlash, { FLASH_TYPES } from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
-import { s__ } from '~/locale';
-import { typeSet } from '../constants';
+import httpStatusCodes from '~/lib/utils/http_status';
+import { typeSet, i18n, tabIndices } from '../constants';
import createPrometheusIntegrationMutation from '../graphql/mutations/create_prometheus_integration.mutation.graphql';
import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_integration.mutation.graphql';
import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutation.graphql';
@@ -28,21 +28,12 @@ import {
RESET_INTEGRATION_TOKEN_ERROR,
UPDATE_INTEGRATION_ERROR,
INTEGRATION_PAYLOAD_TEST_ERROR,
+ INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR,
+ DEFAULT_ERROR,
} from '../utils/error_messages';
import IntegrationsList from './alerts_integrations_list.vue';
import AlertSettingsForm from './alerts_settings_form.vue';
-export const 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.'),
- alertSent: s__(
- 'AlertsIntegrations|The test alert has been successfully sent, and should now be visible on your alerts list.',
- ),
- addNewIntegration: s__('AlertSettings|Add new integration'),
-};
-
export default {
typeSet,
i18n,
@@ -50,14 +41,9 @@ export default {
IntegrationsList,
AlertSettingsForm,
GlButton,
+ GlAlert,
},
inject: {
- generic: {
- default: {},
- },
- prometheus: {
- default: {},
- },
projectPath: {
default: '',
},
@@ -124,7 +110,10 @@ export default {
integrations: {},
httpIntegrations: {},
currentIntegration: null,
+ newIntegration: null,
formVisible: false,
+ showSuccessfulCreateAlert: false,
+ tabIndex: tabIndices.configureDetails,
};
},
computed: {
@@ -139,10 +128,10 @@ export default {
isHttp(type) {
return type === typeSet.http;
},
- createNewIntegration({ type, variables }) {
+ createNewIntegration({ type, variables }, testAfterSubmit) {
const { projectPath } = this;
-
const isHttp = this.isHttp(type);
+
this.isUpdating = true;
this.$apollo
.mutate({
@@ -163,16 +152,19 @@ export default {
.then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => {
const error = httpIntegrationCreate?.errors[0] || prometheusIntegrationCreate?.errors[0];
if (error) {
- return createFlash({ message: error });
+ createFlash({ message: error });
+ return;
}
- const { integration } = httpIntegrationCreate || prometheusIntegrationCreate;
- this.editIntegration(integration);
+ const { integration } = httpIntegrationCreate || prometheusIntegrationCreate;
+ this.newIntegration = integration;
+ this.showSuccessfulCreateAlert = true;
- return createFlash({
- message: this.$options.i18n.changesSaved,
- type: FLASH_TYPES.SUCCESS,
- });
+ if (testAfterSubmit) {
+ this.viewIntegration(this.newIntegration, tabIndices.sendTestAlert);
+ } else {
+ this.setFormVisibility(false);
+ }
})
.catch(() => {
createFlash({ message: ADD_INTEGRATION_ERROR });
@@ -181,9 +173,9 @@ export default {
this.isUpdating = false;
});
},
- updateIntegration({ type, variables }) {
+ updateIntegration({ type, variables }, testAfterSubmit) {
this.isUpdating = true;
- this.$apollo
+ return this.$apollo
.mutate({
mutation: this.isHttp(type)
? updateHttpIntegrationMutation
@@ -196,12 +188,20 @@ export default {
.then(({ data: { httpIntegrationUpdate, prometheusIntegrationUpdate } = {} } = {}) => {
const error = httpIntegrationUpdate?.errors[0] || prometheusIntegrationUpdate?.errors[0];
if (error) {
- return createFlash({ message: error });
+ createFlash({ message: error });
+ return;
}
- this.clearCurrentIntegration({ type });
+ const integration =
+ httpIntegrationUpdate?.integration || prometheusIntegrationUpdate?.integration;
- return createFlash({
+ if (testAfterSubmit) {
+ this.viewIntegration(integration, tabIndices.sendTestAlert);
+ } else {
+ this.clearCurrentIntegration(type);
+ }
+
+ createFlash({
message: this.$options.i18n.changesSaved,
type: FLASH_TYPES.SUCCESS,
});
@@ -261,13 +261,23 @@ export default {
currentIntegration = { ...currentIntegration, ...httpIntegrationMappingData };
}
- this.$apollo.mutate({
- mutation: this.isHttp(type)
- ? updateCurrentHttpIntegrationMutation
- : updateCurrentPrometheusIntegrationMutation,
- variables: currentIntegration,
- });
- this.setFormVisibility(true);
+ this.viewIntegration(currentIntegration, tabIndices.viewCredentials);
+ },
+ viewIntegration(integration, tabIndex) {
+ this.$apollo
+ .mutate({
+ mutation: this.isHttp(integration.type)
+ ? updateCurrentHttpIntegrationMutation
+ : updateCurrentPrometheusIntegrationMutation,
+ variables: integration,
+ })
+ .then(() => {
+ this.setFormVisibility(true);
+ this.tabIndex = tabIndex;
+ })
+ .catch(() => {
+ createFlash({ message: DEFAULT_ERROR });
+ });
},
deleteIntegration({ id, type }) {
const { projectPath } = this;
@@ -319,19 +329,44 @@ export default {
type: FLASH_TYPES.SUCCESS,
});
})
- .catch(() => {
- createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR });
+ .catch((error) => {
+ let message = INTEGRATION_PAYLOAD_TEST_ERROR;
+ if (error.response?.status === httpStatusCodes.FORBIDDEN) {
+ message = INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR;
+ }
+ createFlash({ message });
});
},
+ saveAndTestAlertPayload(integration, payload) {
+ return this.updateIntegration(integration, false).then(() => {
+ this.testAlertPayload(payload);
+ });
+ },
setFormVisibility(visible) {
this.formVisible = visible;
},
+ viewCreatedIntegration() {
+ this.viewIntegration(this.newIntegration, tabIndices.viewCredentials);
+ this.showSuccessfulCreateAlert = false;
+ this.newIntegration = null;
+ },
},
};
</script>
<template>
<div>
+ <gl-alert
+ v-if="showSuccessfulCreateAlert"
+ class="gl-mt-n2"
+ :primary-button-text="$options.i18n.integrationCreated.btnCaption"
+ :title="$options.i18n.integrationCreated.title"
+ @primaryAction="viewCreatedIntegration"
+ @dismiss="showSuccessfulCreateAlert = false"
+ >
+ {{ $options.i18n.integrationCreated.successMsg }}
+ </gl-alert>
+
<integrations-list
:integrations="integrations.list"
:loading="loading"
@@ -353,11 +388,13 @@ export default {
:loading="isUpdating"
:can-add-integration="canAddIntegration"
:alert-fields="alertFields"
+ :tab-index="tabIndex"
@create-new-integration="createNewIntegration"
@update-integration="updateIntegration"
@reset-token="resetToken"
@clear-current-integration="clearCurrentIntegration"
@test-alert-payload="testAlertPayload"
+ @save-and-test-alert-payload="saveAndTestAlertPayload"
/>
</div>
</template>
diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js
index ce6cf61b5dd..4a180ed2bc0 100644
--- a/app/assets/javascripts/alerts_settings/constants.js
+++ b/app/assets/javascripts/alerts_settings/constants.js
@@ -10,96 +10,118 @@ export const i18n = {
selectType: {
label: s__('AlertSettings|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.',
+ 'AlertSettings|Free versions of GitLab are limited to one integration per type. To add more, %{linkStart}upgrade your subscription%{linkEnd}.',
),
},
nameIntegration: {
label: s__('AlertSettings|Name integration'),
placeholder: s__('AlertSettings|Enter integration name'),
activeToggle: __('Active'),
+ error: __("Name can't be blank"),
+ },
+ enableIntegration: {
+ label: s__('AlertSettings|Enable integration'),
+ help: s__(
+ 'AlertSettings|A webhook URL and authorization key is generated for the integration. After you save the integration, both are visible under the “View credentials” tab.',
+ ),
},
setupCredentials: {
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.",
+ 'AlertSettings|Use the URL and authorization key below to configure how an external service sends alerts to GitLab. %{linkStart}How do I configure the endpoint?%{linkEnd}',
),
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.',
+ 'AlertSettings|Use the URL and authorization key below to configure how Prometheus sends alerts to GitLab. Review the %{linkStart}GitLab documentation%{linkEnd} to learn how to configure your endpoint.',
),
webhookUrl: s__('AlertSettings|Webhook URL'),
authorizationKey: s__('AlertSettings|Authorization key'),
reset: s__('AlertSettings|Reset Key'),
},
- setSamplePayload: {
- label: s__('AlertSettings|Sample alert payload (optional)'),
- testPayloadHelpHttp: 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).',
- ),
- testPayloadHelp: s__(
- 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This will allow you to send an alert to an active GitLab alerting point.',
+ mapFields: {
+ label: s__('AlertSettings|Customize alert payload mapping (optional)'),
+ help: s__(
+ 'AlertSettings|To create a custom mapping, enter an example payload from your monitoring tool, in JSON format. Select the "Parse payload fields" button to continue.',
),
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'),
- parsePayload: s__('AlertSettings|Parse payload for custom mapping'),
+ parsePayload: s__('AlertSettings|Parse payload fields'),
payloadParsedSucessMsg: s__(
'AlertSettings|Sample payload has been parsed. You can now map the fields.',
),
+ resetHeader: s__('AlertSettings|Reset the mapping'),
+ resetBody: s__('AlertSettings|If you edit the payload, you must re-map the fields again.'),
+ resetOk: s__('AlertSettings|Proceed with editing'),
+ mapIntro: s__(
+ 'AlertSettings|You can map default GitLab alert fields to your payload keys in the dropdowns below.',
+ ),
},
- mapFields: {
- label: s__('AlertSettings|Customize alert payload mapping (optional)'),
- intro: s__(
- 'AlertSettings|If you intend to create a custom mapping, provide an example payload from your monitoring tool and click "parse payload fields" button to continue. The sample payload is required for completing the custom mapping; if you want to skip the mapping step, progress straight to saving your integration.',
+ testPayload: {
+ help: s__(
+ 'AlertSettings|Enter an example payload from your selected monitoring tool. This supports sending alerts to a GitLab endpoint.',
),
+ placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'),
+ modalTitle: s__('AlertSettings|The form has unsaved changes'),
+ modalBody: s__('AlertSettings|The form has unsaved changes. How would you like to proceed?'),
+ savedAndTest: s__('AlertSettings|Save integration & send'),
+ proceedWithoutSave: s__('AlertSettings|Send without saving'),
+ cancel: __('Cancel'),
},
prometheusFormUrl: {
label: s__('AlertSettings|Prometheus API base URL'),
- help: s__('AlertSettings|URL cannot be blank and must start with http or https'),
+ help: s__('AlertSettings|URL cannot be blank and must start with http: or https:.'),
+ blankUrlError: __('URL cannot be blank'),
+ invalidUrlError: __('URL is invalid'),
},
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.',
+ 'AlertSettings|If you reset the authorization key for this project, you must update the key in every enabled alert source.',
),
},
},
saveIntegration: s__('AlertSettings|Save integration'),
- changesSaved: s__('AlertSettings|Your integration was successfully updated.'),
+ saveAndTestIntegration: s__('AlertSettings|Save & create test alert'),
cancelAndClose: __('Cancel and close'),
- send: s__('AlertSettings|Send'),
+ send: __('Send'),
copy: __('Copy'),
+ integrationCreated: {
+ title: s__('AlertSettings|Integration successfully saved'),
+ successMsg: s__(
+ 'AlertSettings|GitLab has created a URL and authorization key for your integration. You can use them to set up a webhook and authorize your endpoint to send alerts to GitLab.',
+ ),
+ btnCaption: s__('AlertSettings|View URL and authorization key'),
+ },
+ changesSaved: s__('AlertsIntegrations|The integration is saved.'),
+ integrationRemoved: s__('AlertsIntegrations|The integration is deleted.'),
+ alertSent: s__('AlertsIntegrations|The test alert should now be visible in your alerts list.'),
+ addNewIntegration: s__('AlertSettings|Add new integration'),
};
export const integrationSteps = {
selectType: 'SELECT_TYPE',
nameIntegration: 'NAME_INTEGRATION',
- setPrometheusApiUrl: 'SET_PROMETHEUS_API_URL',
- setSamplePayload: 'SET_SAMPLE_PAYLOAD',
+ enableHttpIntegration: 'ENABLE_HTTP_INTEGRATION',
+ enablePrometheusIntegration: 'ENABLE_PROMETHEUS_INTEGRATION',
customizeMapping: 'CUSTOMIZE_MAPPING',
};
export const createStepNumbers = {
[integrationSteps.selectType]: 1,
[integrationSteps.nameIntegration]: 2,
- [integrationSteps.setPrometheusApiUrl]: 2,
- [integrationSteps.setSamplePayload]: 3,
+ [integrationSteps.enableHttpIntegration]: 3,
+ [integrationSteps.enablePrometheusIntegration]: 2,
[integrationSteps.customizeMapping]: 4,
};
export const editStepNumbers = {
- [integrationSteps.selectType]: 1,
[integrationSteps.nameIntegration]: 1,
- [integrationSteps.setPrometheusApiUrl]: null,
- [integrationSteps.setSamplePayload]: 2,
+ [integrationSteps.enableHttpIntegration]: 2,
+ [integrationSteps.enablePrometheusIntegration]: null,
[integrationSteps.customizeMapping]: 3,
};
export const integrationTypes = {
none: { value: '', text: s__('AlertSettings|Select integration type') },
http: { value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') },
- prometheus: { value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') },
+ prometheus: { value: 'PROMETHEUS', text: s__('AlertSettings|Prometheus') },
};
export const typeSet = {
@@ -127,4 +149,10 @@ export const mappingFields = {
fallback: 'fallback',
};
-export const viewCredentialsTabIndex = 1;
+export const tabIndices = {
+ configureDetails: 0,
+ viewCredentials: 1,
+ sendTestAlert: 2,
+};
+
+export const testAlertModalId = 'confirmSendTestAlert';
diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js
index 321af9fedb6..953a867b2b7 100644
--- a/app/assets/javascripts/alerts_settings/index.js
+++ b/app/assets/javascripts/alerts_settings/index.js
@@ -3,12 +3,15 @@ import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import AlertSettingsWrapper from './components/alerts_settings_wrapper.vue';
import apolloProvider from './graphql';
+import getCurrentIntegrationQuery from './graphql/queries/get_current_integration.query.graphql';
-apolloProvider.clients.defaultClient.cache.writeData({
+apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: getCurrentIntegrationQuery,
data: {
currentIntegration: null,
},
});
+
Vue.use(GlToast);
export default (el) => {
@@ -16,23 +19,7 @@ export default (el) => {
return null;
}
- const {
- prometheusActivated,
- prometheusUrl,
- prometheusAuthorizationKey,
- prometheusFormPath,
- prometheusResetKeyPath,
- prometheusApiUrl,
- activated: activatedStr,
- alertsSetupUrl,
- alertsUsageUrl,
- formPath,
- authorizationKey,
- url,
- projectPath,
- multiIntegrations,
- alertFields,
- } = el.dataset;
+ const { alertsUsageUrl, projectPath, multiIntegrations, alertFields } = el.dataset;
return new Vue({
el,
@@ -40,22 +27,7 @@ export default (el) => {
AlertSettingsWrapper,
},
provide: {
- prometheus: {
- active: parseBoolean(prometheusActivated),
- url: prometheusUrl,
- token: prometheusAuthorizationKey,
- prometheusFormPath,
- prometheusResetKeyPath,
- prometheusApiUrl,
- },
- generic: {
- alertsSetupUrl,
- alertsUsageUrl,
- active: parseBoolean(activatedStr),
- formPath,
- token: authorizationKey,
- url,
- },
+ alertsUsageUrl,
projectPath,
multiIntegrations: parseBoolean(multiIntegrations),
},
diff --git a/app/assets/javascripts/alerts_settings/utils/error_messages.js b/app/assets/javascripts/alerts_settings/utils/error_messages.js
index e380257f983..9a0644b4e22 100644
--- a/app/assets/javascripts/alerts_settings/utils/error_messages.js
+++ b/app/assets/javascripts/alerts_settings/utils/error_messages.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
export const DELETE_INTEGRATION_ERROR = s__(
'AlertsIntegrations|The integration could not be deleted. Please try again.',
@@ -19,3 +19,9 @@ export const RESET_INTEGRATION_TOKEN_ERROR = s__(
export const INTEGRATION_PAYLOAD_TEST_ERROR = s__(
'AlertsIntegrations|Integration payload is invalid.',
);
+
+export const INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR = s__(
+ 'AlertsIntegrations|The integration is currently inactive. Enable the integration to send the test alert.',
+);
+
+export const DEFAULT_ERROR = __('Something went wrong on our end.');
diff --git a/app/assets/javascripts/analytics/usage_trends/components/charts_config.js b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js
index 014f823cdc4..ea11ecb0c5b 100644
--- a/app/assets/javascripts/analytics/usage_trends/components/charts_config.js
+++ b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js
@@ -83,7 +83,7 @@ export default [
'UsageTrends|Could not load the issues and merge requests chart. Please refresh the page to try again.',
),
noDataMessage,
- chartTitle: s__('UsageTrends|Issues & Merge Requests'),
+ chartTitle: s__('UsageTrends|Issues & merge requests'),
yAxisTitle: s__('UsageTrends|Items'),
xAxisTitle: s__('UsageTrends|Month'),
queries: [
diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
index 0630cca93ae..80ad36d0519 100644
--- a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
@@ -45,7 +45,7 @@ export default {
projects: s__('UsageTrends|Projects'),
groups: s__('UsageTrends|Groups'),
issues: s__('UsageTrends|Issues'),
- mergeRequests: s__('UsageTrends|Merge Requests'),
+ mergeRequests: s__('UsageTrends|Merge requests'),
pipelines: s__('UsageTrends|Pipelines'),
},
loadCountsError: s__('Could not load usage counts. Please refresh the page to try again.'),
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 48005787d81..516235657cb 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -44,7 +44,7 @@ const Api = {
projectMilestonesPath: '/api/:version/projects/:id/milestones',
projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid',
mergeRequestsPath: '/api/:version/merge_requests',
- groupLabelsPath: '/groups/:namespace_path/-/labels',
+ groupLabelsPath: '/api/:version/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',
@@ -79,6 +79,7 @@ const Api = {
issuePath: '/api/:version/projects/:id/issues/:issue_iid',
tagsPath: '/api/:version/projects/:id/repository/tags',
freezePeriodsPath: '/api/:version/projects/:id/freeze_periods',
+ freezePeriodPath: '/api/:version/projects/:id/freeze_periods/:freeze_period_id',
usageDataIncrementCounterPath: '/api/:version/usage_data/increment_counter',
usageDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users',
featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists',
@@ -282,7 +283,7 @@ const Api = {
},
/**
- * Get all Merge Requests for a project, eventually filtering based on
+ * Get all merge requests for a project, eventually filtering based on
* supplied parameters
* @param projectPath
* @param params
@@ -306,7 +307,7 @@ const Api = {
return axios.post(url, options);
},
- // Return Merge Request for project
+ // Return merge request for project
projectMergeRequest(projectPath, mergeRequestId, params = {}) {
const url = Api.buildUrl(Api.projectMergeRequestPath)
.replace(':id', encodeURIComponent(projectPath))
@@ -401,18 +402,29 @@ const Api = {
newLabel(namespacePath, projectPath, data, callback) {
let url;
+ let payload;
if (projectPath) {
url = Api.buildUrl(Api.projectLabelsPath)
.replace(':namespace_path', namespacePath)
.replace(':project_path', projectPath);
+ payload = {
+ label: data,
+ };
} else {
url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath);
+
+ // groupLabelsPath uses public API which accepts
+ // `name` and `color` props.
+ payload = {
+ name: data.title,
+ color: data.color,
+ };
}
return axios
.post(url, {
- label: data,
+ ...payload,
})
.then((res) => callback(res.data))
.catch((e) => callback(e.response.data));
@@ -784,7 +796,7 @@ const Api = {
return axios.delete(url, { data });
},
- getRawFile(id, path, params = { ref: 'master' }) {
+ getRawFile(id, path, params = {}) {
const url = Api.buildUrl(this.rawFilePath)
.replace(':id', encodeURIComponent(id))
.replace(':path', encodeURIComponent(path));
@@ -832,6 +844,14 @@ const Api = {
return axios.post(url, freezePeriod);
},
+ updateFreezePeriod(id, freezePeriod = {}) {
+ const url = Api.buildUrl(this.freezePeriodPath)
+ .replace(':id', encodeURIComponent(id))
+ .replace(':freeze_period_id', encodeURIComponent(freezePeriod.id));
+
+ return axios.put(url, freezePeriod);
+ },
+
trackRedisCounterEvent(event) {
if (!gon.features?.usageDataApi) {
return null;
diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js
index 5efc7063efa..27901120c53 100644
--- a/app/assets/javascripts/api/user_api.js
+++ b/app/assets/javascripts/api/user_api.js
@@ -55,12 +55,13 @@ export function getUserProjects(userId, query, options, callback) {
.catch(() => flash(__('Something went wrong while fetching projects')));
}
-export function updateUserStatus({ emoji, message, availability }) {
+export function updateUserStatus({ emoji, message, availability, clearStatusAfter }) {
const url = buildApiUrl(USER_POST_STATUS_PATH);
return axios.put(url, {
emoji,
message,
availability,
+ clear_status_after: clearStatusAfter,
});
}
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index dbdc7e43d2d..3a2f2078e44 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -446,7 +446,7 @@ export class AwardsHandler {
createAwardButtonForVotesBlock(votesBlock, emojiName) {
const buttonHtml = `
- <button class="btn award-control js-emoji-btn has-tooltip active" title="You">
+ <button class="gl-button btn btn-default award-control js-emoji-btn has-tooltip active" title="You">
${this.emoji.glEmojiTag(emojiName)}
<span class="award-control-text js-counter">1</span>
</button>
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
index 9e5d70075f3..309af368df9 100644
--- a/app/assets/javascripts/badges/components/badge.vue
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -1,7 +1,11 @@
<script>
import { GlLoadingIcon, GlTooltipDirective, GlIcon, GlButton } from '@gitlab/ui';
+import { s__ } from '~/locale';
export default {
+ i18n: {
+ buttonLabel: s__('Badges|Reload badge image'),
+ },
// name: 'Badge' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'Badge',
@@ -94,7 +98,8 @@ export default {
<gl-button
v-show="hasError"
v-gl-tooltip.hover
- :title="s__('Badges|Reload badge image')"
+ :title="$options.i18n.buttonLabel"
+ :aria-label="$options.i18n.buttonLabel"
category="tertiary"
variant="confirm"
type="button"
diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue
index 756bcfdb3d0..753608cf6f7 100644
--- a/app/assets/javascripts/batch_comments/components/preview_item.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_item.vue
@@ -41,13 +41,17 @@ export default {
titleText() {
const file = this.discussion ? this.discussion.diff_file : this.draft;
- if (file) {
+ if (file?.file_path) {
return file.file_path;
}
- return sprintf(__("%{authorsName}'s thread"), {
- authorsName: this.discussion.notes.find((note) => !note.system).author.name,
- });
+ if (this.discussion) {
+ return sprintf(__("%{authorsName}'s thread"), {
+ authorsName: this.discussion.notes.find((note) => !note.system).author.name,
+ });
+ }
+
+ return __('Your new comment');
},
linePosition() {
if (this.position?.position_type === IMAGE_DIFF_POSITION_TYPE) {
@@ -94,7 +98,7 @@ export default {
<span class="review-preview-item-header">
<gl-icon class="flex-shrink-0" :name="iconName" />
<span class="bold text-nowrap gl-align-items-center">
- <span class="review-preview-item-header-text block-truncated">
+ <span class="review-preview-item-header-text block-truncated gl-ml-2">
{{ titleText }}
</span>
<template v-if="showLinePosition">
diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
index 0b085da1ff9..bec360e3b2e 100644
--- a/app/assets/javascripts/batch_comments/mixins/resolved_status.js
+++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
@@ -1,9 +1,7 @@
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,
@@ -52,16 +50,18 @@ export default {
return this.resolveDiscussion ? 'is-resolving-discussion' : 'is-unresolving-discussion';
},
resolveButtonTitle() {
- if (this.isDraft || this.discussionId) return this.resolvedStatusMessage;
+ const escapeParameters = false;
- let title = __('Mark as resolved');
+ if (this.isDraft || this.discussionId) return this.resolvedStatusMessage;
- if (this.glFeatures.removeResolveNote) {
- title = __('Resolve thread');
- }
+ let title = __('Resolve thread');
if (this.resolvedBy) {
- title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name });
+ title = sprintf(
+ __('Resolved by %{name}'),
+ { name: this.resolvedBy.name },
+ escapeParameters,
+ );
}
return title;
diff --git a/app/assets/javascripts/behaviors/deprecated_remove_row_behavior.js b/app/assets/javascripts/behaviors/deprecated_remove_row_behavior.js
new file mode 100644
index 00000000000..5731474e3a4
--- /dev/null
+++ b/app/assets/javascripts/behaviors/deprecated_remove_row_behavior.js
@@ -0,0 +1,15 @@
+import $ from 'jquery';
+
+export default function initDeprecatedRemoveRowBehavior() {
+ $('.js-remove-row').on('ajax:success', function removeRowAjaxSuccessCallback() {
+ $(this).closest('li').addClass('gl-display-none!');
+ });
+
+ $('.js-remove-tr').on('ajax:before', function removeTRAjaxBeforeCallback() {
+ $(this).parent().find('.btn').addClass('disabled');
+ });
+
+ $('.js-remove-tr').on('ajax:success', function removeTRAjaxSuccessCallback() {
+ $(this).closest('tr').addClass('gl-display-none!');
+ });
+}
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index 0cb13815c7e..5b5148a850b 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import { once } from 'lodash';
import { deprecatedCreateFlash as flash } from '~/flash';
+import { darkModeEnabled } from '~/lib/utils/color_utils';
import { __, sprintf } from '~/locale';
// Renders diagrams and flowcharts from text using Mermaid in any element with the
@@ -27,37 +28,34 @@ let renderedMermaidBlocks = 0;
let mermaidModule = {};
+export function initMermaid(mermaid) {
+ let theme = 'neutral';
+
+ if (darkModeEnabled()) {
+ theme = 'dark';
+ }
+
+ mermaid.initialize({
+ // mermaid core options
+ mermaid: {
+ startOnLoad: false,
+ },
+ // mermaidAPI options
+ theme,
+ flowchart: {
+ useMaxWidth: true,
+ htmlLabels: false,
+ },
+ securityLevel: 'strict',
+ });
+
+ return mermaid;
+}
+
function importMermaidModule() {
return import(/* webpackChunkName: 'mermaid' */ 'mermaid')
.then((mermaid) => {
- let theme = 'neutral';
- const ideDarkThemes = ['dark', 'solarized-dark', 'monokai'];
-
- if (
- ideDarkThemes.includes(window.gon?.user_color_scheme) &&
- // if on the Web IDE page
- document.querySelector('.ide')
- ) {
- theme = 'dark';
- }
-
- mermaid.initialize({
- // mermaid core options
- mermaid: {
- startOnLoad: false,
- },
- // mermaidAPI options
- theme,
- flowchart: {
- useMaxWidth: true,
- htmlLabels: false,
- },
- securityLevel: 'strict',
- });
-
- mermaidModule = mermaid;
-
- return mermaid;
+ mermaidModule = initMermaid(mermaid);
})
.catch((err) => {
flash(sprintf(__("Can't load mermaid module: %{err}"), { err }));
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index a8fe00d26e6..6abbd7f3243 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -1,6 +1,6 @@
import { memoize } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
-import { s__ } from '~/locale';
+import { __ } from '~/locale';
const isCustomizable = (command) =>
'customizable' in command ? Boolean(command.customizable) : true;
@@ -33,42 +33,608 @@ export const getCustomizations = memoize(() => {
});
// All available commands
+export const TOGGLE_KEYBOARD_SHORTCUTS_DIALOG = {
+ id: 'globalShortcuts.toggleKeyboardShortcutsDialog',
+ description: __('Toggle keyboard shortcuts help dialog'),
+ defaultKeys: ['?'],
+};
+
+export const GO_TO_YOUR_PROJECTS = {
+ id: 'globalShortcuts.goToYourProjects',
+ description: __('Go to your projects'),
+ defaultKeys: ['shift+p'],
+};
+
+export const GO_TO_YOUR_GROUPS = {
+ id: 'globalShortcuts.goToYourGroups',
+ description: __('Go to your groups'),
+ defaultKeys: ['shift+g'],
+};
+
+export const GO_TO_ACTIVITY_FEED = {
+ id: 'globalShortcuts.goToActivityFeed',
+ description: __('Go to the activity feed'),
+ defaultKeys: ['shift+a'],
+};
+
+export const GO_TO_MILESTONE_LIST = {
+ id: 'globalShortcuts.goToMilestoneList',
+ description: __('Go to the milestone list'),
+ defaultKeys: ['shift+l'],
+};
+
+export const GO_TO_YOUR_SNIPPETS = {
+ id: 'globalShortcuts.goToYourSnippets',
+ description: __('Go to your snippets'),
+ defaultKeys: ['shift+s'],
+};
+
+export const START_SEARCH = {
+ id: 'globalShortcuts.startSearch',
+ description: __('Start search'),
+ defaultKeys: ['s', '/'],
+};
+
+export const FOCUS_FILTER_BAR = {
+ id: 'globalShortcuts.focusFilterBar',
+ description: __('Focus filter bar'),
+ defaultKeys: ['f'],
+};
+
+export const GO_TO_YOUR_ISSUES = {
+ id: 'globalShortcuts.goToYourIssues',
+ description: __('Go to your issues'),
+ defaultKeys: ['shift+i'],
+};
+
+export const GO_TO_YOUR_MERGE_REQUESTS = {
+ id: 'globalShortcuts.goToYourMergeRequests',
+ description: __('Go to your merge requests'),
+ defaultKeys: ['shift+m'],
+};
+
+export const GO_TO_YOUR_TODO_LIST = {
+ id: 'globalShortcuts.goToYourTodoList',
+ description: __('Go to your To-Do list'),
+ defaultKeys: ['shift+t'],
+};
+
export const TOGGLE_PERFORMANCE_BAR = {
id: 'globalShortcuts.togglePerformanceBar',
- description: s__('KeyboardShortcuts|Toggle the Performance Bar'),
- // eslint-disable-next-line @gitlab/require-i18n-strings
- defaultKeys: ['p b'],
+ description: __('Toggle the Performance Bar'),
+ defaultKeys: ['p b'], // eslint-disable-line @gitlab/require-i18n-strings
};
export const TOGGLE_CANARY = {
id: 'globalShortcuts.toggleCanary',
- description: s__('KeyboardShortcuts|Toggle GitLab Next'),
- // eslint-disable-next-line @gitlab/require-i18n-strings
- defaultKeys: ['g x'],
+ description: __('Toggle GitLab Next'),
+ defaultKeys: ['g x'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const BOLD_TEXT = {
+ id: 'editing.boldText',
+ description: __('Bold text'),
+ defaultKeys: ['mod+b'],
+ customizable: false,
+};
+
+export const ITALIC_TEXT = {
+ id: 'editing.italicText',
+ description: __('Italic text'),
+ defaultKeys: ['mod+i'],
+ customizable: false,
+};
+
+export const LINK_TEXT = {
+ id: 'editing.linkText',
+ description: __('Link text'),
+ defaultKeys: ['mod+k'],
+ customizable: false,
+};
+
+export const TOGGLE_MARKDOWN_PREVIEW = {
+ id: 'editing.toggleMarkdownPreview',
+ description: __('Toggle Markdown preview'),
+ // Note: Ideally, keyboard shortcuts should be made cross-platform by using the special `mod` key
+ // instead of binding both `ctrl` and `command` versions of the shortcut.
+ // See https://docs.gitlab.com/ee/development/fe_guide/keyboard_shortcuts.html#make-cross-platform-shortcuts.
+ // However, this particular shortcut has been in place since before the `mod` key was available.
+ // We've chosen to leave this implemented as-is for the time being to avoid breaking people's workflows.
+ // See discussion in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45308#note_527490548.
+ defaultKeys: ['ctrl+shift+p', 'command+shift+p'],
+};
+
+export const EDIT_RECENT_COMMENT = {
+ id: 'editing.editRecentComment',
+ description: __('Edit your most recent comment in a thread (from an empty textarea)'),
+ defaultKeys: ['up'],
+};
+
+export const EDIT_WIKI_PAGE = {
+ id: 'wiki.editWikiPage',
+ description: __('Edit wiki page'),
+ defaultKeys: ['e'],
+};
+
+export const REPO_GRAPH_SCROLL_LEFT = {
+ id: 'repositoryGraph.scrollLeft',
+ description: __('Scroll left'),
+ defaultKeys: ['left', 'h'],
+};
+
+export const REPO_GRAPH_SCROLL_RIGHT = {
+ id: 'repositoryGraph.scrollRight',
+ description: __('Scroll right'),
+ defaultKeys: ['right', 'l'],
+};
+
+export const REPO_GRAPH_SCROLL_UP = {
+ id: 'repositoryGraph.scrollUp',
+ description: __('Scroll up'),
+ defaultKeys: ['up', 'k'],
+};
+
+export const REPO_GRAPH_SCROLL_DOWN = {
+ id: 'repositoryGraph.scrollDown',
+ description: __('Scroll down'),
+ defaultKeys: ['down', 'j'],
+};
+
+export const REPO_GRAPH_SCROLL_TOP = {
+ id: 'repositoryGraph.scrollToTop',
+ description: __('Scroll to top'),
+ defaultKeys: ['shift+up', 'shift+k'],
+};
+
+export const REPO_GRAPH_SCROLL_BOTTOM = {
+ id: 'repositoryGraph.scrollToBottom',
+ description: __('Scroll to bottom'),
+ defaultKeys: ['shift+down', 'shift+j'],
+};
+
+export const GO_TO_PROJECT_OVERVIEW = {
+ id: 'project.goToOverview',
+ description: __("Go to the project's overview page"),
+ defaultKeys: ['g p'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_ACTIVITY_FEED = {
+ id: 'project.goToActivityFeed',
+ description: __("Go to the project's activity feed"),
+ defaultKeys: ['g v'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_RELEASES = {
+ id: 'project.goToReleases',
+ description: __('Go to releases'),
+ defaultKeys: ['g r'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_FILES = {
+ id: 'project.goToFiles',
+ description: __('Go to files'),
+ defaultKeys: ['g f'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_FIND_FILE = {
+ id: 'project.goToFindFile',
+ description: __('Go to find file'),
+ defaultKeys: ['t'],
+};
+
+export const GO_TO_PROJECT_COMMITS = {
+ id: 'project.goToCommits',
+ description: __('Go to commits'),
+ defaultKeys: ['g c'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_REPO_GRAPH = {
+ id: 'project.goToRepoGraph',
+ description: __('Go to repository graph'),
+ defaultKeys: ['g n'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_REPO_CHARTS = {
+ id: 'project.goToRepoCharts',
+ description: __('Go to repository charts'),
+ defaultKeys: ['g d'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_ISSUES = {
+ id: 'project.goToIssues',
+ description: __('Go to issues'),
+ defaultKeys: ['g i'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const NEW_ISSUE = {
+ id: 'project.newIssue',
+ description: __('New issue'),
+ defaultKeys: ['i'],
+};
+
+export const GO_TO_PROJECT_ISSUE_BOARDS = {
+ id: 'project.goToIssueBoards',
+ description: __('Go to issue boards'),
+ defaultKeys: ['g b'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_MERGE_REQUESTS = {
+ id: 'project.goToMergeRequests',
+ description: __('Go to merge requests'),
+ defaultKeys: ['g m'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_JOBS = {
+ id: 'project.goToJobs',
+ description: __('Go to jobs'),
+ defaultKeys: ['g j'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_METRICS = {
+ id: 'project.goToMetrics',
+ description: __('Go to metrics'),
+ defaultKeys: ['g l'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_ENVIRONMENTS = {
+ id: 'project.goToEnvironments',
+ description: __('Go to environments'),
+ defaultKeys: ['g e'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_KUBERNETES = {
+ id: 'project.goToKubernetes',
+ description: __('Go to kubernetes'),
+ defaultKeys: ['g k'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_SNIPPETS = {
+ id: 'project.goToSnippets',
+ description: __('Go to snippets'),
+ defaultKeys: ['g s'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_WIKI = {
+ id: 'project.goToWiki',
+ description: __('Go to wiki'),
+ defaultKeys: ['g w'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const PROJECT_FILES_MOVE_SELECTION_UP = {
+ id: 'projectFiles.moveSelectionUp',
+ description: __('Move selection up'),
+ defaultKeys: ['up'],
+};
+
+export const PROJECT_FILES_MOVE_SELECTION_DOWN = {
+ id: 'projectFiles.moveSelectionDown',
+ description: __('Move selection down'),
+ defaultKeys: ['down'],
+};
+
+export const PROJECT_FILES_OPEN_SELECTION = {
+ id: 'projectFiles.openSelection',
+ description: __('Open Selection'),
+ defaultKeys: ['enter'],
+};
+
+export const PROJECT_FILES_GO_BACK = {
+ id: 'projectFiles.goBack',
+ description: __('Go back (while searching for files)'),
+ defaultKeys: ['esc'],
+};
+
+export const PROJECT_FILES_GO_TO_PERMALINK = {
+ id: 'projectFiles.goToFilePermalink',
+ description: __('Go to file permalink (while viewing a file)'),
+ defaultKeys: ['y'],
+};
+
+export const ISSUABLE_COMMENT_OR_REPLY = {
+ id: 'issuables.commentReply',
+ description: __('Comment/Reply (quoting selected text)'),
+ defaultKeys: ['r'],
+};
+
+export const ISSUABLE_EDIT_DESCRIPTION = {
+ id: 'issuables.editDescription',
+ description: __('Edit description'),
+ defaultKeys: ['e'],
+};
+
+export const ISSUABLE_CHANGE_LABEL = {
+ id: 'issuables.changeLabel',
+ description: __('Change label'),
+ defaultKeys: ['l'],
+};
+
+export const ISSUE_MR_CHANGE_ASSIGNEE = {
+ id: 'issuesMRs.changeAssignee',
+ description: __('Change assignee'),
+ defaultKeys: ['a'],
+};
+
+export const ISSUE_MR_CHANGE_MILESTONE = {
+ id: 'issuesMRs.changeMilestone',
+ description: __('Change milestone'),
+ defaultKeys: ['m'],
+};
+
+export const MR_NEXT_FILE_IN_DIFF = {
+ id: 'mergeRequests.nextFileInDiff',
+ description: __('Next file in diff'),
+ defaultKeys: [']', 'j'],
+};
+
+export const MR_PREVIOUS_FILE_IN_DIFF = {
+ id: 'mergeRequests.previousFileInDiff',
+ description: __('Previous file in diff'),
+ defaultKeys: ['[', 'k'],
+};
+
+export const MR_GO_TO_FILE = {
+ id: 'mergeRequests.goToFile',
+ description: __('Go to file'),
+ defaultKeys: ['t', 'mod+p'],
+ customizable: false,
+};
+
+export const MR_NEXT_UNRESOLVED_DISCUSSION = {
+ id: 'mergeRequests.nextUnresolvedDiscussion',
+ description: __('Next unresolved discussion'),
+ defaultKeys: ['n'],
+};
+
+export const MR_PREVIOUS_UNRESOLVED_DISCUSSION = {
+ id: 'mergeRequests.previousUnresolvedDiscussion',
+ description: __('Previous unresolved discussion'),
+ defaultKeys: ['p'],
+};
+
+export const MR_COPY_SOURCE_BRANCH_NAME = {
+ id: 'mergeRequests.copySourceBranchName',
+ description: __('Copy source branch name'),
+ defaultKeys: ['b'],
+};
+
+export const MR_COMMITS_NEXT_COMMIT = {
+ id: 'mergeRequestCommits.nextCommit',
+ description: __('Next commit'),
+ defaultKeys: ['c'],
+};
+
+export const MR_COMMITS_PREVIOUS_COMMIT = {
+ id: 'mergeRequestCommits.previousCommit',
+ description: __('Previous commit'),
+ defaultKeys: ['x'],
+};
+
+export const ISSUE_NEXT_DESIGN = {
+ id: 'issues.nextDesign',
+ description: __('Next design'),
+ defaultKeys: ['right'],
+};
+
+export const ISSUE_PREVIOUS_DESIGN = {
+ id: 'issues.previousDesign',
+ description: __('Previous design'),
+ defaultKeys: ['left'],
+};
+
+export const ISSUE_CLOSE_DESIGN = {
+ id: 'issues.closeDesign',
+ description: __('Close design'),
+ defaultKeys: ['esc'],
+};
+
+export const WEB_IDE_GO_TO_FILE = {
+ id: 'webIDE.goToFile',
+ description: __('Go to file'),
+ defaultKeys: ['mod+p'],
};
export const WEB_IDE_COMMIT = {
id: 'webIDE.commit',
- description: s__('KeyboardShortcuts|Commit (when editing commit message)'),
+ description: __('Commit (when editing commit message)'),
defaultKeys: ['mod+enter'],
customizable: false,
};
+export const METRICS_EXPAND_PANEL = {
+ id: 'metrics.expandPanel',
+ description: __('Expand panel'),
+ defaultKeys: ['e'],
+ customizable: false,
+};
+
+export const METRICS_VIEW_LOGS = {
+ id: 'metrics.viewLogs',
+ description: __('View logs'),
+ defaultKeys: ['l'],
+ customizable: false,
+};
+
+export const METRICS_DOWNLOAD_CSV = {
+ id: 'metrics.downloadCSV',
+ description: __('Download CSV'),
+ defaultKeys: ['d'],
+ customizable: false,
+};
+
+export const METRICS_COPY_LINK_TO_CHART = {
+ id: 'metrics.copyLinkToChart',
+ description: __('Copy link to chart'),
+ defaultKeys: ['c'],
+ customizable: false,
+};
+
+export const METRICS_SHOW_ALERTS = {
+ id: 'metrics.showAlerts',
+ description: __('Alerts'),
+ defaultKeys: ['a'],
+ customizable: false,
+};
+
// All keybinding groups
export const GLOBAL_SHORTCUTS_GROUP = {
id: 'globalShortcuts',
- name: s__('KeyboardShortcuts|Global Shortcuts'),
- keybindings: [TOGGLE_PERFORMANCE_BAR, TOGGLE_CANARY],
+ name: __('Global Shortcuts'),
+ keybindings: [
+ TOGGLE_KEYBOARD_SHORTCUTS_DIALOG,
+ GO_TO_YOUR_PROJECTS,
+ GO_TO_YOUR_GROUPS,
+ GO_TO_ACTIVITY_FEED,
+ GO_TO_MILESTONE_LIST,
+ GO_TO_YOUR_SNIPPETS,
+ START_SEARCH,
+ FOCUS_FILTER_BAR,
+ GO_TO_YOUR_ISSUES,
+ GO_TO_YOUR_MERGE_REQUESTS,
+ GO_TO_YOUR_TODO_LIST,
+ TOGGLE_PERFORMANCE_BAR,
+ ],
+};
+
+export const EDITING_SHORTCUTS_GROUP = {
+ id: 'editing',
+ name: __('Editing'),
+ keybindings: [BOLD_TEXT, ITALIC_TEXT, LINK_TEXT, TOGGLE_MARKDOWN_PREVIEW, EDIT_RECENT_COMMENT],
+};
+
+export const WIKI_SHORTCUTS_GROUP = {
+ id: 'wiki',
+ name: __('Wiki'),
+ keybindings: [EDIT_WIKI_PAGE],
+};
+
+export const REPOSITORY_GRAPH_SHORTCUTS_GROUP = {
+ id: 'repositoryGraph',
+ name: __('Repository Graph'),
+ keybindings: [
+ REPO_GRAPH_SCROLL_LEFT,
+ REPO_GRAPH_SCROLL_RIGHT,
+ REPO_GRAPH_SCROLL_UP,
+ REPO_GRAPH_SCROLL_DOWN,
+ REPO_GRAPH_SCROLL_TOP,
+ REPO_GRAPH_SCROLL_BOTTOM,
+ ],
+};
+
+export const PROJECT_SHORTCUTS_GROUP = {
+ id: 'project',
+ name: __('Project'),
+ keybindings: [
+ GO_TO_PROJECT_OVERVIEW,
+ GO_TO_PROJECT_ACTIVITY_FEED,
+ GO_TO_PROJECT_RELEASES,
+ GO_TO_PROJECT_FILES,
+ GO_TO_PROJECT_FIND_FILE,
+ GO_TO_PROJECT_COMMITS,
+ GO_TO_PROJECT_REPO_GRAPH,
+ GO_TO_PROJECT_REPO_CHARTS,
+ GO_TO_PROJECT_ISSUES,
+ NEW_ISSUE,
+ GO_TO_PROJECT_ISSUE_BOARDS,
+ GO_TO_PROJECT_MERGE_REQUESTS,
+ GO_TO_PROJECT_JOBS,
+ GO_TO_PROJECT_METRICS,
+ GO_TO_PROJECT_ENVIRONMENTS,
+ GO_TO_PROJECT_KUBERNETES,
+ GO_TO_PROJECT_SNIPPETS,
+ GO_TO_PROJECT_WIKI,
+ ],
+};
+
+export const PROJECT_FILES_SHORTCUTS_GROUP = {
+ id: 'projectFiles',
+ name: __('Project Files'),
+ keybindings: [
+ PROJECT_FILES_MOVE_SELECTION_UP,
+ PROJECT_FILES_MOVE_SELECTION_DOWN,
+ PROJECT_FILES_OPEN_SELECTION,
+ PROJECT_FILES_GO_BACK,
+ PROJECT_FILES_GO_TO_PERMALINK,
+ ],
+};
+
+export const ISSUABLE_SHORTCUTS_GROUP = {
+ id: 'issuables',
+ name: __('Epics, issues, and merge requests'),
+ keybindings: [ISSUABLE_COMMENT_OR_REPLY, ISSUABLE_EDIT_DESCRIPTION, ISSUABLE_CHANGE_LABEL],
+};
+
+export const ISSUE_MR_SHORTCUTS_GROUP = {
+ id: 'issuesMRs',
+ name: __('Issues and merge requests'),
+ keybindings: [ISSUE_MR_CHANGE_ASSIGNEE, ISSUE_MR_CHANGE_MILESTONE],
};
-export const WEB_IDE_GROUP = {
+export const MR_SHORTCUTS_GROUP = {
+ id: 'mergeRequests',
+ name: __('Merge requests'),
+ keybindings: [
+ MR_NEXT_FILE_IN_DIFF,
+ MR_PREVIOUS_FILE_IN_DIFF,
+ MR_GO_TO_FILE,
+ MR_NEXT_UNRESOLVED_DISCUSSION,
+ MR_PREVIOUS_UNRESOLVED_DISCUSSION,
+ MR_COPY_SOURCE_BRANCH_NAME,
+ ],
+};
+
+export const MR_COMMITS_SHORTCUTS_GROUP = {
+ id: 'mergeRequestCommits',
+ name: __('Merge request commits'),
+ keybindings: [MR_COMMITS_NEXT_COMMIT, MR_COMMITS_PREVIOUS_COMMIT],
+};
+
+export const ISSUES_SHORTCUTS_GROUP = {
+ id: 'issues',
+ name: __('Issues'),
+ keybindings: [ISSUE_NEXT_DESIGN, ISSUE_PREVIOUS_DESIGN, ISSUE_CLOSE_DESIGN],
+};
+
+export const WEB_IDE_SHORTCUTS_GROUP = {
id: 'webIDE',
- name: s__('KeyboardShortcuts|Web IDE'),
- keybindings: [WEB_IDE_COMMIT],
+ name: __('Web IDE'),
+ keybindings: [WEB_IDE_GO_TO_FILE, WEB_IDE_COMMIT],
+};
+
+export const METRICS_SHORTCUTS_GROUP = {
+ id: 'metrics',
+ name: __('Metrics'),
+ keybindings: [
+ METRICS_EXPAND_PANEL,
+ METRICS_VIEW_LOGS,
+ METRICS_DOWNLOAD_CSV,
+ METRICS_COPY_LINK_TO_CHART,
+ METRICS_SHOW_ALERTS,
+ ],
+};
+
+export const MISC_SHORTCUTS_GROUP = {
+ id: 'misc',
+ name: __('Miscellaneous'),
+ keybindings: [TOGGLE_CANARY],
};
/** All keybindings, grouped and ordered with descriptions */
-export const keybindingGroups = [GLOBAL_SHORTCUTS_GROUP, WEB_IDE_GROUP];
+export const keybindingGroups = [
+ GLOBAL_SHORTCUTS_GROUP,
+ EDITING_SHORTCUTS_GROUP,
+ WIKI_SHORTCUTS_GROUP,
+ REPOSITORY_GRAPH_SHORTCUTS_GROUP,
+ PROJECT_SHORTCUTS_GROUP,
+ PROJECT_FILES_SHORTCUTS_GROUP,
+ ISSUABLE_SHORTCUTS_GROUP,
+ ISSUE_MR_SHORTCUTS_GROUP,
+ MR_SHORTCUTS_GROUP,
+ MR_COMMITS_SHORTCUTS_GROUP,
+ ISSUES_SHORTCUTS_GROUP,
+ WEB_IDE_SHORTCUTS_GROUP,
+ METRICS_SHORTCUTS_GROUP,
+ MISC_SHORTCUTS_GROUP,
+];
/**
* Gets keyboard shortcuts associated with a command
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index e4ec68601e0..03cba78cf31 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -6,13 +6,29 @@ import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import findAndFollowLink from '~/lib/utils/navigation_utility';
import { refreshCurrentPage, visitUrl } from '~/lib/utils/url_utility';
-
-import { keysFor, TOGGLE_PERFORMANCE_BAR, TOGGLE_CANARY } from './keybindings';
+import {
+ keysFor,
+ TOGGLE_KEYBOARD_SHORTCUTS_DIALOG,
+ START_SEARCH,
+ FOCUS_FILTER_BAR,
+ TOGGLE_PERFORMANCE_BAR,
+ TOGGLE_CANARY,
+ TOGGLE_MARKDOWN_PREVIEW,
+ GO_TO_YOUR_TODO_LIST,
+ GO_TO_ACTIVITY_FEED,
+ GO_TO_YOUR_ISSUES,
+ GO_TO_YOUR_MERGE_REQUESTS,
+ GO_TO_YOUR_PROJECTS,
+ GO_TO_YOUR_GROUPS,
+ GO_TO_MILESTONE_LIST,
+ GO_TO_YOUR_SNIPPETS,
+ GO_TO_PROJECT_FIND_FILE,
+} from './keybindings';
import { disableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle';
const defaultStopCallback = Mousetrap.prototype.stopCallback;
Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) {
- if (['ctrl+shift+p', 'command+shift+p'].indexOf(combo) !== -1) {
+ if (keysFor(TOGGLE_MARKDOWN_PREVIEW).indexOf(combo) !== -1) {
return false;
}
@@ -58,28 +74,41 @@ export default class Shortcuts {
this.helpModalElement = null;
this.helpModalVueInstance = null;
- Mousetrap.bind('?', this.onToggleHelp);
- Mousetrap.bind('s', Shortcuts.focusSearch);
- Mousetrap.bind('/', Shortcuts.focusSearch);
- Mousetrap.bind('f', this.focusFilter.bind(this));
+ Mousetrap.bind(keysFor(TOGGLE_KEYBOARD_SHORTCUTS_DIALOG), this.onToggleHelp);
+ Mousetrap.bind(keysFor(START_SEARCH), Shortcuts.focusSearch);
+ Mousetrap.bind(keysFor(FOCUS_FILTER_BAR), this.focusFilter.bind(this));
Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), Shortcuts.onTogglePerfBar);
Mousetrap.bind(keysFor(TOGGLE_CANARY), Shortcuts.onToggleCanary);
const findFileURL = document.body.dataset.findFile;
- Mousetrap.bind('shift+t', () => findAndFollowLink('.shortcuts-todos'));
- Mousetrap.bind('shift+a', () => findAndFollowLink('.dashboard-shortcuts-activity'));
- Mousetrap.bind('shift+i', () => findAndFollowLink('.dashboard-shortcuts-issues'));
- Mousetrap.bind('shift+m', () => findAndFollowLink('.dashboard-shortcuts-merge_requests'));
- Mousetrap.bind('shift+p', () => findAndFollowLink('.dashboard-shortcuts-projects'));
- Mousetrap.bind('shift+g', () => findAndFollowLink('.dashboard-shortcuts-groups'));
- Mousetrap.bind('shift+l', () => findAndFollowLink('.dashboard-shortcuts-milestones'));
- Mousetrap.bind('shift+s', () => findAndFollowLink('.dashboard-shortcuts-snippets'));
-
- Mousetrap.bind(['ctrl+shift+p', 'command+shift+p'], Shortcuts.toggleMarkdownPreview);
+ Mousetrap.bind(keysFor(GO_TO_YOUR_TODO_LIST), () => findAndFollowLink('.shortcuts-todos'));
+ Mousetrap.bind(keysFor(GO_TO_ACTIVITY_FEED), () =>
+ findAndFollowLink('.dashboard-shortcuts-activity'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_YOUR_ISSUES), () =>
+ findAndFollowLink('.dashboard-shortcuts-issues'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_YOUR_MERGE_REQUESTS), () =>
+ findAndFollowLink('.dashboard-shortcuts-merge_requests'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_YOUR_PROJECTS), () =>
+ findAndFollowLink('.dashboard-shortcuts-projects'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_YOUR_GROUPS), () =>
+ findAndFollowLink('.dashboard-shortcuts-groups'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_MILESTONE_LIST), () =>
+ findAndFollowLink('.dashboard-shortcuts-milestones'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_YOUR_SNIPPETS), () =>
+ findAndFollowLink('.dashboard-shortcuts-snippets'),
+ );
+
+ Mousetrap.bind(keysFor(TOGGLE_MARKDOWN_PREVIEW), Shortcuts.toggleMarkdownPreview);
if (typeof findFileURL !== 'undefined' && findFileURL !== null) {
- Mousetrap.bind('t', () => {
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_FIND_FILE), () => {
visitUrl(findFileURL);
});
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
index 11b4fcd4e1c..ab7fcbb35f1 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
@@ -1,4 +1,5 @@
import Mousetrap from 'mousetrap';
+import { keysFor, PROJECT_FILES_GO_TO_PERMALINK } from '~/behaviors/shortcuts/keybindings';
import {
getLocationHash,
updateHistory,
@@ -28,7 +29,7 @@ export default class ShortcutsBlob extends Shortcuts {
this.shortcircuitPermalinkButton();
- Mousetrap.bind('y', this.moveToFilePermalink.bind(this));
+ Mousetrap.bind(keysFor(PROJECT_FILES_GO_TO_PERMALINK), this.moveToFilePermalink.bind(this));
}
moveToFilePermalink() {
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js
index f0d2ecfd210..992e571e596 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js
@@ -1,4 +1,11 @@
import Mousetrap from 'mousetrap';
+import {
+ keysFor,
+ PROJECT_FILES_MOVE_SELECTION_UP,
+ PROJECT_FILES_MOVE_SELECTION_DOWN,
+ PROJECT_FILES_OPEN_SELECTION,
+ PROJECT_FILES_GO_BACK,
+} from '~/behaviors/shortcuts/keybindings';
import ShortcutsNavigation from './shortcuts_navigation';
export default class ShortcutsFindFile extends ShortcutsNavigation {
@@ -10,7 +17,10 @@ export default class ShortcutsFindFile extends ShortcutsNavigation {
Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) {
if (
element === projectFindFile.inputElement[0] &&
- (combo === 'up' || combo === 'down' || combo === 'esc' || combo === 'enter')
+ (keysFor(PROJECT_FILES_MOVE_SELECTION_UP).includes(combo) ||
+ keysFor(PROJECT_FILES_MOVE_SELECTION_DOWN).includes(combo) ||
+ keysFor(PROJECT_FILES_GO_BACK).includes(combo) ||
+ keysFor(PROJECT_FILES_OPEN_SELECTION).includes(combo))
) {
// when press up/down key in textbox, cursor prevent to move to home/end
e.preventDefault();
@@ -20,9 +30,9 @@ export default class ShortcutsFindFile extends ShortcutsNavigation {
return oldStopCallback.call(this, e, element, combo);
};
- Mousetrap.bind('up', projectFindFile.selectRowUp);
- Mousetrap.bind('down', projectFindFile.selectRowDown);
- Mousetrap.bind('esc', projectFindFile.goToTree);
- Mousetrap.bind('enter', projectFindFile.goToBlob);
+ Mousetrap.bind(keysFor(PROJECT_FILES_MOVE_SELECTION_UP), projectFindFile.selectRowUp);
+ Mousetrap.bind(keysFor(PROJECT_FILES_MOVE_SELECTION_DOWN), projectFindFile.selectRowDown);
+ Mousetrap.bind(keysFor(PROJECT_FILES_GO_BACK), projectFindFile.goToTree);
+ Mousetrap.bind(keysFor(PROJECT_FILES_OPEN_SELECTION), projectFindFile.goToBlob);
}
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue
index 1277dd0ed37..49216cc4aa0 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue
@@ -397,7 +397,7 @@ export default {
<tbody>
<tr>
<th></th>
- <th>{{ __('Epics, Issues, and Merge Requests') }}</th>
+ <th>{{ __('Epics, issues, and merge requests') }}</th>
</tr>
<tr>
<td class="shortcut">
@@ -421,7 +421,7 @@ export default {
<tbody>
<tr>
<th></th>
- <th>{{ __('Issues and Merge Requests') }}</th>
+ <th>{{ __('Issues and merge requests') }}</th>
</tr>
<tr>
<td class="shortcut">
@@ -439,7 +439,7 @@ export default {
<tbody>
<tr>
<th></th>
- <th>{{ __('Merge Requests') }}</th>
+ <th>{{ __('Merge requests') }}</th>
</tr>
<tr>
<td class="shortcut">
@@ -485,7 +485,7 @@ export default {
<tbody>
<tr>
<th></th>
- <th>{{ __('Merge Request Commits') }}</th>
+ <th>{{ __('Merge request commits') }}</th>
</tr>
<tr>
<td class="shortcut">
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index 476745beb19..c2908133fd0 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -5,18 +5,33 @@ import { getSelectedFragment } from '~/lib/utils/common_utils';
import { isElementVisible } from '~/lib/utils/dom_utils';
import Sidebar from '../../right_sidebar';
import { CopyAsGFM } from '../markdown/copy_as_gfm';
+import {
+ keysFor,
+ ISSUE_MR_CHANGE_ASSIGNEE,
+ ISSUE_MR_CHANGE_MILESTONE,
+ ISSUABLE_CHANGE_LABEL,
+ ISSUABLE_COMMENT_OR_REPLY,
+ ISSUABLE_EDIT_DESCRIPTION,
+ MR_COPY_SOURCE_BRANCH_NAME,
+} from './keybindings';
import Shortcuts from './shortcuts';
export default class ShortcutsIssuable extends Shortcuts {
constructor() {
super();
- Mousetrap.bind('a', () => ShortcutsIssuable.openSidebarDropdown('assignee'));
- Mousetrap.bind('m', () => ShortcutsIssuable.openSidebarDropdown('milestone'));
- Mousetrap.bind('l', () => ShortcutsIssuable.openSidebarDropdown('labels'));
- Mousetrap.bind('r', ShortcutsIssuable.replyWithSelectedText);
- Mousetrap.bind('e', ShortcutsIssuable.editIssue);
- Mousetrap.bind('b', ShortcutsIssuable.copyBranchName);
+ Mousetrap.bind(keysFor(ISSUE_MR_CHANGE_ASSIGNEE), () =>
+ ShortcutsIssuable.openSidebarDropdown('assignee'),
+ );
+ Mousetrap.bind(keysFor(ISSUE_MR_CHANGE_MILESTONE), () =>
+ ShortcutsIssuable.openSidebarDropdown('milestone'),
+ );
+ Mousetrap.bind(keysFor(ISSUABLE_CHANGE_LABEL), () =>
+ ShortcutsIssuable.openSidebarDropdown('labels'),
+ );
+ Mousetrap.bind(keysFor(ISSUABLE_COMMENT_OR_REPLY), ShortcutsIssuable.replyWithSelectedText);
+ Mousetrap.bind(keysFor(ISSUABLE_EDIT_DESCRIPTION), ShortcutsIssuable.editIssue);
+ Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), ShortcutsIssuable.copyBranchName);
}
static replyWithSelectedText() {
@@ -105,7 +120,7 @@ export default class ShortcutsIssuable extends Shortcuts {
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'));
+ const allCopyBtns = Array.from(document.querySelectorAll('.js-sidebar-source-branch button'));
// Select whichever button is currently visible so that
// the "Copied" tooltip is shown when a click is simulated.
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
index b46b4132ba8..b188d3b0ec3 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
@@ -1,27 +1,63 @@
import Mousetrap from 'mousetrap';
import findAndFollowLink from '../../lib/utils/navigation_utility';
+import {
+ keysFor,
+ GO_TO_PROJECT_OVERVIEW,
+ GO_TO_PROJECT_ACTIVITY_FEED,
+ GO_TO_PROJECT_RELEASES,
+ GO_TO_PROJECT_FILES,
+ GO_TO_PROJECT_COMMITS,
+ GO_TO_PROJECT_JOBS,
+ GO_TO_PROJECT_REPO_GRAPH,
+ GO_TO_PROJECT_REPO_CHARTS,
+ GO_TO_PROJECT_ISSUES,
+ GO_TO_PROJECT_ISSUE_BOARDS,
+ GO_TO_PROJECT_MERGE_REQUESTS,
+ GO_TO_PROJECT_WIKI,
+ GO_TO_PROJECT_SNIPPETS,
+ GO_TO_PROJECT_KUBERNETES,
+ GO_TO_PROJECT_ENVIRONMENTS,
+ GO_TO_PROJECT_METRICS,
+ NEW_ISSUE,
+} from './keybindings';
import Shortcuts from './shortcuts';
export default class ShortcutsNavigation extends Shortcuts {
constructor() {
super();
- Mousetrap.bind('g p', () => findAndFollowLink('.shortcuts-project'));
- Mousetrap.bind('g v', () => findAndFollowLink('.shortcuts-project-activity'));
- Mousetrap.bind('g r', () => findAndFollowLink('.shortcuts-project-releases'));
- Mousetrap.bind('g f', () => findAndFollowLink('.shortcuts-tree'));
- Mousetrap.bind('g c', () => findAndFollowLink('.shortcuts-commits'));
- Mousetrap.bind('g j', () => findAndFollowLink('.shortcuts-builds'));
- Mousetrap.bind('g n', () => findAndFollowLink('.shortcuts-network'));
- Mousetrap.bind('g d', () => findAndFollowLink('.shortcuts-repository-charts'));
- Mousetrap.bind('g i', () => findAndFollowLink('.shortcuts-issues'));
- Mousetrap.bind('g b', () => findAndFollowLink('.shortcuts-issue-boards'));
- Mousetrap.bind('g m', () => findAndFollowLink('.shortcuts-merge_requests'));
- Mousetrap.bind('g w', () => findAndFollowLink('.shortcuts-wiki'));
- Mousetrap.bind('g s', () => findAndFollowLink('.shortcuts-snippets'));
- Mousetrap.bind('g k', () => findAndFollowLink('.shortcuts-kubernetes'));
- Mousetrap.bind('g e', () => findAndFollowLink('.shortcuts-environments'));
- Mousetrap.bind('g l', () => findAndFollowLink('.shortcuts-metrics'));
- Mousetrap.bind('i', () => findAndFollowLink('.shortcuts-new-issue'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_OVERVIEW), () => findAndFollowLink('.shortcuts-project'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_ACTIVITY_FEED), () =>
+ findAndFollowLink('.shortcuts-project-activity'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_RELEASES), () =>
+ findAndFollowLink('.shortcuts-project-releases'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_FILES), () => findAndFollowLink('.shortcuts-tree'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_COMMITS), () => findAndFollowLink('.shortcuts-commits'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_JOBS), () => findAndFollowLink('.shortcuts-builds'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_REPO_GRAPH), () =>
+ findAndFollowLink('.shortcuts-network'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_REPO_CHARTS), () =>
+ findAndFollowLink('.shortcuts-repository-charts'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_ISSUES), () => findAndFollowLink('.shortcuts-issues'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_ISSUE_BOARDS), () =>
+ findAndFollowLink('.shortcuts-issue-boards'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_MERGE_REQUESTS), () =>
+ findAndFollowLink('.shortcuts-merge_requests'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_WIKI), () => findAndFollowLink('.shortcuts-wiki'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_SNIPPETS), () => findAndFollowLink('.shortcuts-snippets'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_KUBERNETES), () =>
+ findAndFollowLink('.shortcuts-kubernetes'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_ENVIRONMENTS), () =>
+ findAndFollowLink('.shortcuts-environments'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_METRICS), () => findAndFollowLink('.shortcuts-metrics'));
+ Mousetrap.bind(keysFor(NEW_ISSUE), () => findAndFollowLink('.shortcuts-new-issue'));
}
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js
index 3e791e4673a..c33c092b009 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js
@@ -1,15 +1,24 @@
import Mousetrap from 'mousetrap';
+import {
+ keysFor,
+ REPO_GRAPH_SCROLL_BOTTOM,
+ REPO_GRAPH_SCROLL_DOWN,
+ REPO_GRAPH_SCROLL_LEFT,
+ REPO_GRAPH_SCROLL_RIGHT,
+ REPO_GRAPH_SCROLL_TOP,
+ REPO_GRAPH_SCROLL_UP,
+} from './keybindings';
import ShortcutsNavigation from './shortcuts_navigation';
export default class ShortcutsNetwork extends ShortcutsNavigation {
constructor(graph) {
super();
- Mousetrap.bind(['left', 'h'], graph.scrollLeft);
- Mousetrap.bind(['right', 'l'], graph.scrollRight);
- Mousetrap.bind(['up', 'k'], graph.scrollUp);
- Mousetrap.bind(['down', 'j'], graph.scrollDown);
- Mousetrap.bind(['shift+up', 'shift+k'], graph.scrollTop);
- Mousetrap.bind(['shift+down', 'shift+j'], graph.scrollBottom);
+ Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_LEFT), graph.scrollLeft);
+ Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_RIGHT), graph.scrollRight);
+ Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_UP), graph.scrollUp);
+ Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_DOWN), graph.scrollDown);
+ Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_TOP), graph.scrollTop);
+ Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_BOTTOM), graph.scrollBottom);
}
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
index 8418c0f66ac..6cbe443062a 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
@@ -1,9 +1,13 @@
<script>
import { GlToggle } from '@gitlab/ui';
import AccessorUtilities from '~/lib/utils/accessor';
+import { __ } from '~/locale';
import { disableShortcuts, enableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle';
export default {
+ i18n: {
+ toggleLabel: __('Keyboard shortcuts'),
+ },
components: {
GlToggle,
},
@@ -31,7 +35,7 @@ export default {
<gl-toggle
v-model="shortcutsEnabled"
aria-describedby="shortcutsToggle"
- label="Keyboard shortcuts"
+ :label="$options.i18n.toggleLabel"
label-position="left"
@change="onChange"
/>
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
index c609936a02a..59c1d2654bc 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
@@ -1,11 +1,12 @@
import Mousetrap from 'mousetrap';
import findAndFollowLink from '../../lib/utils/navigation_utility';
+import { keysFor, EDIT_WIKI_PAGE } from './keybindings';
import ShortcutsNavigation from './shortcuts_navigation';
export default class ShortcutsWiki extends ShortcutsNavigation {
constructor() {
super();
- Mousetrap.bind('e', ShortcutsWiki.editWiki);
+ Mousetrap.bind(keysFor(EDIT_WIKI_PAGE), ShortcutsWiki.editWiki);
}
static editWiki() {
diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue
index eb7f45cba6f..f5f06436bcc 100644
--- a/app/assets/javascripts/blob/components/blob_content.vue
+++ b/app/assets/javascripts/blob/components/blob_content.vue
@@ -21,6 +21,11 @@ export default {
default: '',
required: false,
},
+ isRawContent: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
loading: {
type: Boolean,
default: true,
@@ -65,6 +70,8 @@ export default {
v-else
ref="contentViewer"
:content="content"
+ :is-raw-content="isRawContent"
+ :file-name="blob.name"
:type="activeViewer.fileType"
data-qa-selector="file_content"
/>
diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js
index a5c8050b772..e02217d0deb 100644
--- a/app/assets/javascripts/blob/file_template_selector.js
+++ b/app/assets/javascripts/blob/file_template_selector.js
@@ -19,6 +19,17 @@ export default class FileTemplateSelector {
this.$dropdownToggleText = this.$wrapper.find('.dropdown-toggle-text');
this.initDropdown();
+ this.selectInitialTemplate();
+ }
+
+ selectInitialTemplate() {
+ const template = this.$dropdown.data('selected');
+
+ if (!template) {
+ return;
+ }
+
+ this.mediator.selectTemplateFile(this, template);
}
show() {
@@ -27,6 +38,19 @@ export default class FileTemplateSelector {
}
this.$wrapper.removeClass('hidden');
+
+ /**
+ * We set the focus on the dropdown that was just shown. This is done so that, after selecting
+ * a template type, the template selector immediately receives the focus.
+ * This improves the UX of the tour as the suggest_gitlab_ci_yml popover requires its target to
+ * be have the focus to appear. This way, users don't have to interact with the template
+ * selector to actually see the first hint: it is shown as soon as the selector becomes visible.
+ * We also need a timeout here, otherwise the template type selector gets stuck and can not be
+ * closed anymore.
+ */
+ setTimeout(() => {
+ this.$dropdown.focus();
+ }, 0);
}
hide() {
@@ -36,7 +60,7 @@ export default class FileTemplateSelector {
}
isHidden() {
- return this.$wrapper.hasClass('hidden');
+ return !this.$wrapper || this.$wrapper.hasClass('hidden');
}
getToggleText() {
diff --git a/app/assets/javascripts/blob/stl_viewer.js b/app/assets/javascripts/blob/stl_viewer.js
index 339906adc34..0ea623a705a 100644
--- a/app/assets/javascripts/blob/stl_viewer.js
+++ b/app/assets/javascripts/blob/stl_viewer.js
@@ -9,8 +9,8 @@ export default () => {
e.preventDefault();
- document.querySelector('.js-material-changer.active').classList.remove('active');
- target.classList.add('active');
+ document.querySelector('.js-material-changer.selected').classList.remove('selected');
+ target.classList.add('selected');
target.blur();
viewer.changeObjectMaterials(target.dataset.type);
diff --git a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
index 6fee40fb061..aee8bf15e44 100644
--- a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
+++ b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
@@ -108,7 +108,6 @@ export default {
show
:target="target"
placement="right"
- trigger="manual"
container="viewport"
:css-classes="['suggest-gitlab-ci-yml', 'ml-4']"
>
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index 7c8f6646c0d..ab2fc80e653 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -43,7 +43,7 @@ export default class EditBlob {
blobPath: fileNameEl.value,
blobContent: editorEl.innerText,
});
- this.editor.use(new FileTemplateExtension());
+ this.editor.use(new FileTemplateExtension({ instance: this.editor }));
fileNameEl.addEventListener('change', () => {
this.editor.updateModelLanguage(fileNameEl.value);
@@ -82,7 +82,7 @@ export default class EditBlob {
this.$editModePanes.hide();
- currentPane.fadeIn(200);
+ currentPane.show();
if (paneId === '#preview') {
this.$toggleButton.hide();
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 2cd25f58770..a8b870f9b8e 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -1,4 +1,4 @@
-import { sortBy } from 'lodash';
+import { sortBy, cloneDeep } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { ListType, NOT_FILTER } from './constants';
@@ -113,6 +113,37 @@ export function formatIssueInput(issueInput, boardConfig) {
};
}
+export function shouldCloneCard(fromListType, toListType) {
+ const involvesClosed = fromListType === ListType.closed || toListType === ListType.closed;
+ const involvesBacklog = fromListType === ListType.backlog || toListType === ListType.backlog;
+
+ if (involvesClosed || involvesBacklog) {
+ return false;
+ }
+
+ if (fromListType !== toListType) {
+ return true;
+ }
+
+ return false;
+}
+
+export function getMoveData(state, params) {
+ const { boardItems, boardItemsByListId, boardLists } = state;
+ const { itemId, fromListId, toListId } = params;
+ const fromListType = boardLists[fromListId].listType;
+ const toListType = boardLists[toListId].listType;
+
+ return {
+ reordering: fromListId === toListId,
+ shouldClone: shouldCloneCard(fromListType, toListType),
+ itemNotInToList: !boardItemsByListId[toListId].includes(itemId),
+ originalIssue: cloneDeep(boardItems[itemId]),
+ originalIndex: boardItemsByListId[fromListId].indexOf(itemId),
+ ...params,
+ };
+}
+
export function moveItemListHelper(item, fromList, toList) {
const updatedItem = item;
if (
diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue
index 3c7c792b787..d4b559add6e 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column.vue
@@ -1,23 +1,16 @@
<script>
-import {
- GlFormRadio,
- GlFormRadioGroup,
- GlLabel,
- GlTooltipDirective as GlTooltip,
-} from '@gitlab/ui';
+import { GlFormRadio, GlFormRadioGroup, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
import { ListType } from '~/boards/constants';
import boardsStore from '~/boards/stores/boards_store';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { isScopedLabel } from '~/lib/utils/common_utils';
export default {
components: {
BoardAddNewColumnForm,
GlFormRadio,
GlFormRadioGroup,
- GlLabel,
},
directives: {
GlTooltip,
@@ -26,17 +19,12 @@ export default {
data() {
return {
selectedId: null,
+ selectedLabel: null,
};
},
computed: {
...mapState(['labels', 'labelsLoading']),
...mapGetters(['getListByLabelId', 'shouldUseGraphQL']),
- selectedLabel() {
- if (!this.selectedId) {
- return null;
- }
- return this.labels.find(({ id }) => id === this.selectedId);
- },
columnForSelected() {
return this.getListByLabelId(this.selectedId);
},
@@ -89,8 +77,13 @@ export default {
this.fetchLabels(searchTerm);
},
- showScopedLabels(label) {
- return this.scopedLabelsAvailable && isScopedLabel(label);
+ setSelectedItem(selectedId) {
+ const label = this.labels.find(({ id }) => id === selectedId);
+ if (!selectedId || !label) {
+ this.selectedLabel = null;
+ } else {
+ this.selectedLabel = { ...label };
+ }
},
},
};
@@ -99,38 +92,39 @@ export default {
<template>
<board-add-new-column-form
:loading="labelsLoading"
- :form-description="__('A label list displays issues with the selected label.')"
- :search-label="__('Select label')"
+ :none-selected="__('Select a label')"
:search-placeholder="__('Search labels')"
:selected-id="selectedId"
@filter-items="filterItems"
@add-list="addList"
>
- <template slot="selected">
- <gl-label
- v-if="selectedLabel"
- v-gl-tooltip
- :title="selectedLabel.title"
- :description="selectedLabel.description"
- :background-color="selectedLabel.color"
- :scoped="showScopedLabels(selectedLabel)"
- />
+ <template #selected>
+ <template v-if="selectedLabel">
+ <span
+ class="dropdown-label-box gl-top-0 gl-flex-shrink-0"
+ :style="{
+ backgroundColor: selectedLabel.color,
+ }"
+ ></span>
+ <div class="gl-text-truncate">{{ selectedLabel.title }}</div>
+ </template>
</template>
- <template slot="items">
+ <template #items>
<gl-form-radio-group
v-if="labels.length > 0"
v-model="selectedId"
class="gl-overflow-y-auto gl-px-5 gl-pt-3"
+ @change="setSelectedItem"
>
<label
v-for="label in labels"
:key="label.id"
- class="gl-display-flex gl-flex-align-items-center gl-mb-5 gl-font-weight-normal"
+ class="gl-display-flex gl-mb-5 gl-font-weight-normal gl-overflow-break-word"
>
- <gl-form-radio :value="label.id" class="gl-mb-0" />
+ <gl-form-radio :value="label.id" />
<span
- class="dropdown-label-box gl-top-0"
+ class="dropdown-label-box gl-top-0 gl-flex-shrink-0"
:style="{
backgroundColor: label.color,
}"
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
index d85343a5390..70ba90bb1d4 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
@@ -1,5 +1,12 @@
<script>
-import { GlButton, GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
+import {
+ GlButton,
+ GlDropdown,
+ GlFormGroup,
+ GlIcon,
+ GlSearchBoxByType,
+ GlSkeletonLoader,
+} from '@gitlab/ui';
import { mapActions } from 'vuex';
import { __ } from '~/locale';
@@ -8,13 +15,16 @@ export default {
add: __('Add to board'),
cancel: __('Cancel'),
newList: __('New list'),
- noneSelected: __('None'),
noResults: __('No matching results'),
+ scope: __('Scope'),
+ scopeDescription: __('Issues must match this scope to appear in this list.'),
selected: __('Selected'),
},
components: {
GlButton,
+ GlDropdown,
GlFormGroup,
+ GlIcon,
GlSearchBoxByType,
GlSkeletonLoader,
},
@@ -23,11 +33,12 @@ export default {
type: Boolean,
required: true,
},
- formDescription: {
+ searchLabel: {
type: String,
- required: true,
+ required: false,
+ default: null,
},
- searchLabel: {
+ noneSelected: {
type: String,
required: true,
},
@@ -46,8 +57,23 @@ export default {
searchValue: '',
};
},
+ watch: {
+ selectedId(val) {
+ if (val) {
+ this.$refs.dropdown.hide(true);
+ }
+ },
+ },
methods: {
...mapActions(['setAddColumnFormVisibility']),
+ setFocus() {
+ this.$refs.searchBox.focusInput();
+ },
+ onHide() {
+ this.searchValue = '';
+ this.$emit('filter-items', '');
+ this.$emit('hide');
+ },
},
};
</script>
@@ -62,51 +88,64 @@ export default {
class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-white"
>
<h3
- class="gl-font-base gl-px-5 gl-py-5 gl-m-0 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+ class="gl-font-size-h2 gl-px-5 gl-py-4 gl-m-0 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
data-testid="board-add-column-form-title"
>
{{ $options.i18n.newList }}
</h3>
- <div class="gl-display-flex gl-flex-direction-column gl-h-full gl-overflow-hidden">
- <slot name="select-list-type">
- <div class="gl-mb-5"></div>
- </slot>
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-h-full gl-overflow-y-auto gl-align-items-flex-start"
+ >
+ <div class="gl-px-5">
+ <h3 class="gl-font-lg gl-mt-5 gl-mb-2">
+ {{ $options.i18n.scope }}
+ </h3>
+ <p class="gl-mb-3">{{ $options.i18n.scopeDescription }}</p>
+ </div>
- <p class="gl-px-5">{{ formDescription }}</p>
+ <slot name="select-list-type"></slot>
- <div class="gl-px-5 gl-pb-4">
- <label class="gl-mb-2">{{ $options.i18n.selected }}</label>
- <slot name="selected">
- <div class="gl-text-gray-500">{{ $options.i18n.noneSelected }}</div>
- </slot>
- </div>
+ <gl-form-group class="gl-px-5 lg-mb-3 gl-max-w-full" :label="searchLabel">
+ <gl-dropdown
+ ref="dropdown"
+ class="gl-mb-3 gl-max-w-full"
+ toggle-class="gl-max-w-full gl-display-flex gl-align-items-center gl-text-trunate"
+ boundary="viewport"
+ @shown="setFocus"
+ @hide="onHide"
+ >
+ <template #button-content>
+ <slot name="selected">
+ <div>{{ noneSelected }}</div>
+ </slot>
+ <gl-icon class="dropdown-chevron gl-flex-shrink-0" name="chevron-down" />
+ </template>
- <gl-form-group
- class="gl-mx-5 gl-mb-3"
- :label="searchLabel"
- label-for="board-available-column-entities"
- >
- <gl-search-box-by-type
- id="board-available-column-entities"
- v-model="searchValue"
- debounce="250"
- :placeholder="searchPlaceholder"
- @input="$emit('filter-items', $event)"
- />
- </gl-form-group>
+ <template #header>
+ <gl-search-box-by-type
+ ref="searchBox"
+ v-model="searchValue"
+ debounce="250"
+ class="gl-mt-0!"
+ :placeholder="searchPlaceholder"
+ @input="$emit('filter-items', $event)"
+ />
+ </template>
- <div v-if="loading" class="gl-px-5">
- <gl-skeleton-loader :width="500" :height="172">
- <rect width="480" height="20" x="10" y="15" rx="4" />
- <rect width="380" height="20" x="10" y="50" rx="4" />
- <rect width="430" height="20" x="10" y="85" rx="4" />
- </gl-skeleton-loader>
- </div>
+ <div v-if="loading" class="gl-px-5">
+ <gl-skeleton-loader :width="400" :height="172">
+ <rect width="380" height="20" x="10" y="15" rx="4" />
+ <rect width="280" height="20" x="10" y="50" rx="4" />
+ <rect width="330" height="20" x="10" y="85" rx="4" />
+ </gl-skeleton-loader>
+ </div>
- <slot v-else name="items">
- <p class="gl-mx-5">{{ $options.i18n.noResults }}</p>
- </slot>
+ <slot v-else name="items">
+ <p class="gl-mx-5">{{ $options.i18n.noResults }}</p>
+ </slot>
+ </gl-dropdown>
+ </gl-form-group>
</div>
<div
class="gl-display-flex gl-p-3 gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
index 7c08e33be7e..85f001d9d61 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
@@ -13,9 +13,9 @@ export default {
</script>
<template>
- <span class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list">
+ <div class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list">
<gl-button variant="confirm" @click="setAddColumnFormVisibility(true)"
>{{ __('Create list') }}
</gl-button>
- </span>
+ </div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_blocked_icon.vue b/app/assets/javascripts/boards/components/board_blocked_icon.vue
new file mode 100644
index 00000000000..0f92e714752
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_blocked_icon.vue
@@ -0,0 +1,192 @@
+<script>
+import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui';
+import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants';
+import { IssueType } from '~/graphql_shared/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { truncate } from '~/lib/utils/text_utility';
+import { __, n__, s__, sprintf } from '~/locale';
+
+export default {
+ i18n: {
+ issuableType: {
+ [issuableTypes.issue]: __('issue'),
+ },
+ },
+ graphQLIdType: {
+ [issuableTypes.issue]: IssueType,
+ },
+ referenceFormatter: {
+ [issuableTypes.issue]: (r) => r.split('/')[1],
+ },
+ defaultDisplayLimit: 3,
+ textTruncateWidth: 80,
+ components: {
+ GlIcon,
+ GlPopover,
+ GlLink,
+ GlLoadingIcon,
+ },
+ blockingIssuablesQueries,
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ uniqueId: {
+ type: String,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: true,
+ validator(value) {
+ return [issuableTypes.issue].includes(value);
+ },
+ },
+ },
+ apollo: {
+ blockingIssuables: {
+ skip() {
+ return this.skip;
+ },
+ query() {
+ return blockingIssuablesQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ id: convertToGraphQLId(this.$options.graphQLIdType[this.issuableType], this.item.id),
+ };
+ },
+ update(data) {
+ this.skip = true;
+
+ return data?.issuable?.blockingIssuables?.nodes || [];
+ },
+ error(error) {
+ const message = sprintf(s__('Boards|Failed to fetch blocking %{issuableType}s'), {
+ issuableType: this.issuableTypeText,
+ });
+ this.$emit('blocking-issuables-error', { error, message });
+ },
+ },
+ },
+ data() {
+ return {
+ skip: true,
+ blockingIssuables: [],
+ };
+ },
+ computed: {
+ displayedIssuables() {
+ const { defaultDisplayLimit, referenceFormatter } = this.$options;
+ return this.blockingIssuables.slice(0, defaultDisplayLimit).map((i) => {
+ return {
+ ...i,
+ title: truncate(i.title, this.$options.textTruncateWidth),
+ reference: referenceFormatter[this.issuableType](i.reference),
+ };
+ });
+ },
+ loading() {
+ return this.$apollo.queries.blockingIssuables.loading;
+ },
+ issuableTypeText() {
+ return this.$options.i18n.issuableType[this.issuableType];
+ },
+ blockedLabel() {
+ return sprintf(
+ n__(
+ 'Boards|Blocked by %{blockedByCount} %{issuableType}',
+ 'Boards|Blocked by %{blockedByCount} %{issuableType}s',
+ this.item.blockedByCount,
+ ),
+ {
+ blockedByCount: this.item.blockedByCount,
+ issuableType: this.issuableTypeText,
+ },
+ );
+ },
+ glIconId() {
+ return `blocked-icon-${this.uniqueId}`;
+ },
+ hasMoreIssuables() {
+ return this.item.blockedByCount > this.$options.defaultDisplayLimit;
+ },
+ displayedIssuablesCount() {
+ return this.hasMoreIssuables
+ ? this.item.blockedByCount - this.$options.defaultDisplayLimit
+ : this.item.blockedByCount;
+ },
+ moreIssuablesText() {
+ return sprintf(
+ n__(
+ 'Boards|+ %{displayedIssuablesCount} more %{issuableType}',
+ 'Boards|+ %{displayedIssuablesCount} more %{issuableType}s',
+ this.displayedIssuablesCount,
+ ),
+ {
+ displayedIssuablesCount: this.displayedIssuablesCount,
+ issuableType: this.issuableTypeText,
+ },
+ );
+ },
+ viewAllIssuablesText() {
+ return sprintf(s__('Boards|View all blocking %{issuableType}s'), {
+ issuableType: this.issuableTypeText,
+ });
+ },
+ loadingMessage() {
+ return sprintf(s__('Boards|Retrieving blocking %{issuableType}s'), {
+ issuableType: this.issuableTypeText,
+ });
+ },
+ },
+ methods: {
+ handleMouseEnter() {
+ this.skip = false;
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-inline">
+ <gl-icon
+ :id="glIconId"
+ ref="icon"
+ name="issue-block"
+ class="issue-blocked-icon gl-mr-2 gl-cursor-pointer"
+ data-testid="issue-blocked-icon"
+ @mouseenter="handleMouseEnter"
+ />
+ <gl-popover :target="glIconId" placement="top">
+ <template #title
+ ><span data-testid="popover-title">{{ blockedLabel }}</span></template
+ >
+ <template v-if="loading">
+ <gl-loading-icon />
+ <p class="gl-mt-4 gl-mb-0 gl-font-small">{{ loadingMessage }}</p>
+ </template>
+ <template v-else>
+ <ul class="gl-list-style-none gl-p-0">
+ <li v-for="issuable in displayedIssuables" :key="issuable.id">
+ <gl-link :href="issuable.webUrl" class="gl-text-blue-500! gl-font-sm">{{
+ issuable.reference
+ }}</gl-link>
+ <p class="gl-mb-3 gl-display-block!" data-testid="issuable-title">
+ {{ issuable.title }}
+ </p>
+ </li>
+ </ul>
+ <div v-if="hasMoreIssuables" class="gl-mt-4">
+ <p class="gl-mb-3" data-testid="hidden-blocking-count">{{ moreIssuablesText }}</p>
+ <gl-link
+ data-testid="view-all-issues"
+ :href="`${item.webUrl}#related-issues`"
+ class="gl-text-blue-500! gl-font-sm"
+ >{{ viewAllIssuablesText }}</gl-link
+ >
+ </div>
+ </template>
+ </gl-popover>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index d4d6b17a589..9ff2cdd76d0 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -10,6 +10,7 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import { ListType } from '../constants';
import eventHub from '../eventhub';
+import BoardBlockedIcon from './board_blocked_icon.vue';
import IssueDueDate from './issue_due_date.vue';
import IssueTimeEstimate from './issue_time_estimate.vue';
@@ -22,6 +23,7 @@ export default {
IssueDueDate,
IssueTimeEstimate,
IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
+ BoardBlockedIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -52,7 +54,7 @@ export default {
};
},
computed: {
- ...mapState(['isShowingLabels']),
+ ...mapState(['isShowingLabels', 'issuableType']),
...mapGetters(['isEpicBoard']),
cappedAssignees() {
// e.g. maxRender is 4,
@@ -114,7 +116,7 @@ export default {
},
},
methods: {
- ...mapActions(['performSearch']),
+ ...mapActions(['performSearch', 'setError']),
isIndexLessThanlimit(index) {
return index < this.limitBeforeCounter;
},
@@ -164,14 +166,12 @@ export default {
<div>
<div class="gl-display-flex" dir="auto">
<h4 class="board-card-title gl-mb-0 gl-mt-0">
- <gl-icon
+ <board-blocked-icon
v-if="item.blocked"
- v-gl-tooltip
- name="issue-block"
- :title="blockedLabel"
- class="issue-blocked-icon gl-mr-2"
- :aria-label="blockedLabel"
- data-testid="issue-blocked-icon"
+ :item="item"
+ :unique-id="`${item.id}${list.id}`"
+ :issuable-type="issuableType"
+ @blocking-issuables-error="setError"
/>
<gl-icon
v-if="item.confidential"
@@ -181,13 +181,9 @@ export default {
class="confidential-icon gl-mr-2"
:aria-label="__('Confidential')"
/>
- <a
- :href="item.path || item.webUrl || ''"
- :title="item.title"
- class="js-no-trigger"
- @mousemove.stop
- >{{ item.title }}</a
- >
+ <a :href="item.path || item.webUrl || ''" :title="item.title" @mousemove.stop>{{
+ item.title
+ }}</a>
</h4>
</div>
<div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap">
diff --git a/app/assets/javascripts/boards/components/board_card_loading_skeleton.vue b/app/assets/javascripts/boards/components/board_card_loading_skeleton.vue
new file mode 100644
index 00000000000..15bff1226a6
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_card_loading_skeleton.vue
@@ -0,0 +1,26 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ name: 'BoardCardLoading',
+ components: {
+ GlSkeletonLoader,
+ },
+};
+</script>
+
+<template>
+ <div
+ class="board-card-skeleton gl-mb-3 gl-bg-white gl-rounded-base gl-p-5 gl-border-1 gl-border-solid gl-border-gray-50"
+ >
+ <div class="board-card-skeleton-inner">
+ <gl-skeleton-loader :width="340" :height="100">
+ <rect width="340" height="16" rx="4" />
+ <rect y="30" width="118" height="16" rx="8" />
+ <rect x="122" y="30" width="130" height="16" rx="8" />
+ <rect y="62" width="38" height="16" rx="4" />
+ <circle cx="320" cy="68" r="16" />
+ </gl-skeleton-loader>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index e9c4237d759..a4b1e6adacf 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -17,21 +17,20 @@ export default {
gon.features?.graphqlBoardLists || gon.features?.epicBoards
? BoardColumn
: BoardColumnDeprecated,
- BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'),
+ BoardContentSidebar: () => import('~/boards/components/board_content_sidebar.vue'),
+ EpicBoardContentSidebar: () =>
+ import('ee_component/boards/components/epic_board_content_sidebar.vue'),
EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
GlAlert,
},
mixins: [glFeatureFlagMixin()],
+ inject: ['canAdminList'],
props: {
lists: {
type: Array,
required: false,
default: () => [],
},
- canAdminList: {
- type: Boolean,
- required: true,
- },
disabled: {
type: Boolean,
required: true,
@@ -69,7 +68,7 @@ export default {
},
},
methods: {
- ...mapActions(['moveList']),
+ ...mapActions(['moveList', 'unsetError']),
afterFormEnters() {
const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list;
el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
@@ -99,8 +98,8 @@ export default {
</script>
<template>
- <div>
- <gl-alert v-if="error" variant="danger" :dismissible="false">
+ <div v-cloak data-qa-selector="boards_list">
+ <gl-alert v-if="error" variant="danger" :dismissible="true" @dismiss="unsetError">
{{ error }}
</gl-alert>
<component
@@ -127,13 +126,23 @@ export default {
</component>
<epics-swimlanes
- v-else
+ v-else-if="boardListsToUse.length"
ref="swimlanes"
:lists="boardListsToUse"
:can-admin-list="canAdminList"
:disabled="disabled"
/>
- <board-content-sidebar v-if="isSwimlanesOn || glFeatures.graphqlBoardLists" />
+ <board-content-sidebar
+ v-if="isSwimlanesOn || glFeatures.graphqlBoardLists"
+ class="boards-sidebar"
+ data-testid="issue-boards-sidebar"
+ />
+
+ <epic-board-content-sidebar
+ v-else-if="isEpicBoard"
+ class="boards-sidebar"
+ data-testid="epic-boards-sidebar"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
new file mode 100644
index 00000000000..46359cc2bca
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -0,0 +1,96 @@
+<script>
+import { GlDrawer } from '@gitlab/ui';
+import { mapState, mapActions, mapGetters } from 'vuex';
+import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
+import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
+import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
+import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
+import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
+import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
+import { ISSUABLE } from '~/boards/constants';
+import { contentTop } from '~/lib/utils/common_utils';
+import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+export default {
+ headerHeight: `${contentTop()}px`,
+ components: {
+ GlDrawer,
+ BoardSidebarTitle,
+ SidebarAssigneesWidget,
+ BoardSidebarTimeTracker,
+ BoardSidebarLabelsSelect,
+ BoardSidebarDueDate,
+ BoardSidebarSubscription,
+ BoardSidebarMilestoneSelect,
+ BoardSidebarEpicSelect: () =>
+ import('ee_component/boards/components/sidebar/board_sidebar_epic_select.vue'),
+ BoardSidebarWeightInput: () =>
+ import('ee_component/boards/components/sidebar/board_sidebar_weight_input.vue'),
+ SidebarIterationWidget: () =>
+ import('ee_component/sidebar/components/sidebar_iteration_widget.vue'),
+ },
+ mixins: [glFeatureFlagsMixin()],
+ computed: {
+ ...mapGetters([
+ 'isSidebarOpen',
+ 'activeBoardItem',
+ 'groupPathForActiveIssue',
+ 'projectPathForActiveIssue',
+ ]),
+ ...mapState(['sidebarType', 'issuableType']),
+ isIssuableSidebar() {
+ return this.sidebarType === ISSUABLE;
+ },
+ showSidebar() {
+ return this.isIssuableSidebar && this.isSidebarOpen;
+ },
+ fullPath() {
+ return this.activeBoardItem?.referencePath?.split('#')[0] || '';
+ },
+ },
+ methods: {
+ ...mapActions(['toggleBoardItem', 'setAssignees']),
+ handleClose() {
+ this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-drawer
+ v-if="showSidebar"
+ :open="isSidebarOpen"
+ :header-height="$options.headerHeight"
+ @close="handleClose"
+ >
+ <template #header>{{ __('Issue details') }}</template>
+ <template #default>
+ <board-sidebar-title />
+ <sidebar-assignees-widget
+ :iid="activeBoardItem.iid"
+ :full-path="fullPath"
+ :initial-assignees="activeBoardItem.assignees"
+ class="assignee"
+ @assignees-updated="setAssignees"
+ />
+ <board-sidebar-epic-select class="epic" />
+ <div>
+ <board-sidebar-milestone-select />
+ <sidebar-iteration-widget
+ :iid="activeBoardItem.iid"
+ :workspace-path="projectPathForActiveIssue"
+ :iterations-workspace-path="groupPathForActiveIssue"
+ :issuable-type="issuableType"
+ class="gl-mt-5"
+ />
+ </div>
+ <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" />
+ <board-sidebar-due-date />
+ <board-sidebar-labels-select class="labels" />
+ <board-sidebar-weight-input v-if="glFeatures.issueWeights" class="weight" />
+ <board-sidebar-subscription class="subscriptions" />
+ </template>
+ </gl-drawer>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_extra_actions.vue b/app/assets/javascripts/boards/components/board_extra_actions.vue
deleted file mode 100644
index b802ccc7882..00000000000
--- a/app/assets/javascripts/boards/components/board_extra_actions.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-<script>
-import { GlTooltip, GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- name: 'BoardExtraActions',
- components: {
- GlTooltip,
- GlButton,
- },
- props: {
- canAdminList: {
- type: Boolean,
- required: true,
- },
- disabled: {
- type: Boolean,
- required: true,
- },
- openModal: {
- type: Function,
- required: true,
- },
- },
- computed: {
- tooltipTitle() {
- if (this.disabled) {
- return __('Please add a list to your board first');
- }
-
- return '';
- },
- },
-};
-</script>
-
-<template>
- <div class="board-extra-actions">
- <span ref="addIssuesButtonTooltip" class="gl-ml-3">
- <gl-button
- v-if="canAdminList"
- type="button"
- data-placement="bottom"
- data-track-event="click_button"
- data-track-label="board_add_issues"
- :disabled="disabled"
- :aria-disabled="disabled"
- @click="openModal"
- >
- {{ __('Add issues') }}
- </gl-button>
- </span>
- <gl-tooltip v-if="disabled" :target="() => $refs.addIssuesButtonTooltip" placement="bottom">
- {{ tooltipTitle }}
- </gl-tooltip>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index d8504dcfb0f..78da4137d69 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -107,7 +107,7 @@ export default {
};
},
computed: {
- ...mapGetters(['isEpicBoard', 'isGroupBoard', 'isProjectBoard']),
+ ...mapGetters(['isIssueBoard', 'isGroupBoard', 'isProjectBoard']),
isNewForm() {
return this.currentPage === formType.new;
},
@@ -127,7 +127,7 @@ export default {
if (this.isDeleteForm) {
return 'danger';
}
- return 'info';
+ return 'confirm';
},
title() {
if (this.readonly) {
@@ -163,6 +163,9 @@ export default {
currentMutation() {
return this.board.id ? updateBoardMutation : createBoardMutation;
},
+ deleteMutation() {
+ return destroyBoardMutation;
+ },
baseMutationVariables() {
const { board } = this;
const variables = {
@@ -182,7 +185,7 @@ export default {
groupPath: this.isGroupBoard ? this.fullPath : undefined,
};
},
- boardScopeMutationVariables() {
+ issueBoardScopeMutationVariables() {
/* eslint-disable @gitlab/require-i18n-strings */
return {
weight: this.board.weight,
@@ -193,13 +196,18 @@ export default {
this.board.milestone?.id || this.board.milestone?.id === 0
? convertToGraphQLId('Milestone', this.board.milestone.id)
: null,
- labelIds: this.board.labels.map(fullLabelId),
iterationId: this.board.iteration_id
? convertToGraphQLId('Iteration', this.board.iteration_id)
: null,
};
/* eslint-enable @gitlab/require-i18n-strings */
},
+ boardScopeMutationVariables() {
+ return {
+ labelIds: this.board.labels.map(fullLabelId),
+ ...(this.isIssueBoard && this.issueBoardScopeMutationVariables),
+ };
+ },
mutationVariables() {
return {
...this.baseMutationVariables,
@@ -239,17 +247,20 @@ export default {
return this.boardUpdateResponse(response.data);
},
+ async deleteBoard() {
+ await this.$apollo.mutate({
+ mutation: this.deleteMutation,
+ variables: {
+ id: fullBoardId(this.board.id),
+ },
+ });
+ },
async submit() {
if (this.board.name.length === 0) return;
this.isLoading = true;
if (this.isDeleteForm) {
try {
- await this.$apollo.mutate({
- mutation: destroyBoardMutation,
- variables: {
- id: fullBoardId(this.board.id),
- },
- });
+ await this.deleteBoard();
visitUrl(this.rootPath);
} catch {
Flash(this.$options.i18n.deleteErrorMessage);
@@ -324,7 +335,7 @@ export default {
/>
<board-scope
- v-if="scopedIssueBoardFeatureEnabled && !isEpicBoard"
+ v-if="scopedIssueBoardFeatureEnabled"
:collapse-scope="isNewForm"
:board="board"
:can-admin-board="canAdminBoard"
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index ae8434be312..94e29f3ad86 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -190,7 +190,7 @@ export default {
}
this.moveItem({
- itemId,
+ itemId: Number(itemId),
itemIid,
itemPath,
fromListId: from.dataset.listId,
diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue
index d59fbcc1b31..0534e027c86 100644
--- a/app/assets/javascripts/boards/components/board_list_deprecated.vue
+++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue
@@ -134,9 +134,10 @@ export default {
e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list');
const toBoardType = containerEl.dataset.boardType;
const cloneActions = {
- label: ['milestone', 'assignee'],
- assignee: ['milestone', 'label'],
- milestone: ['label', 'assignee'],
+ label: ['milestone', 'assignee', 'iteration'],
+ assignee: ['milestone', 'label', 'iteration'],
+ milestone: ['label', 'assignee', 'iteration'],
+ iteration: ['label', 'assignee', 'milestone'],
};
if (toBoardType) {
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index 6ccaec4a633..ca66ad6934a 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -328,6 +328,7 @@ export default {
<div
class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500"
+ data-testid="issue-count-badge"
:class="{
'gl-display-none!': list.collapsed && isSwimlanesHeader,
'gl-p-0': list.collapsed,
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index a81c28733cd..144cae15ab3 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -2,23 +2,23 @@
import { GlButton } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
+import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue';
import { __ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../eventhub';
import ProjectSelect from './project_select.vue';
export default {
name: 'BoardNewIssue',
i18n: {
- submit: __('Submit issue'),
+ submit: __('Create issue'),
cancel: __('Cancel'),
},
components: {
ProjectSelect,
GlButton,
},
- mixins: [glFeatureFlagMixin()],
- inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'],
+ mixins: [BoardNewIssueMixin],
+ inject: ['groupId'],
props: {
list: {
type: Object,
@@ -53,14 +53,11 @@ export default {
submit(e) {
e.preventDefault();
+ const { title } = this;
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({
@@ -70,7 +67,7 @@ export default {
assigneeIds: assignees?.map((a) => a?.id),
milestoneId: milestone?.id,
projectPath: this.selectedProject.fullPath,
- weight: weight >= 0 ? weight : null,
+ ...this.extraIssueInput(),
},
list: this.list,
}).then(() => {
diff --git a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
index 16f23dfff0e..1218941065f 100644
--- a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
@@ -121,7 +121,7 @@ export default {
variant="success"
category="primary"
type="submit"
- >{{ __('Submit issue') }}</gl-button
+ >{{ __('Create issue') }}</gl-button
>
<gl-button
ref="cancelButton"
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index 7cfedad0aed..997655c346a 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -22,13 +22,7 @@ export default {
import('ee_component/boards/components/board_settings_list_types.vue'),
},
mixins: [glFeatureFlagMixin()],
- props: {
- canAdminList: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
+ inject: ['canAdminList'],
data() {
return {
ListType,
diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue
index 7ec99e51f5b..fdb60d0ae6a 100644
--- a/app/assets/javascripts/boards/components/config_toggle.vue
+++ b/app/assets/javascripts/boards/components/config_toggle.vue
@@ -15,7 +15,8 @@ export default {
props: {
boardsStore: {
type: Object,
- required: true,
+ required: false,
+ default: null,
},
canAdminList: {
type: Boolean,
@@ -26,11 +27,6 @@ export default {
required: true,
},
},
- data() {
- return {
- state: this.boardsStore.state,
- };
- },
computed: {
buttonText() {
return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope');
@@ -42,7 +38,9 @@ export default {
methods: {
showPage() {
eventHub.$emit('showBoardModal', formType.edit);
- return this.boardsStore.showPage(formType.edit);
+ if (this.boardsStore) {
+ this.boardsStore.showPage(formType.edit);
+ }
},
},
};
diff --git a/app/assets/javascripts/boards/components/filtered_search.vue b/app/assets/javascripts/boards/components/filtered_search.vue
deleted file mode 100644
index 8505ea39a6b..00000000000
--- a/app/assets/javascripts/boards/components/filtered_search.vue
+++ /dev/null
@@ -1,54 +0,0 @@
-<script>
-import { mapActions } from 'vuex';
-import { historyPushState } from '~/lib/utils/common_utils';
-import { setUrlParams } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
-
-export default {
- i18n: {
- search: __('Search'),
- },
- components: { FilteredSearch },
- props: {
- search: {
- type: String,
- required: false,
- default: '',
- },
- },
- computed: {
- initialSearch() {
- return [{ type: 'filtered-search-term', value: { data: this.search } }];
- },
- },
- methods: {
- ...mapActions(['performSearch']),
- handleSearch(filters) {
- let itemValue = '';
- const [item] = filters;
-
- if (filters.length === 0) {
- itemValue = '';
- } else {
- itemValue = item?.value?.data;
- }
-
- historyPushState(setUrlParams({ search: itemValue }, window.location.href));
-
- this.performSearch();
- },
- },
-};
-</script>
-
-<template>
- <filtered-search
- class="gl-w-full"
- namespace=""
- :tokens="[]"
- :search-input-placeholder="$options.i18n.search"
- :initial-filter-value="initialSearch"
- @onFilter="handleSearch"
- />
-</template>
diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue
deleted file mode 100644
index 486b012e3d2..00000000000
--- a/app/assets/javascripts/boards/components/modal/empty_state.vue
+++ /dev/null
@@ -1,84 +0,0 @@
-<script>
-import { GlButton, GlSprintf } from '@gitlab/ui';
-import { __ } from '~/locale';
-import modalMixin from '../../mixins/modal_mixins';
-import ModalStore from '../../stores/modal_store';
-
-export default {
- components: {
- GlButton,
- GlSprintf,
- },
- mixins: [modalMixin],
- props: {
- newIssuePath: {
- type: String,
- required: true,
- },
- emptyStateSvg: {
- type: String,
- required: true,
- },
- },
- data() {
- return ModalStore.store;
- },
- computed: {
- contents() {
- const obj = {
- title: __("You haven't added any issues to your project yet"),
- content: __(
- 'An issue can be a bug, a todo or a feature request that needs to be discussed in a project. Besides, issues are searchable and filterable.',
- ),
- };
-
- if (this.activeTab === 'selected') {
- obj.title = __("You haven't selected any issues yet");
- obj.content = __(
- 'Go back to %{tagStart}Open issues%{tagEnd} and select some issues to add to your board.',
- );
- }
-
- return obj;
- },
- },
-};
-</script>
-
-<template>
- <section class="empty-state d-flex mt-0 h-100">
- <div class="row w-100 my-auto mx-0">
- <div class="col-12 col-md-6 order-md-last">
- <aside class="svg-content d-none d-md-block"><img :src="emptyStateSvg" /></aside>
- </div>
- <div class="col-12 col-md-6 order-md-first">
- <div class="text-content">
- <h4>{{ contents.title }}</h4>
- <p>
- <gl-sprintf :message="contents.content">
- <template #tag="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </p>
- <gl-button
- v-if="activeTab === 'all'"
- :href="newIssuePath"
- category="secondary"
- variant="success"
- >
- {{ __('New issue') }}
- </gl-button>
- <gl-button
- v-if="activeTab === 'selected'"
- category="primary"
- variant="default"
- @click="changeTab('all')"
- >
- {{ __('Open issues') }}
- </gl-button>
- </div>
- </div>
- </div>
- </section>
-</template>
diff --git a/app/assets/javascripts/boards/components/modal/filters.js b/app/assets/javascripts/boards/components/modal/filters.js
deleted file mode 100644
index 2fb38a549f3..00000000000
--- a/app/assets/javascripts/boards/components/modal/filters.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import FilteredSearchContainer from '../../../filtered_search/container';
-import FilteredSearchBoards from '../../filtered_search_boards';
-
-export default {
- name: 'modal-filters',
- props: {
- store: {
- type: Object,
- required: true,
- },
- },
- mounted() {
- FilteredSearchContainer.container = this.$el;
-
- this.filteredSearch = new FilteredSearchBoards(this.store);
- this.filteredSearch.setup();
- this.filteredSearch.removeTokens();
- this.filteredSearch.handleInputPlaceholder();
- this.filteredSearch.toggleClearSearchButton();
- },
- destroyed() {
- this.filteredSearch.cleanup();
- FilteredSearchContainer.container = document;
- this.store.path = '';
- },
- template: '#js-board-modal-filter',
-};
diff --git a/app/assets/javascripts/boards/components/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue
deleted file mode 100644
index 05e1219bc70..00000000000
--- a/app/assets/javascripts/boards/components/modal/footer.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import footerEEMixin from 'ee_else_ce/boards/mixins/modal_footer';
-import { deprecatedCreateFlash as Flash } from '../../../flash';
-import { __, n__ } from '../../../locale';
-import modalMixin from '../../mixins/modal_mixins';
-import boardsStore from '../../stores/boards_store';
-import ModalStore from '../../stores/modal_store';
-import ListsDropdown from './lists_dropdown.vue';
-
-export default {
- components: {
- ListsDropdown,
- GlButton,
- },
- mixins: [modalMixin, footerEEMixin],
- data() {
- return {
- modal: ModalStore.store,
- state: boardsStore.state,
- };
- },
- computed: {
- submitDisabled() {
- return !ModalStore.selectedCount();
- },
- submitText() {
- const count = ModalStore.selectedCount();
- if (!count) return __('Add issues');
- return n__(`Add %d issue`, `Add %d issues`, count);
- },
- },
- methods: {
- buildUpdateRequest(list) {
- return {
- add_label_ids: [list.label.id],
- };
- },
- addIssues() {
- const firstListIndex = 1;
- const list = this.modal.selectedList || this.state.lists[firstListIndex];
- const selectedIssues = ModalStore.getSelectedIssues();
- const issueIds = selectedIssues.map((issue) => issue.id);
- const req = this.buildUpdateRequest(list);
-
- // Post the data to the backend
- boardsStore.bulkUpdate(issueIds, req).catch(() => {
- Flash(__('Failed to update issues, please try again.'));
-
- selectedIssues.forEach((issue) => {
- list.removeIssue(issue);
- list.issuesSize -= 1;
- });
- });
-
- // Add the issues on the frontend
- selectedIssues.forEach((issue) => {
- list.addIssue(issue);
- list.issuesSize += 1;
- });
-
- this.toggleModal(false);
- },
- },
-};
-</script>
-<template>
- <footer class="form-actions add-issues-footer">
- <div class="float-left">
- <gl-button :disabled="submitDisabled" category="primary" variant="success" @click="addIssues">
- {{ submitText }}
- </gl-button>
- <span class="inline add-issues-footer-to-list">{{ __('to list') }}</span>
- <lists-dropdown />
- </div>
- <gl-button class="float-right" @click="toggleModal(false)">
- {{ __('Cancel') }}
- </gl-button>
- </footer>
-</template>
diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue
deleted file mode 100644
index c3a71e7177a..00000000000
--- a/app/assets/javascripts/boards/components/modal/header.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
-import { GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
-import modalMixin from '../../mixins/modal_mixins';
-import ModalStore from '../../stores/modal_store';
-import ModalFilters from './filters';
-import ModalTabs from './tabs.vue';
-
-export default {
- components: {
- ModalTabs,
- ModalFilters,
- GlButton,
- },
- mixins: [modalMixin],
- props: {
- projectId: {
- type: Number,
- required: true,
- },
- labelPath: {
- type: String,
- required: true,
- },
- },
- data() {
- return ModalStore.store;
- },
- computed: {
- selectAllText() {
- if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) {
- return __('Select all');
- }
-
- return __('Deselect all');
- },
- showSearch() {
- return this.activeTab === 'all' && !this.loading && this.issuesCount > 0;
- },
- },
- methods: {
- toggleAll() {
- this.$refs.selectAllBtn.$el.blur();
-
- ModalStore.toggleAll();
- },
- },
-};
-</script>
-<template>
- <div>
- <header class="add-issues-header border-top-0 form-actions">
- <h2 class="m-0">
- Add issues
- <gl-button
- category="tertiary"
- icon="close"
- class="close"
- data-dismiss="modal"
- :aria-label="__('Close')"
- @click="toggleModal(false)"
- />
- </h2>
- </header>
- <modal-tabs v-if="!loading && issuesCount > 0" />
- <div v-if="showSearch" class="d-flex gl-mb-3">
- <modal-filters :store="filter" />
- <gl-button
- ref="selectAllBtn"
- category="secondary"
- variant="success"
- class="gl-ml-3"
- @click="toggleAll"
- >
- {{ selectAllText }}
- </gl-button>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue
deleted file mode 100644
index 5af90c1ee66..00000000000
--- a/app/assets/javascripts/boards/components/modal/index.vue
+++ /dev/null
@@ -1,151 +0,0 @@
-<script>
-/* global ListIssue */
-import { GlLoadingIcon } from '@gitlab/ui';
-import boardsStore from '~/boards/stores/boards_store';
-import { urlParamsToObject } from '~/lib/utils/common_utils';
-import ModalStore from '../../stores/modal_store';
-import EmptyState from './empty_state.vue';
-import ModalFooter from './footer.vue';
-import ModalHeader from './header.vue';
-import ModalList from './list.vue';
-
-export default {
- components: {
- EmptyState,
- ModalHeader,
- ModalList,
- ModalFooter,
- GlLoadingIcon,
- },
- props: {
- newIssuePath: {
- type: String,
- required: true,
- },
- emptyStateSvg: {
- type: String,
- required: true,
- },
- projectId: {
- type: Number,
- required: true,
- },
- labelPath: {
- type: String,
- required: true,
- },
- },
- data() {
- return ModalStore.store;
- },
- computed: {
- showList() {
- if (this.activeTab === 'selected') {
- return this.selectedIssues.length > 0;
- }
-
- return this.issuesCount > 0;
- },
- showEmptyState() {
- if (!this.loading && this.issuesCount === 0) {
- return true;
- }
-
- return this.activeTab === 'selected' && this.selectedIssues.length === 0;
- },
- },
- watch: {
- page() {
- this.loadIssues();
- },
- showAddIssuesModal() {
- if (this.showAddIssuesModal && !this.issues.length) {
- this.loading = true;
- const loadingDone = () => {
- this.loading = false;
- };
-
- this.loadIssues().then(loadingDone).catch(loadingDone);
- } else if (!this.showAddIssuesModal) {
- this.issues = [];
- this.selectedIssues = [];
- this.issuesCount = false;
- }
- },
- filter: {
- handler() {
- if (this.$el.tagName) {
- this.page = 1;
- this.filterLoading = true;
- const loadingDone = () => {
- this.filterLoading = false;
- };
-
- this.loadIssues(true).then(loadingDone).catch(loadingDone);
- }
- },
- deep: true,
- },
- },
- created() {
- this.page = 1;
- },
- methods: {
- loadIssues(clearIssues = false) {
- if (!this.showAddIssuesModal) return false;
-
- return boardsStore
- .getBacklog({
- ...urlParamsToObject(this.filter.path),
- page: this.page,
- per: this.perPage,
- })
- .then((res) => res.data)
- .then((data) => {
- if (clearIssues) {
- this.issues = [];
- }
-
- data.issues.forEach((issueObj) => {
- const issue = new ListIssue(issueObj);
- const foundSelectedIssue = ModalStore.findSelectedIssue(issue);
- issue.selected = Boolean(foundSelectedIssue);
-
- this.issues.push(issue);
- });
-
- this.loadingNewPage = false;
-
- if (!this.issuesCount) {
- this.issuesCount = data.size;
- }
- })
- .catch(() => {
- // TODO: handle request error
- });
- },
- },
-};
-</script>
-<template>
- <div
- v-if="showAddIssuesModal"
- class="add-issues-modal d-flex position-fixed position-top-0 position-bottom-0 position-left-0 position-right-0 h-100"
- >
- <div class="add-issues-container d-flex flex-column m-auto rounded">
- <modal-header :project-id="projectId" :label-path="labelPath" />
- <modal-list v-if="!loading && showList && !filterLoading" :empty-state-svg="emptyStateSvg" />
- <empty-state
- v-if="showEmptyState"
- :new-issue-path="newIssuePath"
- :empty-state-svg="emptyStateSvg"
- />
- <section v-if="loading || filterLoading" class="add-issues-list d-flex h-100 text-center">
- <div class="add-issues-list-loading w-100 align-self-center">
- <gl-loading-icon size="md" />
- </div>
- </section>
- <modal-footer />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue
deleted file mode 100644
index e66cae0ce18..00000000000
--- a/app/assets/javascripts/boards/components/modal/list.vue
+++ /dev/null
@@ -1,141 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import ModalStore from '../../stores/modal_store';
-import BoardCardInner from '../board_card_inner.vue';
-
-export default {
- components: {
- BoardCardInner,
- GlIcon,
- },
- props: {
- emptyStateSvg: {
- type: String,
- required: true,
- },
- },
- data() {
- return ModalStore.store;
- },
- computed: {
- loopIssues() {
- if (this.activeTab === 'all') {
- return this.issues;
- }
-
- return this.selectedIssues;
- },
- groupedIssues() {
- const groups = [];
- this.loopIssues.forEach((issue, i) => {
- const index = i % this.columns;
-
- if (!groups[index]) {
- groups.push([]);
- }
-
- groups[index].push(issue);
- });
-
- return groups;
- },
- },
- watch: {
- activeTab() {
- if (this.activeTab === 'all') {
- ModalStore.purgeUnselectedIssues();
- }
- },
- },
- mounted() {
- this.scrollHandlerWrapper = this.scrollHandler.bind(this);
- this.setColumnCountWrapper = this.setColumnCount.bind(this);
- this.setColumnCount();
-
- this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper);
- window.addEventListener('resize', this.setColumnCountWrapper);
- },
- beforeDestroy() {
- this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper);
- window.removeEventListener('resize', this.setColumnCountWrapper);
- },
- methods: {
- scrollHandler() {
- const currentPage = Math.floor(this.issues.length / this.perPage);
-
- if (
- this.scrollTop() > this.scrollHeight() - 100 &&
- !this.loadingNewPage &&
- currentPage === this.page
- ) {
- this.loadingNewPage = true;
- this.page += 1;
- }
- },
- toggleIssue(e, issue) {
- if (e.target.tagName !== 'A') {
- ModalStore.toggleIssue(issue);
- }
- },
- listHeight() {
- return this.$refs.list.getBoundingClientRect().height;
- },
- scrollHeight() {
- return this.$refs.list.scrollHeight;
- },
- scrollTop() {
- return this.$refs.list.scrollTop + this.listHeight();
- },
- showIssue(issue) {
- if (this.activeTab === 'all') return true;
-
- const index = ModalStore.selectedIssueIndex(issue);
-
- return index !== -1;
- },
- setColumnCount() {
- const breakpoint = bp.getBreakpointSize();
-
- if (breakpoint === 'xl' || breakpoint === 'lg') {
- this.columns = 3;
- } else if (breakpoint === 'md') {
- this.columns = 2;
- } else {
- this.columns = 1;
- }
- },
- },
-};
-</script>
-<template>
- <section ref="list" class="add-issues-list add-issues-list-columns d-flex h-100">
- <div
- v-if="issuesCount > 0 && issues.length === 0"
- class="empty-state add-issues-empty-state-filter text-center"
- >
- <div class="svg-content"><img :src="emptyStateSvg" /></div>
- <div class="text-content">
- <h4>{{ __('There are no issues to show.') }}</h4>
- </div>
- </div>
- <div v-for="(group, index) in groupedIssues" :key="index" class="add-issues-list-column">
- <div v-for="issue in group" v-if="showIssue(issue)" :key="issue.id" class="board-card-parent">
- <div
- :class="{ 'is-active': issue.selected }"
- class="board-card position-relative p-3 rounded"
- @click="toggleIssue($event, issue)"
- >
- <board-card-inner :item="issue" />
- <gl-icon
- v-if="issue.selected"
- :aria-label="'Issue #' + issue.id + ' selected'"
- name="mobile-issue-close"
- aria-checked="true"
- class="issue-card-selected text-center"
- />
- </div>
- </div>
- </div>
- </section>
-</template>
diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.vue b/app/assets/javascripts/boards/components/modal/lists_dropdown.vue
deleted file mode 100644
index 2065568d275..00000000000
--- a/app/assets/javascripts/boards/components/modal/lists_dropdown.vue
+++ /dev/null
@@ -1,49 +0,0 @@
-<script>
-import { GlLink, GlIcon } from '@gitlab/ui';
-import boardsStore from '../../stores/boards_store';
-import ModalStore from '../../stores/modal_store';
-
-export default {
- components: {
- GlLink,
- GlIcon,
- },
- data() {
- return {
- modal: ModalStore.store,
- state: boardsStore.state,
- };
- },
- computed: {
- selected() {
- return this.modal.selectedList || this.state.lists[1];
- },
- },
- destroyed() {
- this.modal.selectedList = null;
- },
-};
-</script>
-<template>
- <div class="dropdown inline">
- <button class="dropdown-menu-toggle" type="button" data-toggle="dropdown" aria-expanded="false">
- <span :style="{ backgroundColor: selected.label.color }" class="dropdown-label-box"> </span>
- {{ selected.title }} <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon" />
- </button>
- <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up">
- <ul>
- <li v-for="(list, i) in state.lists" v-if="list.type == 'label'" :key="i">
- <gl-link
- :class="{ 'is-active': list.id == selected.id }"
- href="#"
- role="button"
- @click.prevent="modal.selectedList = list"
- >
- <span :style="{ backgroundColor: list.label.color }" class="dropdown-label-box"> </span>
- {{ list.title }}
- </gl-link>
- </li>
- </ul>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/modal/tabs.vue b/app/assets/javascripts/boards/components/modal/tabs.vue
deleted file mode 100644
index 0b717f516db..00000000000
--- a/app/assets/javascripts/boards/components/modal/tabs.vue
+++ /dev/null
@@ -1,42 +0,0 @@
-<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
-import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
-import modalMixin from '../../mixins/modal_mixins';
-import ModalStore from '../../stores/modal_store';
-
-export default {
- components: {
- GlTabs,
- GlTab,
- GlBadge,
- },
- mixins: [modalMixin],
- data() {
- return ModalStore.store;
- },
- computed: {
- selectedCount() {
- return ModalStore.selectedCount();
- },
- },
- destroyed() {
- this.activeTab = 'all';
- },
-};
-</script>
-<template>
- <gl-tabs class="gl-mt-3">
- <gl-tab @click.prevent="changeTab('all')">
- <template slot="title">
- <span>Open issues</span>
- <gl-badge size="sm" class="gl-tab-counter-badge">{{ issuesCount }}</gl-badge>
- </template>
- </gl-tab>
- <gl-tab @click.prevent="changeTab('selected')">
- <template slot="title">
- <span>Selected issues</span>
- <gl-badge size="sm" class="gl-tab-counter-badge">{{ selectedCount }}</gl-badge>
- </template>
- </gl-tab>
- </gl-tabs>
-</template>
diff --git a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
index 61863bbe2a9..352a25ef6d9 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
@@ -98,14 +98,14 @@ export default {
<gl-button
v-if="canUpdate"
variant="link"
- class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle"
+ class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle edit-link"
data-testid="edit-button"
@click="toggle"
>
{{ __('Edit') }}
</gl-button>
</header>
- <div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content">
+ <div v-show="!edit" class="gl-text-gray-500 value" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot>
</div>
<div v-show="edit" data-testid="expanded-content">
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
index 6d928337396..13e1e232676 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue
@@ -18,16 +18,16 @@ export default {
};
},
computed: {
- ...mapGetters(['activeIssue', 'projectPathForActiveIssue']),
+ ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']),
hasDueDate() {
- return this.activeIssue.dueDate != null;
+ return this.activeBoardItem.dueDate != null;
},
parsedDueDate() {
if (!this.hasDueDate) {
return null;
}
- return parsePikadayDate(this.activeIssue.dueDate);
+ return parsePikadayDate(this.activeBoardItem.dueDate);
},
formattedDueDate() {
if (!this.hasDueDate) {
@@ -69,6 +69,7 @@ export default {
<board-editable-item
ref="sidebarItem"
class="board-sidebar-due-date"
+ data-testid="sidebar-due-date"
:title="$options.i18n.dueDate"
:loading="loading"
@open="openDatePicker"
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 55b1596ee18..f78be83cd82 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 {
};
},
computed: {
- ...mapGetters(['activeIssue', 'projectPathForActiveIssue']),
+ ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']),
selectedLabels() {
- const { labels = [] } = this.activeIssue;
+ const { labels = [] } = this.activeBoardItem;
return labels.map((label) => ({
...label,
@@ -31,7 +31,7 @@ export default {
}));
},
issueLabels() {
- const { labels = [] } = this.activeIssue;
+ const { labels = [] } = this.activeBoardItem;
return labels.map((label) => ({
...label,
@@ -40,7 +40,7 @@ export default {
},
},
methods: {
- ...mapActions(['setActiveIssueLabels']),
+ ...mapActions(['setActiveBoardItemLabels']),
async setLabels(payload) {
this.loading = true;
this.$refs.sidebarItem.collapse();
@@ -52,7 +52,7 @@ export default {
.map((label) => label.id);
const input = { addLabelIds, removeLabelIds, projectPath: this.projectPathForActiveIssue };
- await this.setActiveIssueLabels(input);
+ await this.setActiveBoardItemLabels(input);
} catch (e) {
createFlash({ message: __('An error occurred while updating labels.') });
} finally {
@@ -65,7 +65,7 @@ export default {
try {
const removeLabelIds = [getIdFromGraphQLId(id)];
const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue };
- await this.setActiveIssueLabels(input);
+ await this.setActiveBoardItemLabels(input);
} catch (e) {
createFlash({ message: __('An error occurred when removing the label.') });
} finally {
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue
index 829f1c72806..ad225c7bf5c 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue
@@ -56,20 +56,20 @@ export default {
},
},
computed: {
- ...mapGetters(['activeIssue']),
+ ...mapGetters(['activeBoardItem']),
hasMilestone() {
- return this.activeIssue.milestone !== null;
+ return this.activeBoardItem.milestone !== null;
},
groupFullPath() {
- const { referencePath = '' } = this.activeIssue;
+ const { referencePath = '' } = this.activeBoardItem;
return referencePath.slice(0, referencePath.indexOf('/'));
},
projectPath() {
- const { referencePath = '' } = this.activeIssue;
+ const { referencePath = '' } = this.activeBoardItem;
return referencePath.slice(0, referencePath.indexOf('#'));
},
dropdownText() {
- return this.activeIssue.milestone?.title ?? this.$options.i18n.noMilestone;
+ return this.activeBoardItem.milestone?.title ?? this.$options.i18n.noMilestone;
},
},
methods: {
@@ -113,11 +113,12 @@ export default {
ref="sidebarItem"
:title="$options.i18n.milestone"
:loading="loading"
- @open="handleOpen()"
+ data-testid="sidebar-milestones"
+ @open="handleOpen"
@close="handleClose"
>
<template v-if="hasMilestone" #collapsed>
- <strong class="gl-text-gray-900">{{ activeIssue.milestone.title }}</strong>
+ <strong class="gl-text-gray-900">{{ activeBoardItem.milestone.title }}</strong>
</template>
<gl-dropdown
ref="dropdown"
@@ -130,7 +131,7 @@ export default {
<gl-dropdown-item
data-testid="no-milestone-item"
:is-check-item="true"
- :is-checked="!activeIssue.milestone"
+ :is-checked="!activeBoardItem.milestone"
@click="setMilestone(null)"
>
{{ $options.i18n.noMilestone }}
@@ -142,7 +143,7 @@ export default {
v-for="milestone in milestones"
:key="milestone.id"
:is-check-item="true"
- :is-checked="activeIssue.milestone && milestone.id === activeIssue.milestone.id"
+ :is-checked="activeBoardItem.milestone && milestone.id === activeBoardItem.milestone.id"
data-testid="milestone-item"
@click="setMilestone(milestone.id)"
>
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
index f01c8e8fa20..376985f7cb6 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
@@ -21,27 +21,31 @@ export default {
components: {
GlToggle,
},
+ inject: ['emailsDisabled'],
data() {
return {
loading: false,
};
},
computed: {
- ...mapGetters(['activeIssue', 'projectPathForActiveIssue']),
+ ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue', 'isEpicBoard']),
+ isEmailsDisabled() {
+ return this.isEpicBoard ? this.emailsDisabled : this.activeBoardItem.emailsDisabled;
+ },
notificationText() {
- return this.activeIssue.emailsDisabled
+ return this.isEmailsDisabled
? this.$options.i18n.header.subscribeDisabledDescription
: this.$options.i18n.header.title;
},
},
methods: {
- ...mapActions(['setActiveIssueSubscribed']),
+ ...mapActions(['setActiveItemSubscribed']),
async handleToggleSubscription() {
this.loading = true;
try {
- await this.setActiveIssueSubscribed({
- subscribed: !this.activeIssue.subscribed,
+ await this.setActiveItemSubscribed({
+ subscribed: !this.activeBoardItem.subscribed,
projectPath: this.projectPathForActiveIssue,
});
} catch (error) {
@@ -61,8 +65,8 @@ export default {
>
<span data-testid="notification-header-text"> {{ notificationText }} </span>
<gl-toggle
- v-if="!activeIssue.emailsDisabled"
- :value="activeIssue.subscribed"
+ v-if="!isEmailsDisabled"
+ :value="activeBoardItem.subscribed"
:is-loading="loading"
:label="$options.i18n.header.title"
label-position="hidden"
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue
new file mode 100644
index 00000000000..96d444980a8
--- /dev/null
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue
@@ -0,0 +1,25 @@
+<script>
+import { mapGetters } from 'vuex';
+import IssuableTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
+
+export default {
+ components: {
+ IssuableTimeTracker,
+ },
+ inject: ['timeTrackingLimitToHours'],
+ computed: {
+ ...mapGetters(['activeBoardItem']),
+ },
+};
+</script>
+
+<template>
+ <issuable-time-tracker
+ :time-estimate="activeBoardItem.timeEstimate"
+ :time-spent="activeBoardItem.totalTimeSpent"
+ :human-time-estimate="activeBoardItem.humanTimeEstimate"
+ :human-time-spent="activeBoardItem.humanTotalTimeSpent"
+ :limit-to-hours="timeTrackingLimitToHours"
+ :show-collapsed="false"
+ />
+</template>
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
index 95864bd62a7..b8d3107c377 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
@@ -27,12 +27,12 @@ export default {
};
},
computed: {
- ...mapGetters({ issue: 'activeIssue' }),
+ ...mapGetters({ item: 'activeBoardItem' }),
pendingChangesStorageKey() {
- return this.getPendingChangesKey(this.issue);
+ return this.getPendingChangesKey(this.item);
},
projectPath() {
- const referencePath = this.issue.referencePath || '';
+ const referencePath = this.item.referencePath || '';
return referencePath.slice(0, referencePath.indexOf('#'));
},
validationState() {
@@ -40,29 +40,29 @@ export default {
},
},
watch: {
- issue: {
- handler(updatedIssue, formerIssue) {
- if (formerIssue?.title !== this.title) {
- localStorage.setItem(this.getPendingChangesKey(formerIssue), this.title);
+ item: {
+ handler(updatedItem, formerItem) {
+ if (formerItem?.title !== this.title) {
+ localStorage.setItem(this.getPendingChangesKey(formerItem), this.title);
}
- this.title = updatedIssue.title;
+ this.title = updatedItem.title;
this.setPendingState();
},
immediate: true,
},
},
methods: {
- ...mapActions(['setActiveIssueTitle']),
- getPendingChangesKey(issue) {
- if (!issue) {
+ ...mapActions(['setActiveItemTitle']),
+ getPendingChangesKey(item) {
+ if (!item) {
return '';
}
return joinPaths(
window.location.pathname.slice(1),
- String(issue.id),
- 'issue-title-pending-changes',
+ String(item.id),
+ 'item-title-pending-changes',
);
},
async setPendingState() {
@@ -78,7 +78,7 @@ export default {
}
},
cancel() {
- this.title = this.issue.title;
+ this.title = this.item.title;
this.$refs.sidebarItem.collapse();
this.showChangesAlert = false;
localStorage.removeItem(this.pendingChangesStorageKey);
@@ -86,24 +86,24 @@ export default {
async setTitle() {
this.$refs.sidebarItem.collapse();
- if (!this.title || this.title === this.issue.title) {
+ if (!this.title || this.title === this.item.title) {
return;
}
try {
this.loading = true;
- await this.setActiveIssueTitle({ title: this.title, projectPath: this.projectPath });
+ await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath });
localStorage.removeItem(this.pendingChangesStorageKey);
this.showChangesAlert = false;
} catch (e) {
- this.title = this.issue.title;
+ this.title = this.item.title;
createFlash({ message: this.$options.i18n.updateTitleError });
} finally {
this.loading = false;
}
},
handleOffClick() {
- if (this.title !== this.issue.title) {
+ if (this.title !== this.item.title) {
this.showChangesAlert = true;
localStorage.setItem(this.pendingChangesStorageKey, this.title);
} else {
@@ -112,11 +112,11 @@ export default {
},
},
i18n: {
- issueTitlePlaceholder: __('Issue title'),
+ titlePlaceholder: __('Title'),
submitButton: __('Save changes'),
cancelButton: __('Cancel'),
- updateTitleError: __('An error occurred when updating the issue title'),
- invalidFeedback: __('An issue title is required'),
+ updateTitleError: __('An error occurred when updating the title'),
+ invalidFeedback: __('A title is required'),
reviewYourChanges: __('Changes to the title have not been saved'),
},
};
@@ -131,10 +131,10 @@ export default {
@off-click="handleOffClick"
>
<template #title>
- <span class="gl-font-weight-bold" data-testid="issue-title">{{ issue.title }}</span>
+ <span class="gl-font-weight-bold" data-testid="item-title">{{ item.title }}</span>
</template>
<template #collapsed>
- <span class="gl-text-gray-800">{{ issue.referencePath }}</span>
+ <span class="gl-text-gray-800">{{ item.referencePath }}</span>
</template>
<gl-alert v-if="showChangesAlert" variant="warning" class="gl-mb-5" :dismissible="false">
{{ $options.i18n.reviewYourChanges }}
@@ -144,7 +144,7 @@ export default {
<gl-form-input
v-model="title"
v-autofocusonshow
- :placeholder="$options.i18n.issueTitlePlaceholder"
+ :placeholder="$options.i18n.titlePlaceholder"
:state="validationState"
/>
</gl-form-group>
diff --git a/app/assets/javascripts/boards/components/toggle_focus.vue b/app/assets/javascripts/boards/components/toggle_focus.vue
index 74805f8a681..49f5e7d20a9 100644
--- a/app/assets/javascripts/boards/components/toggle_focus.vue
+++ b/app/assets/javascripts/boards/components/toggle_focus.vue
@@ -38,14 +38,16 @@ export default {
</script>
<template>
- <div class="board-extra-actions gl-ml-3 gl-display-flex gl-align-items-center">
+ <div class="gl-ml-3 gl-display-none gl-md-display-flex gl-align-items-center">
<gl-button
ref="toggleFocusModeButton"
v-gl-tooltip
+ category="tertiary"
:icon="isFullscreen ? 'minimize' : 'maximize'"
class="js-focus-mode-btn"
data-qa-selector="focus_mode_button"
:title="$options.i18n.toggleFocusMode"
+ :aria-label="$options.i18n.toggleFocusMode"
@click="toggleFocusMode"
/>
</div>
diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js
index 7f327c5764d..41938d8e284 100644
--- a/app/assets/javascripts/boards/config_toggle.js
+++ b/app/assets/javascripts/boards/config_toggle.js
@@ -2,14 +2,15 @@ import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import ConfigToggle from './components/config_toggle.vue';
-export default (boardsStore) => {
+export default (boardsStore = undefined) => {
const el = document.querySelector('.js-board-config');
if (!el) {
return;
}
- gl.boardConfigToggle = new Vue({
+ // eslint-disable-next-line no-new
+ new Vue({
el,
render(h) {
return h(ConfigToggle, {
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 65ebfe7be6c..4ebd30fe67b 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -1,4 +1,9 @@
import { __ } from '~/locale';
+import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql';
+import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql';
+import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql';
+import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql';
+import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql';
export const issuableTypes = {
issue: 'issue',
@@ -45,3 +50,27 @@ export default {
BoardType,
ListType,
};
+
+export const blockingIssuablesQueries = {
+ [issuableTypes.issue]: {
+ query: boardBlockingIssuesQuery,
+ },
+};
+
+export const titleQueries = {
+ [issuableTypes.issue]: {
+ mutation: issueSetTitleMutation,
+ },
+ [issuableTypes.epic]: {
+ mutation: updateEpicTitleMutation,
+ },
+};
+
+export const subscriptionQueries = {
+ [issuableTypes.issue]: {
+ mutation: issueSetSubscriptionMutation,
+ },
+ [issuableTypes.epic]: {
+ mutation: updateEpicSubscriptionMutation,
+ },
+};
diff --git a/app/assets/javascripts/boards/ee_functions.js b/app/assets/javascripts/boards/ee_functions.js
index b6b34556663..62a0d930ec0 100644
--- a/app/assets/javascripts/boards/ee_functions.js
+++ b/app/assets/javascripts/boards/ee_functions.js
@@ -2,4 +2,3 @@ export const setWeightFetchingState = () => {};
export const setEpicFetchingState = () => {};
export const getMilestoneTitle = () => ({});
-export const getBoardsModalData = () => ({});
diff --git a/app/assets/javascripts/boards/filtered_search.js b/app/assets/javascripts/boards/filtered_search.js
deleted file mode 100644
index 182a2cf3724..00000000000
--- a/app/assets/javascripts/boards/filtered_search.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import Vue from 'vue';
-import store from '~/boards/stores';
-import { queryToObject } from '~/lib/utils/url_utility';
-import FilteredSearch from './components/filtered_search.vue';
-
-export default () => {
- const queryParams = queryToObject(window.location.search);
- const el = document.getElementById('js-board-filtered-search');
-
- /*
- When https://github.com/vuejs/vue-apollo/pull/1153 is merged and deployed
- we can remove apolloProvider option from here. Currently without it its causing
- an error
- */
-
- return new Vue({
- el,
- store,
- apolloProvider: {},
- render: (createElement) =>
- createElement(FilteredSearch, {
- props: { search: queryParams.search },
- }),
- });
-};
diff --git a/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql b/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql
new file mode 100644
index 00000000000..4dc245660a4
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql
@@ -0,0 +1,16 @@
+query BoardBlockingIssues($id: IssueID!) {
+ issuable: issue(id: $id) {
+ __typename
+ id
+ blockingIssuables: blockedByIssues {
+ __typename
+ nodes {
+ id
+ iid
+ title
+ reference(full: true)
+ webUrl
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/issue.fragment.graphql b/app/assets/javascripts/boards/graphql/issue.fragment.graphql
index 1395bef39ed..7ecf9261214 100644
--- a/app/assets/javascripts/boards/graphql/issue.fragment.graphql
+++ b/app/assets/javascripts/boards/graphql/issue.fragment.graphql
@@ -7,6 +7,10 @@ fragment IssueNode on Issue {
referencePath: reference(full: true)
dueDate
timeEstimate
+ totalTimeSpent
+ humanTimeEstimate
+ humanTotalTimeSpent
+ emailsDisabled
confidential
webUrl
subscribed
diff --git a/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql
index 1f383245ac2..bfb87758e17 100644
--- a/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql
@@ -1,5 +1,5 @@
mutation issueSetSubscription($input: IssueSetSubscriptionInput!) {
- issueSetSubscription(input: $input) {
+ updateIssuableSubscription: issueSetSubscription(input: $input) {
issue {
subscribed
}
diff --git a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql
index 62e6c1352a6..6ad12d982e0 100644
--- a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql
@@ -1,5 +1,5 @@
mutation issueSetTitle($input: UpdateIssueInput!) {
- updateIssue(input: $input) {
+ updateIssuableTitle: updateIssue(input: $input) {
issue {
title
}
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index ceca5b0a451..e3f9d2f24c2 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -10,26 +10,21 @@ import {
setWeightFetchingState,
setEpicFetchingState,
getMilestoneTitle,
- getBoardsModalData,
} from 'ee_else_ce/boards/ee_functions';
import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes';
import toggleLabels from 'ee_else_ce/boards/toggle_labels';
import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue';
import BoardContent from '~/boards/components/board_content.vue';
-import BoardExtraActions from '~/boards/components/board_extra_actions.vue';
import './models/label';
import './models/assignee';
import '~/boards/models/milestone';
import '~/boards/models/project';
import '~/boards/filters/due_date_filters';
-import BoardAddIssuesModal from '~/boards/components/modal/index.vue';
import { issuableTypes } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import FilteredSearchBoards from '~/boards/filtered_search_boards';
-import modalMixin from '~/boards/mixins/modal_mixins';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
-import ModalStore from '~/boards/stores/modal_store';
import toggleFocusMode from '~/boards/toggle_focus';
import { deprecatedCreateFlash as Flash } from '~/flash';
import createDefaultClient from '~/lib/graphql';
@@ -72,21 +67,12 @@ export default () => {
boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours);
}
- if (gon?.features?.boardsFilteredSearch) {
- import('~/boards/filtered_search')
- .then(({ default: initFilteredSearch }) => {
- initFilteredSearch(apolloProvider);
- })
- .catch(() => {});
- }
-
// eslint-disable-next-line @gitlab/no-runtime-template-compiler
issueBoardsApp = new Vue({
el: $boardApp,
components: {
BoardContent,
BoardSidebar,
- BoardAddIssuesModal,
BoardSettingsSidebar: () => import('~/boards/components/board_settings_sidebar.vue'),
},
provide: {
@@ -95,6 +81,7 @@ export default () => {
rootPath: $boardApp.dataset.rootPath,
currentUserId: gon.current_user_id || null,
canUpdate: parseBoolean($boardApp.dataset.canUpdate),
+ canAdminList: parseBoolean($boardApp.dataset.canAdminList),
labelsFetchPath: $boardApp.dataset.labelsFetchPath,
labelsManagePath: $boardApp.dataset.labelsManagePath,
labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath,
@@ -107,6 +94,8 @@ export default () => {
milestoneListsAvailable: parseBoolean($boardApp.dataset.milestoneListsAvailable),
assigneeListsAvailable: parseBoolean($boardApp.dataset.assigneeListsAvailable),
iterationListsAvailable: parseBoolean($boardApp.dataset.iterationListsAvailable),
+ issuableType: issuableTypes.issue,
+ emailsDisabled: parseBoolean($boardApp.dataset.emailsDisabled),
},
store,
apolloProvider,
@@ -174,15 +163,9 @@ export default () => {
eventHub.$off('initialBoardLoad', this.initialBoardLoad);
},
mounted() {
- if (!gon.features?.boardsFilteredSearch) {
- this.filterManager = new FilteredSearchBoards(
- boardsStore.filter,
- true,
- boardsStore.cantEdit,
- );
+ this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit);
- this.filterManager.setup();
- }
+ this.filterManager.setup();
this.performSearch();
@@ -323,49 +306,7 @@ export default () => {
boardConfigToggle(boardsStore);
- const issueBoardsModal = document.getElementById('js-add-issues-btn');
-
- if (issueBoardsModal && gon.features.addIssuesButton) {
- // eslint-disable-next-line no-new
- new Vue({
- el: issueBoardsModal,
- mixins: [modalMixin],
- data() {
- return {
- modal: ModalStore.store,
- store: boardsStore.state,
- ...getBoardsModalData(),
- canAdminList: this.$options.el.hasAttribute('data-can-admin-list'),
- };
- },
- computed: {
- disabled() {
- if (!this.store) {
- return true;
- }
- return !this.store.lists.filter((list) => !list.preset).length;
- },
- },
- methods: {
- openModal() {
- if (!this.disabled) {
- this.toggleModal(true);
- }
- },
- },
- render(createElement) {
- return createElement(BoardExtraActions, {
- props: {
- canAdminList: this.$options.el.hasAttribute('data-can-admin-list'),
- openModal: this.openModal,
- disabled: this.disabled,
- },
- });
- },
- });
- }
-
- toggleFocusMode(ModalStore, boardsStore);
+ toggleFocusMode();
toggleLabels();
if (gon.licensed_features?.swimlanes) {
diff --git a/app/assets/javascripts/boards/mixins/board_new_issue.js b/app/assets/javascripts/boards/mixins/board_new_issue.js
new file mode 100644
index 00000000000..d4b74544735
--- /dev/null
+++ b/app/assets/javascripts/boards/mixins/board_new_issue.js
@@ -0,0 +1,6 @@
+export default {
+ // EE-only
+ methods: {
+ extraIssueInput: () => {},
+ },
+};
diff --git a/app/assets/javascripts/boards/mixins/modal_footer.js b/app/assets/javascripts/boards/mixins/modal_footer.js
deleted file mode 100644
index ff8b4c56321..00000000000
--- a/app/assets/javascripts/boards/mixins/modal_footer.js
+++ /dev/null
@@ -1 +0,0 @@
-export default {};
diff --git a/app/assets/javascripts/boards/mixins/modal_mixins.js b/app/assets/javascripts/boards/mixins/modal_mixins.js
deleted file mode 100644
index 6c97e1629bf..00000000000
--- a/app/assets/javascripts/boards/mixins/modal_mixins.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import ModalStore from '../stores/modal_store';
-
-export default {
- methods: {
- toggleModal(toggle) {
- ModalStore.store.showAddIssuesModal = toggle;
- },
- changeTab(tab) {
- ModalStore.store.activeTab = tab;
- },
- },
-};
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 19b31ee7291..8005414962c 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -1,16 +1,21 @@
+import * as Sentry from '@sentry/browser';
import { pick } from 'lodash';
import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
+import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
import {
BoardType,
ListType,
inactiveId,
flashAnimationDuration,
ISSUABLE,
+ titleQueries,
+ subscriptionQueries,
} from '~/boards/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils';
+import { s__ } from '~/locale';
import {
formatBoardLists,
formatListIssues,
@@ -20,18 +25,17 @@ import {
formatIssueInput,
updateListPosition,
transformNotFilters,
+ moveItemListHelper,
+ getMoveData,
} from '../boards_util';
import boardLabelsQuery from '../graphql/board_labels.query.graphql';
import destroyBoardListMutation from '../graphql/board_list_destroy.mutation.graphql';
import updateBoardListMutation from '../graphql/board_list_update.mutation.graphql';
import groupProjectsQuery from '../graphql/group_projects.query.graphql';
import issueCreateMutation from '../graphql/issue_create.mutation.graphql';
-import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql';
import issueSetDueDateMutation from '../graphql/issue_set_due_date.mutation.graphql';
import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql';
import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.graphql';
-import issueSetSubscriptionMutation from '../graphql/issue_set_subscription.mutation.graphql';
-import issueSetTitleMutation from '../graphql/issue_set_title.mutation.graphql';
import listsIssuesQuery from '../graphql/lists_issues.query.graphql';
import * as types from './mutation_types';
@@ -68,6 +72,7 @@ export default {
'milestoneTitle',
'releaseTag',
'search',
+ 'myReactionEmoji',
]);
filterParams.not = transformNotFilters(filters);
commit(types.SET_FILTERS, filterParams);
@@ -326,63 +331,155 @@ export default {
commit(types.RESET_ISSUES);
},
- moveItem: ({ dispatch }) => {
- dispatch('moveIssue');
+ moveItem: ({ dispatch }, payload) => {
+ dispatch('moveIssue', payload);
},
- moveIssue: (
- { state, commit },
- { itemId, itemIid, itemPath, fromListId, toListId, moveBeforeId, moveAfterId },
+ moveIssue: ({ dispatch, state }, params) => {
+ const moveData = getMoveData(state, params);
+
+ dispatch('moveIssueCard', moveData);
+ dispatch('updateMovedIssue', moveData);
+ dispatch('updateIssueOrder', { moveData });
+ },
+
+ moveIssueCard: ({ commit }, moveData) => {
+ const {
+ reordering,
+ shouldClone,
+ itemNotInToList,
+ originalIndex,
+ itemId,
+ fromListId,
+ toListId,
+ moveBeforeId,
+ moveAfterId,
+ } = moveData;
+
+ commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId });
+
+ if (reordering) {
+ commit(types.ADD_BOARD_ITEM_TO_LIST, {
+ itemId,
+ listId: toListId,
+ moveBeforeId,
+ moveAfterId,
+ });
+
+ return;
+ }
+
+ if (itemNotInToList) {
+ commit(types.ADD_BOARD_ITEM_TO_LIST, {
+ itemId,
+ listId: toListId,
+ moveBeforeId,
+ moveAfterId,
+ });
+ }
+
+ if (shouldClone) {
+ commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex });
+ }
+ },
+
+ updateMovedIssue: (
+ { commit, state: { boardItems, boardLists } },
+ { itemId, fromListId, toListId },
) => {
- const originalIssue = state.boardItems[itemId];
- const fromList = state.boardItemsByListId[fromListId];
- const originalIndex = fromList.indexOf(Number(itemId));
- commit(types.MOVE_ISSUE, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId });
+ const updatedIssue = moveItemListHelper(
+ boardItems[itemId],
+ boardLists[fromListId],
+ boardLists[toListId],
+ );
- const { boardId } = state;
- const [fullProjectPath] = itemPath.split(/[#]/);
+ commit(types.UPDATE_BOARD_ITEM, updatedIssue);
+ },
- gqlClient
- .mutate({
+ undoMoveIssueCard: ({ commit }, moveData) => {
+ const {
+ reordering,
+ shouldClone,
+ itemNotInToList,
+ itemId,
+ fromListId,
+ toListId,
+ originalIssue,
+ originalIndex,
+ } = moveData;
+
+ commit(types.UPDATE_BOARD_ITEM, originalIssue);
+
+ if (reordering) {
+ commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId });
+ commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex });
+ return;
+ }
+
+ if (shouldClone) {
+ commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId });
+ }
+ if (itemNotInToList) {
+ commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: toListId });
+ }
+
+ commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex });
+ },
+
+ updateIssueOrder: async ({ commit, dispatch, state }, { moveData, mutationVariables = {} }) => {
+ try {
+ const { itemId, fromListId, toListId, moveBeforeId, moveAfterId } = moveData;
+ const {
+ boardId,
+ boardItems: {
+ [itemId]: { iid, referencePath },
+ },
+ } = state;
+
+ const { data } = await gqlClient.mutate({
mutation: issueMoveListMutation,
variables: {
- projectPath: fullProjectPath,
+ iid,
+ projectPath: referencePath.split(/[#]/)[0],
boardId: fullBoardId(boardId),
- iid: itemIid,
fromListId: getIdFromGraphQLId(fromListId),
toListId: getIdFromGraphQLId(toListId),
moveBeforeId,
moveAfterId,
+ // 'mutationVariables' allows EE code to pass in extra parameters.
+ ...mutationVariables,
},
- })
- .then(({ data }) => {
- if (data?.issueMoveList?.errors.length) {
- throw new Error();
- } else {
- const issue = data.issueMoveList?.issue;
- commit(types.MOVE_ISSUE_SUCCESS, { issue });
- }
- })
- .catch(() =>
- commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex }),
+ });
+
+ if (data?.issueMoveList?.errors.length || !data.issueMoveList) {
+ throw new Error('issueMoveList empty');
+ }
+
+ commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issueMoveList.issue });
+ } catch {
+ commit(
+ types.SET_ERROR,
+ s__('Boards|An error occurred while moving the issue. Please try again.'),
);
+ dispatch('undoMoveIssueCard', moveData);
+ }
},
setAssignees: ({ commit, getters }, assigneeUsernames) => {
- commit('UPDATE_ISSUE_BY_ID', {
- issueId: getters.activeIssue.id,
+ commit('UPDATE_BOARD_ITEM_BY_ID', {
+ itemId: getters.activeBoardItem.id,
prop: 'assignees',
value: assigneeUsernames,
});
},
setActiveIssueMilestone: async ({ commit, getters }, input) => {
- const { activeIssue } = getters;
+ const { activeBoardItem } = getters;
const { data } = await gqlClient.mutate({
mutation: issueSetMilestoneMutation,
variables: {
input: {
- iid: String(activeIssue.iid),
+ iid: String(activeBoardItem.iid),
milestoneId: getIdFromGraphQLId(input.milestoneId),
projectPath: input.projectPath,
},
@@ -393,65 +490,71 @@ export default {
throw new Error(data.updateIssue.errors);
}
- commit(types.UPDATE_ISSUE_BY_ID, {
- issueId: activeIssue.id,
+ commit(types.UPDATE_BOARD_ITEM_BY_ID, {
+ itemId: activeBoardItem.id,
prop: 'milestone',
value: data.updateIssue.issue.milestone,
});
},
- createNewIssue: ({ commit, state }, issueInput) => {
- const { boardConfig } = state;
+ addListItem: ({ commit }, { list, item, position }) => {
+ commit(types.ADD_BOARD_ITEM_TO_LIST, { listId: list.id, itemId: item.id, atIndex: position });
+ commit(types.UPDATE_BOARD_ITEM, item);
+ },
+
+ removeListItem: ({ commit }, { listId, itemId }) => {
+ commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { listId, itemId });
+ commit(types.REMOVE_BOARD_ITEM, itemId);
+ },
+ addListNewIssue: (
+ { state: { boardConfig, boardType, fullPath }, dispatch, commit },
+ { issueInput, list, placeholderId = `tmp-${new Date().getTime()}` },
+ ) => {
const input = formatIssueInput(issueInput, boardConfig);
- const { boardType, fullPath } = state;
if (boardType === BoardType.project) {
input.projectPath = fullPath;
}
- return gqlClient
+ const placeholderIssue = formatIssue({ ...issueInput, id: placeholderId });
+ dispatch('addListItem', { list, item: placeholderIssue, position: 0 });
+
+ gqlClient
.mutate({
mutation: issueCreateMutation,
variables: { input },
})
.then(({ data }) => {
if (data.createIssue.errors.length) {
- commit(types.CREATE_ISSUE_FAILURE);
- } else {
- return data.createIssue?.issue;
+ throw new Error();
}
- return null;
- })
- .catch(() => commit(types.CREATE_ISSUE_FAILURE));
- },
- addListIssue: ({ commit }, { list, issue, position }) => {
- commit(types.ADD_ISSUE_TO_LIST, { list, issue, position });
+ const rawIssue = data.createIssue?.issue;
+ const formattedIssue = formatIssue({ ...rawIssue, id: getIdFromGraphQLId(rawIssue.id) });
+ dispatch('removeListItem', { listId: list.id, itemId: placeholderId });
+ dispatch('addListItem', { list, item: formattedIssue, position: 0 });
+ })
+ .catch(() => {
+ dispatch('removeListItem', { listId: list.id, itemId: placeholderId });
+ commit(
+ types.SET_ERROR,
+ s__('Boards|An error occurred while creating the issue. Please try again.'),
+ );
+ });
},
- 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 }));
+ setActiveBoardItemLabels: ({ dispatch }, params) => {
+ dispatch('setActiveIssueLabels', params);
},
setActiveIssueLabels: async ({ commit, getters }, input) => {
- const { activeIssue } = getters;
+ const { activeBoardItem } = getters;
const { data } = await gqlClient.mutate({
mutation: issueSetLabelsMutation,
variables: {
input: {
- iid: String(activeIssue.iid),
+ iid: String(activeBoardItem.iid),
addLabelIds: input.addLabelIds ?? [],
removeLabelIds: input.removeLabelIds ?? [],
projectPath: input.projectPath,
@@ -463,20 +566,20 @@ export default {
throw new Error(data.updateIssue.errors);
}
- commit(types.UPDATE_ISSUE_BY_ID, {
- issueId: activeIssue.id,
+ commit(types.UPDATE_BOARD_ITEM_BY_ID, {
+ itemId: activeBoardItem.id,
prop: 'labels',
value: data.updateIssue.issue.labels.nodes,
});
},
setActiveIssueDueDate: async ({ commit, getters }, input) => {
- const { activeIssue } = getters;
+ const { activeBoardItem } = getters;
const { data } = await gqlClient.mutate({
mutation: issueSetDueDateMutation,
variables: {
input: {
- iid: String(activeIssue.iid),
+ iid: String(activeBoardItem.iid),
projectPath: input.projectPath,
dueDate: input.dueDate,
},
@@ -487,57 +590,66 @@ export default {
throw new Error(data.updateIssue.errors);
}
- commit(types.UPDATE_ISSUE_BY_ID, {
- issueId: activeIssue.id,
+ commit(types.UPDATE_BOARD_ITEM_BY_ID, {
+ itemId: activeBoardItem.id,
prop: 'dueDate',
value: data.updateIssue.issue.dueDate,
});
},
- setActiveIssueSubscribed: async ({ commit, getters }, input) => {
+ setActiveItemSubscribed: async ({ commit, getters, state }, input) => {
+ const { activeBoardItem, isEpicBoard } = getters;
+ const { fullPath, issuableType } = state;
+ const workspacePath = isEpicBoard
+ ? { groupPath: fullPath }
+ : { projectPath: input.projectPath };
const { data } = await gqlClient.mutate({
- mutation: issueSetSubscriptionMutation,
+ mutation: subscriptionQueries[issuableType].mutation,
variables: {
input: {
- iid: String(getters.activeIssue.iid),
- projectPath: input.projectPath,
+ ...workspacePath,
+ iid: String(activeBoardItem.iid),
subscribedState: input.subscribed,
},
},
});
- if (data.issueSetSubscription?.errors?.length > 0) {
- throw new Error(data.issueSetSubscription.errors);
+ if (data.updateIssuableSubscription?.errors?.length > 0) {
+ throw new Error(data.updateIssuableSubscription[issuableType].errors);
}
- commit(types.UPDATE_ISSUE_BY_ID, {
- issueId: getters.activeIssue.id,
+ commit(types.UPDATE_BOARD_ITEM_BY_ID, {
+ itemId: activeBoardItem.id,
prop: 'subscribed',
- value: data.issueSetSubscription.issue.subscribed,
+ value: data.updateIssuableSubscription[issuableType].subscribed,
});
},
- setActiveIssueTitle: async ({ commit, getters }, input) => {
- const { activeIssue } = getters;
+ setActiveItemTitle: async ({ commit, getters, state }, input) => {
+ const { activeBoardItem, isEpicBoard } = getters;
+ const { fullPath, issuableType } = state;
+ const workspacePath = isEpicBoard
+ ? { groupPath: fullPath }
+ : { projectPath: input.projectPath };
const { data } = await gqlClient.mutate({
- mutation: issueSetTitleMutation,
+ mutation: titleQueries[issuableType].mutation,
variables: {
input: {
- iid: String(activeIssue.iid),
- projectPath: input.projectPath,
+ ...workspacePath,
+ iid: String(activeBoardItem.iid),
title: input.title,
},
},
});
- if (data.updateIssue?.errors?.length > 0) {
- throw new Error(data.updateIssue.errors);
+ if (data.updateIssuableTitle?.errors?.length > 0) {
+ throw new Error(data.updateIssuableTitle.errors);
}
- commit(types.UPDATE_ISSUE_BY_ID, {
- issueId: activeIssue.id,
+ commit(types.UPDATE_BOARD_ITEM_BY_ID, {
+ itemId: activeBoardItem.id,
prop: 'title',
- value: data.updateIssue.issue.title,
+ value: data.updateIssuableTitle[issuableType].title,
});
},
@@ -576,10 +688,10 @@ export default {
const { selectedBoardItems } = state;
const index = selectedBoardItems.indexOf(boardItem);
- // If user already selected an item (activeIssue) without using mult-select,
+ // If user already selected an item (activeBoardItem) without using mult-select,
// include that item in the selection and unset state.ActiveId to hide the sidebar.
- if (getters.activeIssue) {
- commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeIssue);
+ if (getters.activeBoardItem) {
+ commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeBoardItem);
dispatch('unsetActiveId');
}
@@ -608,6 +720,18 @@ export default {
}
},
+ setError: ({ commit }, { message, error, captureError = false }) => {
+ commit(types.SET_ERROR, message);
+
+ if (captureError) {
+ Sentry.captureException(error);
+ }
+ },
+
+ unsetError: ({ commit }) => {
+ commit(types.SET_ERROR, undefined);
+ },
+
fetchBacklog: () => {
notImplemented();
},
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
index caa518f91ce..0589851c658 100644
--- a/app/assets/javascripts/boards/stores/getters.js
+++ b/app/assets/javascripts/boards/stores/getters.js
@@ -1,5 +1,5 @@
import { find } from 'lodash';
-import { BoardType, inactiveId } from '../constants';
+import { BoardType, inactiveId, issuableTypes } from '../constants';
export default {
isGroupBoard: (state) => state.boardType === BoardType.group,
@@ -15,17 +15,17 @@ export default {
return listItemsIds.map((id) => getters.getBoardItemById(id));
},
- activeIssue: (state) => {
+ activeBoardItem: (state) => {
return state.boardItems[state.activeId] || {};
},
groupPathForActiveIssue: (_, getters) => {
- const { referencePath = '' } = getters.activeIssue;
+ const { referencePath = '' } = getters.activeBoardItem;
return referencePath.slice(0, referencePath.indexOf('/'));
},
projectPathForActiveIssue: (_, getters) => {
- const { referencePath = '' } = getters.activeIssue;
+ const { referencePath = '' } = getters.activeBoardItem;
return referencePath.slice(0, referencePath.indexOf('#'));
},
@@ -44,6 +44,10 @@ export default {
return find(state.boardLists, (l) => l.title === title);
},
+ isIssueBoard: (state) => {
+ return state.issuableType === issuableTypes.issue;
+ },
+
isEpicBoard: () => {
return false;
},
diff --git a/app/assets/javascripts/boards/stores/modal_store.js b/app/assets/javascripts/boards/stores/modal_store.js
deleted file mode 100644
index 8a8fa61361c..00000000000
--- a/app/assets/javascripts/boards/stores/modal_store.js
+++ /dev/null
@@ -1,95 +0,0 @@
-class ModalStore {
- constructor() {
- this.store = {
- columns: 3,
- issues: [],
- issuesCount: false,
- selectedIssues: [],
- showAddIssuesModal: false,
- activeTab: 'all',
- selectedList: null,
- searchTerm: '',
- loading: false,
- loadingNewPage: false,
- filterLoading: false,
- page: 1,
- perPage: 50,
- filter: {
- path: '',
- },
- };
- }
-
- selectedCount() {
- return this.getSelectedIssues().length;
- }
-
- toggleIssue(issueObj) {
- const issue = issueObj;
- const { selected } = issue;
-
- issue.selected = !selected;
-
- if (!selected) {
- this.addSelectedIssue(issue);
- } else {
- this.removeSelectedIssue(issue);
- }
- }
-
- toggleAll() {
- const select = this.selectedCount() !== this.store.issues.length;
-
- this.store.issues.forEach((issue) => {
- const issueUpdate = issue;
-
- if (issueUpdate.selected !== select) {
- issueUpdate.selected = select;
-
- if (select) {
- this.addSelectedIssue(issue);
- } else {
- this.removeSelectedIssue(issue);
- }
- }
- });
- }
-
- getSelectedIssues() {
- return this.store.selectedIssues.filter((issue) => issue.selected);
- }
-
- addSelectedIssue(issue) {
- const index = this.selectedIssueIndex(issue);
-
- if (index === -1) {
- this.store.selectedIssues.push(issue);
- }
- }
-
- removeSelectedIssue(issue, forcePurge = false) {
- if (this.store.activeTab === 'all' || forcePurge) {
- this.store.selectedIssues = this.store.selectedIssues.filter(
- (fIssue) => fIssue.id !== issue.id,
- );
- }
- }
-
- purgeUnselectedIssues() {
- this.store.selectedIssues.forEach((issue) => {
- if (!issue.selected) {
- this.removeSelectedIssue(issue, true);
- }
- });
- }
-
- selectedIssueIndex(issue) {
- return this.store.selectedIssues.indexOf(issue);
- }
-
- findSelectedIssue(issue) {
- return this.store.selectedIssues.filter((filteredIssue) => filteredIssue.id === issue.id)[0];
- }
-}
-
-export default new ModalStore();
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index e7c034fb087..22b9905ee62 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -20,23 +20,21 @@ export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE';
export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST';
export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE';
export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_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';
-export const MOVE_ISSUE = 'MOVE_ISSUE';
-export const MOVE_ISSUE_SUCCESS = 'MOVE_ISSUE_SUCCESS';
-export const MOVE_ISSUE_FAILURE = 'MOVE_ISSUE_FAILURE';
+export const UPDATE_BOARD_ITEM = 'UPDATE_BOARD_ITEM';
+export const REMOVE_BOARD_ITEM = 'REMOVE_BOARD_ITEM';
export const REQUEST_UPDATE_ISSUE = 'REQUEST_UPDATE_ISSUE';
+export const MUTATE_ISSUE_SUCCESS = 'MUTATE_ISSUE_SUCCESS';
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 ADD_BOARD_ITEM_TO_LIST = 'ADD_BOARD_ITEM_TO_LIST';
+export const REMOVE_BOARD_ITEM_FROM_LIST = 'REMOVE_BOARD_ITEM_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';
-export const UPDATE_ISSUE_BY_ID = 'UPDATE_ISSUE_BY_ID';
+export const UPDATE_BOARD_ITEM_BY_ID = 'UPDATE_BOARD_ITEM_BY_ID';
export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING';
export const RESET_ISSUES = 'RESET_ISSUES';
export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS';
@@ -49,3 +47,4 @@ export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE';
export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS';
export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS';
export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION';
+export const SET_ERROR = 'SET_ERROR';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 75b60366b6a..561c21b78c1 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -2,7 +2,7 @@ import { pull, union } from 'lodash';
import Vue from 'vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
-import { formatIssue, moveItemListHelper } from '../boards_util';
+import { formatIssue } from '../boards_util';
import { issuableTypes } from '../constants';
import * as mutationTypes from './mutation_types';
@@ -158,13 +158,13 @@ export default {
});
},
- [mutationTypes.UPDATE_ISSUE_BY_ID]: (state, { issueId, prop, value }) => {
- if (!state.boardItems[issueId]) {
+ [mutationTypes.UPDATE_BOARD_ITEM_BY_ID]: (state, { itemId, prop, value }) => {
+ if (!state.boardItems[itemId]) {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
throw new Error('No issue found.');
}
- Vue.set(state.boardItems[issueId], prop, value);
+ Vue.set(state.boardItems[itemId], prop, value);
},
[mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) {
@@ -183,40 +183,11 @@ export default {
notImplemented();
},
- [mutationTypes.MOVE_ISSUE]: (
- state,
- { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId },
- ) => {
- const fromList = state.boardLists[fromListId];
- const toList = state.boardLists[toListId];
-
- const issue = moveItemListHelper(originalIssue, fromList, toList);
- Vue.set(state.boardItems, issue.id, issue);
-
- removeItemFromList({ state, listId: fromListId, itemId: issue.id });
- addItemToList({ state, listId: toListId, itemId: issue.id, moveBeforeId, moveAfterId });
- },
-
- [mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => {
+ [mutationTypes.MUTATE_ISSUE_SUCCESS]: (state, { issue }) => {
const issueId = getIdFromGraphQLId(issue.id);
Vue.set(state.boardItems, issueId, formatIssue({ ...issue, id: issueId }));
},
- [mutationTypes.MOVE_ISSUE_FAILURE]: (
- state,
- { originalIssue, fromListId, toListId, originalIndex },
- ) => {
- state.error = s__('Boards|An error occurred while moving the issue. Please try again.');
- Vue.set(state.boardItems, originalIssue.id, originalIssue);
- removeItemFromList({ state, listId: toListId, itemId: originalIssue.id });
- addItemToList({
- state,
- listId: fromListId,
- itemId: originalIssue.id,
- atIndex: originalIndex,
- });
- },
-
[mutationTypes.REQUEST_UPDATE_ISSUE]: () => {
notImplemented();
},
@@ -229,28 +200,23 @@ export default {
notImplemented();
},
- [mutationTypes.CREATE_ISSUE_FAILURE]: (state) => {
- state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
+ [mutationTypes.ADD_BOARD_ITEM_TO_LIST]: (
+ state,
+ { itemId, listId, moveBeforeId, moveAfterId, atIndex },
+ ) => {
+ addItemToList({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex });
},
- [mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => {
- addItemToList({
- state,
- listId: list.id,
- itemId: issue.id,
- atIndex: position,
- });
- Vue.set(state.boardItems, issue.id, issue);
+ [mutationTypes.REMOVE_BOARD_ITEM_FROM_LIST]: (state, { itemId, listId }) => {
+ removeItemFromList({ state, listId, itemId });
},
- [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => {
- state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
- removeItemFromList({ state, listId: list.id, itemId: issueId });
+ [mutationTypes.UPDATE_BOARD_ITEM]: (state, item) => {
+ Vue.set(state.boardItems, item.id, item);
},
- [mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => {
- removeItemFromList({ state, listId: list.id, itemId: issue.id });
- Vue.delete(state.boardItems, issue.id);
+ [mutationTypes.REMOVE_BOARD_ITEM]: (state, itemId) => {
+ Vue.delete(state.boardItems, itemId);
},
[mutationTypes.SET_CURRENT_PAGE]: () => {
@@ -309,4 +275,8 @@ export default {
[mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => {
state.selectedBoardItems = [];
},
+
+ [mutationTypes.SET_ERROR]: (state, error) => {
+ state.error = error;
+ },
};
diff --git a/app/assets/javascripts/branches/branch_sort_dropdown.js b/app/assets/javascripts/branches/branch_sort_dropdown.js
new file mode 100644
index 00000000000..9914ce05a95
--- /dev/null
+++ b/app/assets/javascripts/branches/branch_sort_dropdown.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import SortDropdown from './components/sort_dropdown.vue';
+
+const mountDropdownApp = (el) => {
+ const { mode, projectBranchesFilteredPath, sortOptions } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'SortBranchesDropdownApp',
+ components: {
+ SortDropdown,
+ },
+ provide: {
+ mode,
+ projectBranchesFilteredPath,
+ sortOptions: JSON.parse(sortOptions),
+ },
+ render: (createElement) => createElement(SortDropdown),
+ });
+};
+
+export default () => {
+ const el = document.getElementById('js-branches-sort-dropdown');
+ return el ? mountDropdownApp(el) : null;
+};
diff --git a/app/assets/javascripts/branches/components/sort_dropdown.vue b/app/assets/javascripts/branches/components/sort_dropdown.vue
new file mode 100644
index 00000000000..ddb4c5c0015
--- /dev/null
+++ b/app/assets/javascripts/branches/components/sort_dropdown.vue
@@ -0,0 +1,88 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui';
+import { mergeUrlParams, visitUrl, getParameterValues } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
+
+const OVERVIEW_MODE = 'overview';
+
+export default {
+ i18n: {
+ searchPlaceholder: s__('Branches|Filter by branch name'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByClick,
+ },
+ inject: ['projectBranchesFilteredPath', 'sortOptions', 'mode'],
+ data() {
+ return {
+ selectedKey: 'updated_desc',
+ searchTerm: '',
+ };
+ },
+ computed: {
+ shouldShowDropdown() {
+ return this.mode !== OVERVIEW_MODE;
+ },
+ selectedSortMethodName() {
+ return this.sortOptions[this.selectedKey];
+ },
+ },
+ created() {
+ const sortValue = getParameterValues('sort');
+ const searchValue = getParameterValues('search');
+
+ if (sortValue.length > 0) {
+ [this.selectedKey] = sortValue;
+ }
+
+ if (searchValue.length > 0) {
+ [this.searchTerm] = searchValue;
+ }
+ },
+ methods: {
+ isSortMethodSelected(sortKey) {
+ return sortKey === this.selectedKey;
+ },
+ visitUrlFromOption(sortKey) {
+ this.selectedKey = sortKey;
+ const urlParams = {};
+
+ if (this.mode !== OVERVIEW_MODE) {
+ urlParams.sort = sortKey;
+ }
+
+ urlParams.search = this.searchTerm.length > 0 ? this.searchTerm : null;
+
+ const newUrl = mergeUrlParams(urlParams, this.projectBranchesFilteredPath);
+ visitUrl(newUrl);
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-pr-4">
+ <gl-search-box-by-click
+ v-model="searchTerm"
+ :placeholder="$options.i18n.searchPlaceholder"
+ class="gl-pr-4"
+ data-testid="branch-search"
+ @submit="visitUrlFromOption(selectedKey)"
+ />
+ <gl-dropdown
+ v-if="shouldShowDropdown"
+ :text="selectedSortMethodName"
+ data-testid="branches-dropdown"
+ >
+ <gl-dropdown-item
+ v-for="(value, key) in sortOptions"
+ :key="key"
+ :is-checked="isSortMethodSelected(key)"
+ is-check-item
+ @click="visitUrlFromOption(key)"
+ >{{ value }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js
index ca019bc4178..66e8d982113 100644
--- a/app/assets/javascripts/branches/divergence_graph.js
+++ b/app/assets/javascripts/branches/divergence_graph.js
@@ -4,13 +4,13 @@ import axios from '../lib/utils/axios_utils';
import { __ } from '../locale';
import DivergenceGraph from './components/divergence_graph.vue';
-export function createGraphVueApp(el, data, maxCommits) {
+export function createGraphVueApp(el, data, maxCommits, defaultBranch) {
return new Vue({
el,
render(h) {
return h(DivergenceGraph, {
props: {
- defaultBranch: 'master',
+ defaultBranch,
distance: data.distance ? parseInt(data.distance, 10) : null,
aheadCount: parseInt(data.ahead, 10),
behindCount: parseInt(data.behind, 10),
@@ -21,7 +21,7 @@ export function createGraphVueApp(el, data, maxCommits) {
});
}
-export default (endpoint) => {
+export default (endpoint, defaultBranch) => {
const names = [...document.querySelectorAll('.js-branch-item')].map(
({ dataset }) => dataset.name,
);
@@ -47,7 +47,7 @@ export default (endpoint) => {
if (!el) return;
- createGraphVueApp(el, val, maxCommits);
+ createGraphVueApp(el, val, maxCommits, defaultBranch);
});
})
.catch(() =>
diff --git a/app/assets/javascripts/captcha/apollo_captcha_link.js b/app/assets/javascripts/captcha/apollo_captcha_link.js
new file mode 100644
index 00000000000..e49abc10b29
--- /dev/null
+++ b/app/assets/javascripts/captcha/apollo_captcha_link.js
@@ -0,0 +1,37 @@
+import { ApolloLink, Observable } from 'apollo-link';
+
+export const apolloCaptchaLink = new ApolloLink((operation, forward) =>
+ forward(operation).flatMap((result) => {
+ const { errors = [] } = result;
+
+ // Our API will return with a top-level GraphQL error with extensions
+ // in case a captcha is required.
+ const captchaError = errors.find((e) => e?.extensions?.needs_captcha_response);
+ if (captchaError) {
+ const captchaSiteKey = captchaError.extensions.captcha_site_key;
+ const spamLogId = captchaError.extensions.spam_log_id;
+
+ return new Observable((observer) => {
+ import('~/captcha/wait_for_captcha_to_be_solved')
+ .then(({ waitForCaptchaToBeSolved }) => waitForCaptchaToBeSolved(captchaSiteKey))
+ .then((captchaResponse) => {
+ // If the captcha was solved correctly, we re-do our action while setting
+ // captcha response headers.
+ operation.setContext({
+ headers: {
+ 'X-GitLab-Captcha-Response': captchaResponse,
+ 'X-GitLab-Spam-Log-Id': spamLogId,
+ },
+ });
+ forward(operation).subscribe(observer);
+ })
+ .catch((error) => {
+ observer.error(error);
+ observer.complete();
+ });
+ });
+ }
+
+ return Observable.of(result);
+ }),
+);
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue
index 9a55177b15f..ced07dea7be 100644
--- a/app/assets/javascripts/ci_lint/components/ci_lint.vue
+++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue
@@ -32,7 +32,7 @@ export default {
return {
content: '',
loading: false,
- valid: false,
+ isValid: false,
errors: null,
warnings: null,
jobs: [],
@@ -61,7 +61,7 @@ export default {
});
this.showingResults = true;
- this.valid = valid;
+ this.isValid = valid;
this.errors = errors;
this.warnings = warnings;
this.jobs = jobs;
@@ -120,7 +120,7 @@ export default {
<ci-lint-results
v-if="showingResults"
class="col-sm-12 gl-mt-5"
- :valid="valid"
+ :is-valid="isValid"
:jobs="jobs"
:errors="errors"
:warnings="warnings"
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
index 0233ffaccdc..bc1e401d373 100644
--- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
+++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
@@ -7,6 +7,10 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
+ i18n: {
+ editButton: s__('Pipelines|Edit'),
+ revokeButton: s__('Pipelines|Revoke'),
+ },
components: {
GlTable,
GlButton,
@@ -108,13 +112,15 @@ export default {
</template>
<template #cell(actions)="{ item }">
<gl-button
- :title="s__('Pipelines|Edit')"
+ :title="$options.i18n.editButton"
+ :aria-label="$options.i18n.editButton"
icon="pencil"
data-testid="edit-btn"
:href="item.editProjectTriggerPath"
/>
<gl-button
- :title="s__('Pipelines|Revoke')"
+ :title="$options.i18n.revokeButton"
+ :aria-label="$options.i18n.revokeButton"
icon="remove"
variant="warning"
:data-confirm="
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 be7c0b68b4c..12def6e7eef 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
@@ -7,6 +7,7 @@ import {
GlFormCombobox,
GlFormGroup,
GlFormSelect,
+ GlFormInput,
GlFormTextarea,
GlIcon,
GlLink,
@@ -41,6 +42,7 @@ export default {
GlFormCombobox,
GlFormGroup,
GlFormSelect,
+ GlFormInput,
GlFormTextarea,
GlIcon,
GlLink,
@@ -128,6 +130,9 @@ export default {
return true;
},
+ scopedVariablesAvailable() {
+ return !this.isGroup || this.glFeatures.groupScopedCiVariables;
+ },
variableValidationFeedback() {
return `${this.tokenValidationFeedback} ${this.maskedFeedback}`;
},
@@ -222,28 +227,25 @@ export default {
</gl-form-group>
<div class="d-flex">
- <gl-form-group
- :label="__('Type')"
- label-for="ci-variable-type"
- class="w-50 gl-mr-5"
- :class="{ 'w-100': isGroup }"
- >
+ <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="w-50 gl-mr-5">
<gl-form-select id="ci-variable-type" v-model="variable_type" :options="typeOptions" />
</gl-form-group>
<gl-form-group
- v-if="!isGroup"
:label="__('Environment scope')"
label-for="ci-variable-env"
class="w-50"
data-testid="environment-scope"
>
<ci-environments-dropdown
+ v-if="scopedVariablesAvailable"
class="w-100"
:value="environment_scope"
@selectEnvironment="setEnvironmentScope"
@createClicked="addWildCardScope"
/>
+
+ <gl-form-input v-else v-model="environment_scope" class="w-100" readonly />
</gl-form-group>
</div>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue
index 6e6527df63f..605da5d9352 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue
@@ -37,7 +37,7 @@ export default {
<template>
<div id="popover-container">
- <gl-popover :target="target" triggers="hover" placement="top" container="popover-container">
+ <gl-popover :target="target" placement="top" container="popover-container">
<div
class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-word-break-all"
>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
index c9943052356..e5923124653 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
@@ -2,6 +2,7 @@
import { GlTable, GlButton, GlModalDirective, GlIcon } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { s__, __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
import CiVariablePopover from './ci_variable_popover.vue';
@@ -59,8 +60,9 @@ export default {
directives: {
GlModalDirective,
},
+ mixins: [glFeatureFlagsMixin()],
computed: {
- ...mapState(['variables', 'valuesHidden', 'isGroup', 'isLoading', 'isDeleting']),
+ ...mapState(['variables', 'valuesHidden', 'isLoading', 'isDeleting']),
valuesButtonText() {
return this.valuesHidden ? __('Reveal values') : __('Hide values');
},
@@ -68,9 +70,6 @@ export default {
return this.variables && this.variables.length > 0;
},
fields() {
- if (this.isGroup) {
- return this.$options.fields.filter((field) => field.key !== 'environment_scope');
- }
return this.$options.fields;
},
},
diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js
index 37b5f7e6df7..50856ca9533 100644
--- a/app/assets/javascripts/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci_variable_list/index.js
@@ -3,8 +3,7 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import CiVariableSettings from './components/ci_variable_settings.vue';
import createStore from './store';
-export default (containerId = 'js-ci-project-variables') => {
- const containerEl = document.getElementById(containerId);
+const mountCiVariableListApp = (containerEl) => {
const {
endpoint,
projectId,
@@ -43,3 +42,9 @@ export default (containerId = 'js-ci-project-variables') => {
},
});
};
+
+export default (containerId = 'js-ci-project-variables') => {
+ const el = document.getElementById(containerId);
+
+ return !el ? {} : mountCiVariableListApp(el);
+};
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index 76fe076d4ff..a53b63ea592 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -141,6 +141,9 @@ export default {
isInstalling() {
return this.status === APPLICATION_STATUS.INSTALLING;
},
+ isExternallyInstalled() {
+ return this.status === APPLICATION_STATUS.EXTERNALLY_INSTALLED;
+ },
canInstall() {
return (
this.status === APPLICATION_STATUS.NOT_INSTALLABLE ||
@@ -193,10 +196,17 @@ export default {
label = __('Installing');
} else if (this.installed) {
label = __('Installed');
+ } else if (this.isExternallyInstalled) {
+ label = __('Externally installed');
}
return label;
},
+ buttonGridCellClass() {
+ return this.showManageButton || this.status === APPLICATION_STATUS.EXTERNALLY_INSTALLED
+ ? 'section-25'
+ : 'section-15';
+ },
showManageButton() {
return this.manageLink && this.status === APPLICATION_STATUS.INSTALLED;
},
@@ -427,8 +437,7 @@ export default {
</div>
</div>
<div
- :class="{ 'section-25': showManageButton, 'section-15': !showManageButton }"
- class="table-section table-button-footer section-align-top"
+ :class="[buttonGridCellClass, 'table-section', 'table-button-footer', 'section-align-top']"
role="gridcell"
>
<div v-if="showManageButton" class="btn-group table-action-buttons">
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index e2227c61cee..90ec3f2377c 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -26,6 +26,7 @@ export const APPLICATION_STATUS = {
ERROR: 'errored',
PRE_INSTALLED: 'pre_installed',
UNINSTALLED: 'uninstalled',
+ EXTERNALLY_INSTALLED: 'externally_installed',
};
/*
diff --git a/app/assets/javascripts/clusters/forms/show/index.js b/app/assets/javascripts/clusters/forms/show/index.js
index 47a3016c777..102b240042f 100644
--- a/app/assets/javascripts/clusters/forms/show/index.js
+++ b/app/assets/javascripts/clusters/forms/show/index.js
@@ -1,9 +1,12 @@
import Vue from 'vue';
+import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import IntegrationForm from '../components/integration_form.vue';
import { createStore } from '../stores';
export default () => {
- const entryPoint = document.querySelector('#js-cluster-integration-form');
+ dirtySubmitFactory(document.querySelectorAll('.js-cluster-integrations-form'));
+
+ const entryPoint = document.querySelector('#js-cluster-details-form');
if (!entryPoint) {
return;
diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js
index 1dd815ae44d..2ff604af9a7 100644
--- a/app/assets/javascripts/clusters/services/application_state_machine.js
+++ b/app/assets/javascripts/clusters/services/application_state_machine.js
@@ -15,6 +15,7 @@ const {
UNINSTALL_ERRORED,
PRE_INSTALLED,
UNINSTALLED,
+ EXTERNALLY_INSTALLED,
} = APPLICATION_STATUS;
const applicationStateMachine = {
@@ -71,6 +72,9 @@ const applicationStateMachine = {
[UNINSTALLED]: {
target: UNINSTALLED,
},
+ [EXTERNALLY_INSTALLED]: {
+ target: EXTERNALLY_INSTALLED,
+ },
},
},
[NOT_INSTALLABLE]: {
diff --git a/app/assets/javascripts/clusters_list/components/node_error_help_text.vue b/app/assets/javascripts/clusters_list/components/node_error_help_text.vue
index 1a396694bc8..9903a1bdb3e 100644
--- a/app/assets/javascripts/clusters_list/components/node_error_help_text.vue
+++ b/app/assets/javascripts/clusters_list/components/node_error_help_text.vue
@@ -34,7 +34,7 @@ export default {
<gl-icon name="status_warning" :size="24" class="gl-p-2" />
- <gl-popover :container="popoverId" :target="popoverId" placement="top" triggers="hover focus">
+ <gl-popover :container="popoverId" :target="popoverId" placement="top">
<template #title>
<span class="gl-display-block gl-text-left">{{ errorContent.title }}</span>
</template>
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index 2e050c066f1..6f496ffc6ae 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -5,8 +5,8 @@ import CommitPipelinesTable from './pipelines_table.vue';
* Used in:
* - Project Pipelines List (projects:pipelines:index)
* - Commit details View > Pipelines Tab > Pipelines Table (projects:commit:pipelines)
- * - Merge Request details View > Pipelines Tab > Pipelines Table (projects:merge_requests:show)
- * - New Merge Request View > Pipelines Tab > Pipelines Table (projects:merge_requests:creations:new)
+ * - Merge request details View > Pipelines Tab > Pipelines Table (projects:merge_requests:show)
+ * - New merge request View > Pipelines Tab > Pipelines Table (projects:merge_requests:creations:new)
*/
export default () => {
const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index e1cca5adc73..ddca5bc7d4f 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -1,7 +1,6 @@
<script>
-import { GlButton, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui';
import { getParameterByName } from '~/lib/utils/common_utils';
-import SvgBlankState from '~/pipelines/components/pipelines_list/blank_state.vue';
import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue';
import eventHub from '~/pipelines/event_hub';
import PipelinesMixin from '~/pipelines/mixins/pipelines_mixin';
@@ -13,12 +12,12 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
GlButton,
+ GlEmptyState,
GlLink,
GlLoadingIcon,
GlModal,
PipelinesTableComponent,
TablePagination,
- SvgBlankState,
},
mixins: [PipelinesMixin, glFeatureFlagMixin()],
props: {
@@ -82,7 +81,7 @@ export default {
return this.hasError && !this.isLoading;
},
/**
- * The Run Pipeline button can only be rendered when:
+ * The "Run pipeline" button can only be rendered when:
* - In MR view - we use `canCreatePipelineInTargetProject` for that purpose
* - If the latest pipeline has the `detached_merge_request_pipeline` flag
*
@@ -91,9 +90,6 @@ export default {
canRenderPipelineButton() {
return this.latestPipelineDetachedFlag;
},
- pipelineButtonClass() {
- return !this.glFeatures.newPipelinesTable ? 'gl-md-display-none' : 'gl-lg-display-none';
- },
isForkMergeRequest() {
return this.sourceProjectFullPath !== this.targetProjectFullPath;
},
@@ -149,7 +145,7 @@ export default {
}
},
/**
- * When the user clicks on the Run Pipeline button
+ * When the user clicks on the "Run pipeline" button
* we need to make a post request and
* to update the table content once the request is finished.
*
@@ -178,17 +174,17 @@ export default {
<div class="content-list pipelines">
<gl-loading-icon
v-if="isLoading"
- :label="s__('Pipelines|Loading Pipelines')"
+ :label="s__('Pipelines|Loading pipelines')"
size="lg"
class="prepend-top-20"
/>
- <svg-blank-state
+ <gl-empty-state
v-else-if="shouldRenderErrorState"
:svg-path="errorStateSvgPath"
- :message="
+ :title="
s__(`Pipelines|There was an error fetching the pipelines.
- Try again in a few moments or contact your support team.`)
+ Try again in a few moments or contact your support team.`)
"
/>
@@ -196,14 +192,13 @@ export default {
<gl-button
v-if="canRenderPipelineButton"
block
- class="gl-mt-3 gl-mb-3"
- :class="pipelineButtonClass"
- variant="success"
+ class="gl-mt-3 gl-mb-3 gl-lg-display-none"
+ variant="confirm"
data-testid="run_pipeline_button_mobile"
:loading="state.isRunningMergeRequestPipeline"
@click="tryRunPipeline"
>
- {{ s__('Pipelines|Run Pipeline') }}
+ {{ s__('Pipeline|Run pipeline') }}
</gl-button>
<pipelines-table-component
@@ -214,12 +209,12 @@ export default {
<template #table-header-actions>
<div v-if="canRenderPipelineButton" class="gl-text-right">
<gl-button
- variant="success"
+ variant="confirm"
data-testid="run_pipeline_button"
:loading="state.isRunningMergeRequestPipeline"
@click="tryRunPipeline"
>
- {{ s__('Pipelines|Run Pipeline') }}
+ {{ s__('Pipeline|Run pipeline') }}
</gl-button>
</div>
</template>
@@ -232,7 +227,7 @@ export default {
ref="modal"
:modal-id="modalId"
:title="s__('Pipelines|Are you sure you want to run this pipeline?')"
- :ok-title="s__('Pipelines|Run Pipeline')"
+ :ok-title="s__('Pipeline|Run pipeline')"
ok-variant="danger"
@ok="onClickRunPipeline"
>
diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js
index 5cbe9a24fc4..da7fc88d8ac 100644
--- a/app/assets/javascripts/commits.js
+++ b/app/assets/javascripts/commits.js
@@ -10,7 +10,7 @@ export default class CommitsList {
this.$contentList = $('.content_list');
- Pager.init(parseInt(limit, 10), false, false, this.processCommits.bind(this));
+ Pager.init({ limit: parseInt(limit, 10), prepareData: this.processCommits.bind(this) });
this.content = $('#commits-list');
this.searchField = $('#commits-search');
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
new file mode 100644
index 00000000000..839d4de912d
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -0,0 +1,18 @@
+<script>
+import { EditorContent } from 'tiptap';
+import createEditor from '../services/create_editor';
+
+export default {
+ components: {
+ EditorContent,
+ },
+ data() {
+ return {
+ editor: createEditor(),
+ };
+ },
+};
+</script>
+<template>
+ <editor-content :editor="editor" />
+</template>
diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js
new file mode 100644
index 00000000000..eb6deff434d
--- /dev/null
+++ b/app/assets/javascripts/content_editor/constants.js
@@ -0,0 +1,5 @@
+import { s__ } from '~/locale';
+
+export const PROVIDE_SERIALIZER_OR_RENDERER_ERROR = s__(
+ 'ContentEditor|You have to provide a renderMarkdown function or a custom serializer',
+);
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
new file mode 100644
index 00000000000..1d050ed208b
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -0,0 +1,38 @@
+import { CodeBlockHighlight as BaseCodeBlockHighlight } from 'tiptap-extensions';
+
+export default class GlCodeBlockHighlight extends BaseCodeBlockHighlight {
+ get schema() {
+ const baseSchema = super.schema;
+
+ return {
+ ...baseSchema,
+ attrs: {
+ params: {
+ default: null,
+ },
+ },
+ parseDOM: [
+ {
+ tag: 'pre',
+ preserveWhitespace: 'full',
+ getAttrs: (node) => {
+ const code = node.querySelector('code');
+
+ if (!code) {
+ return null;
+ }
+
+ return {
+ /* `params` is the name of the attribute that
+ prosemirror-markdown uses to extract the language
+ of a codeblock.
+ https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/to_markdown.js#L62
+ */
+ params: code.getAttribute('lang'),
+ };
+ },
+ },
+ ],
+ };
+ }
+}
diff --git a/app/assets/javascripts/content_editor/index.js b/app/assets/javascripts/content_editor/index.js
new file mode 100644
index 00000000000..e6ef3965da1
--- /dev/null
+++ b/app/assets/javascripts/content_editor/index.js
@@ -0,0 +1,2 @@
+export { default as createEditor } from './services/create_editor';
+export { default as ContentEditor } from './components/content_editor.vue';
diff --git a/app/assets/javascripts/content_editor/services/create_editor.js b/app/assets/javascripts/content_editor/services/create_editor.js
new file mode 100644
index 00000000000..128d332b0a2
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/create_editor.js
@@ -0,0 +1,60 @@
+import { isFunction, isString } from 'lodash';
+import { Editor } from 'tiptap';
+import {
+ Bold,
+ Italic,
+ Code,
+ Link,
+ Image,
+ Heading,
+ Blockquote,
+ HorizontalRule,
+ BulletList,
+ OrderedList,
+ ListItem,
+} from 'tiptap-extensions';
+import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
+import CodeBlockHighlight from '../extensions/code_block_highlight';
+import createMarkdownSerializer from './markdown_serializer';
+
+const createEditor = async ({ content, renderMarkdown, serializer: customSerializer } = {}) => {
+ if (!customSerializer && !isFunction(renderMarkdown)) {
+ throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
+ }
+
+ const editor = new Editor({
+ extensions: [
+ new Bold(),
+ new Italic(),
+ new Code(),
+ new Link(),
+ new Image(),
+ new Heading({ levels: [1, 2, 3, 4, 5, 6] }),
+ new Blockquote(),
+ new HorizontalRule(),
+ new BulletList(),
+ new ListItem(),
+ new OrderedList(),
+ new CodeBlockHighlight(),
+ ],
+ });
+ const serializer = customSerializer || createMarkdownSerializer({ render: renderMarkdown });
+
+ editor.setSerializedContent = async (serializedContent) => {
+ editor.setContent(
+ await serializer.deserialize({ schema: editor.schema, content: serializedContent }),
+ );
+ };
+
+ editor.getSerializedContent = () => {
+ return serializer.serialize({ schema: editor.schema, content: editor.getJSON() });
+ };
+
+ if (isString(content)) {
+ await editor.setSerializedContent(content);
+ }
+
+ return editor;
+};
+
+export default createEditor;
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
new file mode 100644
index 00000000000..e3b5775e320
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -0,0 +1,73 @@
+import {
+ MarkdownSerializer as ProseMirrorMarkdownSerializer,
+ defaultMarkdownSerializer,
+} from 'prosemirror-markdown';
+import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
+
+const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
+
+/**
+ * A markdown serializer converts arbitrary Markdown content
+ * into a ProseMirror document and viceversa. To convert Markdown
+ * into a ProseMirror document, the Markdown should be rendered.
+ *
+ * The client should provide a render function to allow flexibility
+ * on the desired rendering approach.
+ *
+ * @param {Function} params.render Render function
+ * that parses the Markdown and converts it into HTML.
+ * @returns a markdown serializer
+ */
+const create = ({ render = () => null }) => {
+ return {
+ /**
+ * Converts a Markdown string into a ProseMirror JSONDocument based
+ * on a ProseMirror schema.
+ * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
+ * the types of content supported in the document
+ * @param {String} params.content An arbitrary markdown string
+ * @returns A ProseMirror JSONDocument
+ */
+ deserialize: async ({ schema, content }) => {
+ const html = await render(content);
+
+ if (!html) {
+ return null;
+ }
+
+ const parser = new DOMParser();
+ const {
+ body: { firstElementChild },
+ } = parser.parseFromString(wrapHtmlPayload(html), 'text/html');
+ const state = ProseMirrorDOMParser.fromSchema(schema).parse(firstElementChild);
+
+ return state.toJSON();
+ },
+
+ /**
+ * Converts a ProseMirror JSONDocument based
+ * on a ProseMirror schema into Markdown
+ * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
+ * the types of content supported in the document
+ * @param {String} params.content A ProseMirror JSONDocument
+ * @returns A Markdown string
+ */
+ serialize: ({ schema, content }) => {
+ const document = schema.nodeFromJSON(content);
+ const serializer = new ProseMirrorMarkdownSerializer(defaultMarkdownSerializer.nodes, {
+ ...defaultMarkdownSerializer.marks,
+ bold: {
+ // creates a bold alias for the strong mark converter
+ ...defaultMarkdownSerializer.marks.strong,
+ },
+ italic: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true },
+ });
+
+ return serializer.serialize(document, {
+ tightLists: true,
+ });
+ },
+ };
+};
+
+export default create;
diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue
index 7426e570864..25ce6500094 100644
--- a/app/assets/javascripts/contributors/components/contributors.vue
+++ b/app/assets/javascripts/contributors/components/contributors.vue
@@ -201,11 +201,12 @@ export default {
</div>
<div v-else-if="showChart" class="contributors-charts">
- <h4>{{ __('Commits to') }} {{ branch }}</h4>
+ <h4 class="gl-mb-2 gl-mt-5">{{ __('Commits to') }} {{ branch }}</h4>
<span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span>
<resizable-chart-container>
<gl-area-chart
slot-scope="{ width }"
+ class="gl-mb-5"
:width="width"
:data="masterChartData"
:option="masterChartOptions"
@@ -218,10 +219,12 @@ export default {
<div
v-for="(contributor, index) in individualChartsData"
:key="index"
- class="col-lg-6 col-12"
+ class="col-lg-6 col-12 gl-my-5"
>
- <h4>{{ contributor.name }}</h4>
- <p>{{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})</p>
+ <h4 class="gl-mb-2 gl-mt-0">{{ contributor.name }}</h4>
+ <p class="gl-mb-3">
+ {{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})
+ </p>
<resizable-chart-container>
<gl-area-chart
slot-scope="{ width }"
diff --git a/app/assets/javascripts/contributors/index.js b/app/assets/javascripts/contributors/index.js
index b6063589734..f66133a074d 100644
--- a/app/assets/javascripts/contributors/index.js
+++ b/app/assets/javascripts/contributors/index.js
@@ -1,12 +1,15 @@
import Vue from 'vue';
import ContributorsGraphs from './components/contributors.vue';
-import store from './stores';
+import { createStore } from './stores';
export default () => {
const el = document.querySelector('.js-contributors-graph');
if (!el) return null;
+ const { projectGraphPath, projectBranch, defaultBranch } = el.dataset;
+ const store = createStore(defaultBranch);
+
return new Vue({
el,
store,
@@ -14,8 +17,8 @@ export default () => {
render(createElement) {
return createElement(ContributorsGraphs, {
props: {
- endpoint: el.dataset.projectGraphPath,
- branch: el.dataset.projectBranch,
+ endpoint: projectGraphPath,
+ branch: projectBranch,
},
});
},
diff --git a/app/assets/javascripts/contributors/stores/index.js b/app/assets/javascripts/contributors/stores/index.js
index 38259f46d4c..a4d0004cee5 100644
--- a/app/assets/javascripts/contributors/stores/index.js
+++ b/app/assets/javascripts/contributors/stores/index.js
@@ -7,12 +7,12 @@ import state from './state';
Vue.use(Vuex);
-export const createStore = () =>
+export const createStore = (defaultBranch) =>
new Vuex.Store({
actions,
mutations,
getters,
- state: state(),
+ state: state(defaultBranch),
});
-export default createStore();
+export default createStore;
diff --git a/app/assets/javascripts/contributors/stores/state.js b/app/assets/javascripts/contributors/stores/state.js
index 1dc1a3c7b75..9c6b993e5cb 100644
--- a/app/assets/javascripts/contributors/stores/state.js
+++ b/app/assets/javascripts/contributors/stores/state.js
@@ -1,5 +1,5 @@
-export default () => ({
+export default (branch) => ({
loading: false,
chartData: null,
- branch: 'master',
+ branch,
});
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index 35176c19f69..000faacb7d7 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -1,4 +1,3 @@
-/* eslint-disable no-new */
import { debounce } from 'lodash';
import {
init as initConfidentialMergeRequest,
@@ -8,7 +7,7 @@ import {
import confidentialMergeRequestState from './confidential_merge_request/state';
import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter';
-import { deprecatedCreateFlash as Flash } from './flash';
+import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { __, sprintf } from './locale';
@@ -36,6 +35,7 @@ export default class CreateMergeRequestDropdown {
this.branchInput = this.wrapperEl.querySelector('.js-branch-name');
this.branchMessage = this.wrapperEl.querySelector('.js-branch-message');
this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request');
+ this.createMergeRequestLoading = this.createMergeRequestButton.querySelector('.js-spinner');
this.createTargetButton = this.wrapperEl.querySelector('.js-create-target');
this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu');
this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle');
@@ -132,7 +132,9 @@ export default class CreateMergeRequestDropdown {
.catch(() => {
this.unavailable();
this.disable();
- Flash(__('Failed to check related branches.'));
+ createFlash({
+ message: __('Failed to check related branches.'),
+ });
});
}
@@ -147,7 +149,11 @@ export default class CreateMergeRequestDropdown {
this.branchCreated = true;
window.location.href = data.url;
})
- .catch(() => Flash(__('Failed to create a branch for this issue. Please try again.')));
+ .catch(() =>
+ createFlash({
+ message: __('Failed to create a branch for this issue. Please try again.'),
+ }),
+ );
}
createMergeRequest() {
@@ -163,13 +169,21 @@ export default class CreateMergeRequestDropdown {
this.mergeRequestCreated = true;
window.location.href = data.url;
})
- .catch(() => Flash(__('Failed to create Merge Request. Please try again.')));
+ .catch(() =>
+ createFlash({
+ message: __('Failed to create merge request. Please try again.'),
+ }),
+ );
}
disable() {
this.disableCreateAction();
}
+ setLoading(loading) {
+ this.createMergeRequestLoading.classList.toggle('gl-display-none', !loading);
+ }
+
disableCreateAction() {
this.createMergeRequestButton.classList.add('disabled');
this.createMergeRequestButton.setAttribute('disabled', 'disabled');
@@ -256,7 +270,9 @@ export default class CreateMergeRequestDropdown {
.catch(() => {
this.unavailable();
this.disable();
- new Flash(__('Failed to get ref.'));
+ createFlash({
+ message: __('Failed to get ref.'),
+ });
this.isGettingRef = false;
@@ -376,8 +392,10 @@ export default class CreateMergeRequestDropdown {
this.isCreatingBranch = false;
this.enable();
+ this.setLoading(false);
});
+ this.setLoading(true);
this.disable();
}
diff --git a/app/assets/javascripts/delete_label_modal.js b/app/assets/javascripts/delete_label_modal.js
new file mode 100644
index 00000000000..cf7c9e7734f
--- /dev/null
+++ b/app/assets/javascripts/delete_label_modal.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import DeleteLabelModal from '~/vue_shared/components/delete_label_modal.vue';
+
+const mountDeleteLabelModal = (optionalProps) =>
+ new Vue({
+ render(h) {
+ return h(DeleteLabelModal, {
+ props: {
+ selector: '.js-delete-label-modal-button',
+ ...optionalProps,
+ },
+ });
+ },
+ }).$mount();
+
+export default (optionalProps = {}) => mountDeleteLabelModal(optionalProps);
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
index d05a0761ae3..051ab710e5f 100644
--- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
@@ -18,7 +18,6 @@ export default {
modalOptions: {
ref: 'modal',
modalId: 'deploy-freeze-modal',
- title: __('Add deploy freeze'),
actionCancel: {
text: __('Cancel'),
},
@@ -30,10 +29,13 @@ export default {
cronSyntaxInstructions: __(
'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}',
),
+ addTitle: __('Add deploy freeze'),
+ editTitle: __('Edit deploy freeze'),
},
computed: {
...mapState([
'projectId',
+ 'selectedId',
'selectedTimezone',
'timezoneData',
'freezeStartCron',
@@ -45,9 +47,9 @@ export default {
]),
addDeployFreezeButton() {
return {
- text: __('Add deploy freeze'),
+ text: this.isEditing ? __('Save deploy freeze') : __('Add deploy freeze'),
attributes: [
- { variant: 'success' },
+ { variant: 'confirm' },
{
disabled:
!isValidCron(this.freezeStartCron) ||
@@ -77,9 +79,17 @@ export default {
this.setSelectedTimezone(selectedTimezone);
},
},
+ isEditing() {
+ return Boolean(this.selectedId);
+ },
+ modalTitle() {
+ return this.isEditing
+ ? this.$options.translations.editTitle
+ : this.$options.translations.addTitle;
+ },
},
methods: {
- ...mapActions(['addFreezePeriod', 'setSelectedTimezone', 'resetModal']),
+ ...mapActions(['addFreezePeriod', 'updateFreezePeriod', 'setSelectedTimezone', 'resetModal']),
resetModalHandler() {
this.resetModal();
},
@@ -89,6 +99,13 @@ export default {
}
return '';
},
+ submit() {
+ if (this.isEditing) {
+ this.updateFreezePeriod();
+ } else {
+ this.addFreezePeriod();
+ }
+ },
},
};
</script>
@@ -96,8 +113,9 @@ export default {
<template>
<gl-modal
v-bind="$options.modalOptions"
+ :title="modalTitle"
:action-primary="addDeployFreezeButton"
- @primary="addFreezePeriod"
+ @primary="submit"
@canceled="resetModalHandler"
>
<p>
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
index 0d6657973c3..8282f1d910a 100644
--- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
@@ -1,7 +1,7 @@
<script>
import { GlTable, GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
-import { s__, __ } from '~/locale';
+import { s__ } from '~/locale';
export default {
fields: [
@@ -17,9 +17,16 @@ export default {
key: 'cronTimezone',
label: s__('DeployFreeze|Time zone'),
},
+ {
+ key: 'edit',
+ label: s__('DeployFreeze|Edit'),
+ },
],
translations: {
- addDeployFreeze: __('Add deploy freeze'),
+ addDeployFreeze: s__('DeployFreeze|Add deploy freeze'),
+ emptyStateText: s__(
+ 'DeployFreeze|No deploy freezes exist for this project. To add one, select %{strongStart}Add deploy freeze%{strongEnd}',
+ ),
},
components: {
GlTable,
@@ -39,7 +46,7 @@ export default {
this.fetchFreezePeriods();
},
methods: {
- ...mapActions(['fetchFreezePeriods']),
+ ...mapActions(['fetchFreezePeriods', 'setFreezePeriod']),
},
};
</script>
@@ -53,15 +60,21 @@ export default {
show-empty
stacked="lg"
>
+ <template #cell(cronTimezone)="{ item }">
+ {{ item.cronTimezone.formattedTimezone }}
+ </template>
+ <template #cell(edit)="{ item }">
+ <gl-button
+ v-gl-modal.deploy-freeze-modal
+ icon="pencil"
+ data-testid="edit-deploy-freeze"
+ :aria-label="__('Edit deploy freeze')"
+ @click="setFreezePeriod(item)"
+ />
+ </template>
<template #empty>
<p data-testid="empty-freeze-periods" class="gl-text-center text-plain">
- <gl-sprintf
- :message="
- s__(
- 'DeployFreeze|No deploy freezes exist for this project. To add one, click %{strongStart}Add deploy freeze%{strongEnd}',
- )
- "
- >
+ <gl-sprintf :message="$options.translations.emptyStateText">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
@@ -73,7 +86,7 @@ export default {
v-gl-modal.deploy-freeze-modal
data-testid="add-deploy-freeze"
category="primary"
- variant="success"
+ variant="confirm"
>
{{ $options.translations.addDeployFreeze }}
</gl-button>
diff --git a/app/assets/javascripts/deploy_freeze/store/actions.js b/app/assets/javascripts/deploy_freeze/store/actions.js
index 62045d2517d..56e45595dc5 100644
--- a/app/assets/javascripts/deploy_freeze/store/actions.js
+++ b/app/assets/javascripts/deploy_freeze/store/actions.js
@@ -3,37 +3,53 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __ } from '~/locale';
import * as types from './mutation_types';
-export const requestAddFreezePeriod = ({ commit }) => {
+export const requestFreezePeriod = ({ commit }) => {
commit(types.REQUEST_ADD_FREEZE_PERIOD);
};
-export const receiveAddFreezePeriodSuccess = ({ commit }) => {
+export const receiveFreezePeriodSuccess = ({ commit }) => {
commit(types.RECEIVE_ADD_FREEZE_PERIOD_SUCCESS);
};
-export const receiveAddFreezePeriodError = ({ commit }, error) => {
+export const receiveFreezePeriodError = ({ commit }, error) => {
commit(types.RECEIVE_ADD_FREEZE_PERIOD_ERROR, error);
};
-export const addFreezePeriod = ({ state, dispatch, commit }) => {
- dispatch('requestAddFreezePeriod');
+const receiveFreezePeriod = (store, request) => {
+ const { dispatch, commit } = store;
+ dispatch('requestFreezePeriod');
- return Api.createFreezePeriod(state.projectId, {
- freeze_start: state.freezeStartCron,
- freeze_end: state.freezeEndCron,
- cron_timezone: state.selectedTimezoneIdentifier,
- })
+ request(store)
.then(() => {
- dispatch('receiveAddFreezePeriodSuccess');
+ dispatch('receiveFreezePeriodSuccess');
commit(types.RESET_MODAL);
dispatch('fetchFreezePeriods');
})
.catch((error) => {
createFlash(__('Error: Unable to create deploy freeze'));
- dispatch('receiveAddFreezePeriodError', error);
+ dispatch('receiveFreezePeriodError', error);
});
};
+export const addFreezePeriod = (store) =>
+ receiveFreezePeriod(store, ({ state }) =>
+ Api.createFreezePeriod(state.projectId, {
+ freeze_start: state.freezeStartCron,
+ freeze_end: state.freezeEndCron,
+ cron_timezone: state.selectedTimezoneIdentifier,
+ }),
+ );
+
+export const updateFreezePeriod = (store) =>
+ receiveFreezePeriod(store, ({ state }) =>
+ Api.updateFreezePeriod(state.projectId, {
+ id: state.selectedId,
+ freeze_start: state.freezeStartCron,
+ freeze_end: state.freezeEndCron,
+ cron_timezone: state.selectedTimezoneIdentifier,
+ }),
+ );
+
export const fetchFreezePeriods = ({ commit, state }) => {
commit(types.REQUEST_FREEZE_PERIODS);
@@ -46,6 +62,13 @@ export const fetchFreezePeriods = ({ commit, state }) => {
});
};
+export const setFreezePeriod = ({ commit }, freezePeriod) => {
+ commit(types.SET_SELECTED_ID, freezePeriod.id);
+ commit(types.SET_SELECTED_TIMEZONE, freezePeriod.cronTimezone);
+ commit(types.SET_FREEZE_START_CRON, freezePeriod.freezeStart);
+ commit(types.SET_FREEZE_END_CRON, freezePeriod.freezeEnd);
+};
+
export const setSelectedTimezone = ({ commit }, timezone) => {
commit(types.SET_SELECTED_TIMEZONE, timezone);
};
diff --git a/app/assets/javascripts/deploy_freeze/store/mutation_types.js b/app/assets/javascripts/deploy_freeze/store/mutation_types.js
index 47a4874a5cf..8e6fdfd4443 100644
--- a/app/assets/javascripts/deploy_freeze/store/mutation_types.js
+++ b/app/assets/javascripts/deploy_freeze/store/mutation_types.js
@@ -6,6 +6,7 @@ export const RECEIVE_ADD_FREEZE_PERIOD_SUCCESS = 'RECEIVE_ADD_FREEZE_PERIOD_SUCC
export const RECEIVE_ADD_FREEZE_PERIOD_ERROR = 'RECEIVE_ADD_FREEZE_PERIOD_ERROR';
export const SET_SELECTED_TIMEZONE = 'SET_SELECTED_TIMEZONE';
+export const SET_SELECTED_ID = 'SET_SELECTED_ID';
export const SET_FREEZE_START_CRON = 'SET_FREEZE_START_CRON';
export const SET_FREEZE_END_CRON = 'SET_FREEZE_END_CRON';
diff --git a/app/assets/javascripts/deploy_freeze/store/mutations.js b/app/assets/javascripts/deploy_freeze/store/mutations.js
index 3b34f3950e6..e62000c007c 100644
--- a/app/assets/javascripts/deploy_freeze/store/mutations.js
+++ b/app/assets/javascripts/deploy_freeze/store/mutations.js
@@ -4,7 +4,11 @@ import * as types from './mutation_types';
const formatTimezoneName = (freezePeriod, timezoneList) =>
convertObjectPropsToCamelCase({
...freezePeriod,
- cron_timezone: timezoneList.find((tz) => tz.identifier === freezePeriod.cron_timezone)?.name,
+ cron_timezone: {
+ formattedTimezone: timezoneList.find((tz) => tz.identifier === freezePeriod.cron_timezone)
+ ?.name,
+ identifier: freezePeriod.cronTimezone,
+ },
});
export default {
@@ -45,10 +49,15 @@ export default {
state.freezeEndCron = freezeEndCron;
},
+ [types.SET_SELECTED_ID](state, id) {
+ state.selectedId = id;
+ },
+
[types.RESET_MODAL](state) {
state.freezeStartCron = '';
state.freezeEndCron = '';
state.selectedTimezone = '';
state.selectedTimezoneIdentifier = '';
+ state.selectedId = '';
},
};
diff --git a/app/assets/javascripts/deploy_freeze/store/state.js b/app/assets/javascripts/deploy_freeze/store/state.js
index 4cc38c097b6..1b16b4c645b 100644
--- a/app/assets/javascripts/deploy_freeze/store/state.js
+++ b/app/assets/javascripts/deploy_freeze/store/state.js
@@ -6,6 +6,7 @@ export default ({
selectedTimezoneIdentifier = '',
freezeStartCron = '',
freezeEndCron = '',
+ selectedId = '',
}) => ({
projectId,
freezePeriods,
@@ -14,4 +15,5 @@ export default ({
selectedTimezoneIdentifier,
freezeStartCron,
freezeEndCron,
+ selectedId,
});
diff --git a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
new file mode 100644
index 00000000000..e026391ae22
--- /dev/null
+++ b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ GlButton,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ inject: {
+ token: {
+ default: null,
+ },
+ revokePath: {
+ default: '',
+ },
+ buttonClass: {
+ default: '',
+ },
+ },
+ computed: {
+ modalId() {
+ return `revoke-modal-${this.token.id}`;
+ },
+ },
+ methods: {
+ cancelHandler() {
+ this.$refs.modal.hide();
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-button
+ v-gl-modal="modalId"
+ :class="buttonClass"
+ category="primary"
+ variant="danger"
+ class="float-right"
+ data-testid="revoke-button"
+ >{{ s__('DeployTokens|Revoke') }}</gl-button
+ >
+ <gl-modal ref="modal" :modal-id="modalId">
+ <template #modal-title>
+ <gl-sprintf :message="s__(`DeployTokens|Revoke %{boldStart}${token.name}%{boldEnd}?`)">
+ <template #bold="{ content }"
+ ><b>{{ content }}</b></template
+ >
+ </gl-sprintf>
+ </template>
+ <gl-sprintf
+ :message="s__(`DeployTokens|You are about to revoke %{boldStart}${token.name}%{boldEnd}.`)"
+ >
+ <template #bold="{ content }">
+ <b>{{ content }}</b>
+ </template>
+ </gl-sprintf>
+ {{ s__('DeployTokens|This action cannot be undone.') }}
+ <template #modal-footer>
+ <gl-button category="secondary" @click="cancelHandler">{{ s__('Cancel') }}</gl-button>
+ <gl-button
+ category="primary"
+ variant="danger"
+ :href="revokePath"
+ data-method="put"
+ class="text-truncate"
+ data-testid="primary-revoke-btn"
+ >
+ <gl-sprintf :message="s__('DeployTokens|Revoke %{name}')">
+ <template #name>{{ token.name }}</template>
+ </gl-sprintf>
+ </gl-button>
+ </template>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/deploy_tokens/init_revoke_button.js b/app/assets/javascripts/deploy_tokens/init_revoke_button.js
new file mode 100644
index 00000000000..20187150a60
--- /dev/null
+++ b/app/assets/javascripts/deploy_tokens/init_revoke_button.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import RevokeButton from './components/revoke_button.vue';
+
+export default () => {
+ const containers = document.querySelectorAll('.js-deploy-token-revoke-button');
+
+ if (!containers.length) {
+ return false;
+ }
+
+ return containers.forEach((el) => {
+ const { token, revokePath, buttonClass } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ token: JSON.parse(token),
+ revokePath,
+ buttonClass,
+ },
+ render(h) {
+ return h(RevokeButton);
+ },
+ });
+ });
+};
diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js
index b1d486c5d66..8ca4dc587a8 100644
--- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js
+++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js
@@ -2,6 +2,7 @@
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import $ from 'jquery';
+import { debounce } from 'lodash';
import { isObject } from '~/lib/utils/type_utility';
const BLUR_KEYCODES = [27, 40];
@@ -11,13 +12,21 @@ const HAS_VALUE_CLASS = 'has-value';
export class GitLabDropdownFilter {
constructor(input, options) {
let ref;
- let timeout;
this.input = input;
this.options = options;
// eslint-disable-next-line no-cond-assign
this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true;
const $inputContainer = this.input.parent();
const $clearButton = $inputContainer.find('.js-dropdown-input-clear');
+ const filterRemoteDebounced = debounce(() => {
+ $inputContainer.parent().addClass('is-loading');
+
+ return this.options.query(this.input.val(), (data) => {
+ $inputContainer.parent().removeClass('is-loading');
+ return this.options.callback(data);
+ });
+ }, 500);
+
$clearButton.on('click', (e) => {
// Clear click
e.preventDefault();
@@ -25,7 +34,6 @@ export class GitLabDropdownFilter {
return this.input.val('').trigger('input').focus();
});
// Key events
- timeout = '';
this.input
.on('keydown', (e) => {
const keyCode = e.which;
@@ -41,16 +49,7 @@ export class GitLabDropdownFilter {
}
// Only filter asynchronously only if option remote is set
if (this.options.remote) {
- clearTimeout(timeout);
- // eslint-disable-next-line no-return-assign
- return (timeout = setTimeout(() => {
- $inputContainer.parent().addClass('is-loading');
-
- return this.options.query(this.input.val(), (data) => {
- $inputContainer.parent().removeClass('is-loading');
- return this.options.callback(data);
- });
- }, 250));
+ return filterRemoteDebounced();
}
return this.filter(this.input.val());
});
diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue
index fbcce22ec1e..ae2ce7c3e5e 100644
--- a/app/assets/javascripts/design_management/components/delete_button.vue
+++ b/app/assets/javascripts/design_management/components/delete_button.vue
@@ -63,7 +63,7 @@ export default {
title: s__('DesignManagement|Are you sure you want to archive the selected designs?'),
actionPrimary: {
text: s__('DesignManagement|Archive designs'),
- attributes: { variant: 'warning', 'data-qa-selector': 'confirm_archiving_button' },
+ attributes: { variant: 'confirm', 'data-qa-selector': 'confirm_archiving_button' },
},
actionCancel: {
text: __('Cancel'),
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 2b867217327..833d7081a2c 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
@@ -1,6 +1,7 @@
<script>
import { GlTooltipDirective, GlIcon, GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
import { ApolloMutation } from 'vue-apollo';
+import { __ } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
@@ -10,6 +11,9 @@ import { findNoteId, extractDesignNoteId } from '../../utils/design_management_u
import DesignReplyForm from './design_reply_form.vue';
export default {
+ i18n: {
+ editCommentLabel: __('Edit comment'),
+ },
components: {
UserAvatarLink,
TimelineEntryItem,
@@ -113,7 +117,8 @@ export default {
v-if="isEditButtonVisible"
v-gl-tooltip
type="button"
- :title="__('Edit comment')"
+ :title="$options.i18n.editCommentLabel"
+ :aria-label="$options.i18n.editCommentLabel"
class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
@click="isEditing = true"
>
diff --git a/app/assets/javascripts/design_management/components/design_scaler.vue b/app/assets/javascripts/design_management/components/design_scaler.vue
index 85c6bd4d79e..c9273f97bed 100644
--- a/app/assets/javascripts/design_management/components/design_scaler.vue
+++ b/app/assets/javascripts/design_management/components/design_scaler.vue
@@ -51,8 +51,18 @@ export default {
<template>
<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
+ icon="dash"
+ :disabled="disableDecrease"
+ :aria-label="__('Decrease')"
+ @click="decrementScale"
+ />
+ <gl-button icon="redo" :disabled="disableReset" :aria-label="__('Reset')" @click="resetScale" />
+ <gl-button
+ icon="plus"
+ :disabled="disableIncrease"
+ :aria-label="__('Increase')"
+ @click="incrementScale"
+ />
</gl-button-group>
</template>
diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
index 2169c9111d2..b6163491abc 100644
--- a/app/assets/javascripts/design_management/components/list/item.vue
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -137,8 +137,7 @@ export default {
<span :title="icon.tooltip" :aria-label="icon.tooltip">
<gl-icon
:name="icon.name"
- :size="18"
- use-deprecated-sizes
+ :size="16"
:class="icon.classes"
data-qa-selector="design_status_icon"
:data-qa-status="icon.name"
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 6091a3183ac..3ebcde817f9 100644
--- a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
@@ -2,11 +2,20 @@
/* global Mousetrap */
import 'mousetrap';
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
+import {
+ keysFor,
+ ISSUE_PREVIOUS_DESIGN,
+ ISSUE_NEXT_DESIGN,
+} from '~/behaviors/shortcuts/keybindings';
import { s__, sprintf } from '~/locale';
import allDesignsMixin from '../../mixins/all_designs';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
export default {
+ i18n: {
+ nextButton: s__('DesignManagement|Go to next design'),
+ previousButton: s__('DesignManagement|Go to previous design'),
+ },
components: {
GlButton,
GlButtonGroup,
@@ -46,11 +55,14 @@ export default {
},
},
mounted() {
- Mousetrap.bind('left', () => this.navigateToDesign(this.previousDesign));
- Mousetrap.bind('right', () => this.navigateToDesign(this.nextDesign));
+ Mousetrap.bind(keysFor(ISSUE_PREVIOUS_DESIGN), () =>
+ this.navigateToDesign(this.previousDesign),
+ );
+ Mousetrap.bind(keysFor(ISSUE_NEXT_DESIGN), () => this.navigateToDesign(this.nextDesign));
},
beforeDestroy() {
- Mousetrap.unbind(['left', 'right'], this.navigateToDesign);
+ Mousetrap.unbind(keysFor(ISSUE_PREVIOUS_DESIGN));
+ Mousetrap.unbind(keysFor(ISSUE_NEXT_DESIGN));
},
methods: {
navigateToDesign(design) {
@@ -73,7 +85,8 @@ export default {
<gl-button
v-gl-tooltip.bottom
:disabled="!previousDesign"
- :title="s__('DesignManagement|Go to previous design')"
+ :title="$options.i18n.previousButton"
+ :aria-label="$options.i18n.previousButton"
icon="angle-left"
class="js-previous-design"
@click="navigateToDesign(previousDesign)"
@@ -81,7 +94,8 @@ export default {
<gl-button
v-gl-tooltip.bottom
:disabled="!nextDesign"
- :title="s__('DesignManagement|Go to next design')"
+ :title="$options.i18n.nextButton"
+ :aria-label="$options.i18n.nextButton"
icon="angle-right"
class="js-next-design"
@click="navigateToDesign(nextDesign)"
diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue
index 8abf1529f3c..b84fe45b77e 100644
--- a/app/assets/javascripts/design_management/components/toolbar/index.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/index.vue
@@ -1,13 +1,16 @@
<script>
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
-import { __, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
import DeleteButton from '../delete_button.vue';
import DesignNavigation from './design_navigation.vue';
export default {
+ i18n: {
+ downloadButtonLabel: s__('DesignManagement|Download design'),
+ },
components: {
GlButton,
GlIcon,
@@ -119,7 +122,8 @@ export default {
v-gl-tooltip.bottom
:href="image"
icon="download"
- :title="s__('DesignManagement|Download design')"
+ :title="$options.i18n.downloadButtonLabel"
+ :aria-label="$options.i18n.downloadButtonLabel"
/>
<delete-button
v-if="isLatestVersion && canDeleteDesign"
diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue
index 394ccb3c483..98b7ab5c094 100644
--- a/app/assets/javascripts/design_management/components/upload/button.vue
+++ b/app/assets/javascripts/design_management/components/upload/button.vue
@@ -38,7 +38,8 @@ export default {
"
:disabled="isSaving"
:loading="isSaving"
- variant="default"
+ category="secondary"
+ variant="confirm"
size="small"
@click="openFileUpload"
>
diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js
index f0930ade1b5..aa9f377ef16 100644
--- a/app/assets/javascripts/design_management/index.js
+++ b/app/assets/javascripts/design_management/index.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import App from './components/app.vue';
import apolloProvider from './graphql';
+import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql';
import createRouter from './router';
export default () => {
@@ -8,7 +9,8 @@ export default () => {
const { issueIid, projectPath, issuePath } = el.dataset;
const router = createRouter(issuePath);
- apolloProvider.clients.defaultClient.cache.writeData({
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: activeDiscussionQuery,
data: {
activeDiscussion: {
__typename: 'ActiveDiscussion',
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index 8a11c25a795..ad78433c7ce 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -2,6 +2,7 @@
import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
import Mousetrap from 'mousetrap';
import { ApolloMutation } from 'vue-apollo';
+import { keysFor, ISSUE_CLOSE_DESIGN } from '~/behaviors/shortcuts/keybindings';
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -171,7 +172,7 @@ export default {
},
},
mounted() {
- Mousetrap.bind('esc', this.closeDesign);
+ Mousetrap.bind(keysFor(ISSUE_CLOSE_DESIGN), this.closeDesign);
this.trackPageViewEvent();
// Set active discussion immediately.
@@ -180,7 +181,7 @@ export default {
this.updateActiveDiscussionFromUrl();
},
beforeDestroy() {
- Mousetrap.unbind('esc', this.closeDesign);
+ Mousetrap.unbind(keysFor(ISSUE_CLOSE_DESIGN));
},
methods: {
addImageDiffNoteToStore(store, { data: { createImageDiffNote } }) {
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index 99ac38fc554..04d80dc0069 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -379,8 +379,7 @@ export default {
<delete-button
v-if="isLatestVersion"
:is-deleting="loading"
- button-variant="warning"
- button-category="secondary"
+ button-variant="default"
button-class="gl-mr-3"
button-size="small"
data-qa-selector="archive_button"
@@ -485,9 +484,7 @@ export default {
<template #upload-text="{ openFileUpload }">
<gl-sprintf :message="$options.i18n.dropzoneDescriptionText">
<template #link="{ content }">
- <gl-link @click.stop="openFileUpload">
- {{ content }}
- </gl-link>
+ <gl-link @click.stop="openFileUpload">{{ content }}</gl-link>
</template>
</gl-sprintf>
</template>
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 253e1e3b70e..7c610968209 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -3,6 +3,13 @@ import { GlLoadingIcon, GlPagination, GlSprintf } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Mousetrap from 'mousetrap';
import { mapState, mapGetters, mapActions } from 'vuex';
+import {
+ keysFor,
+ MR_PREVIOUS_FILE_IN_DIFF,
+ MR_NEXT_FILE_IN_DIFF,
+ MR_COMMITS_NEXT_COMMIT,
+ MR_COMMITS_PREVIOUS_COMMIT,
+} from '~/behaviors/shortcuts/keybindings';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { isSingleViewStyle } from '~/helpers/diffs_helper';
import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
@@ -77,6 +84,16 @@ export default {
required: false,
default: '',
},
+ endpointCodequality: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ endpointUpdateUser: {
+ type: String,
+ required: false,
+ default: '',
+ },
projectPath: {
type: String,
required: true,
@@ -153,6 +170,7 @@ export default {
plainDiffPath: (state) => state.diffs.plainDiffPath,
emailPatchPath: (state) => state.diffs.emailPatchPath,
retrievingBatches: (state) => state.diffs.retrievingBatches,
+ codequalityDiff: (state) => state.diffs.codequalityDiff,
}),
...mapState('diffs', [
'showTreeList',
@@ -167,6 +185,7 @@ export default {
'mrReviews',
]),
...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']),
+ ...mapGetters('batchComments', ['draftsCount']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
diffs() {
if (!this.viewDiffsFileByFile) {
@@ -264,6 +283,7 @@ export default {
endpointMetadata: this.endpointMetadata,
endpointBatch: this.endpointBatch,
endpointCoverage: this.endpointCoverage,
+ endpointUpdateUser: this.endpointUpdateUser,
projectPath: this.projectPath,
dismissEndpoint: this.dismissEndpoint,
showSuggestPopover: this.showSuggestPopover,
@@ -272,6 +292,10 @@ export default {
mrReviews: this.rehydratedMrReviews,
});
+ if (this.endpointCodequality) {
+ this.setCodequalityEndpoint(this.endpointCodequality);
+ }
+
if (this.shouldShow) {
this.fetchData();
}
@@ -316,9 +340,11 @@ export default {
...mapActions('diffs', [
'moveToNeighboringCommit',
'setBaseConfig',
+ 'setCodequalityEndpoint',
'fetchDiffFilesMeta',
'fetchDiffFilesBatch',
'fetchCoverageFiles',
+ 'fetchCodequality',
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
'setHighlightedRow',
@@ -342,14 +368,6 @@ export default {
refetchDiffData() {
this.fetchData(false);
},
- startDiffRendering() {
- requestIdleCallback(
- () => {
- this.startRenderDiffsQueue();
- },
- { timeout: 1000 },
- );
- },
needsReload() {
return this.diffFiles.length && isSingleViewStyle(this.diffFiles[0]);
},
@@ -361,8 +379,6 @@ export default {
.then(({ real_size }) => {
this.diffFilesLength = parseInt(real_size, 10);
if (toggleTree) this.setTreeDisplay();
-
- this.startDiffRendering();
})
.catch(() => {
createFlash(__('Something went wrong on our end. Please try again!'));
@@ -377,7 +393,6 @@ export default {
// change when loading the other half of the diff files.
this.setDiscussions();
})
- .then(() => this.startDiffRendering())
.catch(() => {
createFlash(__('Something went wrong on our end. Please try again!'));
});
@@ -386,6 +401,10 @@ export default {
this.fetchCoverageFiles();
}
+ if (this.endpointCodequality) {
+ this.fetchCodequality();
+ }
+
if (!this.isNotesFetched) {
notesEventHub.$emit('fetchNotesData');
}
@@ -406,30 +425,23 @@ export default {
}
},
setEventListeners() {
- Mousetrap.bind(['[', 'k', ']', 'j'], (e, combo) => {
- switch (combo) {
- case '[':
- case 'k':
- this.jumpToFile(-1);
- break;
- case ']':
- case 'j':
- this.jumpToFile(+1);
- break;
- default:
- break;
- }
- });
+ Mousetrap.bind(keysFor(MR_PREVIOUS_FILE_IN_DIFF), () => this.jumpToFile(-1));
+ Mousetrap.bind(keysFor(MR_NEXT_FILE_IN_DIFF), () => this.jumpToFile(+1));
if (this.commit) {
- Mousetrap.bind('c', () => this.moveToNeighboringCommit({ direction: 'next' }));
- Mousetrap.bind('x', () => this.moveToNeighboringCommit({ direction: 'previous' }));
+ Mousetrap.bind(keysFor(MR_COMMITS_NEXT_COMMIT), () =>
+ this.moveToNeighboringCommit({ direction: 'next' }),
+ );
+ Mousetrap.bind(keysFor(MR_COMMITS_PREVIOUS_COMMIT), () =>
+ this.moveToNeighboringCommit({ direction: 'previous' }),
+ );
}
},
removeEventListeners() {
- Mousetrap.unbind(['[', 'k', ']', 'j']);
- Mousetrap.unbind('c');
- Mousetrap.unbind('x');
+ Mousetrap.unbind(keysFor(MR_PREVIOUS_FILE_IN_DIFF));
+ Mousetrap.unbind(keysFor(MR_NEXT_FILE_IN_DIFF));
+ Mousetrap.unbind(keysFor(MR_COMMITS_NEXT_COMMIT));
+ Mousetrap.unbind(keysFor(MR_COMMITS_PREVIOUS_COMMIT));
},
jumpToFile(step) {
const targetIndex = this.currentDiffIndex + step;
@@ -489,6 +501,7 @@ export default {
<div
v-if="renderFileTree"
:style="{ width: `${treeWidth}px` }"
+ :class="{ 'review-bar-visible': draftsCount > 0 }"
class="diff-tree-list js-diff-tree-list px-3 pr-md-0"
>
<panel-resizer
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 92b317eb3f0..bc0f2fb0b69 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -1,7 +1,6 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlButtonGroup, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import { mapActions } from 'vuex';
+import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
@@ -9,7 +8,6 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { setUrlParams } from '../../lib/utils/url_utility';
import initUserPopovers from '../../user_popovers';
/**
@@ -24,14 +22,6 @@ import initUserPopovers from '../../user_popovers';
* coexist, but there is an issue to remove the duplication.
* https://gitlab.com/gitlab-org/gitlab-foss/issues/51613
*
- * EXCEPTION WARNING
- * 1. The commit navigation buttons (next neighbor, previous neighbor)
- * are not duplicated because:
- * - We don't have the same data available on the Rails side (yet,
- * without backend work)
- * - This Vue component should always be what's used when in the
- * context of an MR diff, so the HAML should never have any idea
- * about navigating among commits.
*/
export default {
@@ -42,7 +32,6 @@ export default {
CommitPipelineStatus,
GlButtonGroup,
GlButton,
- GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -94,28 +83,12 @@ export default {
// Strip the newline at the beginning
return this.commit.description_html.replace(/^&#x000A;/, '');
},
- nextCommitUrl() {
- return this.commit.next_commit_id
- ? setUrlParams({ commit_id: this.commit.next_commit_id })
- : '';
- },
- previousCommitUrl() {
- return this.commit.prev_commit_id
- ? setUrlParams({ commit_id: this.commit.prev_commit_id })
- : '';
- },
- hasNeighborCommits() {
- return this.commit.next_commit_id || this.commit.prev_commit_id;
- },
},
created() {
this.$nextTick(() => {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
- methods: {
- ...mapActions('diffs', ['moveToNeighboringCommit']),
- },
};
</script>
@@ -146,38 +119,6 @@ export default {
class="input-group-text"
/>
</gl-button-group>
- <div v-if="hasNeighborCommits" class="commit-nav-buttons ml-3">
- <gl-button-group>
- <gl-button
- :href="previousCommitUrl"
- :disabled="!commit.prev_commit_id"
- @click.prevent="moveToNeighboringCommit({ direction: 'previous' })"
- >
- <span
- v-if="!commit.prev_commit_id"
- v-gl-tooltip
- class="h-100 w-100 position-absolute"
- :title="__('You\'re at the first commit')"
- ></span>
- <gl-icon name="chevron-left" />
- {{ __('Prev') }}
- </gl-button>
- <gl-button
- :href="nextCommitUrl"
- :disabled="!commit.next_commit_id"
- @click.prevent="moveToNeighboringCommit({ direction: 'next' })"
- >
- <span
- v-if="!commit.next_commit_id"
- v-gl-tooltip
- class="h-100 w-100 position-absolute"
- :title="__('You\'re at the last commit')"
- ></span>
- {{ __('Next') }}
- <gl-icon name="chevron-right" />
- </gl-button>
- </gl-button-group>
- </div>
</div>
<div>
<div class="d-flex float-left align-items-center align-self-start">
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index 6b1e2bfb34e..7526c5347f7 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -1,7 +1,8 @@
<script>
-import { GlTooltipDirective, GlLink, GlButton, GlSprintf } from '@gitlab/ui';
+import { GlTooltipDirective, GlIcon, GlLink, GlButtonGroup, GlButton, GlSprintf } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
+import { setUrlParams } from '../../lib/utils/url_utility';
import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants';
import eventHub from '../event_hub';
import CompareDropdownLayout from './compare_dropdown_layout.vue';
@@ -11,7 +12,9 @@ import SettingsDropdown from './settings_dropdown.vue';
export default {
components: {
CompareDropdownLayout,
+ GlIcon,
GlLink,
+ GlButtonGroup,
GlButton,
GlSprintf,
SettingsDropdown,
@@ -56,6 +59,19 @@ export default {
hasSourceVersions() {
return this.diffCompareDropdownSourceVersions.length > 0;
},
+ nextCommitUrl() {
+ return this.commit.next_commit_id
+ ? setUrlParams({ commit_id: this.commit.next_commit_id })
+ : '';
+ },
+ previousCommitUrl() {
+ return this.commit.prev_commit_id
+ ? setUrlParams({ commit_id: this.commit.prev_commit_id })
+ : '';
+ },
+ hasNeighborCommits() {
+ return this.commit && (this.commit.next_commit_id || this.commit.prev_commit_id);
+ },
},
created() {
this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES;
@@ -65,6 +81,7 @@ export default {
expandAllFiles() {
eventHub.$emit(EVT_EXPAND_ALL_FILES);
},
+ ...mapActions('diffs', ['moveToNeighboringCommit']),
},
};
</script>
@@ -84,6 +101,7 @@ export default {
icon="file-tree"
class="gl-mr-3 js-toggle-tree-list"
:title="toggleFileBrowserTitle"
+ :aria-label="toggleFileBrowserTitle"
:selected="showTreeList"
@click="setShowTreeList({ showTreeList: !showTreeList })"
/>
@@ -91,6 +109,38 @@ export default {
{{ __('Viewing commit') }}
<gl-link :href="commit.commit_url" class="monospace">{{ commit.short_id }}</gl-link>
</div>
+ <div v-if="hasNeighborCommits" class="commit-nav-buttons ml-3">
+ <gl-button-group>
+ <gl-button
+ :href="previousCommitUrl"
+ :disabled="!commit.prev_commit_id"
+ @click.prevent="moveToNeighboringCommit({ direction: 'previous' })"
+ >
+ <span
+ v-if="!commit.prev_commit_id"
+ v-gl-tooltip
+ class="h-100 w-100 position-absolute position-top-0 position-left-0"
+ :title="__('You\'re at the first commit')"
+ ></span>
+ <gl-icon name="chevron-left" />
+ {{ __('Prev') }}
+ </gl-button>
+ <gl-button
+ :href="nextCommitUrl"
+ :disabled="!commit.next_commit_id"
+ @click.prevent="moveToNeighboringCommit({ direction: 'next' })"
+ >
+ <span
+ v-if="!commit.next_commit_id"
+ v-gl-tooltip
+ class="h-100 w-100 position-absolute position-top-0 position-left-0"
+ :title="__('You\'re at the last commit')"
+ ></span>
+ {{ __('Next') }}
+ <gl-icon name="chevron-right" />
+ </gl-button>
+ </gl-button-group>
+ </div>
<gl-sprintf
v-else-if="hasSourceVersions"
class="d-flex align-items-center compare-versions-container"
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index d0d457d8582..5e05ec87f84 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -68,6 +68,7 @@ export default {
}"
type="button"
class="js-diff-notes-toggle"
+ :aria-label="__('Show comments')"
@click="toggleDiscussion({ discussionId: discussion.id })"
>
<gl-icon v-if="discussion.expanded" name="collapse" class="collapse-icon" />
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index ca4543f7002..bdbc13a38c4 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -80,7 +80,7 @@ export default {
genericError: GENERIC_ERROR,
},
computed: {
- ...mapState('diffs', ['currentDiffFileId']),
+ ...mapState('diffs', ['currentDiffFileId', 'codequalityDiff']),
...mapGetters(['isNotesFetched']),
...mapGetters('diffs', ['getDiffFileDiscussions']),
viewBlobHref() {
@@ -148,6 +148,11 @@ export default {
return loggedIn && featureOn;
},
+ hasCodequalityChanges() {
+ return (
+ this.codequalityDiff?.files && this.codequalityDiff?.files[this.file.file_path]?.length > 0
+ );
+ },
},
watch: {
'file.id': {
@@ -294,6 +299,7 @@ export default {
:add-merge-request-buttons="true"
:view-diffs-file-by-file="viewDiffsFileByFile"
:show-local-file-reviews="showLocalFileReviews"
+ :has-codequality-changes="hasCodequalityChanges"
class="js-file-title file-title gl-border-1 gl-border-solid gl-border-gray-100"
:class="hasBodyClasses.header"
@toggleFile="handleToggle"
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 1f50b3a38a6..3b4e21ab61b 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -41,6 +41,7 @@ export default {
GlDropdownDivider,
GlFormCheckbox,
GlLoadingIcon,
+ CodeQualityBadge: () => import('ee_component/diffs/components/code_quality_badge.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -49,6 +50,7 @@ export default {
mixins: [glFeatureFlagsMixin()],
i18n: {
...DIFF_FILE_HEADER,
+ compareButtonLabel: s__('Compare submodule commit revisions'),
},
props: {
discussionPath: {
@@ -94,6 +96,11 @@ export default {
required: false,
default: false,
},
+ hasCodequalityChanges: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -192,6 +199,9 @@ export default {
isReviewable() {
return reviewable(this.diffFile);
},
+ externalUrlLabel() {
+ return sprintf(__('View on %{url}'), { url: this.diffFile.formatted_external_url });
+ },
},
methods: {
...mapActions('diffs', [
@@ -323,6 +333,8 @@ export default {
data-track-property="diff_copy_file"
/>
+ <code-quality-badge v-if="hasCodequalityChanges" class="gl-mr-2" />
+
<small v-if="isModeChanged" ref="fileMode" class="mr-1">
{{ diffFile.a_mode }} → {{ diffFile.b_mode }}
</small>
@@ -352,7 +364,8 @@ export default {
ref="externalLink"
v-gl-tooltip.hover
:href="diffFile.external_url"
- :title="`View on ${diffFile.formatted_external_url}`"
+ :title="externalUrlLabel"
+ :aria-label="externalUrlLabel"
target="_blank"
data-track-event="click_toggle_external_button"
data-track-label="diff_toggle_external_button"
@@ -444,7 +457,8 @@ export default {
v-gl-tooltip.hover
v-safe-html="submoduleDiffCompareLinkText"
class="submodule-compare"
- :title="s__('Compare submodule commit revisions')"
+ :title="$options.i18n.compareButtonLabel"
+ :aria-label="$options.i18n.compareButtonLabel"
:href="diffFile.submodule_compare.url"
/>
</div>
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 2f09f2e24b2..51da1966630 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -10,7 +10,12 @@ import {
} from '../../notes/components/multiline_comment_utils';
import noteForm from '../../notes/components/note_form.vue';
import autosave from '../../notes/mixins/autosave';
-import { DIFF_NOTE_TYPE, INLINE_DIFF_LINES_KEY, PARALLEL_DIFF_VIEW_TYPE } from '../constants';
+import {
+ DIFF_NOTE_TYPE,
+ INLINE_DIFF_LINES_KEY,
+ PARALLEL_DIFF_VIEW_TYPE,
+ OLD_LINE_TYPE,
+} from '../constants';
export default {
components: {
@@ -113,6 +118,34 @@ export default {
const lines = getDiffLines();
return commentLineOptions(lines, this.line, this.line.line_code, side);
},
+ commentLines() {
+ if (!this.selectedCommentPosition) return [];
+
+ const lines = [];
+ const { start, end } = this.selectedCommentPosition;
+ const diffLines = this.diffFile[INLINE_DIFF_LINES_KEY];
+ let isAdding = false;
+
+ for (let i = 0, diffLinesLength = diffLines.length - 1; i <= diffLinesLength; i += 1) {
+ const line = diffLines[i];
+
+ if (start.line_code === line.line_code) {
+ isAdding = true;
+ }
+
+ if (isAdding) {
+ if (line.type !== OLD_LINE_TYPE) {
+ lines.push(line);
+ }
+
+ if (end.line_code === line.line_code) {
+ break;
+ }
+ }
+ }
+
+ return lines;
+ },
},
mounted() {
if (this.isLoggedIn) {
@@ -177,6 +210,7 @@ export default {
:is-editing="true"
:line-code="line.line_code"
:line="line"
+ :lines="commentLines"
:help-page-path="helpPagePath"
:diff-file="diffFile"
:show-suggest-popover="showSuggestPopover"
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index ab6890d66b5..8d398a2ded4 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -1,5 +1,6 @@
<script>
-import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+/* eslint-disable vue/no-v-html */
+import { GlTooltipDirective } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -12,17 +13,20 @@ import {
CONFLICT_THEIR,
CONFLICT_MARKER,
} from '../constants';
+import {
+ getInteropInlineAttributes,
+ getInteropOldSideAttributes,
+ getInteropNewSideAttributes,
+} from '../utils/interoperability';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
import * as utils from './diff_row_utils';
export default {
components: {
- GlIcon,
DiffGutterAvatars,
},
directives: {
GlTooltip: GlTooltipDirective,
- SafeHtml,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -117,6 +121,16 @@ export default {
isLeftConflictMarker() {
return [CONFLICT_MARKER_OUR, CONFLICT_MARKER_THEIR].includes(this.line.left?.type);
},
+ interopLeftAttributes() {
+ if (this.inline) {
+ return getInteropInlineAttributes(this.line.left);
+ }
+
+ return getInteropOldSideAttributes(this.line.left);
+ },
+ interopRightAttributes() {
+ return getInteropNewSideAttributes(this.line.right);
+ },
},
mounted() {
this.scrollToLineIfNeededParallel(this.line);
@@ -182,6 +196,7 @@ export default {
<div
data-testid="left-side"
class="diff-grid-left left-side"
+ v-bind="interopLeftAttributes"
@dragover.prevent
@dragenter="onDragEnter(line.left, index)"
@dragend="onDragEnd"
@@ -203,14 +218,13 @@ export default {
<button
:draggable="glFeatures.dragCommentSelection"
type="button"
- class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
+ class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button qa-diff-comment"
+ data-qa-selector="diff_comment_button"
:class="{ 'gl-cursor-grab': dragging }"
:disabled="line.left.commentsDisabled"
@click="handleCommentButton(line.left)"
@dragstart="onDragStart({ ...line.left, index })"
- >
- <gl-icon :size="12" name="comment" />
- </button>
+ ></button>
</span>
</template>
<a
@@ -258,7 +272,7 @@ export default {
@mousedown="handleParallelLineMouseDown"
>
<strong v-if="isLeftConflictMarker">{{ conflictText(line.left) }}</strong>
- <span v-else v-safe-html="line.left.rich_text"></span>
+ <span v-else v-html="line.left.rich_text"></span>
</div>
</template>
<template v-else-if="!inline || (line.left && line.left.type === $options.CONFLICT_MARKER)">
@@ -288,6 +302,7 @@ export default {
v-if="!inline"
data-testid="right-side"
class="diff-grid-right right-side"
+ v-bind="interopRightAttributes"
@dragover.prevent
@dragenter="onDragEnter(line.right, index)"
@dragend="onDragEnd"
@@ -305,14 +320,12 @@ export default {
<button
:draggable="glFeatures.dragCommentSelection"
type="button"
- class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
+ class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button qa-diff-comment"
:class="{ 'gl-cursor-grab': dragging }"
:disabled="line.right.commentsDisabled"
@click="handleCommentButton(line.right)"
@dragstart="onDragStart({ ...line.right, index })"
- >
- <gl-icon :size="12" name="comment" />
- </button>
+ ></button>
</span>
</template>
<a
@@ -349,7 +362,6 @@ export default {
<div
:id="line.right.line_code"
:key="line.right.rich_text"
- v-safe-html="line.right.rich_text"
:class="[
line.right.type,
{
@@ -364,7 +376,7 @@ export default {
<strong v-if="line.right.type === $options.CONFLICT_MARKER_THEIR">{{
conflictText(line.right)
}}</strong>
- <span v-else v-safe-html="line.right.rich_text"></span>
+ <span v-else v-html="line.right.rich_text"></span>
</div>
</template>
<template v-else>
diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
index 3d05202fb2d..5572338908f 100644
--- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue
+++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
@@ -122,6 +122,7 @@ export default {
:disabled="!shouldToggleDiscussion"
class="js-image-badge"
type="button"
+ :aria-label="__('Show comments')"
@click="clickedToggle(discussion)"
>
<gl-icon v-if="showCommentIcon" name="image-comment-dark" :size="24" />
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 fb9202c5aab..25403b1547e 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -2,6 +2,7 @@
import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { CONTEXT_LINE_CLASS_NAME } from '../constants';
+import { getInteropInlineAttributes } from '../utils/interoperability';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
import {
isHighlighted,
@@ -96,6 +97,9 @@ export default {
shouldShowAvatarsOnGutter() {
return this.line.hasDiscussions;
},
+ interopAttrs() {
+ return getInteropInlineAttributes(this.line);
+ },
},
mounted() {
this.scrollToLineIfNeededInline(this.line);
@@ -124,6 +128,7 @@ export default {
:id="inlineRowId"
:class="classNameMap"
class="line_holder"
+ v-bind="interopAttrs"
@mouseover="handleMouseMove"
@mouseout="handleMouseMove"
>
@@ -140,8 +145,8 @@ export default {
ref="addDiffNoteButton"
type="button"
class="add-diff-note note-button js-add-diff-note-button"
- data-qa-selector="diff_comment_button"
:disabled="line.commentsDisabled"
+ :aria-label="addCommentTooltip"
@click="handleCommentButton"
>
<gl-icon :size="12" name="comment" />
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 3d20dfd0c9b..96946d0fd88 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
@@ -3,6 +3,10 @@ import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gi
import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
import { CONTEXT_LINE_CLASS_NAME, PARALLEL_DIFF_VIEW_TYPE } from '../constants';
+import {
+ getInteropOldSideAttributes,
+ getInteropNewSideAttributes,
+} from '../utils/interoperability';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
import * as utils from './diff_row_utils';
@@ -108,6 +112,12 @@ export default {
this.line.hasDiscussionsRight,
);
},
+ interopLeftAttributes() {
+ return getInteropOldSideAttributes(this.line.left);
+ },
+ interopRightAttributes() {
+ return getInteropNewSideAttributes(this.line.right);
+ },
},
mounted() {
this.scrollToLineIfNeededParallel(this.line);
@@ -185,6 +195,7 @@ export default {
type="button"
class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
:disabled="line.left.commentsDisabled"
+ :aria-label="addCommentTooltipLeft"
@click="handleCommentButton(line.left)"
>
<gl-icon :size="12" name="comment" />
@@ -217,6 +228,7 @@ export default {
:key="line.left.line_code"
v-safe-html="line.left.rich_text"
:class="parallelViewLeftLineType"
+ v-bind="interopLeftAttributes"
class="line_content with-coverage parallel left-side"
@mousedown="handleParallelLineMouseDown"
></td>
@@ -241,6 +253,7 @@ export default {
type="button"
class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
:disabled="line.right.commentsDisabled"
+ :aria-label="addCommentTooltipRight"
@click="handleCommentButton(line.right)"
>
<gl-icon :size="12" name="comment" />
@@ -283,6 +296,7 @@ export default {
hll: isHighlighted,
},
]"
+ v-bind="interopRightAttributes"
class="line_content with-coverage parallel right-side"
@mousedown="handleParallelLineMouseDown"
></td>
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 87e9af174e5..5a8862c2b70 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -73,6 +73,8 @@ export default function initDiffsApp(store) {
endpointMetadata: dataset.endpointMetadata || '',
endpointBatch: dataset.endpointBatch || '',
endpointCoverage: dataset.endpointCoverage || '',
+ endpointCodequality: dataset.endpointCodequality || '',
+ endpointUpdateUser: dataset.updateCurrentUserPath,
projectPath: dataset.projectPath,
helpPagePath: dataset.helpPagePath,
currentUser: JSON.parse(dataset.currentUserData) || {},
@@ -114,6 +116,8 @@ export default function initDiffsApp(store) {
endpointMetadata: this.endpointMetadata,
endpointBatch: this.endpointBatch,
endpointCoverage: this.endpointCoverage,
+ endpointCodequality: this.endpointCodequality,
+ endpointUpdateUser: this.endpointUpdateUser,
currentUser: this.currentUser,
projectPath: this.projectPath,
helpPagePath: this.helpPagePath,
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 8796016def9..428faf693b0 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -49,7 +49,6 @@ import {
convertExpandLines,
idleCallback,
allDiscussionWrappersExpanded,
- prepareDiffData,
prepareLineForRenamedFile,
} from './utils';
@@ -59,6 +58,7 @@ export const setBaseConfig = ({ commit }, options) => {
endpointMetadata,
endpointBatch,
endpointCoverage,
+ endpointUpdateUser,
projectPath,
dismissEndpoint,
showSuggestPopover,
@@ -71,6 +71,7 @@ export const setBaseConfig = ({ commit }, options) => {
endpointMetadata,
endpointBatch,
endpointCoverage,
+ endpointUpdateUser,
projectPath,
dismissEndpoint,
showSuggestPopover,
@@ -163,7 +164,15 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
return pagination.next_page;
})
- .then((nextPage) => nextPage && getBatch(nextPage))
+ .then((nextPage) => {
+ dispatch('startRenderDiffsQueue');
+
+ if (nextPage) {
+ return getBatch(nextPage);
+ }
+
+ return null;
+ })
.catch(() => commit(types.SET_RETRIEVING_BATCHES, false));
return getBatch()
@@ -197,13 +206,7 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
commit(types.SET_MERGE_REQUEST_DIFFS, data.merge_request_diffs || []);
commit(types.SET_DIFF_METADATA, strippedData);
- worker.postMessage(
- prepareDiffData({
- diff: data,
- priorFiles: state.diffFiles,
- meta: true,
- }),
- );
+ worker.postMessage(data.diff_files);
return data;
})
@@ -304,33 +307,41 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi
};
export const startRenderDiffsQueue = ({ state, commit }) => {
- const checkItem = () =>
- new Promise((resolve) => {
- const nextFile = state.diffFiles.find(
- (file) =>
- !file.renderIt &&
- file.viewer &&
- (!isCollapsed(file) || file.viewer.name !== diffViewerModes.text),
- );
-
- if (nextFile) {
- requestAnimationFrame(() => {
- commit(types.RENDER_FILE, nextFile);
+ const diffFilesToRender = state.diffFiles.filter(
+ (file) =>
+ !file.renderIt &&
+ file.viewer &&
+ (!isCollapsed(file) || file.viewer.name !== diffViewerModes.text),
+ );
+ let currentDiffFileIndex = 0;
+
+ const checkItem = () => {
+ const nextFile = diffFilesToRender[currentDiffFileIndex];
+
+ if (nextFile) {
+ let retryCount = 0;
+ currentDiffFileIndex += 1;
+ commit(types.RENDER_FILE, nextFile);
+
+ const requestIdle = () =>
+ requestIdleCallback((idleDeadline) => {
+ // Wait for at least 5ms before trying to render
+ // or for 5 tries and then force render the file
+ if (idleDeadline.timeRemaining() >= 5 || retryCount > 4) {
+ checkItem();
+ } else {
+ requestIdle();
+ retryCount += 1;
+ }
});
- requestIdleCallback(
- () => {
- checkItem()
- .then(resolve)
- .catch(() => {});
- },
- { timeout: 1000 },
- );
- } else {
- resolve();
- }
- });
- return checkItem();
+ requestIdle();
+ }
+ };
+
+ if (diffFilesToRender.length) {
+ checkItem();
+ }
};
export const setRenderIt = ({ commit }, file) => commit(types.RENDER_FILE, file);
@@ -738,10 +749,22 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => {
commit(types.VIEW_DIFF_FILE, fileHash);
};
-export const setFileByFile = ({ commit }, { fileByFile }) => {
+export const setFileByFile = ({ state, commit }, { fileByFile }) => {
const fileViewMode = fileByFile ? DIFF_VIEW_FILE_BY_FILE : DIFF_VIEW_ALL_FILES;
commit(types.SET_FILE_BY_FILE, fileByFile);
Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, fileViewMode);
+
+ return axios
+ .put(state.endpointUpdateUser, {
+ view_diffs_file_by_file: fileByFile,
+ })
+ .then(() => {
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/326961
+ // We can't even do a simple console warning here because
+ // the pipeline will fail. However, the issue above will
+ // eventually handle errors appropriately.
+ // console.warn('Saving the file-by-fil user preference failed.');
+ });
};
export function reviewFile({ commit, state }, { file, reviewed = true }) {
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index 1fc2a684e95..dec3f87b03e 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -156,16 +156,16 @@ export const diffLines = (state) => (file, unifiedDiffComponents) => {
);
};
-export function suggestionCommitMessage(state) {
+export function suggestionCommitMessage(state, _, rootState) {
return (values = {}) =>
computeSuggestionCommitMessage({
message: state.defaultSuggestionCommitMessage,
values: {
- branch_name: state.branchName,
- project_path: state.projectPath,
- project_name: state.projectName,
- username: state.username,
- user_full_name: state.userFullName,
+ branch_name: rootState.page.mrMetadata.branch_name,
+ project_path: rootState.page.mrMetadata.project_path,
+ project_name: rootState.page.mrMetadata.project_name,
+ username: rootState.page.mrMetadata.username,
+ user_full_name: rootState.page.mrMetadata.user_full_name,
...values,
},
});
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index f93435363ec..1674d3d3b5a 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -9,7 +9,8 @@ import {
import { fileByFile } from '../../utils/preferences';
import { getDefaultWhitespace } from '../utils';
-const viewTypeFromQueryString = getParameterValues('view')[0];
+const getViewTypeFromQueryString = () => getParameterValues('view')[0];
+
const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
const defaultViewType = INLINE_DIFF_VIEW_TYPE;
const whiteSpaceFromQueryString = getParameterValues('w')[0];
@@ -23,6 +24,7 @@ export default () => ({
addedLines: null,
removedLines: null,
endpoint: '',
+ endpointUpdateUser: '',
basePath: '',
commit: null,
startVersion: null, // Null unless a target diff is selected for comparison that is not the "base" diff
@@ -30,7 +32,7 @@ export default () => ({
coverageFiles: {},
mergeRequestDiffs: [],
mergeRequestDiff: null,
- diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType,
+ diffViewType: getViewTypeFromQueryString() || viewTypeFromCookie || defaultViewType,
tree: [],
treeEntries: {},
showTreeList: true,
diff --git a/app/assets/javascripts/diffs/store/modules/index.js b/app/assets/javascripts/diffs/store/modules/index.js
index 6860e24db6b..03d11e60745 100644
--- a/app/assets/javascripts/diffs/store/modules/index.js
+++ b/app/assets/javascripts/diffs/store/modules/index.js
@@ -1,7 +1,7 @@
-import * as actions from '../actions';
+import * as actions from 'ee_else_ce/diffs/store/actions';
+import createState from 'ee_else_ce/diffs/store/modules/diff_state';
+import mutations from 'ee_else_ce/diffs/store/mutations';
import * as getters from '../getters';
-import mutations from '../mutations';
-import createState from './diff_state';
export default () => ({
namespaced: true,
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index d06793c05af..9ff9a02d444 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -33,6 +33,7 @@ export default {
endpointMetadata,
endpointBatch,
endpointCoverage,
+ endpointUpdateUser,
projectPath,
dismissEndpoint,
showSuggestPopover,
@@ -45,6 +46,7 @@ export default {
endpointMetadata,
endpointBatch,
endpointCoverage,
+ endpointUpdateUser,
projectPath,
dismissEndpoint,
showSuggestPopover,
@@ -77,15 +79,10 @@ export default {
},
[types.SET_DIFF_DATA_BATCH](state, data) {
- const files = prepareDiffData({
+ state.diffFiles = prepareDiffData({
diff: data,
priorFiles: state.diffFiles,
});
-
- Object.assign(state, {
- ...convertObjectPropsToCamelCase(data),
- });
- updateDiffFilesInState(state, files);
},
[types.SET_COVERAGE_DATA](state, coverageFiles) {
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index b37a75eb2a3..7fa51b9ddea 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -381,22 +381,13 @@ function prepareDiffFileLines(file) {
inlineLines.forEach((line) => prepareLine(line, file)); // WARNING: In-Place Mutations!
- Object.assign(file, {
- inlineLinesCount: inlineLines.length,
- });
-
return file;
}
-function getVisibleDiffLines(file) {
- return file.inlineLinesCount;
-}
-
-function finalizeDiffFile(file) {
- const lines = getVisibleDiffLines(file);
-
+function finalizeDiffFile(file, index) {
Object.assign(file, {
- renderIt: lines < LINES_TO_BE_RENDERED_DIRECTLY,
+ renderIt:
+ index < 3 ? file[INLINE_DIFF_LINES_KEY].length < LINES_TO_BE_RENDERED_DIRECTLY : false,
isShowingFullFile: false,
isLoadingFullFile: false,
discussions: [],
@@ -424,7 +415,7 @@ export function prepareDiffData({ diff, priorFiles = [], meta = false }) {
.map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles, meta }))
.map(ensureBasicDiffFileLines)
.map(prepareDiffFileLines)
- .map(finalizeDiffFile);
+ .map((file, index) => finalizeDiffFile(file, priorFiles.length + index));
return deduplicateFilesList([...priorFiles, ...cleanedFiles]);
}
diff --git a/app/assets/javascripts/diffs/utils/interoperability.js b/app/assets/javascripts/diffs/utils/interoperability.js
new file mode 100644
index 00000000000..a52e8fd25f5
--- /dev/null
+++ b/app/assets/javascripts/diffs/utils/interoperability.js
@@ -0,0 +1,49 @@
+const OLD = 'old';
+const NEW = 'new';
+const ATTR_PREFIX = 'data-interop-';
+
+export const ATTR_TYPE = `${ATTR_PREFIX}type`;
+export const ATTR_LINE = `${ATTR_PREFIX}line`;
+export const ATTR_NEW_LINE = `${ATTR_PREFIX}new-line`;
+export const ATTR_OLD_LINE = `${ATTR_PREFIX}old-line`;
+
+export const getInteropInlineAttributes = (line) => {
+ if (!line) {
+ return null;
+ }
+
+ const interopType = line.type?.startsWith(OLD) ? OLD : NEW;
+
+ const interopLine = interopType === OLD ? line.old_line : line.new_line;
+
+ return {
+ [ATTR_TYPE]: interopType,
+ [ATTR_LINE]: interopLine,
+ [ATTR_NEW_LINE]: line.new_line,
+ [ATTR_OLD_LINE]: line.old_line,
+ };
+};
+
+export const getInteropOldSideAttributes = (line) => {
+ if (!line) {
+ return null;
+ }
+
+ return {
+ [ATTR_TYPE]: OLD,
+ [ATTR_LINE]: line.old_line,
+ [ATTR_OLD_LINE]: line.old_line,
+ };
+};
+
+export const getInteropNewSideAttributes = (line) => {
+ if (!line) {
+ return null;
+ }
+
+ return {
+ [ATTR_TYPE]: NEW,
+ [ATTR_LINE]: line.new_line,
+ [ATTR_NEW_LINE]: line.new_line,
+ };
+};
diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/droplab/drop_lab.js
index 74fa6887ba5..6f068aaa800 100644
--- a/app/assets/javascripts/droplab/drop_lab.js
+++ b/app/assets/javascripts/droplab/drop_lab.js
@@ -60,21 +60,24 @@ class DropLab {
addEvents() {
this.eventWrapper.documentClicked = this.documentClicked.bind(this);
- document.addEventListener('mousedown', this.eventWrapper.documentClicked);
+ document.addEventListener('click', this.eventWrapper.documentClicked);
}
documentClicked(e) {
- let thisTag = e.target;
+ if (e.defaultPrevented) return;
- if (thisTag.tagName !== 'UL') thisTag = utils.closest(thisTag, 'UL');
- if (utils.isDropDownParts(thisTag, this.hooks)) return;
- if (utils.isDropDownParts(e.target, this.hooks)) return;
+ if (utils.isDropDownParts(e.target)) return;
+
+ if (e.target.tagName !== 'UL') {
+ const closestUl = utils.closest(e.target, 'UL');
+ if (utils.isDropDownParts(closestUl)) return;
+ }
this.hooks.forEach((hook) => hook.list.hide());
}
removeEvents() {
- document.removeEventListener('mousedown', this.eventWrapper.documentClicked);
+ document.removeEventListener('click', this.eventWrapper.documentClicked);
}
changeHookList(trigger, list, plugins, config) {
diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/droplab/hook_button.js
index c58d0052251..c51d6167fa3 100644
--- a/app/assets/javascripts/droplab/hook_button.js
+++ b/app/assets/javascripts/droplab/hook_button.js
@@ -18,6 +18,8 @@ class HookButton extends Hook {
}
clicked(e) {
+ e.preventDefault();
+
const buttonEvent = new CustomEvent('click.dl', {
detail: {
hook: this,
diff --git a/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js b/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js
index 8d350068973..3d4f08131c1 100644
--- a/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js
+++ b/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js
@@ -1,11 +1,85 @@
-import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION } from '../constants';
+import { Range } from 'monaco-editor';
+import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION, EDITOR_TYPE_CODE } from '../constants';
+
+const hashRegexp = new RegExp('#?L', 'g');
+
+const createAnchor = (href) => {
+ const fragment = new DocumentFragment();
+ const el = document.createElement('a');
+ el.classList.add('link-anchor');
+ el.href = href;
+ fragment.appendChild(el);
+ el.addEventListener('contextmenu', (e) => {
+ e.stopPropagation();
+ });
+ return fragment;
+};
export class EditorLiteExtension {
constructor({ instance, ...options } = {}) {
if (instance) {
Object.assign(instance, options);
+ EditorLiteExtension.highlightLines(instance);
+ if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) {
+ EditorLiteExtension.setupLineLinking(instance);
+ }
} else if (Object.entries(options).length) {
throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION);
}
}
+
+ static highlightLines(instance) {
+ const { hash } = window.location;
+ if (!hash) {
+ return;
+ }
+ const [start, end] = hash.replace(hashRegexp, '').split('-');
+ let startLine = start ? parseInt(start, 10) : null;
+ let endLine = end ? parseInt(end, 10) : startLine;
+ if (endLine < startLine) {
+ [startLine, endLine] = [endLine, startLine];
+ }
+ if (startLine) {
+ window.requestAnimationFrame(() => {
+ instance.revealLineInCenter(startLine);
+ Object.assign(instance, {
+ lineDecorations: instance.deltaDecorations(
+ [],
+ [
+ {
+ range: new Range(startLine, 1, endLine, 1),
+ options: { isWholeLine: true, className: 'active-line-text' },
+ },
+ ],
+ ),
+ });
+ });
+ }
+ }
+
+ static onMouseMoveHandler(e) {
+ const target = e.target.element;
+ if (target.classList.contains('line-numbers')) {
+ const lineNum = e.target.position.lineNumber;
+ const hrefAttr = `#L${lineNum}`;
+ let el = target.querySelector('a');
+ if (!el) {
+ el = createAnchor(hrefAttr);
+ target.appendChild(el);
+ }
+ }
+ }
+
+ static setupLineLinking(instance) {
+ instance.onMouseMove(EditorLiteExtension.onMouseMoveHandler);
+ instance.onMouseDown((e) => {
+ const isCorrectAnchor = e.target.element.classList.contains('link-anchor');
+ if (!isCorrectAnchor) {
+ return;
+ }
+ if (instance.lineDecorations) {
+ instance.deltaDecorations(instance.lineDecorations, []);
+ }
+ });
+ }
}
diff --git a/app/assets/javascripts/emoji/awards_app/index.js b/app/assets/javascripts/emoji/awards_app/index.js
new file mode 100644
index 00000000000..16268910f49
--- /dev/null
+++ b/app/assets/javascripts/emoji/awards_app/index.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import { mapActions, mapState } from 'vuex';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import AwardsList from '~/vue_shared/components/awards_list.vue';
+import createstore from './store';
+
+export default (el) => {
+ const {
+ dataset: { path },
+ } = el;
+ const canAwardEmoji = parseBoolean(el.dataset.canAwardEmoji);
+
+ return new Vue({
+ el,
+ store: createstore(),
+ computed: {
+ ...mapState(['currentUserId', 'canAwardEmoji', 'awards']),
+ },
+ created() {
+ this.setInitialData({ path, currentUserId: window.gon.current_user_id, canAwardEmoji });
+ },
+ mounted() {
+ this.fetchAwards();
+ },
+ methods: {
+ ...mapActions(['setInitialData', 'fetchAwards', 'toggleAward']),
+ },
+ render(createElement) {
+ return createElement(AwardsList, {
+ props: {
+ awards: this.awards,
+ canAwardEmoji: this.canAwardEmoji,
+ currentUserId: this.currentUserId,
+ defaultAwards: ['thumbsup', 'thumbsdown'],
+ selectedClass: 'gl-bg-blue-50! is-active',
+ },
+ on: {
+ award: this.toggleAward,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/emoji/awards_app/store/actions.js b/app/assets/javascripts/emoji/awards_app/store/actions.js
new file mode 100644
index 00000000000..482acc5a3a9
--- /dev/null
+++ b/app/assets/javascripts/emoji/awards_app/store/actions.js
@@ -0,0 +1,51 @@
+import * as Sentry from '@sentry/browser';
+import axios from '~/lib/utils/axios_utils';
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import showToast from '~/vue_shared/plugins/global_toast';
+import {
+ SET_INITIAL_DATA,
+ FETCH_AWARDS_SUCCESS,
+ ADD_NEW_AWARD,
+ REMOVE_AWARD,
+} from './mutation_types';
+
+export const setInitialData = ({ commit }, data) => commit(SET_INITIAL_DATA, data);
+
+export const fetchAwards = async ({ commit, dispatch, state }, page = '1') => {
+ try {
+ const { data, headers } = await axios.get(state.path, { params: { per_page: 100, page } });
+ const normalizedHeaders = normalizeHeaders(headers);
+ const nextPage = normalizedHeaders['X-NEXT-PAGE'];
+
+ commit(FETCH_AWARDS_SUCCESS, data);
+
+ if (nextPage) {
+ dispatch('fetchAwards', nextPage);
+ }
+ } catch (error) {
+ Sentry.captureException(error);
+ }
+};
+
+export const toggleAward = async ({ commit, state }, name) => {
+ const award = state.awards.find((a) => a.name === name && a.user.id === state.currentUserId);
+
+ try {
+ if (award) {
+ await axios.delete(`${state.path}/${award.id}`);
+
+ commit(REMOVE_AWARD, award.id);
+
+ showToast(__('Award removed'));
+ } else {
+ const { data } = await axios.post(state.path, { name });
+
+ commit(ADD_NEW_AWARD, data);
+
+ showToast(__('Award added'));
+ }
+ } catch (error) {
+ Sentry.captureException(error);
+ }
+};
diff --git a/app/assets/javascripts/emoji/awards_app/store/index.js b/app/assets/javascripts/emoji/awards_app/store/index.js
new file mode 100644
index 00000000000..53ed50f9f5d
--- /dev/null
+++ b/app/assets/javascripts/emoji/awards_app/store/index.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+const createState = () => ({
+ awards: [],
+ awardPath: '',
+ currentUserId: null,
+ canAwardEmoji: false,
+});
+
+export default () =>
+ new Vuex.Store({
+ state: createState(),
+ actions,
+ mutations,
+ });
diff --git a/app/assets/javascripts/emoji/awards_app/store/mutation_types.js b/app/assets/javascripts/emoji/awards_app/store/mutation_types.js
new file mode 100644
index 00000000000..af6289d0943
--- /dev/null
+++ b/app/assets/javascripts/emoji/awards_app/store/mutation_types.js
@@ -0,0 +1,6 @@
+export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
+
+export const FETCH_AWARDS_SUCCESS = 'FETCH_AWARDS_SUCCESS';
+
+export const ADD_NEW_AWARD = 'ADD_NEW_AWARD';
+export const REMOVE_AWARD = 'REMOVE_AWARD';
diff --git a/app/assets/javascripts/emoji/awards_app/store/mutations.js b/app/assets/javascripts/emoji/awards_app/store/mutations.js
new file mode 100644
index 00000000000..8edcfa92885
--- /dev/null
+++ b/app/assets/javascripts/emoji/awards_app/store/mutations.js
@@ -0,0 +1,23 @@
+import {
+ SET_INITIAL_DATA,
+ FETCH_AWARDS_SUCCESS,
+ ADD_NEW_AWARD,
+ REMOVE_AWARD,
+} from './mutation_types';
+
+export default {
+ [SET_INITIAL_DATA](state, { path, currentUserId, canAwardEmoji }) {
+ state.path = path;
+ state.currentUserId = currentUserId;
+ state.canAwardEmoji = canAwardEmoji;
+ },
+ [FETCH_AWARDS_SUCCESS](state, data) {
+ state.awards.push(...data);
+ },
+ [ADD_NEW_AWARD](state, data) {
+ state.awards.push(data);
+ },
+ [REMOVE_AWARD](state, awardId) {
+ state.awards = state.awards.filter(({ id }) => id !== awardId);
+ },
+};
diff --git a/app/assets/javascripts/emoji/components/category.vue b/app/assets/javascripts/emoji/components/category.vue
index db6ead3ff69..39881979c4f 100644
--- a/app/assets/javascripts/emoji/components/category.vue
+++ b/app/assets/javascripts/emoji/components/category.vue
@@ -39,7 +39,7 @@ export default {
<template>
<gl-intersection-observer class="gl-px-5 gl-h-full" @appear="categoryAppeared">
- <div class="gl-top-0 gl-py-3 gl-w-full emoji-picker-category-header">
+ <div class="gl-top-0 gl-py-3 gl-w-full gl-z-index-1 emoji-picker-category-header">
<b>{{ categoryTitle }}</b>
</div>
<template v-if="emojis.length">
diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue
index 37f3433b781..71cabe80529 100644
--- a/app/assets/javascripts/emoji/components/picker.vue
+++ b/app/assets/javascripts/emoji/components/picker.vue
@@ -82,6 +82,8 @@ export default {
no-flip
right
lazy
+ @shown="$emit('shown')"
+ @hidden="$emit('hidden')"
>
<template #button-content><slot name="button-content"></slot></template>
<gl-search-box-by-type
@@ -99,10 +101,11 @@ export default {
v-for="(category, index) in categoryNames"
:key="category.name"
:class="{
- 'gl-text-black-normal! emoji-picker-category-active': index === currentCategory,
+ 'gl-text-body! emoji-picker-category-active': index === currentCategory,
}"
type="button"
class="gl-border-0 gl-border-b-2 gl-border-b-solid gl-flex-fill-1 gl-text-gray-300 gl-pt-3 gl-pb-3 gl-bg-transparent emoji-picker-category-tab"
+ :aria-label="category.name"
@click="scrollToCategory(category.name)"
>
<gl-icon :name="category.icon" :size="12" />
diff --git a/app/assets/javascripts/ensure_data.js b/app/assets/javascripts/ensure_data.js
new file mode 100644
index 00000000000..5b4d1afc9d0
--- /dev/null
+++ b/app/assets/javascripts/ensure_data.js
@@ -0,0 +1,56 @@
+import emptySvg from '@gitlab/svgs/dist/illustrations/security-dashboard-empty-state.svg';
+import { GlEmptyState } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { __ } from '~/locale';
+
+const ERROR_FETCHING_DATA_HEADER = __('Could not get the data properly');
+const ERROR_FETCHING_DATA_DESCRIPTION = __(
+ 'Please try and refresh the page. If the problem persists please contact support.',
+);
+
+/**
+ * This function takes a Component and extends it with data from the `parseData` function.
+ * The data will be made available through `props` and `proivde`.
+ * If the `parseData` throws, the `GlEmptyState` will be returned.
+ * @param {Component} Component a component to render
+ * @param {Object} options
+ * @param {Function} options.parseData a function to parse `data`
+ * @param {Object} options.data an object to pass to `parseData`
+ * @param {Boolean} options.shouldLog to tell whether to log any thrown error by `parseData` to Sentry
+ * @param {Object} options.props to override passed `props` data
+ * @param {Object} options.provide to override passed `provide` data
+ * @param {*} ...options the remaining options will be passed as properties to `createElement`
+ * @return {Component} a Vue component to render, either the GlEmptyState or the extended Component
+ */
+export default function ensureData(Component, options = {}) {
+ const { parseData, data, shouldLog = false, props, provide, ...rest } = options;
+ try {
+ const parsedData = parseData(data);
+ return {
+ provide: { ...parsedData, ...provide },
+ render(createElement) {
+ return createElement(Component, {
+ props: { ...parsedData, ...props },
+ ...rest,
+ });
+ },
+ };
+ } catch (error) {
+ if (shouldLog) {
+ Sentry.captureException(error);
+ }
+
+ return {
+ functional: true,
+ render(createElement) {
+ return createElement(GlEmptyState, {
+ props: {
+ title: ERROR_FETCHING_DATA_HEADER,
+ description: ERROR_FETCHING_DATA_DESCRIPTION,
+ svgPath: `data:image/svg+xml;utf8,${encodeURIComponent(emptySvg)}`,
+ },
+ });
+ },
+ };
+ }
+}
diff --git a/app/assets/javascripts/environments/components/enable_review_app_modal.vue b/app/assets/javascripts/environments/components/enable_review_app_modal.vue
index 2494968857c..b0c0f83b88a 100644
--- a/app/assets/javascripts/environments/components/enable_review_app_modal.vue
+++ b/app/assets/javascripts/environments/components/enable_review_app_modal.vue
@@ -10,6 +10,7 @@ export default {
GlSprintf,
ModalCopyButton,
},
+ inject: ['defaultBranchName'],
props: {
modalId: {
type: String,
@@ -28,7 +29,11 @@ export default {
modalInfo: {
closeText: s__('EnableReviewApp|Close'),
copyToClipboardText: s__('EnableReviewApp|Copy snippet text'),
- copyString: `deploy_review:
+ title: s__('ReviewApp|Enable Review App'),
+ },
+ computed: {
+ modalInfoCopyStr() {
+ return `deploy_review:
stage: deploy
script:
- echo "Deploy a review app"
@@ -38,8 +43,8 @@ export default {
only:
- branches
except:
- - master`,
- title: s__('ReviewApp|Enable Review App'),
+ - ${this.defaultBranchName}`;
+ },
},
};
</script>
@@ -75,7 +80,9 @@ export default {
</gl-sprintf>
</p>
<div class="gl-display-flex align-items-start">
- <pre class="gl-w-full"> {{ $options.modalInfo.copyString }} </pre>
+ <pre class="gl-w-full" data-testid="enable-review-app-copy-string">
+ {{ modalInfoCopyStr }} </pre
+ >
<modal-copy-button
:title="$options.modalInfo.copyToClipboardText"
:text="$options.modalInfo.copyString"
@@ -90,7 +97,9 @@ export default {
<strong>{{ content }}</strong>
</template>
<template #link="{ content }">
- <gl-link href="blob/master/.gitlab-ci.yml" target="_blank">{{ content }}</gl-link>
+ <gl-link :href="`blob/${defaultBranchName}/.gitlab-ci.yml`" target="_blank">{{
+ content
+ }}</gl-link>
</template>
</gl-sprintf>
</p>
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index 397616c654f..c0b4e96cea2 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -71,6 +71,7 @@ export default {
class="gl-display-none gl-md-display-block text-secondary"
:loading="isLoading"
:title="title"
+ :aria-label="title"
:icon="isLastDeployment ? 'repeat' : 'redo'"
@click="onClick"
/>
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index 68348648e61..b99872f7a6c 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -22,6 +22,7 @@ export default () => {
apolloProvider,
provide: {
projectPath: el.dataset.projectPath,
+ defaultBranchName: el.dataset.defaultBranchName,
},
data() {
const environmentsData = el.dataset;
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index 03b8df50c54..f05f0cb7c6d 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -322,7 +322,7 @@ export default {
<gl-button
v-if="!error.gitlabIssuePath"
category="primary"
- variant="success"
+ variant="confirm"
:loading="issueCreationInProgress"
data-qa-selector="create_issue_button"
@click="createIssue"
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue b/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue
index db61957d452..9438900c736 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue
@@ -51,6 +51,7 @@ export default {
v-gl-tooltip.hover
class="gl-display-block gl-mb-4 mb-md-0 gl-w-full"
:title="ignoreBtn.title"
+ :aria-label="ignoreBtn.title"
@click="$emit('update-issue-status', { errorId: error.id, status: ignoreBtn.status })"
>
<gl-icon class="gl-display-none d-md-inline gl-m-0" :name="ignoreBtn.icon" :size="12" />
@@ -62,6 +63,7 @@ export default {
v-gl-tooltip.hover
class="gl-display-block gl-mb-4 mb-md-0 gl-w-full"
:title="resolveBtn.title"
+ :aria-label="resolveBtn.title"
@click="$emit('update-issue-status', { errorId: error.id, status: resolveBtn.status })"
>
<gl-icon class="gl-display-none d-md-inline gl-m-0" :name="resolveBtn.icon" :size="12" />
diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
index dbcda0877b4..4df324b396c 100644
--- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
@@ -33,7 +33,7 @@ export default {
<p class="form-text text-muted">
{{
s__(
- "ErrorTracking|If you self-host Sentry, enter the full URL of your Sentry instance. If you're using Sentry's hosted solution, enter https://sentry.io",
+ "ErrorTracking|If you self-host Sentry, enter your Sentry instance's full URL. If you use Sentry's hosted solution, enter https://sentry.io",
)
}}
</p>
@@ -75,12 +75,12 @@ export default {
</div>
</div>
<p v-if="connectError" class="gl-field-error">
- {{ s__('ErrorTracking|Connection has failed. Re-check Auth Token and try again.') }}
+ {{ s__('ErrorTracking|Connection failed. Check Auth Token and try again.') }}
</p>
<p v-else class="form-text text-muted">
{{
s__(
- "ErrorTracking|After adding your Auth Token, use the 'Connect' button to load projects",
+ 'ErrorTracking|After adding your Auth Token, select the Connect button to load projects.',
)
}}
</p>
diff --git a/app/assets/javascripts/error_tracking_settings/store/getters.js b/app/assets/javascripts/error_tracking_settings/store/getters.js
index 30828778574..f203a259b16 100644
--- a/app/assets/javascripts/error_tracking_settings/store/getters.js
+++ b/app/assets/javascripts/error_tracking_settings/store/getters.js
@@ -34,8 +34,8 @@ export const invalidProjectLabel = (state) => {
export const projectSelectionLabel = (state) => {
if (state.token) {
return s__(
- "ErrorTracking|Click 'Connect' to re-establish the connection to Sentry and activate the dropdown.",
+ 'ErrorTracking|Click Connect to reestablish the connection to Sentry and activate the dropdown.',
);
}
- return s__('ErrorTracking|To enable project selection, enter a valid Auth Token');
+ return s__('ErrorTracking|To enable project selection, enter a valid Auth Token.');
};
diff --git a/app/assets/javascripts/experimentation/components/experiment.vue b/app/assets/javascripts/experimentation/components/experiment.vue
new file mode 100644
index 00000000000..294dbf77991
--- /dev/null
+++ b/app/assets/javascripts/experimentation/components/experiment.vue
@@ -0,0 +1,15 @@
+<script>
+import { getExperimentVariant } from '../utils';
+
+export default {
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ },
+ render() {
+ return this.$slots?.[getExperimentVariant(this.name)];
+ },
+};
+</script>
diff --git a/app/assets/javascripts/experimentation/constants.js b/app/assets/javascripts/experimentation/constants.js
index b7e61d43b11..76e8fdb684b 100644
--- a/app/assets/javascripts/experimentation/constants.js
+++ b/app/assets/javascripts/experimentation/constants.js
@@ -1 +1,3 @@
export const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0';
+export const DEFAULT_VARIANT = 'control';
+export const CANDIDATE_VARIANT = 'candidate';
diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js
index d3e7800f643..572907f226d 100644
--- a/app/assets/javascripts/experimentation/utils.js
+++ b/app/assets/javascripts/experimentation/utils.js
@@ -1,5 +1,6 @@
// This file only applies to use of experiments through https://gitlab.com/gitlab-org/gitlab-experiment
import { get } from 'lodash';
+import { DEFAULT_VARIANT, CANDIDATE_VARIANT } from './constants';
export function getExperimentData(experimentName) {
return get(window, ['gon', 'experiment', experimentName]);
@@ -8,3 +9,20 @@ export function getExperimentData(experimentName) {
export function isExperimentVariant(experimentName, variantName) {
return getExperimentData(experimentName)?.variant === variantName;
}
+
+export function getExperimentVariant(experimentName) {
+ return getExperimentData(experimentName)?.variant || DEFAULT_VARIANT;
+}
+
+export function experiment(experimentName, variants) {
+ const variant = getExperimentVariant(experimentName);
+
+ switch (variant) {
+ case DEFAULT_VARIANT:
+ return variants.use.call();
+ case CANDIDATE_VARIANT:
+ return variants.try.call();
+ default:
+ return variants[variant].call();
+ }
+}
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
index 222815407ea..9220077af71 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -7,6 +7,8 @@ import { labelForStrategy } from '../utils';
export default {
i18n: {
+ deleteLabel: __('Delete'),
+ editLabel: __('Edit'),
toggleLabel: __('Feature flag status'),
},
components: {
@@ -215,19 +217,21 @@ export default {
<div class="table-action-buttons btn-group">
<template v-if="featureFlag.edit_path">
<gl-button
- v-gl-tooltip.hover.bottom="__('Edit')"
+ v-gl-tooltip.hover.bottom="$options.i18n.editLabel"
class="js-feature-flag-edit-button"
icon="pencil"
+ :aria-label="$options.i18n.editLabel"
:href="featureFlag.edit_path"
/>
</template>
<template v-if="featureFlag.destroy_path">
<gl-button
- v-gl-tooltip.hover.bottom="__('Delete')"
+ v-gl-tooltip.hover.bottom="$options.i18n.deleteLabel"
class="js-feature-flag-delete-button"
variant="danger"
icon="remove"
:disabled="!canDeleteFlag(featureFlag)"
+ :aria-label="$options.i18n.deleteLabel"
@click="setDeleteModalData(featureFlag)"
/>
</template>
diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue
index f6a14d9996f..67ddceaf080 100644
--- a/app/assets/javascripts/feature_flags/components/form.vue
+++ b/app/assets/javascripts/feature_flags/components/form.vue
@@ -29,6 +29,10 @@ import EnvironmentsDropdown from './environments_dropdown.vue';
import Strategy from './strategy.vue';
export default {
+ i18n: {
+ removeLabel: s__('FeatureFlags|Remove'),
+ statusLabel: s__('FeatureFlags|Status'),
+ },
components: {
GlButton,
GlBadge,
@@ -314,7 +318,7 @@ export default {
<h4>{{ s__('FeatureFlags|Strategies') }}</h4>
<div class="flex align-items-baseline justify-content-between">
<p class="mr-3">{{ $options.translations.newHelpText }}</p>
- <gl-button variant="success" category="secondary" @click="addStrategy">
+ <gl-button variant="confirm" category="secondary" @click="addStrategy">
{{ s__('FeatureFlags|Add strategy') }}
</gl-button>
</div>
@@ -396,12 +400,14 @@ export default {
<div class="table-section section-20 text-center" role="gridcell">
<div class="table-mobile-header" role="rowheader">
- {{ s__('FeatureFlags|Status') }}
+ {{ $options.i18n.statusLabel }}
</div>
<div class="table-mobile-content gl-display-flex gl-justify-content-center">
<gl-toggle
:value="scope.active"
:disabled="!active || !canUpdateScope(scope)"
+ :label="$options.i18n.statusLabel"
+ label-position="hidden"
@change="(status) => (scope.active = status)"
/>
</div>
@@ -502,7 +508,8 @@ export default {
<gl-button
v-if="!isAllEnvironment(scope.environmentScope) && canUpdateScope(scope)"
v-gl-tooltip
- :title="s__('FeatureFlags|Remove')"
+ :title="$options.i18n.removeLabel"
+ :aria-label="$options.i18n.removeLabel"
class="js-delete-scope btn-transparent pr-3 pl-3"
icon="clear"
data-testid="feature-flag-delete"
@@ -529,11 +536,13 @@ export default {
<div class="table-section section-20 text-center" role="gridcell">
<div class="table-mobile-header" role="rowheader">
- {{ s__('FeatureFlags|Status') }}
+ {{ $options.i18n.statusLabel }}
</div>
<div class="table-mobile-content gl-display-flex gl-justify-content-center">
<gl-toggle
:disabled="!active"
+ :label="$options.i18n.statusLabel"
+ label-position="hidden"
:value="false"
@change="createNewScope({ active: true })"
/>
@@ -575,7 +584,7 @@ export default {
ref="submitButton"
:disabled="readOnly"
type="button"
- variant="success"
+ variant="confirm"
class="js-ff-submit col-xs-12"
@click="handleSubmit"
>{{ submitText }}</gl-button
diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue
index 170f120b036..3f515dcdf18 100644
--- a/app/assets/javascripts/feature_flags/components/strategy.vue
+++ b/app/assets/javascripts/feature_flags/components/strategy.vue
@@ -165,6 +165,7 @@ export default {
data-testid="delete-strategy-button"
variant="danger"
icon="remove"
+ :aria-label="__('Delete')"
@click="$emit('delete')"
/>
</div>
diff --git a/app/assets/javascripts/feature_flags/components/user_lists_table.vue b/app/assets/javascripts/feature_flags/components/user_lists_table.vue
index 0bfd18f992c..765f59228a6 100644
--- a/app/assets/javascripts/feature_flags/components/user_lists_table.vue
+++ b/app/assets/javascripts/feature_flags/components/user_lists_table.vue
@@ -7,7 +7,7 @@ import {
GlTooltipDirective,
GlModalDirective,
} from '@gitlab/ui';
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
@@ -24,11 +24,12 @@ export default {
createdTimeagoLabel: s__('UserList|created %{timeago}'),
deleteListTitle: s__('UserList|Delete %{name}?'),
deleteListMessage: s__('User list %{name} will be removed. Are you sure?'),
+ editUserListLabel: s__('FeatureFlags|Edit User List'),
},
modal: {
id: 'deleteListModal',
actionPrimary: {
- text: s__('Delete user list'),
+ text: __('Delete user list'),
attributes: { variant: 'danger', 'data-testid': 'modal-confirm' },
},
},
@@ -93,6 +94,7 @@ export default {
:href="list.path"
category="secondary"
icon="pencil"
+ :aria-label="$options.translations.editUserListLabel"
data-testid="edit-user-list"
/>
<gl-button
@@ -100,6 +102,7 @@ export default {
category="secondary"
variant="danger"
icon="remove"
+ :aria-label="$options.modal.actionPrimary.text"
data-testid="delete-user-list"
@click="confirmDeleteList(list)"
/>
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue b/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue
index 2fd92a1bb11..79d7eb94569 100644
--- a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue
@@ -71,7 +71,6 @@ export default {
ref="popover"
:target="$options.targetId"
:css-classes="['feature-highlight-popover']"
- triggers="hover"
container="body"
placement="right"
boundary="viewport"
diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js
index a22430833a3..91af3a6b812 100644
--- a/app/assets/javascripts/filtered_search/dropdown_emoji.js
+++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js
@@ -1,7 +1,7 @@
import { __ } from '~/locale';
import Ajax from '../droplab/plugins/ajax';
import Filter from '../droplab/plugins/filter';
-import { deprecatedCreateFlash as Flash } from '../flash';
+import createFlash from '../flash';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
@@ -14,9 +14,9 @@ export default class DropdownEmoji extends FilteredSearchDropdown {
method: 'setData',
loadingTemplate: this.loadingTemplate,
onError() {
- /* eslint-disable no-new */
- new Flash(__('An error occurred fetching the dropdown data.'));
- /* eslint-enable no-new */
+ createFlash({
+ message: __('An error occurred fetching the dropdown data.'),
+ });
},
},
Filter: {
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index 4df1120f169..93051b00756 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -1,7 +1,7 @@
import { __ } from '~/locale';
import Ajax from '../droplab/plugins/ajax';
import Filter from '../droplab/plugins/filter';
-import { deprecatedCreateFlash as Flash } from '../flash';
+import createFlash from '../flash';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
@@ -17,9 +17,9 @@ export default class DropdownNonUser extends FilteredSearchDropdown {
loadingTemplate: this.loadingTemplate,
preprocessing,
onError() {
- /* eslint-disable no-new */
- new Flash(__('An error occurred fetching the dropdown data.'));
- /* eslint-enable no-new */
+ createFlash({
+ message: __('An error occurred fetching the dropdown data.'),
+ });
},
},
Filter: {
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 69d19074cd0..d0996c9200b 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -10,7 +10,7 @@ import {
DOWN_KEY_CODE,
} from '~/lib/utils/keycodes';
import { __ } from '~/locale';
-import { deprecatedCreateFlash as Flash } from '../flash';
+import createFlash from '../flash';
import { addClassIfElementExists } from '../lib/utils/dom_utils';
import { visitUrl } from '../lib/utils/url_utility';
import FilteredSearchContainer from './container';
@@ -92,8 +92,9 @@ export default class FilteredSearchManager {
.fetch()
.catch((error) => {
if (error.name === 'RecentSearchesServiceError') return undefined;
- // eslint-disable-next-line no-new
- new Flash(__('An error occurred while parsing recent searches'));
+ createFlash({
+ message: __('An error occurred while parsing recent searches'),
+ });
// Gracefully fail to empty array
return [];
})
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index d26a6bc5f6b..2bec39ff4d8 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -66,55 +66,6 @@ const removeFlashClickListener = (flashEl, fadeTransition) => {
* along with ability to provide actionConfig which can be used to show
* additional action or link on banner next to message
*
- * @param {String} message Flash message text
- * @param {String} type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default)
- * @param {Object} parent Reference to parent element under which Flash needs to appear
- * @param {Object} actionConfig Map of config to show action on banner
- * @param {String} href URL to which action config should point to (default: '#')
- * @param {String} title Title of action
- * @param {Function} clickHandler Method to call when action is clicked on
- * @param {Boolean} fadeTransition Boolean to determine whether to fade the alert out
- */
-const deprecatedCreateFlash = function deprecatedCreateFlash(
- message,
- type = FLASH_TYPES.ALERT,
- parent = document,
- actionConfig = null,
- fadeTransition = true,
- addBodyClass = false,
-) {
- const flashContainer = parent.querySelector('.flash-container');
-
- if (!flashContainer) return null;
-
- flashContainer.innerHTML = createFlashEl(message, type);
-
- const flashEl = flashContainer.querySelector(`.flash-${type}`);
-
- if (actionConfig) {
- flashEl.innerHTML += createAction(actionConfig);
-
- if (actionConfig.clickHandler) {
- flashEl
- .querySelector('.flash-action')
- .addEventListener('click', (e) => actionConfig.clickHandler(e));
- }
- }
-
- removeFlashClickListener(flashEl, fadeTransition);
-
- flashContainer.style.display = 'block';
-
- if (addBodyClass) document.body.classList.add('flash-shown');
-
- return flashContainer;
-};
-
-/*
- * Flash banner supports different types of Flash configurations
- * along with ability to provide actionConfig which can be used to show
- * additional action or link on banner next to message
- *
* @param {Object} options Options to control the flash message
* @param {String} options.message Flash message text
* @param {String} options.type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default)
@@ -166,6 +117,31 @@ const createFlash = function createFlash({
return flashContainer;
};
+/*
+ * Flash banner supports different types of Flash configurations
+ * along with ability to provide actionConfig which can be used to show
+ * additional action or link on banner next to message
+ *
+ * @param {String} message Flash message text
+ * @param {String} type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default)
+ * @param {Object} parent Reference to parent element under which Flash needs to appear
+ * @param {Object} actionConfig Map of config to show action on banner
+ * @param {String} href URL to which action config should point to (default: '#')
+ * @param {String} title Title of action
+ * @param {Function} clickHandler Method to call when action is clicked on
+ * @param {Boolean} fadeTransition Boolean to determine whether to fade the alert out
+ */
+const deprecatedCreateFlash = function deprecatedCreateFlash(
+ message,
+ type,
+ parent,
+ actionConfig,
+ fadeTransition,
+ addBodyClass,
+) {
+ return createFlash({ message, type, parent, actionConfig, fadeTransition, addBodyClass });
+};
+
export {
createFlash as default,
deprecatedCreateFlash,
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index c5ea4cc92fd..22f88b1caa7 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import '~/lib/utils/jquery_at_who';
-import { escape, template } from 'lodash';
+import { escape, sortBy, template } from 'lodash';
import * as Emoji from '~/emoji';
import axios from '~/lib/utils/axios_utils';
import { s__, __, sprintf } from '~/locale';
@@ -325,25 +325,7 @@ class GfmAutoComplete {
return items;
}
- const lowercaseQuery = query.toLowerCase();
- const members = items.slice();
- const { nameOrUsernameStartsWith, nameOrUsernameIncludes } = GfmAutoComplete.Members;
-
- return members.sort((a, b) => {
- if (nameOrUsernameStartsWith(a, lowercaseQuery)) {
- return -1;
- }
- if (nameOrUsernameStartsWith(b, lowercaseQuery)) {
- return 1;
- }
- if (nameOrUsernameIncludes(a, lowercaseQuery)) {
- return -1;
- }
- if (nameOrUsernameIncludes(b, lowercaseQuery)) {
- return 1;
- }
- return 0;
- });
+ return GfmAutoComplete.Members.sort(query, items);
},
},
});
@@ -837,6 +819,15 @@ GfmAutoComplete.Members = {
// `member.search` is a name:username string like `MargeSimpson msimpson`
return member.search.toLowerCase().includes(query);
},
+ sort(query, members) {
+ const lowercaseQuery = query.toLowerCase();
+ const { nameOrUsernameStartsWith, nameOrUsernameIncludes } = GfmAutoComplete.Members;
+
+ return sortBy(members, [
+ (member) => (nameOrUsernameStartsWith(member, lowercaseQuery) ? -1 : 0),
+ (member) => (nameOrUsernameIncludes(member, lowercaseQuery) ? -1 : 0),
+ ]);
+ },
};
GfmAutoComplete.Labels = {
templateFunction(color, title) {
diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
index ab13450bb1e..e941318dce0 100644
--- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
+++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
@@ -61,7 +61,9 @@ export default {
<template>
<section id="grafana" class="settings no-animate js-grafana-integration">
<div class="settings-header">
- <h4 class="js-section-header">
+ <h4
+ class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
+ >
{{ s__('GrafanaIntegration|Grafana authentication') }}
</h4>
<gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js
new file mode 100644
index 00000000000..7e897be9e9a
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/constants.js
@@ -0,0 +1,2 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+export const IssueType = 'Issue';
diff --git a/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql
deleted file mode 100644
index b5b4ba4e772..00000000000
--- a/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql
+++ /dev/null
@@ -1,11 +0,0 @@
-fragment EpicNode on Epic {
- id
- iid
- title
- state
- reference
- webPath
- webUrl
- createdAt
- closedAt
-}
diff --git a/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql
new file mode 100644
index 00000000000..0b451262b5a
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql
@@ -0,0 +1,5 @@
+fragment UserAvailability on User {
+ status {
+ availability
+ }
+}
diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
index aaaaf3485ad..e18eea33041 100644
--- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
@@ -1,11 +1,13 @@
#import "../fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query usersSearch($search: String!, $fullPath: ID!) {
workspace: project(fullPath: $fullPath) {
- users: projectMembers(search: $search) {
+ users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) {
nodes {
user {
...User
+ ...UserAvailability
}
}
}
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
index 39c8a88d485..c1fc75fbea6 100644
--- a/app/assets/javascripts/group.js
+++ b/app/assets/javascripts/group.js
@@ -16,9 +16,7 @@ export default class Group {
if (groupName.value === '') {
groupName.addEventListener('keyup', this.updateHandler);
- if (!this.parentId.value) {
- groupName.addEventListener('blur', this.updateGroupPathSlugHandler);
- }
+ groupName.addEventListener('blur', this.updateGroupPathSlugHandler);
}
});
@@ -53,7 +51,7 @@ export default class Group {
const slug = this.groupPaths[0]?.value || slugify(value);
if (!slug) return;
- fetchGroupPathAvailability(slug)
+ fetchGroupPathAvailability(slug, this.parentId?.value)
.then(({ data }) => data)
.then(({ exists, suggests }) => {
if (exists && suggests.length) {
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 9d46fcec09b..f2c608a8912 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -180,16 +180,12 @@ export default {
<div v-if="isGroupPendingRemoval">
<gl-badge variant="warning">{{ __('pending removal') }}</gl-badge>
</div>
- <div
- class="metadata align-items-md-center d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between"
- >
- <item-actions
- v-if="isGroup"
- :group="group"
- :parent-group="parentGroup"
- :action="action"
+ <div class="metadata d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between">
+ <item-actions v-if="isGroup" :group="group" :parent-group="parentGroup" />
+ <item-stats
+ :item="group"
+ class="group-stats gl-mt-2 d-none d-md-flex gl-align-items-center"
/>
- <item-stats :item="group" class="group-stats gl-mt-2 d-none d-md-flex" />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 22c648a76a7..4fed7f555f6 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -46,6 +46,7 @@ function initStatusTriggers() {
currentMessage,
currentAvailability,
canSetUserAvailability,
+ currentClearStatusAfter,
} = setStatusModalWrapperEl.dataset;
return {
@@ -54,6 +55,7 @@ function initStatusTriggers() {
currentMessage,
currentAvailability,
canSetUserAvailability,
+ currentClearStatusAfter,
};
},
render(createElement) {
@@ -63,6 +65,7 @@ function initStatusTriggers() {
currentMessage,
currentAvailability,
canSetUserAvailability,
+ currentClearStatusAfter,
} = this;
return createElement(SetStatusModalWrapper, {
@@ -72,6 +75,7 @@ function initStatusTriggers() {
currentMessage,
currentAvailability,
canSetUserAvailability,
+ currentClearStatusAfter,
},
});
},
diff --git a/app/assets/javascripts/ide/components/cannot_push_code_alert.vue b/app/assets/javascripts/ide/components/cannot_push_code_alert.vue
new file mode 100644
index 00000000000..d3e51e6e140
--- /dev/null
+++ b/app/assets/javascripts/ide/components/cannot_push_code_alert.vue
@@ -0,0 +1,40 @@
+<script>
+import { GlAlert, GlButton } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ },
+ props: {
+ message: {
+ type: String,
+ required: true,
+ },
+ action: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ hasAction() {
+ return Boolean(this.action?.href);
+ },
+ actionButtonMethod() {
+ return this.action?.isForm ? 'post' : null;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert :dismissible="false">
+ {{ message }}
+ <template v-if="hasAction" #actions>
+ <gl-button variant="confirm" :href="action.href" :data-method="actionButtonMethod">
+ {{ action.text }}
+ </gl-button>
+ </template>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index ff2644704d9..0c9fd324f8c 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
import {
@@ -14,6 +14,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { modalTypes } from '../constants';
import eventHub from '../eventhub';
import { measurePerformance } from '../utils';
+import CannotPushCodeAlert from './cannot_push_code_alert.vue';
import IdeSidebar from './ide_side_bar.vue';
import RepoEditor from './repo_editor.vue';
@@ -29,7 +30,6 @@ export default {
components: {
IdeSidebar,
RepoEditor,
- GlAlert,
GlButton,
GlLoadingIcon,
ErrorMessage: () => import(/* webpackChunkName: 'ide_runtime' */ './error_message.vue'),
@@ -41,6 +41,7 @@ export default {
import(/* webpackChunkName: 'ide_runtime' */ '~/vue_shared/components/file_finder/index.vue'),
RightPane: () => import(/* webpackChunkName: 'ide_runtime' */ './panes/right.vue'),
NewModal: () => import(/* webpackChunkName: 'ide_runtime' */ './new_dropdown/modal.vue'),
+ CannotPushCodeAlert,
},
mixins: [glFeatureFlagsMixin()],
data() {
@@ -120,9 +121,11 @@ export default {
class="ide position-relative d-flex flex-column align-items-stretch"
:class="{ [`theme-${themeName}`]: themeName }"
>
- <gl-alert v-if="!canPushCodeStatus.isAllowed" :dismissible="false">{{
- canPushCodeStatus.message
- }}</gl-alert>
+ <cannot-push-code-alert
+ v-if="!canPushCodeStatus.isAllowed"
+ :message="canPushCodeStatus.message"
+ :action="canPushCodeStatus.action"
+ />
<error-message v-if="errorMessage" :message="errorMessage" />
<div class="ide-view flex-grow d-flex">
<template v-if="loadDeferred">
diff --git a/app/assets/javascripts/ide/components/ide_status_mr.vue b/app/assets/javascripts/ide/components/ide_status_mr.vue
index a3b26d23a17..d05ca4141c8 100644
--- a/app/assets/javascripts/ide/components/ide_status_mr.vue
+++ b/app/assets/javascripts/ide/components/ide_status_mr.vue
@@ -20,7 +20,7 @@ export default {
</script>
<template>
- <div class="d-flex-center flex-nowrap text-nowrap js-ide-status-mr">
+ <div class="d-flex-center gl-flex-nowrap text-nowrap js-ide-status-mr">
<gl-icon name="merge-request" />
<span class="ml-1 d-none d-sm-block">{{ s__('WebIDE|Merge request') }}</span>
<gl-link class="ml-1" :href="url">{{ text }}</gl-link>
diff --git a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue
index f4859b9f312..6e1929a1948 100644
--- a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue
@@ -55,6 +55,7 @@ export default {
:disabled="disabled"
class="btn-scroll btn-transparent btn-blank"
type="button"
+ :aria-label="tooltipTitle"
@click="clickedScroll"
>
<gl-icon :name="iconName" />
diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue
index f7cfe80df5c..829a9d64cb7 100644
--- a/app/assets/javascripts/ide/components/merge_requests/list.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/list.vue
@@ -87,7 +87,7 @@ export default {
@input="searchMergeRequests"
@removeToken="setSearchType(null)"
/>
- <gl-icon :size="18" name="search" class="ml-3 input-icon" use-deprecated-sizes />
+ <gl-icon :size="16" name="search" class="ml-3 input-icon" />
</label>
<div class="dropdown-content ide-merge-requests-dropdown-content d-flex">
<gl-loading-icon
@@ -105,7 +105,7 @@ export default {
@click.stop="setSearchType(searchType)"
>
<span class="d-flex gl-mr-3 ide-search-list-current-icon">
- <gl-icon :size="18" name="search" use-deprecated-sizes />
+ <gl-icon :size="16" name="search" />
</span>
<span>{{ searchType.label }}</span>
</button>
diff --git a/app/assets/javascripts/ide/components/nav_dropdown_button.vue b/app/assets/javascripts/ide/components/nav_dropdown_button.vue
index 0db43123562..3699073adb8 100644
--- a/app/assets/javascripts/ide/components/nav_dropdown_button.vue
+++ b/app/assets/javascripts/ide/components/nav_dropdown_button.vue
@@ -31,12 +31,12 @@ export default {
<template>
<dropdown-button>
- <span class="row flex-nowrap">
+ <span class="row gl-flex-nowrap">
<span class="col-auto flex-fill text-truncate">
<gl-icon :size="16" :aria-label="__('Current Branch')" name="branch" /> {{ branchLabel }}
</span>
<span v-if="showMergeRequests" class="col-5 pl-0 text-truncate">
- <gl-icon :size="16" :aria-label="__('Merge Request')" name="merge-request" />
+ <gl-icon :size="16" :aria-label="__('Merge request')" name="merge-request" />
{{ mergeRequestLabel }}
</span>
</span>
diff --git a/app/assets/javascripts/ide/components/nav_form.vue b/app/assets/javascripts/ide/components/nav_form.vue
index 98f0504298b..750c2c3e215 100644
--- a/app/assets/javascripts/ide/components/nav_form.vue
+++ b/app/assets/javascripts/ide/components/nav_form.vue
@@ -26,7 +26,7 @@ export default {
<gl-tab :title="__('Branches')">
<branches-search-list />
</gl-tab>
- <gl-tab :title="__('Merge Requests')">
+ <gl-tab :title="__('Merge requests')">
<merge-request-search-list />
</gl-tab>
</gl-tabs>
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 056df3739ee..6304423a3c0 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -110,5 +110,5 @@ export const SIDE_RIGHT = 'right';
export const LIVE_PREVIEW_DEBOUNCE = 2000;
// This is the maximum number of files to auto open when opening the Web IDE
-// from a Merge Request
+// from a merge request
export const MAX_MR_FILES_AUTO_OPEN = 10;
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index f4a0f324e4a..2ce5bf7e271 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -54,6 +54,7 @@ export function initIde(el, options = {}) {
});
this.setLinks({
webIDEHelpPagePath: el.dataset.webIdeHelpPagePath,
+ forkInfo: el.dataset.forkInfo ? JSON.parse(el.dataset.forkInfo) : null,
});
this.setInitialData({
clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled),
diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js
index 9f2a9a8cf4a..52da9942efe 100644
--- a/app/assets/javascripts/ide/lib/editor_options.js
+++ b/app/assets/javascripts/ide/lib/editor_options.js
@@ -7,6 +7,7 @@ export const defaultEditorOptions = {
enabled: false,
},
wordWrap: 'on',
+ glyphMargin: true,
};
export const defaultDiffOptions = {
@@ -21,6 +22,7 @@ export const defaultDiffEditorOptions = {
readOnly: false,
renderLineHighlight: 'none',
hideCursorInOverviewRuler: true,
+ glyphMargin: true,
};
export const defaultModelOptions = {
diff --git a/app/assets/javascripts/ide/lib/languages/README.md b/app/assets/javascripts/ide/lib/languages/README.md
index c4f3de00783..86c5045ec71 100644
--- a/app/assets/javascripts/ide/lib/languages/README.md
+++ b/app/assets/javascripts/ide/lib/languages/README.md
@@ -1,21 +1,21 @@
# Web IDE Languages
-The Web IDE uses the [Monaco editor](https://microsoft.github.io/monaco-editor/) which uses the [Monarch library](https://microsoft.github.io/monaco-editor/monarch.html) for syntax highlighting.
-The Web IDE currently supports all languages defined in the [monaco-languages](https://github.com/microsoft/monaco-languages/tree/master/src) repository.
+The Web IDE uses the [Monaco editor](https://microsoft.github.io/monaco-editor/) which uses the [Monarch library](https://microsoft.github.io/monaco-editor/monarch.html) for syntax highlighting.
+The Web IDE currently supports all languages defined in the [monaco-languages](https://github.com/microsoft/monaco-languages/tree/master/src) repository.
## Adding New Languages
-While Monaco supports a wide variety of languages, there's always the chance that it's missing something.
+While Monaco supports a wide variety of languages, there's always the chance that it's missing something.
You'll find a list of [unsupported languages in this epic](https://gitlab.com/groups/gitlab-org/-/epics/1474), which is the right place to add more if needed.
Should you be willing to help us and add support to GitLab for any missing languages, here are the steps to do so:
1. Create a new issue and add it to [this epic](https://gitlab.com/groups/gitlab-org/-/epics/1474), if it doesn't already exist.
2. Create a new file in this folder called `{languageName}.js`, where `{languageName}` is the name of the language you want to add support for.
-3. Follow the [Monarch documentation](https://microsoft.github.io/monaco-editor/monarch.html) to add a configuration for the new language.
+3. Follow the [Monarch documentation](https://microsoft.github.io/monaco-editor/monarch.html) to add a configuration for the new language.
- Example: The [`vue.js`](./vue.js) file in the current directory adds support for Vue.js Syntax Highlighting.
4. Add tests for the new language implementation in `spec/frontend/ide/lib/languages/{langaugeName}.js`.
- Example: See [`vue_spec.js`](spec/frontend/ide/lib/languages/vue_spec.js).
-5. Create a [Merge Request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) with your newly added language.
+5. Create a [merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) with your newly added language.
Thank you!
diff --git a/app/assets/javascripts/ide/messages.js b/app/assets/javascripts/ide/messages.js
index 4298d4c627c..189226ef835 100644
--- a/app/assets/javascripts/ide/messages.js
+++ b/app/assets/javascripts/ide/messages.js
@@ -1,10 +1,14 @@
import { s__ } from '~/locale';
-export const MSG_CANNOT_PUSH_CODE = s__(
+export const MSG_CANNOT_PUSH_CODE_SHOULD_FORK = s__(
'WebIDE|You need permission to edit files directly in this project. Fork this project to make your changes and submit a merge request.',
);
-export const MSG_CANNOT_PUSH_CODE_SHORT = s__(
+export const MSG_CANNOT_PUSH_CODE_GO_TO_FORK = s__(
+ 'WebIDE|You need permission to edit files directly in this project. Go to your fork to make changes and submit a merge request.',
+);
+
+export const MSG_CANNOT_PUSH_CODE = s__(
'WebIDE|You need permission to edit files directly in this project.',
);
@@ -15,3 +19,7 @@ export const MSG_CANNOT_PUSH_UNSIGNED = s__(
export const MSG_CANNOT_PUSH_UNSIGNED_SHORT = s__(
'WebIDE|This project does not accept unsigned commits.',
);
+
+export const MSG_FORK = s__('WebIDE|Fork project');
+
+export const MSG_GO_TO_FORK = s__('WebIDE|Go to fork');
diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js
index 753f6b9cd47..74423cd7376 100644
--- a/app/assets/javascripts/ide/stores/actions/merge_request.js
+++ b/app/assets/javascripts/ide/stores/actions/merge_request.js
@@ -73,7 +73,7 @@ export const getMergeRequestData = (
actionText: __('Please try again'),
actionPayload: { projectId, mergeRequestId, force },
});
- reject(new Error(`Merge Request not loaded ${projectId}`));
+ reject(new Error(`Merge request not loaded ${projectId}`));
});
} else {
resolve(state.projects[projectId].mergeRequests[mergeRequestId]);
@@ -106,7 +106,7 @@ export const getMergeRequestChanges = (
actionText: __('Please try again'),
actionPayload: { projectId, mergeRequestId, force },
});
- reject(new Error(`Merge Request Changes not loaded ${projectId}`));
+ reject(new Error(`Merge request changes not loaded ${projectId}`));
});
} else {
resolve(state.projects[projectId].mergeRequests[mergeRequestId].changes);
@@ -140,7 +140,7 @@ export const getMergeRequestVersions = (
actionText: __('Please try again'),
actionPayload: { projectId, mergeRequestId, force },
});
- reject(new Error(`Merge Request Versions not loaded ${projectId}`));
+ reject(new Error(`Merge request versions not loaded ${projectId}`));
});
} else {
resolve(state.projects[projectId].mergeRequests[mergeRequestId].versions);
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index a5bb32ec44a..e8b1a0ea494 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -11,12 +11,42 @@ import {
} from '../constants';
import {
MSG_CANNOT_PUSH_CODE,
- MSG_CANNOT_PUSH_CODE_SHORT,
+ MSG_CANNOT_PUSH_CODE_SHOULD_FORK,
+ MSG_CANNOT_PUSH_CODE_GO_TO_FORK,
MSG_CANNOT_PUSH_UNSIGNED,
MSG_CANNOT_PUSH_UNSIGNED_SHORT,
+ MSG_FORK,
+ MSG_GO_TO_FORK,
} from '../messages';
import { getChangesCountForFiles, filePathMatches } from './utils';
+const getCannotPushCodeViewModel = (state) => {
+ const { ide_path: idePath, fork_path: forkPath } = state.links.forkInfo || {};
+
+ if (idePath) {
+ return {
+ message: MSG_CANNOT_PUSH_CODE_GO_TO_FORK,
+ action: {
+ href: idePath,
+ text: MSG_GO_TO_FORK,
+ },
+ };
+ } else if (forkPath) {
+ return {
+ message: MSG_CANNOT_PUSH_CODE_SHOULD_FORK,
+ action: {
+ href: forkPath,
+ isForm: true,
+ text: MSG_FORK,
+ },
+ };
+ }
+
+ return {
+ message: MSG_CANNOT_PUSH_CODE,
+ };
+};
+
export const activeFile = (state) => state.openFiles.find((file) => file.active) || null;
export const addedFiles = (state) => state.changedFiles.filter((f) => f.tempFile);
@@ -178,7 +208,7 @@ export const canPushCodeStatus = (state, getters) => {
PUSH_RULE_REJECT_UNSIGNED_COMMITS
];
- if (rejectUnsignedCommits) {
+ if (window.gon?.features?.rejectUnsignedCommitsByGitlab && rejectUnsignedCommits) {
return {
isAllowed: false,
message: MSG_CANNOT_PUSH_UNSIGNED,
@@ -188,8 +218,8 @@ export const canPushCodeStatus = (state, getters) => {
if (!canPushCode) {
return {
isAllowed: false,
- message: MSG_CANNOT_PUSH_CODE,
- messageShort: MSG_CANNOT_PUSH_CODE_SHORT,
+ messageShort: MSG_CANNOT_PUSH_CODE,
+ ...getCannotPushCodeViewModel(state),
};
}
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index 22ff29e8866..76ba8339703 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -10,7 +10,7 @@ export const SET_PROJECT = 'SET_PROJECT';
export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT';
export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE';
-// Merge Request Mutation Types
+// Merge request mutation types
export const SET_MERGE_REQUEST = 'SET_MERGE_REQUEST';
export const SET_CURRENT_MERGE_REQUEST = 'SET_CURRENT_MERGE_REQUEST';
export const SET_MERGE_REQUEST_CHANGES = 'SET_MERGE_REQUEST_CHANGES';
diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue
index 8df51ef7f9b..cc6a057f587 100644
--- a/app/assets/javascripts/import_entities/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/components/import_status.vue
@@ -1,13 +1,11 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { GlIcon } from '@gitlab/ui';
import STATUS_MAP from '../constants';
export default {
name: 'ImportStatus',
components: {
- CiIcon,
- GlLoadingIcon,
+ GlIcon,
},
props: {
status: {
@@ -20,28 +18,13 @@ export default {
mappedStatus() {
return STATUS_MAP[this.status];
},
-
- ciIconStatus() {
- const { icon } = this.mappedStatus;
-
- return {
- icon: `status_${icon}`,
- group: icon,
- };
- },
},
};
</script>
<template>
- <div class="gl-display-flex gl-h-7 gl-align-items-center">
- <gl-loading-icon
- v-if="mappedStatus.loadingIcon"
- :inline="true"
- :class="mappedStatus.textClass"
- class="align-middle mr-2"
- />
- <ci-icon v-else css-classes="align-middle mr-2" :status="ciIconStatus" />
- <span :class="mappedStatus.textClass">{{ mappedStatus.text }}</span>
+ <div>
+ <gl-icon :name="mappedStatus.icon" :class="mappedStatus.iconClass" :size="12" class="gl-mr-2" />
+ <span>{{ mappedStatus.text }}</span>
</div>
</template>
diff --git a/app/assets/javascripts/import_entities/constants.js b/app/assets/javascripts/import_entities/constants.js
index c2f398cb8a8..156e92e2d00 100644
--- a/app/assets/javascripts/import_entities/constants.js
+++ b/app/assets/javascripts/import_entities/constants.js
@@ -11,43 +11,43 @@ export const STATUSES = {
STARTED: 'started',
NONE: 'none',
SCHEDULING: 'scheduling',
+ CANCELLED: 'cancelled',
+};
+
+const SCHEDULED_STATUS = {
+ icon: 'status-scheduled',
+ text: __('Pending'),
+ iconClass: 'gl-text-orange-400',
};
const STATUS_MAP = {
+ [STATUSES.NONE]: {
+ icon: 'status-waiting',
+ text: __('Not started'),
+ iconClass: 'gl-text-gray-400',
+ },
+ [STATUSES.SCHEDULING]: SCHEDULED_STATUS,
+ [STATUSES.SCHEDULED]: SCHEDULED_STATUS,
+ [STATUSES.CREATED]: SCHEDULED_STATUS,
+ [STATUSES.STARTED]: {
+ icon: 'status-running',
+ text: __('Importing...'),
+ iconClass: 'gl-text-blue-400',
+ },
[STATUSES.FINISHED]: {
- icon: 'success',
- text: __('Done'),
- textClass: 'text-success',
+ icon: 'status-success',
+ text: __('Complete'),
+ iconClass: 'gl-text-green-400',
},
[STATUSES.FAILED]: {
- icon: 'failed',
+ icon: 'status-failed',
text: __('Failed'),
- textClass: 'text-danger',
- },
- [STATUSES.CREATED]: {
- icon: 'pending',
- text: __('Scheduled'),
- textClass: 'text-warning',
- },
- [STATUSES.SCHEDULED]: {
- icon: 'pending',
- text: __('Scheduled'),
- textClass: 'text-warning',
- },
- [STATUSES.STARTED]: {
- icon: 'running',
- text: __('Running…'),
- textClass: 'text-info',
- },
- [STATUSES.NONE]: {
- icon: 'created',
- text: __('Not started'),
- textClass: 'text-muted',
+ iconClass: 'gl-text-red-600',
},
- [STATUSES.SCHEDULING]: {
- loadingIcon: true,
- text: __('Scheduling'),
- textClass: 'text-warning',
+ [STATUSES.CANCELLED]: {
+ icon: 'status-stopped',
+ text: __('Cancelled'),
+ iconClass: 'gl-text-red-600',
},
};
diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
index ebb09947663..a803afeb901 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
@@ -111,7 +111,7 @@ export default {
</gl-link>
</td>
<td
- class="gl-display-flex gl-flex-sm-wrap gl-p-4 gl-pt-5 gl-vertical-align-top"
+ class="gl-display-flex gl-sm-flex-wrap gl-p-4 gl-pt-5 gl-vertical-align-top"
data-testid="fullPath"
data-qa-selector="project_path_content"
>
diff --git a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue
index 9d5f37dc3b7..0746725153d 100644
--- a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue
+++ b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue
@@ -26,7 +26,10 @@ export default {
class="settings no-animate qa-incident-management-settings"
>
<div class="settings-header">
- <h4 ref="sectionHeader">
+ <h4
+ ref="sectionHeader"
+ class="settings-title js-settings-toggle js-settings-toggle-trigger-only"
+ >
{{ $options.i18n.headerText }}
</h4>
<gl-button ref="toggleBtn" class="js-settings-toggle">{{
diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
index a4baca20ac9..3655f94f06f 100644
--- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
+++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
@@ -3,7 +3,6 @@
import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui';
import { capitalize, lowerCase, isEmpty } from 'lodash';
import { mapGetters } from 'vuex';
-import { __, sprintf } from '~/locale';
import eventHub from '../event_hub';
export default {
@@ -77,14 +76,6 @@ export default {
isNonEmptyPassword() {
return this.isPassword && !isEmpty(this.value);
},
- label() {
- if (this.isNonEmptyPassword) {
- return sprintf(__('Enter new %{field_title}'), {
- field_title: this.humanizedTitle,
- });
- }
- return this.humanizedTitle;
- },
humanizedTitle() {
return this.title || capitalize(lowerCase(this.name));
},
@@ -136,7 +127,7 @@ export default {
<template>
<gl-form-group
- :label="label"
+ :label="humanizedTitle"
:label-for="fieldId"
:invalid-feedback="__('This field is required.')"
:state="valid"
diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
index d3d1fd8ddc3..aea4a8b1c0b 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
@@ -1,15 +1,8 @@
<script>
-import {
- GlFormGroup,
- GlFormCheckbox,
- GlFormInput,
- GlSprintf,
- GlLink,
- GlButton,
- GlCard,
-} from '@gitlab/ui';
+import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
+import JiraUpgradeCta from './jira_upgrade_cta.vue';
export default {
name: 'JiraIssuesFields',
@@ -19,8 +12,7 @@ export default {
GlFormInput,
GlSprintf,
GlLink,
- GlButton,
- GlCard,
+ JiraUpgradeCta,
JiraIssueCreationVulnerabilities: () =>
import('ee_component/integrations/edit/components/jira_issue_creation_vulnerabilities.vue'),
},
@@ -84,11 +76,13 @@ export default {
return !this.enableJiraIssues || Boolean(this.projectKey) || !this.validated;
},
showJiraVulnerabilitiesOptions() {
- return (
- this.enableJiraIssues &&
- this.showJiraVulnerabilitiesIntegration &&
- this.glFeatures.jiraForVulnerabilities
- );
+ return this.showJiraVulnerabilitiesIntegration && this.glFeatures.jiraForVulnerabilities;
+ },
+ showUltimateUpgrade() {
+ return this.showJiraIssuesIntegration && !this.showJiraVulnerabilitiesIntegration;
+ },
+ showPremiumUpgrade() {
+ return !this.showJiraIssuesIntegration;
},
},
created() {
@@ -129,33 +123,29 @@ export default {
<template #help>
{{
s__(
- 'JiraService|Warning: All GitLab users that have access to this GitLab project will be able to view all issues from the Jira project specified below.',
+ 'JiraService|Warning: All GitLab users that have access to this GitLab project are able to view all issues from the Jira project specified below.',
)
}}
</template>
</gl-form-checkbox>
<jira-issue-creation-vulnerabilities
- v-if="showJiraVulnerabilitiesOptions"
+ v-if="enableJiraIssues"
:project-key="projectKey"
:initial-is-enabled="initialEnableJiraVulnerabilities"
:initial-issue-type-id="initialVulnerabilitiesIssuetype"
+ :show-full-feature="showJiraVulnerabilitiesOptions"
data-testid="jira-for-vulnerabilities"
@request-get-issue-types="getJiraIssueTypes"
/>
</template>
- <gl-card v-else class="gl-mt-7">
- <strong>{{ __('This is a Premium feature') }}</strong>
- <p>{{ __('Upgrade your plan to enable this feature of the Jira Integration.') }}</p>
- <gl-button
- v-if="upgradePlanPath"
- category="primary"
- variant="info"
- :href="upgradePlanPath"
- target="_blank"
- >
- {{ __('Upgrade your plan') }}
- </gl-button>
- </gl-card>
+ <jira-upgrade-cta
+ v-if="showUltimateUpgrade || showPremiumUpgrade"
+ class="gl-mt-2"
+ :class="{ 'gl-ml-6': showUltimateUpgrade }"
+ :upgrade-plan-path="upgradePlanPath"
+ :show-ultimate-message="showUltimateUpgrade"
+ :show-premium-message="showPremiumUpgrade"
+ />
</div>
</gl-form-group>
<template v-if="showJiraIssuesIntegration">
@@ -169,7 +159,7 @@ export default {
id="service_project_key"
v-model="projectKey"
name="service[project_key]"
- :placeholder="s__('JiraService|e.g. AB')"
+ :placeholder="s__('JiraService|For example, AB')"
:required="enableJiraIssues"
:state="validProjectKey"
:disabled="!enableJiraIssues"
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 af4e9acf4ba..b0f19e5b585 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -1,7 +1,16 @@
<script>
-import { GlFormGroup, GlFormCheckbox, GlFormRadio } from '@gitlab/ui';
+import {
+ GlFormGroup,
+ GlFormCheckbox,
+ GlFormRadio,
+ GlFormInput,
+ GlLink,
+ GlSprintf,
+} from '@gitlab/ui';
import { mapGetters } from 'vuex';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
+import eventHub from '../event_hub';
const commentDetailOptions = [
{
@@ -18,12 +27,41 @@ const commentDetailOptions = [
},
];
+const ISSUE_TRANSITION_AUTO = true;
+const ISSUE_TRANSITION_CUSTOM = false;
+
+const issueTransitionOptions = [
+ {
+ value: ISSUE_TRANSITION_AUTO,
+ label: s__('JiraService|Move to Done'),
+ help: s__(
+ 'JiraService|Automatically transitions Jira issues to the "Done" category. %{linkStart}Learn more%{linkEnd}',
+ ),
+ link: helpPagePath('user/project/integrations/jira.html', {
+ anchor: 'automatic-issue-transitions',
+ }),
+ },
+ {
+ value: ISSUE_TRANSITION_CUSTOM,
+ label: s__('JiraService|Use custom transitions'),
+ help: s__(
+ 'JiraService|Set a custom final state by using transition IDs. %{linkStart}Learn about transition IDs%{linkEnd}',
+ ),
+ link: helpPagePath('user/project/integrations/jira.html', {
+ anchor: 'custom-issue-transitions',
+ }),
+ },
+];
+
export default {
name: 'JiraTriggerFields',
components: {
GlFormGroup,
GlFormCheckbox,
GlFormRadio,
+ GlFormInput,
+ GlLink,
+ GlSprintf,
},
props: {
initialTriggerCommit: {
@@ -43,21 +81,58 @@ export default {
required: false,
default: 'standard',
},
+ initialJiraIssueTransitionAutomatic: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ initialJiraIssueTransitionId: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
+ validated: false,
triggerCommit: this.initialTriggerCommit,
triggerMergeRequest: this.initialTriggerMergeRequest,
enableComments: this.initialEnableComments,
commentDetail: this.initialCommentDetail,
+ jiraIssueTransitionAutomatic:
+ this.initialJiraIssueTransitionAutomatic || !this.initialJiraIssueTransitionId,
+ jiraIssueTransitionId: this.initialJiraIssueTransitionId,
+ issueTransitionEnabled:
+ this.initialJiraIssueTransitionAutomatic || Boolean(this.initialJiraIssueTransitionId),
commentDetailOptions,
+ issueTransitionOptions,
};
},
computed: {
...mapGetters(['isInheriting']),
- showEnableComments() {
+ showTriggerSettings() {
return this.triggerCommit || this.triggerMergeRequest;
},
+ validIssueTransitionId() {
+ return !this.validated || Boolean(this.jiraIssueTransitionId);
+ },
+ },
+ created() {
+ eventHub.$on('validateForm', this.validateForm);
+ },
+ beforeDestroy() {
+ eventHub.$off('validateForm', this.validateForm);
+ },
+ methods: {
+ validateForm() {
+ this.validated = true;
+ },
+ showCustomIssueTransitions(currentOption) {
+ return (
+ this.jiraIssueTransitionAutomatic === ISSUE_TRANSITION_CUSTOM &&
+ currentOption === ISSUE_TRANSITION_CUSTOM
+ );
+ },
},
};
</script>
@@ -69,7 +144,7 @@ export default {
label-for="service[trigger]"
:description="
s__(
- 'Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) will be created.',
+ 'Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) is created.',
)
"
>
@@ -89,7 +164,7 @@ export default {
</gl-form-group>
<gl-form-group
- v-show="showEnableComments"
+ v-show="showTriggerSettings"
:label="s__('Integrations|Comment settings:')"
label-for="service[comment_on_event_enabled]"
class="gl-pl-6"
@@ -106,7 +181,7 @@ export default {
</gl-form-group>
<gl-form-group
- v-show="showEnableComments && enableComments"
+ v-show="showTriggerSettings && enableComments"
:label="s__('Integrations|Comment detail:')"
label-for="service[comment_detail]"
class="gl-pl-9"
@@ -126,5 +201,67 @@ export default {
</template>
</gl-form-radio>
</gl-form-group>
+
+ <gl-form-group
+ v-if="showTriggerSettings"
+ :label="s__('JiraService|Transition Jira issues to their final state:')"
+ class="gl-pl-6"
+ data-testid="issue-transition-enabled"
+ >
+ <input type="hidden" name="service[jira_issue_transition_automatic]" value="false" />
+ <input type="hidden" name="service[jira_issue_transition_id]" value="" />
+
+ <gl-form-checkbox
+ v-model="issueTransitionEnabled"
+ :disabled="isInheriting"
+ data-qa-selector="service_jira_issue_transition_enabled_checkbox"
+ >
+ {{ s__('JiraService|Enable Jira transitions') }}
+ </gl-form-checkbox>
+ </gl-form-group>
+
+ <gl-form-group
+ v-if="showTriggerSettings && issueTransitionEnabled"
+ class="gl-pl-9"
+ data-testid="issue-transition-mode"
+ >
+ <gl-form-radio
+ v-for="issueTransitionOption in issueTransitionOptions"
+ :key="issueTransitionOption.value"
+ v-model="jiraIssueTransitionAutomatic"
+ name="service[jira_issue_transition_automatic]"
+ :value="issueTransitionOption.value"
+ :disabled="isInheriting"
+ :data-qa-selector="`service_jira_issue_transition_automatic_${issueTransitionOption.value}_radio`"
+ >
+ {{ issueTransitionOption.label }}
+
+ <template v-if="showCustomIssueTransitions(issueTransitionOption.value)">
+ <gl-form-input
+ v-model="jiraIssueTransitionId"
+ name="service[jira_issue_transition_id]"
+ type="text"
+ class="gl-my-3"
+ data-qa-selector="service_jira_issue_transition_id_field"
+ :placeholder="s__('JiraService|For example, 12, 24')"
+ :disabled="isInheriting"
+ :required="true"
+ :state="validIssueTransitionId"
+ />
+
+ <span class="invalid-feedback">
+ {{ __('This field is required.') }}
+ </span>
+ </template>
+
+ <template #help>
+ <gl-sprintf :message="issueTransitionOption.help">
+ <template #link="{ content }">
+ <gl-link :href="issueTransitionOption.link" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-form-radio>
+ </gl-form-group>
</div>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/jira_upgrade_cta.vue b/app/assets/javascripts/integrations/edit/components/jira_upgrade_cta.vue
new file mode 100644
index 00000000000..9164e484440
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/jira_upgrade_cta.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlButton, GlCard } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlCard,
+ },
+ props: {
+ upgradePlanPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showPremiumMessage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showUltimateMessage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ title() {
+ return this.showUltimateMessage
+ ? this.$options.i18n.titleUltimate
+ : this.$options.i18n.titlePremium;
+ },
+ },
+ i18n: {
+ titleUltimate: s__('JiraService|This is an Ultimate feature'),
+ titlePremium: s__('JiraService|This is a Premium feature'),
+ content: s__('JiraService|Upgrade your plan to enable this feature of the Jira Integration.'),
+ upgrade: __('Upgrade your plan'),
+ },
+};
+</script>
+
+<template>
+ <gl-card>
+ <strong>{{ title }}</strong>
+ <p>{{ $options.i18n.content }}</p>
+ <gl-button v-if="upgradePlanPath" category="primary" variant="info" :href="upgradePlanPath">
+ {{ $options.i18n.upgrade }}
+ </gl-button>
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
index 1bbecea05ad..42bc9e4c8a1 100644
--- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
@@ -10,8 +10,8 @@ const typeWithPlaceholder = {
};
const placeholderForType = {
- [typeWithPlaceholder.SLACK]: __('Slack channels (e.g. general, development)'),
- [typeWithPlaceholder.MATTERMOST]: __('Channel handle (e.g. town-square)'),
+ [typeWithPlaceholder.SLACK]: __('general, development'),
+ [typeWithPlaceholder.MATTERMOST]: __('my-channel'),
};
export default {
diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js
index ab9bdd9ca2e..792e7d8e85e 100644
--- a/app/assets/javascripts/integrations/edit/index.js
+++ b/app/assets/javascripts/integrations/edit/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
+
import IntegrationForm from './components/integration_form.vue';
import { createStore } from './store';
@@ -28,6 +29,8 @@ function parseDatasetToProps(data) {
testPath,
resetPath,
vulnerabilitiesIssuetype,
+ jiraIssueTransitionAutomatic,
+ jiraIssueTransitionId,
...booleanAttributes
} = data;
const {
@@ -59,6 +62,8 @@ function parseDatasetToProps(data) {
initialTriggerMergeRequest: mergeRequestEvents,
initialEnableComments: enableComments,
initialCommentDetail: commentDetail,
+ initialJiraIssueTransitionAutomatic: jiraIssueTransitionAutomatic,
+ initialJiraIssueTransitionId: jiraIssueTransitionId,
},
jiraIssuesProps: {
showJiraIssuesIntegration,
@@ -73,7 +78,7 @@ function parseDatasetToProps(data) {
},
learnMorePath,
triggerEvents: JSON.parse(triggerEvents),
- fields: JSON.parse(fields),
+ fields: convertObjectPropsToCamelCase(JSON.parse(fields), { deep: true }),
inheritFromId: parseInt(inheritFromId, 10),
integrationLevel,
id: parseInt(id, 10),
diff --git a/app/assets/javascripts/integrations/index/components/integrations_list.vue b/app/assets/javascripts/integrations/index/components/integrations_list.vue
new file mode 100644
index 00000000000..7331437d484
--- /dev/null
+++ b/app/assets/javascripts/integrations/index/components/integrations_list.vue
@@ -0,0 +1,59 @@
+<script>
+import { s__ } from '~/locale';
+import IntegrationsTable from './integrations_table.vue';
+
+export default {
+ name: 'IntegrationsList',
+ components: {
+ IntegrationsTable,
+ },
+ props: {
+ integrations: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ integrationsGrouped() {
+ return this.integrations.reduce(
+ (integrations, integration) => {
+ if (integration.active) {
+ integrations.active.push(integration);
+ } else {
+ integrations.inactive.push(integration);
+ }
+
+ return integrations;
+ },
+ { active: [], inactive: [] },
+ );
+ },
+ },
+ i18n: {
+ activeTableEmptyText: s__("Integrations|You haven't activated any integrations yet."),
+ inactiveTableEmptyText: s__("Integrations|You've activated every integration 🎉"),
+ activeIntegrationsHeading: s__('Integrations|Active integrations'),
+ inactiveIntegrationsHeading: s__('Integrations|Add an integration'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h4>{{ $options.i18n.activeIntegrationsHeading }}</h4>
+ <integrations-table
+ class="gl-mb-7!"
+ :integrations="integrationsGrouped.active"
+ :empty-text="$options.i18n.activeTableEmptyText"
+ show-updated-at
+ data-testid="active-integrations-table"
+ />
+
+ <h4>{{ $options.i18n.inactiveIntegrationsHeading }}</h4>
+ <integrations-table
+ :integrations="integrationsGrouped.inactive"
+ :empty-text="$options.i18n.inactiveTableEmptyText"
+ data-testid="inactive-integrations-table"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/index/components/integrations_table.vue b/app/assets/javascripts/integrations/index/components/integrations_table.vue
new file mode 100644
index 00000000000..439c243f418
--- /dev/null
+++ b/app/assets/javascripts/integrations/index/components/integrations_table.vue
@@ -0,0 +1,95 @@
+<script>
+import { GlIcon, GlLink, GlTable, GlTooltipDirective } from '@gitlab/ui';
+import { sprintf, s__, __ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ GlTable,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ integrations: {
+ type: Array,
+ required: true,
+ },
+ showUpdatedAt: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ emptyText: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ },
+ computed: {
+ fields() {
+ return [
+ {
+ key: 'active',
+ label: '',
+ thClass: 'gl-w-10',
+ },
+ {
+ key: 'title',
+ label: __('Integration'),
+ thClass: 'gl-w-quarter',
+ },
+ {
+ key: 'description',
+ label: __('Description'),
+ thClass: 'gl-display-none d-sm-table-cell',
+ tdClass: 'gl-display-none d-sm-table-cell',
+ },
+ {
+ key: 'updated_at',
+ label: this.showUpdatedAt ? __('Last updated') : '',
+ thClass: 'gl-w-20p',
+ },
+ ];
+ },
+ },
+ methods: {
+ getStatusTooltipTitle(integration) {
+ return sprintf(s__('Integrations|%{integrationTitle}: active'), {
+ integrationTitle: integration.title,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-table :items="integrations" :fields="fields" :empty-text="emptyText" show-empty fixed>
+ <template #cell(active)="{ item }">
+ <gl-icon
+ v-if="item.active"
+ v-gl-tooltip
+ name="check"
+ class="gl-text-green-500"
+ :title="getStatusTooltipTitle(item)"
+ />
+ </template>
+
+ <template #cell(title)="{ item }">
+ <gl-link
+ :href="item.edit_path"
+ class="gl-font-weight-bold"
+ :data-qa-selector="`${item.name}_link`"
+ >
+ {{ item.title }}
+ </gl-link>
+ </template>
+
+ <template #cell(updated_at)="{ item }">
+ <time-ago-tooltip v-if="showUpdatedAt && item.updated_at" :time="item.updated_at" />
+ </template>
+ </gl-table>
+</template>
diff --git a/app/assets/javascripts/integrations/index/index.js b/app/assets/javascripts/integrations/index/index.js
new file mode 100644
index 00000000000..09dca3a6d02
--- /dev/null
+++ b/app/assets/javascripts/integrations/index/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import IntegrationList from './components/integrations_list.vue';
+
+export default () => {
+ const el = document.querySelector('.js-integrations-list');
+
+ if (!el) {
+ return null;
+ }
+
+ const { integrations } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(IntegrationList, {
+ props: {
+ integrations: JSON.parse(integrations),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/invite_member/components/invite_member_modal.vue b/app/assets/javascripts/invite_member/components/invite_member_modal.vue
index 144c1a2c22a..ec77e49ae53 100644
--- a/app/assets/javascripts/invite_member/components/invite_member_modal.vue
+++ b/app/assets/javascripts/invite_member/components/invite_member_modal.vue
@@ -19,8 +19,10 @@ export default {
GlLink,
GlModal,
},
- inject: {
+ props: {
membersPath: {
+ type: String,
+ required: false,
default: '',
},
},
diff --git a/app/assets/javascripts/invite_member/components/invite_member_trigger.vue b/app/assets/javascripts/invite_member/components/invite_member_trigger.vue
index 56cf1ab2fc2..ee89e0bbf71 100644
--- a/app/assets/javascripts/invite_member/components/invite_member_trigger.vue
+++ b/app/assets/javascripts/invite_member/components/invite_member_trigger.vue
@@ -7,14 +7,20 @@ export default {
components: {
GlLink,
},
- inject: {
+ props: {
displayText: {
+ type: String,
+ required: false,
default: '',
},
event: {
+ type: String,
+ required: false,
default: '',
},
label: {
+ type: String,
+ required: false,
default: '',
},
},
diff --git a/app/assets/javascripts/invite_member/init_invite_member_modal.js b/app/assets/javascripts/invite_member/init_invite_member_modal.js
index c292bda1931..a50d31c9e7a 100644
--- a/app/assets/javascripts/invite_member/init_invite_member_modal.js
+++ b/app/assets/javascripts/invite_member/init_invite_member_modal.js
@@ -1,13 +1,17 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
+import { isInIssuePage, isInDesignPage } from '~/lib/utils/common_utils';
import InviteMemberModal from './components/invite_member_modal.vue';
Vue.use(GlToast);
+const isAssigneesWidgetShown =
+ (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget;
+
export default function initInviteMembersModal() {
const el = document.querySelector('.js-invite-member-modal');
- if (!el) {
+ if (!el || isAssigneesWidgetShown) {
return false;
}
@@ -15,7 +19,9 @@ export default function initInviteMembersModal() {
return new Vue({
el,
- provide: { membersPath },
- render: (createElement) => createElement(InviteMemberModal),
+ render: (createElement) =>
+ createElement(InviteMemberModal, {
+ props: { membersPath },
+ }),
});
}
diff --git a/app/assets/javascripts/invite_member/init_invite_member_trigger.js b/app/assets/javascripts/invite_member/init_invite_member_trigger.js
index 5e763e4f47d..eb765ae83b0 100644
--- a/app/assets/javascripts/invite_member/init_invite_member_trigger.js
+++ b/app/assets/javascripts/invite_member/init_invite_member_trigger.js
@@ -10,7 +10,9 @@ export default function initInviteMembersTrigger() {
return new Vue({
el,
- provide: { ...el.dataset },
- render: (createElement) => createElement(InviteMemberTrigger),
+ render: (createElement) =>
+ createElement(InviteMemberTrigger, {
+ props: { ...el.dataset },
+ }),
});
}
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 47f1405c980..d00a0f1633b 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -11,10 +11,12 @@ import {
} from '@gitlab/ui';
import { partition, isString } from 'lodash';
import Api from '~/api';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
import GroupSelect from '~/invite_members/components/group_select.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { s__, sprintf } from '~/locale';
+import { INVITE_MEMBERS_IN_COMMENT } from '../constants';
import eventHub from '../event_hub';
export default {
@@ -122,8 +124,9 @@ export default {
usersToAddById.map((user) => user.id).join(','),
];
},
- openModal({ inviteeType }) {
+ openModal({ inviteeType, source }) {
this.inviteeType = inviteeType;
+ this.source = source;
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
@@ -138,6 +141,12 @@ export default {
}
this.closeModal();
},
+ trackInvite() {
+ if (this.source === INVITE_MEMBERS_IN_COMMENT) {
+ const tracking = new ExperimentTracking(INVITE_MEMBERS_IN_COMMENT);
+ tracking.event('comment_invite_success');
+ }
+ },
cancelInvite() {
this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined;
@@ -177,6 +186,8 @@ export default {
promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
}
+ this.trackInvite();
+
Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError);
},
inviteByEmailPostData(usersToInviteByEmail) {
@@ -211,9 +222,9 @@ export default {
},
labels: {
members: {
- modalTitle: s__('InviteMembersModal|Invite team members'),
- searchField: s__('InviteMembersModal|GitLab member or Email address'),
- placeHolder: s__('InviteMembersModal|Search for members to invite'),
+ modalTitle: s__('InviteMembersModal|Invite members'),
+ searchField: s__('InviteMembersModal|GitLab member or email address'),
+ placeHolder: s__('InviteMembersModal|Select members or type email addresses'),
toGroup: {
introText: s__(
"InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} group.",
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
index 666693e934f..e297bb6c806 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -1,10 +1,11 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlLink } from '@gitlab/ui';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
export default {
- components: { GlButton },
+ components: { GlButton, GlLink },
props: {
displayText: {
type: String,
@@ -26,10 +27,65 @@ export default {
required: false,
default: undefined,
},
+ triggerSource: {
+ type: String,
+ required: false,
+ default: 'unknown',
+ },
+ trackExperiment: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ triggerElement: {
+ type: String,
+ required: false,
+ default: 'button',
+ },
+ event: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ label: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ isButton() {
+ return this.triggerElement === 'button';
+ },
+ componentAttributes() {
+ const baseAttributes = {
+ class: this.classes,
+ 'data-qa-selector': 'invite_members_button',
+ };
+
+ if (this.event && this.label) {
+ return {
+ ...baseAttributes,
+ 'data-track-event': this.event,
+ 'data-track-label': this.label,
+ };
+ }
+
+ return baseAttributes;
+ },
+ },
+ mounted() {
+ this.trackExperimentOnShow();
},
methods: {
openModal() {
- eventHub.$emit('openModal', { inviteeType: 'members' });
+ eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource });
+ },
+ trackExperimentOnShow() {
+ if (this.trackExperiment) {
+ const tracking = new ExperimentTracking(this.trackExperiment);
+ tracking.event('comment_invite_shown');
+ }
},
},
};
@@ -37,12 +93,15 @@ export default {
<template>
<gl-button
- :class="classes"
- :icon="icon"
+ v-if="isButton"
+ v-bind="componentAttributes"
:variant="variant"
- data-qa-selector="invite_members_button"
+ :icon="icon"
@click="openModal"
>
{{ displayText }}
</gl-button>
+ <gl-link v-else v-bind="componentAttributes" data-is-link="true" @click="openModal">
+ {{ displayText }}
+ </gl-link>
</template>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 2044dad896f..a651b81c60e 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1 +1,3 @@
export const SEARCH_DELAY = 200;
+
+export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment';
diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue
index 78987a5c629..7bdd55ddda3 100644
--- a/app/assets/javascripts/issuable/components/csv_export_modal.vue
+++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue
@@ -12,19 +12,23 @@ export default {
},
inject: {
issuableType: {
- default: '',
- },
- issuableCount: {
- default: 0,
+ default: ISSUABLE_TYPE.issues,
},
email: {
default: '',
},
+ },
+ props: {
exportCsvPath: {
+ type: String,
+ required: false,
default: '',
},
- },
- props: {
+ issuableCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
modalId: {
type: String,
required: true,
diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
index bbf4160ce35..fb4d5aca2f5 100644
--- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
+++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
@@ -13,6 +13,10 @@ import CsvExportModal from './csv_export_modal.vue';
import CsvImportModal from './csv_import_modal.vue';
export default {
+ i18n: {
+ exportAsCsvButtonText: __('Export as CSV'),
+ importIssuesText: __('Import issues'),
+ },
name: 'CsvImportExportButtons',
components: {
GlButtonGroup,
@@ -49,6 +53,18 @@ export default {
default: false,
},
},
+ props: {
+ exportCsvPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ issuableCount: {
+ type: Number,
+ required: false,
+ default: undefined,
+ },
+ },
computed: {
exportModalId() {
return `${this.issuableType}-export-modal`;
@@ -57,16 +73,15 @@ export default {
return `${this.issuableType}-import-modal`;
},
importButtonText() {
- return this.showLabel ? this.$options.importIssuesText : null;
+ return this.showLabel ? this.$options.i18n.importIssuesText : null;
},
importButtonTooltipText() {
- return this.showLabel ? null : this.$options.importIssuesText;
+ return this.showLabel ? null : this.$options.i18n.importIssuesText;
},
importButtonIcon() {
return this.showLabel ? null : 'import';
},
},
- importIssuesText: __('Import issues'),
};
</script>
@@ -75,9 +90,10 @@ export default {
<gl-button-group>
<gl-button
v-if="showExportButton"
- v-gl-tooltip.hover="__('Export as CSV')"
+ v-gl-tooltip.hover="$options.i18n.exportAsCsvButtonText"
v-gl-modal="exportModalId"
icon="export"
+ :aria-label="$options.i18n.exportAsCsvButtonText"
data-qa-selector="export_as_csv_button"
data-testid="export-csv-button"
/>
@@ -101,7 +117,12 @@ export default {
>
</gl-dropdown>
</gl-button-group>
- <csv-export-modal v-if="showExportButton" :modal-id="exportModalId" />
+ <csv-export-modal
+ v-if="showExportButton"
+ :modal-id="exportModalId"
+ :export-csv-path="exportCsvPath"
+ :issuable-count="issuableCount"
+ />
<csv-import-modal v-if="showImportButton" :modal-id="importModalId" />
</div>
</template>
diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue
index 6d063b59922..d0ce8c2c34b 100644
--- a/app/assets/javascripts/issuable/components/issuable_by_email.vue
+++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue
@@ -14,6 +14,9 @@ import { sprintf, __ } from '~/locale';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
export default {
+ i18n: {
+ sendEmail: __('Send email'),
+ },
name: 'IssuableByEmail',
components: {
GlButton,
@@ -116,7 +119,8 @@ export default {
<gl-button
v-gl-tooltip.hover
:href="mailToLink"
- :title="__('Send email')"
+ :title="$options.i18n.sendEmail"
+ :aria-label="$options.i18n.sendEmail"
icon="mail"
data-testid="mail-to-btn"
/>
diff --git a/app/assets/javascripts/issuable/init_csv_import_export_buttons.js b/app/assets/javascripts/issuable/init_csv_import_export_buttons.js
index 5a720b89d33..83163e3c478 100644
--- a/app/assets/javascripts/issuable/init_csv_import_export_buttons.js
+++ b/app/assets/javascripts/issuable/init_csv_import_export_buttons.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
-import ImportExportButtons from './components/csv_import_export_buttons.vue';
+import CsvImportExportButtons from './components/csv_import_export_buttons.vue';
export default () => {
const el = document.querySelector('.js-csv-import-export-buttons');
@@ -28,9 +28,7 @@ export default () => {
showExportButton: parseBoolean(showExportButton),
showImportButton: parseBoolean(showImportButton),
issuableType,
- issuableCount,
email,
- exportCsvPath,
importCsvIssuesPath,
containerClass,
canEdit: parseBoolean(canEdit),
@@ -39,7 +37,12 @@ export default () => {
showLabel,
},
render(h) {
- return h(ImportExportButtons);
+ return h(CsvImportExportButtons, {
+ props: {
+ exportCsvPath,
+ issuableCount: parseInt(issuableCount, 10),
+ },
+ });
},
});
};
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
index f507f072253..366a9a8a883 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -87,7 +87,7 @@ export default {
// From issuable's initial bulk selection
getOriginalCommonIds() {
const labelIds = [];
- this.getElement('.selected-issuable:checked').each((i, el) => {
+ this.getElement('.issuable-list input[type="checkbox"]:checked').each((i, el) => {
labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
});
return intersection.apply(this, labelIds);
@@ -100,7 +100,7 @@ export default {
let issuableLabels = [];
// Collect unique label IDs for all checked issues
- this.getElement('.selected-issuable:checked').each((i, el) => {
+ this.getElement('.issuable-list input[type="checkbox"]:checked').each((i, el) => {
issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels');
issuableLabels.forEach((labelId) => {
// Store unique IDs
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js
index ef98db5151a..97d50dde9f7 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js
@@ -34,7 +34,7 @@ export default class IssuableBulkUpdateSidebar {
this.$otherFilters = $('.issues-other-filters');
this.$checkAllContainer = $('.check-all-holder');
this.$issueChecks = $('.issue-check');
- this.$issuesList = $('.selected-issuable');
+ this.$issuesList = $('.issuable-list input[type="checkbox"]');
this.$issuableIdsInput = $('#update_issuable_ids');
}
@@ -46,16 +46,11 @@ export default class IssuableBulkUpdateSidebar {
this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit());
this.$checkAllContainer.on('click', () => this.updateFormState());
- if (this.vueIssuablesListFeature) {
- issueableEventHub.$on('issuables:updateBulkEdit', () => {
- // Danger! Strong coupling ahead!
- // The bulk update sidebar and its dropdowns look for .selected-issuable checkboxes, and get data on which issue
- // is selected by inspecting the DOM. Ideally, we would pass the selected issuable IDs and their properties
- // explicitly, but this component is used in too many places right now to refactor straight away.
-
- this.updateFormState();
- });
- }
+ // The event hub connects this bulk update logic with `issues_list_app.vue`.
+ // We can remove it once we've refactored the issues list page bulk edit sidebar to Vue.
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/325874
+ issueableEventHub.$on('issuables:enableBulkEdit', () => this.toggleBulkEdit(null, true));
+ issueableEventHub.$on('issuables:updateBulkEdit', () => this.updateFormState());
}
initDropdowns() {
@@ -96,7 +91,7 @@ export default class IssuableBulkUpdateSidebar {
}
updateFormState() {
- const noCheckedIssues = !$('.selected-issuable:checked').length;
+ const noCheckedIssues = !$('.issuable-list input[type="checkbox"]:checked').length;
this.toggleSubmitButtonDisabled(noCheckedIssues);
this.updateSelectedIssuableIds();
@@ -112,7 +107,7 @@ export default class IssuableBulkUpdateSidebar {
}
toggleBulkEdit(e, enable) {
- e.preventDefault();
+ e?.preventDefault();
issueableEventHub.$emit('issuables:toggleBulkEdit', enable);
@@ -166,7 +161,7 @@ export default class IssuableBulkUpdateSidebar {
}
static getCheckedIssueIds() {
- const $checkedIssues = $('.selected-issuable:checked');
+ const $checkedIssues = $('.issuable-list input[type="checkbox"]:checked');
if ($checkedIssues.length > 0) {
return $.map($checkedIssues, (value) => $(value).data('id'));
diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js
index 4856f9781ce..cdeee68b762 100644
--- a/app/assets/javascripts/issuable_index.js
+++ b/app/assets/javascripts/issuable_index.js
@@ -1,7 +1,7 @@
import issuableInitBulkUpdateSidebar from './issuable_init_bulk_update_sidebar';
export default class IssuableIndex {
- constructor(pagePrefix) {
+ constructor(pagePrefix = 'issuable_') {
issuableInitBulkUpdateSidebar.init(pagePrefix);
}
}
diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue
index 92c527c79ff..5d497369f5a 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_item.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue
@@ -65,6 +65,9 @@ export default {
labels() {
return this.issuable.labels?.nodes || this.issuable.labels || [];
},
+ labelIdsString() {
+ return JSON.stringify(this.labels.map((label) => label.id));
+ },
assignees() {
return this.issuable.assignees || [];
},
@@ -149,12 +152,13 @@ export default {
</script>
<template>
- <li class="issue gl-px-5!">
+ <li :id="`issuable_${issuable.id}`" class="issue gl-px-5!" :data-labels="labelIdsString">
<div class="issuable-info-container">
<div v-if="showCheckbox" class="issue-check">
<gl-form-checkbox
class="gl-mr-0"
:checked="checked"
+ :data-id="issuable.id"
@input="$emit('checked-input', $event)"
/>
</div>
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 40b0fcbb8c6..6b95c3a578e 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
@@ -10,7 +10,14 @@ import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue';
import IssuableItem from './issuable_item.vue';
import IssuableTabs from './issuable_tabs.vue';
+const VueDraggable = () => import('vuedraggable');
+
export default {
+ vueDraggableAttributes: {
+ animation: 200,
+ ghostClass: 'gl-visibility-hidden',
+ tag: 'ul',
+ },
components: {
GlSkeletonLoading,
IssuableTabs,
@@ -18,6 +25,7 @@ export default {
IssuableItem,
IssuableBulkEditSidebar,
GlPagination,
+ VueDraggable,
},
props: {
namespace: {
@@ -127,6 +135,11 @@ export default {
required: false,
default: null,
},
+ isManualOrdering: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -159,6 +172,9 @@ export default {
return acc;
}, []);
},
+ issuablesWrapper() {
+ return this.isManualOrdering ? VueDraggable : 'ul';
+ },
},
watch: {
issuables(list) {
@@ -202,11 +218,16 @@ export default {
},
handleIssuableCheckedInput(issuable, value) {
this.checkedIssuables[this.issuableId(issuable)].checked = value;
+ this.$emit('update-legacy-bulk-edit');
},
handleAllIssuablesCheckedInput(value) {
Object.keys(this.checkedIssuables).forEach((issuableId) => {
this.checkedIssuables[issuableId].checked = value;
});
+ this.$emit('update-legacy-bulk-edit');
+ },
+ handleVueDraggableUpdate({ newIndex, oldIndex }) {
+ this.$emit('reorder', { newIndex, oldIndex });
},
},
};
@@ -253,13 +274,18 @@ export default {
<gl-skeleton-loading />
</li>
</ul>
- <ul
+ <component
+ :is="issuablesWrapper"
v-if="!issuablesLoading && issuables.length"
class="content-list issuable-list issues-list"
+ :class="{ 'manual-ordering': isManualOrdering }"
+ v-bind="$options.vueDraggableAttributes"
+ @update="handleVueDraggableUpdate"
>
<issuable-item
v-for="issuable in issuables"
:key="issuableId(issuable)"
+ :class="{ 'gl-cursor-grab': isManualOrdering }"
:issuable-symbol="issuableSymbol"
:issuable="issuable"
:enable-label-permalinks="enableLabelPermalinks"
@@ -284,7 +310,7 @@ export default {
<slot name="statistics" :issuable="issuable"></slot>
</template>
</issuable-item>
- </ul>
+ </component>
<slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot>
<gl-pagination
v-if="showPaginationControls"
diff --git a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue
index 57da030e22e..6bc621b52e6 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue
@@ -26,6 +26,9 @@ export default {
isTabActive(tabName) {
return tabName === this.currentTab;
},
+ isTabCountNumeric(tab) {
+ return Number.isInteger(this.tabCounts[tab.name]);
+ },
},
};
</script>
@@ -44,9 +47,13 @@ export default {
>
<template #title>
<span :title="tab.titleTooltip">{{ tab.title }}</span>
- <gl-badge v-if="tabCounts" variant="neutral" size="sm" class="gl-tab-counter-badge">{{
- tabCounts[tab.name]
- }}</gl-badge>
+ <gl-badge
+ v-if="isTabCountNumeric(tab)"
+ variant="neutral"
+ size="sm"
+ class="gl-tab-counter-badge"
+ >{{ tabCounts[tab.name] }}</gl-badge
+ >
</template>
</gl-tab>
</gl-tabs>
diff --git a/app/assets/javascripts/issuable_show/components/issuable_body.vue b/app/assets/javascripts/issuable_show/components/issuable_body.vue
index fe102e942c9..05dc1650379 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_body.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_body.vue
@@ -42,6 +42,10 @@ export default {
type: Boolean,
required: true,
},
+ enableZenMode: {
+ type: Boolean,
+ required: true,
+ },
enableTaskList: {
type: Boolean,
required: false,
@@ -144,6 +148,7 @@ export default {
:issuable="issuable"
:enable-autocomplete="enableAutocomplete"
:enable-autosave="enableAutosave"
+ :enable-zen-mode="enableZenMode"
:show-field-title="showFieldTitle"
:description-preview-path="descriptionPreviewPath"
:description-help-path="descriptionHelpPath"
diff --git a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue b/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue
index 6d139541524..33dca3e9332 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue
@@ -4,6 +4,7 @@ import $ from 'jquery';
import Autosave from '~/autosave';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import ZenMode from '~/zen_mode';
import eventHub from '../event_hub';
@@ -27,6 +28,10 @@ export default {
type: Boolean,
required: true,
},
+ enableZenMode: {
+ type: Boolean,
+ required: true,
+ },
showFieldTitle: {
type: Boolean,
required: true,
@@ -62,6 +67,9 @@ export default {
},
mounted() {
if (this.enableAutosave) this.initAutosave();
+
+ // eslint-disable-next-line no-new
+ if (this.enableZenMode) new ZenMode();
},
beforeDestroy() {
eventHub.$off('update.issuable', this.resetAutosave);
diff --git a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
index b514a6b01d8..ca057094868 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
@@ -42,6 +42,11 @@ export default {
required: false,
default: true,
},
+ enableZenMode: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
enableTaskList: {
type: Boolean,
required: false,
@@ -120,6 +125,7 @@ export default {
:enable-edit="enableEdit"
:enable-autocomplete="enableAutocomplete"
:enable-autosave="enableAutosave"
+ :enable-zen-mode="enableZenMode"
:enable-task-list="enableTaskList"
:edit-form-visible="editFormVisible"
:show-field-title="showFieldTitle"
diff --git a/app/assets/javascripts/issuable_show/components/issuable_title.vue b/app/assets/javascripts/issuable_show/components/issuable_title.vue
index b7ea4a010a3..b96ce0c43f7 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_title.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_title.vue
@@ -6,8 +6,12 @@ import {
GlTooltipDirective,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
+import { __ } from '~/locale';
export default {
+ i18n: {
+ editTitleAndDescription: __('Edit title and description'),
+ },
components: {
GlIcon,
GlButton,
@@ -58,7 +62,8 @@ export default {
<gl-button
v-if="enableEdit"
v-gl-tooltip.bottom
- :title="__('Edit title and description')"
+ :title="$options.i18n.editTitleAndDescription"
+ :aria-label="$options.i18n.editTitleAndDescription"
icon="pencil"
class="btn-edit js-issuable-edit qa-edit-button"
@click="$emit('edit-issuable', $event)"
diff --git a/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql b/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql
deleted file mode 100644
index 42e646391a8..00000000000
--- a/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql
+++ /dev/null
@@ -1,16 +0,0 @@
-#import "~/graphql_shared/fragments/author.fragment.graphql"
-
-query getProjectIssue($iid: String!, $fullPath: ID!) {
- project(fullPath: $fullPath) {
- issue(iid: $iid) {
- id
- assignees {
- nodes {
- ...Author
- id
- state
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/issuable_type_selector/components/info_popover.vue b/app/assets/javascripts/issuable_type_selector/components/info_popover.vue
new file mode 100644
index 00000000000..3a20ccba814
--- /dev/null
+++ b/app/assets/javascripts/issuable_type_selector/components/info_popover.vue
@@ -0,0 +1,41 @@
+<script>
+import { GlIcon, GlPopover } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ issueTypes: __('Issue types'),
+ issue: __('Issue'),
+ incident: __('Incident'),
+ issueHelpText: __('For general work'),
+ incidentHelpText: __('For investigating IT service disruptions or outages'),
+ },
+ components: {
+ GlIcon,
+ GlPopover,
+ },
+};
+</script>
+
+<template>
+ <span id="popovercontainer">
+ <gl-icon id="issuable-type-info" name="question-o" class="gl-ml-5 gl-text-gray-500" />
+ <gl-popover
+ target="issuable-type-info"
+ container="popovercontainer"
+ :title="$options.i18n.issueTypes"
+ triggers="focus hover"
+ >
+ <ul class="gl-list-style-none gl-p-0 gl-m-0">
+ <li class="gl-mb-3">
+ <div class="gl-font-weight-bold">{{ $options.i18n.issue }}</div>
+ <span>{{ $options.i18n.issueHelpText }}</span>
+ </li>
+ <li>
+ <div class="gl-font-weight-bold">{{ $options.i18n.incident }}</div>
+ <span>{{ $options.i18n.incidentHelpText }}</span>
+ </li>
+ </ul>
+ </gl-popover>
+ </span>
+</template>
diff --git a/app/assets/javascripts/issuable_type_selector/index.js b/app/assets/javascripts/issuable_type_selector/index.js
new file mode 100644
index 00000000000..433a62d1ae8
--- /dev/null
+++ b/app/assets/javascripts/issuable_type_selector/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import InfoPopover from './components/info_popover.vue';
+
+export default function initIssuableTypeSelector() {
+ const el = document.getElementById('js-type-popover');
+
+ return new Vue({
+ el,
+ components: {
+ InfoPopover,
+ },
+ render(h) {
+ return h(InfoPopover);
+ },
+ });
+}
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 9b978483cc6..d153ff21a35 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -238,7 +238,7 @@ export default {
: '';
},
statusIcon() {
- return this.isClosed ? 'mobile-issue-close' : 'issue-open-m';
+ return this.isClosed ? 'issue-close' : 'issue-open-m';
},
statusText() {
return IssuableStatusText[this.issuableStatus];
diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue
index 20c759cfbbd..7733e366c4f 100644
--- a/app/assets/javascripts/issue_show/components/edit_actions.vue
+++ b/app/assets/javascripts/issue_show/components/edit_actions.vue
@@ -47,7 +47,7 @@ export default {
},
deleteIssuableButtonText() {
return sprintf(__('Delete %{issuableType}'), {
- issuableType: issuableTypes[this.issuableType],
+ issuableType: issuableTypes[this.issuableType].toLowerCase(),
});
},
},
@@ -79,23 +79,23 @@ export default {
:loading="formState.updateLoading"
:disabled="formState.updateLoading || !isSubmitEnabled"
category="primary"
- variant="success"
- class="float-left qa-save-button"
+ variant="confirm"
+ class="float-left qa-save-button gl-mr-3"
type="submit"
@click.prevent="updateIssuable"
>
{{ __('Save changes') }}
</gl-button>
- <gl-button class="float-right" @click="closeForm">
+ <gl-button @click="closeForm">
{{ __('Cancel') }}
</gl-button>
<gl-button
v-if="shouldShowDeleteButton"
:loading="deleteLoading"
:disabled="deleteLoading"
- category="primary"
+ category="secondary"
variant="danger"
- class="float-right gl-mr-3 qa-delete-button"
+ class="float-right qa-delete-button"
@click="deleteIssuable"
>
{{ deleteIssuableButtonText }}
diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue
index 2f2c4c6e341..2bddbe4faa0 100644
--- a/app/assets/javascripts/issue_show/components/header_actions.vue
+++ b/app/assets/javascripts/issue_show/components/header_actions.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui';
+import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import createFlash, { FLASH_TYPES } from '~/flash';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
@@ -17,7 +17,6 @@ export default {
GlButton,
GlDropdown,
GlDropdownItem,
- GlIcon,
GlLink,
GlModal,
},
@@ -26,7 +25,6 @@ export default {
},
actionPrimary: {
text: __('Yes, close issue'),
- attributes: [{ variant: 'warning' }],
},
i18n: {
promoteErrorMessage: __(
@@ -88,9 +86,6 @@ export default {
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),
@@ -192,9 +187,9 @@ export default {
</script>
<template>
- <div class="detail-page-header-actions">
+ <div class="detail-page-header-actions gl-display-flex">
<gl-dropdown
- class="gl-display-block gl-sm-display-none!"
+ class="gl-sm-display-none! w-100"
block
:text="dropdownText"
:loading="isToggleStateButtonLoading"
@@ -224,26 +219,22 @@ export default {
<gl-button
v-if="showToggleIssueStateButton"
class="gl-display-none gl-sm-display-inline-flex!"
- category="secondary"
:data-qa-selector="qaSelector"
:loading="isToggleStateButtonLoading"
- :variant="buttonVariant"
@click="toggleIssueState"
>
{{ buttonText }}
</gl-button>
<gl-dropdown
- class="gl-display-none gl-sm-display-inline-flex!"
- toggle-class="gl-border-0! gl-shadow-none!"
+ class="gl-display-none gl-sm-display-inline-flex! gl-ml-3"
+ icon="ellipsis_v"
+ category="tertiary"
+ :text="dropdownText"
+ :text-sr-only="true"
no-caret
right
>
- <template #button-content>
- <gl-icon name="ellipsis_v" />
- <span class="gl-sr-only">{{ dropdownText }}</span>
- </template>
-
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
{{ newIssueTypeText }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue
index 806d95ca748..5e92211685a 100644
--- a/app/assets/javascripts/issue_show/components/title.vue
+++ b/app/assets/javascripts/issue_show/components/title.vue
@@ -1,9 +1,13 @@
<script>
import { GlButton, GlTooltipDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { __ } from '~/locale';
import eventHub from '../event_hub';
import animateMixin from '../mixins/animate';
export default {
+ i18n: {
+ editTitleAndDescription: __('Edit title and description'),
+ },
components: {
GlButton,
},
@@ -78,7 +82,8 @@ export default {
v-gl-tooltip.bottom
icon="pencil"
class="btn-edit js-issuable-edit qa-edit-button"
- title="Edit title and description"
+ :title="$options.i18n.editTitleAndDescription"
+ :aria-label="$options.i18n.editTitleAndDescription"
@click="edit"
/>
</div>
diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue
index 0b413ce0b06..51cad662ebf 100644
--- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue
@@ -30,6 +30,9 @@ import issueableEventHub from '../eventhub';
import { emptyStateHelper } from '../service_desk_helper';
import Issuable from './issuable.vue';
+/**
+ * @deprecated Use app/assets/javascripts/issuable_list/components/issuable_list_root.vue instead
+ */
export default {
LOADING_LIST_ITEMS_LENGTH,
directives: {
diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue
index c57fa5a82fa..57c5107fcbb 100644
--- a/app/assets/javascripts/issues_list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue
@@ -1,19 +1,63 @@
<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { toNumber } from 'lodash';
import createFlash from '~/flash';
+import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
-import { IssuableStatus } from '~/issue_show/constants';
-import { PAGE_SIZE } from '~/issues_list/constants';
+import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
+import {
+ CREATED_DESC,
+ PAGE_SIZE,
+ RELATIVE_POSITION_ASC,
+ sortOptions,
+ sortParams,
+} from '~/issues_list/constants';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
+import eventHub from '../eventhub';
import IssueCardTimeInfo from './issue_card_time_info.vue';
export default {
+ CREATED_DESC,
+ IssuableListTabs,
PAGE_SIZE,
+ sortOptions,
+ sortParams,
+ i18n: {
+ calendarLabel: __('Subscribe to calendar'),
+ jiraIntegrationMessage: s__(
+ 'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.',
+ ),
+ jiraIntegrationSecondaryMessage: s__('JiraService|This feature requires a Premium plan.'),
+ jiraIntegrationTitle: s__('JiraService|Using Jira for issue tracking?'),
+ newIssueLabel: __('New issue'),
+ noClosedIssuesTitle: __('There are no closed issues'),
+ noOpenIssuesDescription: __('To keep this project going, create a new issue'),
+ noOpenIssuesTitle: __('There are no open issues'),
+ noIssuesSignedInDescription: __(
+ 'Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.',
+ ),
+ noIssuesSignedInTitle: __(
+ 'The Issue Tracker is the place to add things that need to be improved or solved in a project',
+ ),
+ noIssuesSignedOutButtonText: __('Register / Sign In'),
+ noIssuesSignedOutDescription: __(
+ 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.',
+ ),
+ noIssuesSignedOutTitle: __('There are no issues to show'),
+ noSearchResultsDescription: __('To widen your search, change or remove filters above'),
+ noSearchResultsTitle: __('Sorry, your filter produced no results'),
+ reorderError: __('An error occurred while reordering issues.'),
+ rssLabel: __('Subscribe to RSS feed'),
+ },
components: {
+ CsvImportExportButtons,
+ GlButton,
+ GlEmptyState,
GlIcon,
+ GlLink,
+ GlSprintf,
IssuableList,
IssueCardTimeInfo,
BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'),
@@ -22,49 +66,147 @@ export default {
GlTooltip: GlTooltipDirective,
},
inject: {
+ calendarPath: {
+ default: '',
+ },
+ canBulkUpdate: {
+ default: false,
+ },
+ emptyStateSvgPath: {
+ default: '',
+ },
endpoint: {
default: '',
},
+ exportCsvPath: {
+ default: '',
+ },
fullPath: {
default: '',
},
+ hasIssues: {
+ default: false,
+ },
+ isSignedIn: {
+ default: false,
+ },
+ issuesPath: {
+ default: '',
+ },
+ jiraIntegrationPath: {
+ default: '',
+ },
+ newIssuePath: {
+ default: '',
+ },
+ rssPath: {
+ default: '',
+ },
+ showNewIssueLink: {
+ default: false,
+ },
+ signInPath: {
+ default: '',
+ },
},
data() {
+ const orderBy = getParameterByName('order_by');
+ const sort = getParameterByName('sort');
+ const sortKey = Object.keys(sortParams).find(
+ (key) => sortParams[key].order_by === orderBy && sortParams[key].sort === sort,
+ );
+
+ const search = getParameterByName('search') || '';
+ const tokens = search.split(' ').map((searchWord) => ({
+ type: 'filtered-search-term',
+ value: {
+ data: searchWord,
+ },
+ }));
+
return {
- currentPage: toNumber(getParameterByName('page')) || 1,
+ exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
+ filters: sortParams[sortKey] || {},
+ filterTokens: tokens,
isLoading: false,
issues: [],
+ page: toNumber(getParameterByName('page')) || 1,
+ showBulkEditSidebar: false,
+ sortKey: sortKey || CREATED_DESC,
+ state: getParameterByName('state') || IssuableStates.Opened,
totalIssues: 0,
};
},
computed: {
+ isManualOrdering() {
+ return this.sortKey === RELATIVE_POSITION_ASC;
+ },
+ isOpenTab() {
+ return this.state === IssuableStates.Opened;
+ },
+ searchQuery() {
+ return (
+ this.filterTokens
+ .map((searchTerm) => searchTerm.value.data)
+ .filter((searchWord) => Boolean(searchWord))
+ .join(' ') || undefined
+ );
+ },
+ showPaginationControls() {
+ return this.issues.length > 0;
+ },
+ tabCounts() {
+ return Object.values(IssuableStates).reduce(
+ (acc, state) => ({
+ ...acc,
+ [state]: this.state === state ? this.totalIssues : undefined,
+ }),
+ {},
+ );
+ },
urlParams() {
return {
- page: this.currentPage,
- state: IssuableStatus.Open,
+ page: this.page,
+ search: this.searchQuery,
+ state: this.state,
+ ...this.filters,
};
},
},
mounted() {
+ eventHub.$on('issuables:toggleBulkEdit', (showBulkEditSidebar) => {
+ this.showBulkEditSidebar = showBulkEditSidebar;
+ });
this.fetchIssues();
},
+ beforeDestroy() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
+ eventHub.$off('issuables:toggleBulkEdit');
+ },
methods: {
- fetchIssues(pageToFetch) {
+ fetchIssues() {
+ if (!this.hasIssues) {
+ return undefined;
+ }
+
this.isLoading = true;
return axios
.get(this.endpoint, {
params: {
- page: pageToFetch || this.currentPage,
+ page: this.page,
per_page: this.$options.PAGE_SIZE,
- state: IssuableStatus.Open,
+ search: this.searchQuery,
+ state: this.state,
with_labels_details: true,
+ ...this.filters,
},
})
.then(({ data, headers }) => {
- this.currentPage = Number(headers['x-page']);
+ this.page = Number(headers['x-page']);
this.totalIssues = Number(headers['x-total']);
this.issues = data.map((issue) => convertObjectPropsToCamelCase(issue, { deep: true }));
+ this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
})
.catch(() => {
createFlash({ message: __('An error occurred while loading issues') });
@@ -73,8 +215,71 @@ export default {
this.isLoading = false;
});
},
+ getExportCsvPathWithQuery() {
+ return `${this.exportCsvPath}${window.location.search}`;
+ },
+ handleUpdateLegacyBulkEdit() {
+ // If "select all" checkbox was checked, wait for all checkboxes
+ // to be checked before updating IssuableBulkUpdateSidebar class
+ this.$nextTick(() => {
+ eventHub.$emit('issuables:updateBulkEdit');
+ });
+ },
+ handleBulkUpdateClick() {
+ eventHub.$emit('issuables:enableBulkEdit');
+ },
+ handleClickTab(state) {
+ if (this.state !== state) {
+ this.page = 1;
+ }
+ this.state = state;
+ this.fetchIssues();
+ },
+ handleFilter(filter) {
+ this.filterTokens = filter;
+ this.fetchIssues();
+ },
handlePageChange(page) {
- this.fetchIssues(page);
+ this.page = page;
+ this.fetchIssues();
+ },
+ handleReorder({ newIndex, oldIndex }) {
+ const issueToMove = this.issues[oldIndex];
+ const isDragDropDownwards = newIndex > oldIndex;
+ const isMovingToBeginning = newIndex === 0;
+ const isMovingToEnd = newIndex === this.issues.length - 1;
+
+ let moveBeforeId;
+ let moveAfterId;
+
+ if (isDragDropDownwards) {
+ const afterIndex = isMovingToEnd ? newIndex : newIndex + 1;
+ moveBeforeId = this.issues[newIndex].id;
+ moveAfterId = this.issues[afterIndex].id;
+ } else {
+ const beforeIndex = isMovingToBeginning ? newIndex : newIndex - 1;
+ moveBeforeId = this.issues[beforeIndex].id;
+ moveAfterId = this.issues[newIndex].id;
+ }
+
+ return axios
+ .put(`${this.issuesPath}/${issueToMove.iid}/reorder`, {
+ move_before_id: isMovingToBeginning ? null : moveBeforeId,
+ move_after_id: isMovingToEnd ? null : moveAfterId,
+ })
+ .then(() => {
+ // Move issue to new position in list
+ this.issues.splice(oldIndex, 1);
+ this.issues.splice(newIndex, 0, issueToMove);
+ })
+ .catch(() => {
+ createFlash({ message: this.$options.i18n.reorderError });
+ });
+ },
+ handleSort(value) {
+ this.sortKey = value;
+ this.filters = sortParams[value];
+ this.fetchIssues();
},
},
};
@@ -82,26 +287,70 @@ export default {
<template>
<issuable-list
+ v-if="hasIssues"
:namespace="fullPath"
recent-searches-storage-key="issues"
:search-input-placeholder="__('Search or filter results…')"
:search-tokens="[]"
- :sort-options="[]"
+ :initial-filter-value="filterTokens"
+ :sort-options="$options.sortOptions"
+ :initial-sort-by="sortKey"
:issuables="issues"
- :tabs="[]"
- current-tab=""
+ :tabs="$options.IssuableListTabs"
+ :current-tab="state"
+ :tab-counts="tabCounts"
:issuables-loading="isLoading"
- :show-pagination-controls="true"
+ :is-manual-ordering="isManualOrdering"
+ :show-bulk-edit-sidebar="showBulkEditSidebar"
+ :show-pagination-controls="showPaginationControls"
:total-items="totalIssues"
- :current-page="currentPage"
- :previous-page="currentPage - 1"
- :next-page="currentPage + 1"
+ :current-page="page"
+ :previous-page="page - 1"
+ :next-page="page + 1"
:url-params="urlParams"
+ @click-tab="handleClickTab"
+ @filter="handleFilter"
@page-change="handlePageChange"
+ @reorder="handleReorder"
+ @sort="handleSort"
+ @update-legacy-bulk-edit="handleUpdateLegacyBulkEdit"
>
+ <template #nav-actions>
+ <gl-button
+ v-gl-tooltip
+ :href="rssPath"
+ icon="rss"
+ :title="$options.i18n.rssLabel"
+ :aria-label="$options.i18n.rssLabel"
+ />
+ <gl-button
+ v-gl-tooltip
+ :href="calendarPath"
+ icon="calendar"
+ :title="$options.i18n.calendarLabel"
+ :aria-label="$options.i18n.calendarLabel"
+ />
+ <csv-import-export-buttons
+ class="gl-mr-3"
+ :export-csv-path="exportCsvPathWithQuery"
+ :issuable-count="totalIssues"
+ />
+ <gl-button
+ v-if="canBulkUpdate"
+ :disabled="showBulkEditSidebar"
+ @click="handleBulkUpdateClick"
+ >
+ {{ __('Edit issues') }}
+ </gl-button>
+ <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
+ {{ $options.i18n.newIssueLabel }}
+ </gl-button>
+ </template>
+
<template #timeframe="{ issuable = {} }">
<issue-card-time-info :issue="issuable" />
</template>
+
<template #statistics="{ issuable = {} }">
<li
v-if="issuable.mergeRequestsCount"
@@ -139,5 +388,81 @@ export default {
:is-list-item="true"
/>
</template>
+
+ <template #empty-state>
+ <gl-empty-state
+ v-if="searchQuery"
+ :description="$options.i18n.noSearchResultsDescription"
+ :title="$options.i18n.noSearchResultsTitle"
+ :svg-path="emptyStateSvgPath"
+ >
+ <template #actions>
+ <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
+ {{ $options.i18n.newIssueLabel }}
+ </gl-button>
+ </template>
+ </gl-empty-state>
+
+ <gl-empty-state
+ v-else-if="isOpenTab"
+ :description="$options.i18n.noOpenIssuesDescription"
+ :title="$options.i18n.noOpenIssuesTitle"
+ :svg-path="emptyStateSvgPath"
+ >
+ <template #actions>
+ <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
+ {{ $options.i18n.newIssueLabel }}
+ </gl-button>
+ </template>
+ </gl-empty-state>
+
+ <gl-empty-state
+ v-else
+ :title="$options.i18n.noClosedIssuesTitle"
+ :svg-path="emptyStateSvgPath"
+ />
+ </template>
</issuable-list>
+
+ <div v-else-if="isSignedIn">
+ <gl-empty-state
+ :description="$options.i18n.noIssuesSignedInDescription"
+ :title="$options.i18n.noIssuesSignedInTitle"
+ :svg-path="emptyStateSvgPath"
+ >
+ <template #actions>
+ <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
+ {{ $options.i18n.newIssueLabel }}
+ </gl-button>
+ <csv-import-export-buttons
+ class="gl-mr-3"
+ :export-csv-path="exportCsvPathWithQuery"
+ :issuable-count="totalIssues"
+ />
+ </template>
+ </gl-empty-state>
+ <hr />
+ <p class="gl-text-center gl-font-weight-bold gl-mb-0">
+ {{ $options.i18n.jiraIntegrationTitle }}
+ </p>
+ <p class="gl-text-center gl-mb-0">
+ <gl-sprintf :message="$options.i18n.jiraIntegrationMessage">
+ <template #jiraDocsLink="{ content }">
+ <gl-link :href="jiraIntegrationPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p class="gl-text-center gl-text-gray-500">
+ {{ $options.i18n.jiraIntegrationSecondaryMessage }}
+ </p>
+ </div>
+
+ <gl-empty-state
+ v-else
+ :description="$options.i18n.noIssuesSignedOutDescription"
+ :title="$options.i18n.noIssuesSignedOutTitle"
+ :svg-path="emptyStateSvgPath"
+ :primary-button-text="$options.i18n.noIssuesSignedOutButtonText"
+ :primary-button-link="signInPath"
+ />
</template>
diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js
index f008ba1bf4a..f6f23af80ba 100644
--- a/app/assets/javascripts/issues_list/constants.js
+++ b/app/assets/javascripts/issues_list/constants.js
@@ -54,3 +54,191 @@ export const availableSortOptionsJira = [
];
export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map';
+
+export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC';
+export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC';
+export const CREATED_ASC = 'CREATED_ASC';
+export const CREATED_DESC = 'CREATED_DESC';
+export const DUE_DATE_ASC = 'DUE_DATE_ASC';
+export const DUE_DATE_DESC = 'DUE_DATE_DESC';
+export const LABEL_PRIORITY_ASC = 'LABEL_PRIORITY_ASC';
+export const LABEL_PRIORITY_DESC = 'LABEL_PRIORITY_DESC';
+export const MILESTONE_DUE_ASC = 'MILESTONE_DUE_ASC';
+export const MILESTONE_DUE_DESC = 'MILESTONE_DUE_DESC';
+export const POPULARITY_ASC = 'POPULARITY_ASC';
+export const POPULARITY_DESC = 'POPULARITY_DESC';
+export const PRIORITY_ASC = 'PRIORITY_ASC';
+export const PRIORITY_DESC = 'PRIORITY_DESC';
+export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC';
+export const UPDATED_ASC = 'UPDATED_ASC';
+export const UPDATED_DESC = 'UPDATED_DESC';
+export const WEIGHT_ASC = 'WEIGHT_ASC';
+export const WEIGHT_DESC = 'WEIGHT_DESC';
+
+const SORT_ASC = 'asc';
+const SORT_DESC = 'desc';
+
+const BLOCKING_ISSUES = 'blocking_issues';
+
+export const sortParams = {
+ [PRIORITY_ASC]: {
+ order_by: PRIORITY,
+ sort: SORT_ASC,
+ },
+ [PRIORITY_DESC]: {
+ order_by: PRIORITY,
+ sort: SORT_DESC,
+ },
+ [CREATED_ASC]: {
+ order_by: CREATED_AT,
+ sort: SORT_ASC,
+ },
+ [CREATED_DESC]: {
+ order_by: CREATED_AT,
+ sort: SORT_DESC,
+ },
+ [UPDATED_ASC]: {
+ order_by: UPDATED_AT,
+ sort: SORT_ASC,
+ },
+ [UPDATED_DESC]: {
+ order_by: UPDATED_AT,
+ sort: SORT_DESC,
+ },
+ [MILESTONE_DUE_ASC]: {
+ order_by: MILESTONE_DUE,
+ sort: SORT_ASC,
+ },
+ [MILESTONE_DUE_DESC]: {
+ order_by: MILESTONE_DUE,
+ sort: SORT_DESC,
+ },
+ [DUE_DATE_ASC]: {
+ order_by: DUE_DATE,
+ sort: SORT_ASC,
+ },
+ [DUE_DATE_DESC]: {
+ order_by: DUE_DATE,
+ sort: SORT_DESC,
+ },
+ [POPULARITY_ASC]: {
+ order_by: POPULARITY,
+ sort: SORT_ASC,
+ },
+ [POPULARITY_DESC]: {
+ order_by: POPULARITY,
+ sort: SORT_DESC,
+ },
+ [LABEL_PRIORITY_ASC]: {
+ order_by: LABEL_PRIORITY,
+ sort: SORT_ASC,
+ },
+ [LABEL_PRIORITY_DESC]: {
+ order_by: LABEL_PRIORITY,
+ sort: SORT_DESC,
+ },
+ [RELATIVE_POSITION_ASC]: {
+ order_by: RELATIVE_POSITION,
+ per_page: 100,
+ sort: SORT_ASC,
+ },
+ [WEIGHT_ASC]: {
+ order_by: WEIGHT,
+ sort: SORT_ASC,
+ },
+ [WEIGHT_DESC]: {
+ order_by: WEIGHT,
+ sort: SORT_DESC,
+ },
+ [BLOCKING_ISSUES_ASC]: {
+ order_by: BLOCKING_ISSUES,
+ sort: SORT_ASC,
+ },
+ [BLOCKING_ISSUES_DESC]: {
+ order_by: BLOCKING_ISSUES,
+ sort: SORT_DESC,
+ },
+};
+
+export const sortOptions = [
+ {
+ id: 1,
+ title: __('Priority'),
+ sortDirection: {
+ ascending: PRIORITY_ASC,
+ descending: PRIORITY_DESC,
+ },
+ },
+ {
+ id: 2,
+ title: __('Created date'),
+ sortDirection: {
+ ascending: CREATED_ASC,
+ descending: CREATED_DESC,
+ },
+ },
+ {
+ id: 3,
+ title: __('Last updated'),
+ sortDirection: {
+ ascending: UPDATED_ASC,
+ descending: UPDATED_DESC,
+ },
+ },
+ {
+ id: 4,
+ title: __('Milestone due date'),
+ sortDirection: {
+ ascending: MILESTONE_DUE_ASC,
+ descending: MILESTONE_DUE_DESC,
+ },
+ },
+ {
+ id: 5,
+ title: __('Due date'),
+ sortDirection: {
+ ascending: DUE_DATE_ASC,
+ descending: DUE_DATE_DESC,
+ },
+ },
+ {
+ id: 6,
+ title: __('Popularity'),
+ sortDirection: {
+ ascending: POPULARITY_ASC,
+ descending: POPULARITY_DESC,
+ },
+ },
+ {
+ id: 7,
+ title: __('Label priority'),
+ sortDirection: {
+ ascending: LABEL_PRIORITY_ASC,
+ descending: LABEL_PRIORITY_DESC,
+ },
+ },
+ {
+ id: 8,
+ title: __('Manual'),
+ sortDirection: {
+ ascending: RELATIVE_POSITION_ASC,
+ descending: RELATIVE_POSITION_ASC,
+ },
+ },
+ {
+ id: 9,
+ title: __('Weight'),
+ sortDirection: {
+ ascending: WEIGHT_ASC,
+ descending: WEIGHT_DESC,
+ },
+ },
+ {
+ id: 10,
+ title: __('Blocking'),
+ sortDirection: {
+ ascending: BLOCKING_ISSUES_ASC,
+ descending: BLOCKING_ISSUES_DESC,
+ },
+ },
+];
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index a283cbdc86b..0b64df50691 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -73,11 +73,29 @@ export function initIssuesListApp() {
}
const {
+ calendarPath,
+ canBulkUpdate,
+ canEdit,
+ canImportIssues,
+ email,
+ emptyStateSvgPath,
endpoint,
+ exportCsvPath,
fullPath,
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
+ hasIssues,
hasIssueWeightsFeature,
+ importCsvIssuesPath,
+ isSignedIn,
+ issuesPath,
+ jiraIntegrationPath,
+ maxAttachmentSize,
+ newIssuePath,
+ projectImportJiraPath,
+ rssPath,
+ showNewIssueLink,
+ signInPath,
} = el.dataset;
return new Vue({
@@ -86,11 +104,32 @@ export function initIssuesListApp() {
// issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153
apolloProvider: {},
provide: {
+ calendarPath,
+ canBulkUpdate: parseBoolean(canBulkUpdate),
+ emptyStateSvgPath,
endpoint,
fullPath,
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
+ hasIssues: parseBoolean(hasIssues),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
+ isSignedIn: parseBoolean(isSignedIn),
+ issuesPath,
+ jiraIntegrationPath,
+ newIssuePath,
+ rssPath,
+ showNewIssueLink: parseBoolean(showNewIssueLink),
+ signInPath,
+ // For CsvImportExportButtons component
+ canEdit: parseBoolean(canEdit),
+ email,
+ exportCsvPath,
+ importCsvIssuesPath,
+ maxAttachmentSize,
+ projectImportJiraPath,
+ showExportButton: parseBoolean(hasIssues),
+ showImportButton: parseBoolean(canImportIssues),
+ showLabel: !parseBoolean(hasIssues),
},
render: (createComponent) => createComponent(IssuesListApp),
});
diff --git a/app/assets/javascripts/jira_connect/api.js b/app/assets/javascripts/jira_connect/api.js
index d78aba0a3f7..abf2c070e68 100644
--- a/app/assets/javascripts/jira_connect/api.js
+++ b/app/assets/javascripts/jira_connect/api.js
@@ -1,24 +1,5 @@
import axios from 'axios';
-
-export const getJwt = () => {
- return new Promise((resolve) => {
- AP.context.getToken((token) => {
- resolve(token);
- });
- });
-};
-
-export const getLocation = () => {
- return new Promise((resolve) => {
- if (typeof AP.getLocation !== 'function') {
- resolve();
- }
-
- AP.getLocation((location) => {
- resolve(location);
- });
- });
-};
+import { getJwt } from '~/jira_connect/utils';
export const addSubscription = async (addPath, namespace) => {
const jwt = await getJwt();
@@ -39,11 +20,12 @@ export const removeSubscription = async (removePath) => {
});
};
-export const fetchGroups = async (groupsPath, { page, perPage }) => {
+export const fetchGroups = async (groupsPath, { page, perPage, search }) => {
return axios.get(groupsPath, {
params: {
page,
per_page: perPage,
+ search,
},
});
};
diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue
index fe5ad8b67d7..ff4dfb23687 100644
--- a/app/assets/javascripts/jira_connect/components/app.vue
+++ b/app/assets/javascripts/jira_connect/components/app.vue
@@ -1,27 +1,26 @@
<script>
-import { GlAlert, GlButton, GlModal, GlModalDirective, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlButton, GlLink, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { mapState, mapMutations } from 'vuex';
-import { getLocation } from '~/jira_connect/api';
+import { retrieveAlert, getLocation } from '~/jira_connect/utils';
import { __ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { SET_ALERT } from '../store/mutation_types';
-import { retrieveAlert } from '../utils';
import GroupsList from './groups_list.vue';
+import SubscriptionsList from './subscriptions_list.vue';
export default {
name: 'JiraConnectApp',
components: {
GlAlert,
GlButton,
- GlModal,
- GroupsList,
GlLink,
+ GlModal,
GlSprintf,
+ GroupsList,
+ SubscriptionsList,
},
directives: {
GlModalDirective,
},
- mixins: [glFeatureFlagsMixin()],
inject: {
usersPath: {
default: '',
@@ -91,37 +90,36 @@ export default {
<h2 class="gl-text-center">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
- <div
- class="jira-connect-app-body gl-display-flex gl-justify-content-space-between gl-my-7 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200"
- >
- <h5 class="gl-align-self-center gl-mb-0" data-testid="new-jira-connect-ui-heading">
- {{ s__('Integrations|Linked namespaces') }}
- </h5>
- <gl-button
- v-if="usersPath"
- category="primary"
- variant="info"
- class="gl-align-self-center"
- :href="usersPathWithReturnTo"
- target="_blank"
- >{{ s__('Integrations|Sign in to add namespaces') }}</gl-button
- >
- <template v-else>
+ <div class="jira-connect-app-body gl-my-7 gl-px-5 gl-pb-4">
+ <div class="gl-display-flex gl-justify-content-end">
<gl-button
- v-gl-modal-directive="'add-namespace-modal'"
+ v-if="usersPath"
category="primary"
variant="info"
class="gl-align-self-center"
- >{{ s__('Integrations|Add namespace') }}</gl-button
- >
- <gl-modal
- modal-id="add-namespace-modal"
- :title="s__('Integrations|Link namespaces')"
- :action-cancel="$options.modal.cancelProps"
+ :href="usersPathWithReturnTo"
+ target="_blank"
+ >{{ s__('Integrations|Sign in to add namespaces') }}</gl-button
>
- <groups-list />
- </gl-modal>
- </template>
+ <template v-else>
+ <gl-button
+ v-gl-modal-directive="'add-namespace-modal'"
+ category="primary"
+ variant="info"
+ class="gl-align-self-center"
+ >{{ s__('Integrations|Add namespace') }}</gl-button
+ >
+ <gl-modal
+ modal-id="add-namespace-modal"
+ :title="s__('Integrations|Link namespaces')"
+ :action-cancel="$options.modal.cancelProps"
+ >
+ <groups-list />
+ </gl-modal>
+ </template>
+ </div>
+
+ <subscriptions-list />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/components/group_item_name.vue b/app/assets/javascripts/jira_connect/components/group_item_name.vue
new file mode 100644
index 00000000000..e6c172dae9e
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/components/group_item_name.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlAvatar, GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlAvatar,
+ GlIcon,
+ },
+ props: {
+ group: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-icon name="folder-o" class="gl-mr-3" />
+ <div class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3">
+ <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatar_url" />
+ </div>
+
+ <div>
+ <span class="gl-mr-3 gl-text-gray-900! gl-font-weight-bold">
+ {{ group.full_name }}
+ </span>
+ <div v-if="group.description">
+ <p class="gl-mt-2! gl-mb-0 gl-text-gray-600" v-text="group.description"></p>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jira_connect/components/groups_list.vue b/app/assets/javascripts/jira_connect/components/groups_list.vue
index 69f2903388c..275ff820419 100644
--- a/app/assets/javascripts/jira_connect/components/groups_list.vue
+++ b/app/assets/javascripts/jira_connect/components/groups_list.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTabs, GlTab, GlLoadingIcon, GlPagination, GlAlert } from '@gitlab/ui';
+import { GlLoadingIcon, GlPagination, GlAlert, GlSearchBoxByType } from '@gitlab/ui';
import { fetchGroups } from '~/jira_connect/api';
import { defaultPerPage } from '~/jira_connect/constants';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
@@ -8,11 +8,10 @@ import GroupsListItem from './groups_list_item.vue';
export default {
components: {
- GlTabs,
- GlTab,
GlLoadingIcon,
GlPagination,
GlAlert,
+ GlSearchBoxByType,
GroupsListItem,
},
inject: {
@@ -23,7 +22,8 @@ export default {
data() {
return {
groups: [],
- isLoading: false,
+ isLoadingInitial: true,
+ isLoadingMore: false,
page: 1,
perPage: defaultPerPage,
totalItems: 0,
@@ -31,15 +31,18 @@ export default {
};
},
mounted() {
- this.loadGroups();
+ return this.loadGroups().finally(() => {
+ this.isLoadingInitial = false;
+ });
},
methods: {
- loadGroups() {
- this.isLoading = true;
+ loadGroups({ searchTerm } = {}) {
+ this.isLoadingMore = true;
- fetchGroups(this.groupsPath, {
+ return fetchGroups(this.groupsPath, {
page: this.page,
perPage: this.perPage,
+ search: searchTerm,
})
.then((response) => {
const { page, total } = parseIntPagination(normalizeHeaders(response.headers));
@@ -51,50 +54,61 @@ export default {
this.errorMessage = s__('Integrations|Failed to load namespaces. Please try again.');
})
.finally(() => {
- this.isLoading = false;
+ this.isLoadingMore = false;
});
},
+ onGroupSearch(searchTerm) {
+ return this.loadGroups({ searchTerm });
+ },
},
};
</script>
<template>
<div>
- <gl-alert v-if="errorMessage" class="gl-mb-6" variant="danger" @dismiss="errorMessage = null">
+ <gl-alert v-if="errorMessage" class="gl-mb-5" variant="danger" @dismiss="errorMessage = null">
{{ errorMessage }}
</gl-alert>
- <gl-tabs>
- <gl-tab :title="__('Groups and subgroups')" class="gl-pt-3">
- <gl-loading-icon v-if="isLoading" size="md" />
- <div v-else-if="groups.length === 0" class="gl-text-center">
- <h5>{{ s__('Integrations|No available namespaces.') }}</h5>
- <p class="gl-mt-5">
- {{
- s__('Integrations|You must have owner or maintainer permissions to link namespaces.')
- }}
- </p>
- </div>
- <ul v-else class="gl-list-style-none gl-pl-0">
- <groups-list-item
- v-for="group in groups"
- :key="group.id"
- :group="group"
- @error="errorMessage = $event"
- />
- </ul>
+ <gl-search-box-by-type
+ class="gl-mb-5"
+ debounce="500"
+ :placeholder="__('Search by name')"
+ :is-loading="isLoadingMore"
+ @input="onGroupSearch"
+ />
+
+ <gl-loading-icon v-if="isLoadingInitial" size="md" />
+ <div v-else-if="groups.length === 0" class="gl-text-center">
+ <h5>{{ s__('Integrations|No available namespaces.') }}</h5>
+ <p class="gl-mt-5">
+ {{ s__('Integrations|You must have owner or maintainer permissions to link namespaces.') }}
+ </p>
+ </div>
+ <ul
+ v-else
+ class="gl-list-style-none gl-pl-0 gl-border-t-1 gl-border-t-solid gl-border-t-gray-100"
+ :class="{ 'gl-opacity-5': isLoadingMore }"
+ data-testid="groups-list"
+ >
+ <groups-list-item
+ v-for="group in groups"
+ :key="group.id"
+ :group="group"
+ :disabled="isLoadingMore"
+ @error="errorMessage = $event"
+ />
+ </ul>
- <div class="gl-display-flex gl-justify-content-center gl-mt-5">
- <gl-pagination
- v-if="totalItems > perPage && groups.length > 0"
- v-model="page"
- class="gl-mb-0"
- :per-page="perPage"
- :total-items="totalItems"
- @input="loadGroups"
- />
- </div>
- </gl-tab>
- </gl-tabs>
+ <div class="gl-display-flex gl-justify-content-center gl-mt-5">
+ <gl-pagination
+ v-if="totalItems > perPage && groups.length > 0"
+ v-model="page"
+ class="gl-mb-0"
+ :per-page="perPage"
+ :total-items="totalItems"
+ @input="loadGroups"
+ />
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/components/groups_list_item.vue b/app/assets/javascripts/jira_connect/components/groups_list_item.vue
index b8959a2a505..ad046920dd1 100644
--- a/app/assets/javascripts/jira_connect/components/groups_list_item.vue
+++ b/app/assets/javascripts/jira_connect/components/groups_list_item.vue
@@ -1,15 +1,15 @@
<script>
-import { GlAvatar, GlButton, GlIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { addSubscription } from '~/jira_connect/api';
+import { persistAlert, reloadPage } from '~/jira_connect/utils';
import { s__ } from '~/locale';
-import { persistAlert } from '../utils';
+import GroupItemName from './group_item_name.vue';
export default {
components: {
- GlAvatar,
GlButton,
- GlIcon,
+ GroupItemName,
},
inject: {
subscriptionsPath: {
@@ -21,6 +21,11 @@ export default {
type: Object,
required: true,
},
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -42,7 +47,7 @@ export default {
variant: 'success',
});
- AP.navigator.reload();
+ reloadPage();
})
.catch((error) => {
this.$emit(
@@ -50,8 +55,6 @@ export default {
error?.response?.data?.error ||
s__('Integrations|Failed to link namespace. Please try again.'),
);
- })
- .finally(() => {
this.isLoading = false;
});
},
@@ -60,34 +63,22 @@ export default {
</script>
<template>
- <li class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-200">
+ <li class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100">
<div class="gl-display-flex gl-align-items-center gl-py-3">
- <gl-icon name="folder-o" class="gl-mr-3" />
- <div class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3">
- <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatar_url" />
- </div>
<div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center">
<div class="gl-min-w-0 gl-flex-grow-1 flex-shrink-1">
- <div class="gl-display-flex gl-align-items-center gl-flex-wrap">
- <span
- class="gl-mr-3 gl-text-gray-900! gl-font-weight-bold"
- data-testid="group-list-item-name"
- >
- {{ group.full_name }}
- </span>
- </div>
- <div v-if="group.description" data-testid="group-list-item-description">
- <p class="gl-mt-2! gl-mb-0 gl-text-gray-600" v-text="group.description"></p>
- </div>
+ <group-item-name :group="group" />
</div>
<gl-button
category="secondary"
- variant="success"
+ variant="confirm"
:loading="isLoading"
+ :disabled="disabled"
@click.prevent="onClick"
- >{{ __('Link') }}</gl-button
>
+ {{ __('Link') }}
+ </gl-button>
</div>
</div>
</li>
diff --git a/app/assets/javascripts/jira_connect/components/subscriptions_list.vue b/app/assets/javascripts/jira_connect/components/subscriptions_list.vue
new file mode 100644
index 00000000000..a606e2edbbb
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/components/subscriptions_list.vue
@@ -0,0 +1,109 @@
+<script>
+import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui';
+import { isEmpty } from 'lodash';
+import { mapMutations } from 'vuex';
+import { removeSubscription } from '~/jira_connect/api';
+import { reloadPage } from '~/jira_connect/utils';
+import { __, s__ } from '~/locale';
+import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { SET_ALERT } from '../store/mutation_types';
+import GroupItemName from './group_item_name.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlEmptyState,
+ GlTable,
+ GroupItemName,
+ TimeagoTooltip,
+ },
+ inject: {
+ subscriptions: {
+ default: [],
+ },
+ },
+ data() {
+ return {
+ loadingItem: null,
+ };
+ },
+ fields: [
+ {
+ key: 'name',
+ label: s__('Integrations|Linked namespaces'),
+ },
+ {
+ key: 'created_at',
+ label: __('Added'),
+ tdClass: 'gl-vertical-align-middle! gl-w-20p',
+ },
+ {
+ key: 'actions',
+ label: '',
+ tdClass: 'gl-text-right gl-vertical-align-middle! gl-pl-0!',
+ },
+ ],
+ i18n: {
+ emptyTitle: s__('Integrations|No linked namespaces'),
+ emptyDescription: s__(
+ 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.',
+ ),
+ unlinkError: s__('Integrations|Failed to unlink namespace. Please try again.'),
+ },
+ methods: {
+ ...mapMutations({
+ setAlert: SET_ALERT,
+ }),
+ isEmpty,
+ isLoadingItem(item) {
+ return this.loadingItem === item;
+ },
+ unlinkBtnClass(item) {
+ return this.isLoadingItem(item) ? '' : 'gl-ml-6';
+ },
+ onClick(item) {
+ this.loadingItem = item;
+
+ removeSubscription(item.unlink_path)
+ .then(() => {
+ reloadPage();
+ })
+ .catch((error) => {
+ this.setAlert({
+ message: error?.response?.data?.error || this.$options.i18n.unlinkError,
+ variant: 'danger',
+ });
+ this.loadingItem = null;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-empty-state
+ v-if="isEmpty(subscriptions)"
+ :title="$options.i18n.emptyTitle"
+ :description="$options.i18n.emptyDescription"
+ />
+ <gl-table v-else :items="subscriptions" :fields="$options.fields">
+ <template #cell(name)="{ item }">
+ <group-item-name :group="item.group" />
+ </template>
+ <template #cell(created_at)="{ item }">
+ <timeago-tooltip :time="item.created_at" />
+ </template>
+ <template #cell(actions)="{ item }">
+ <gl-button
+ :class="unlinkBtnClass(item)"
+ category="secondary"
+ :loading="isLoadingItem(item)"
+ :disabled="!isEmpty(loadingItem)"
+ @click.prevent="onClick(item)"
+ >{{ __('Unlink') }}</gl-button
+ >
+ </template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js
index ecdb41607a4..dc8bb3b0c77 100644
--- a/app/assets/javascripts/jira_connect/index.js
+++ b/app/assets/javascripts/jira_connect/index.js
@@ -1,25 +1,14 @@
import setConfigs from '@gitlab/ui/dist/config';
import Vue from 'vue';
-import { addSubscription, removeSubscription, getLocation } from '~/jira_connect/api';
+import { getLocation, sizeToParent } from '~/jira_connect/utils';
import GlFeatureFlagsPlugin from '~/vue_shared/gl_feature_flags_plugin';
import Translate from '~/vue_shared/translate';
import JiraConnectApp from './components/app.vue';
import createStore from './store';
-import { SET_ALERT } from './store/mutation_types';
const store = createStore();
-const reqComplete = () => {
- AP.navigator.reload();
-};
-
-const reqFailed = (res, fallbackErrorMessage) => {
- const { error = fallbackErrorMessage } = res || {};
-
- store.commit(SET_ALERT, { message: error, variant: 'danger' });
-};
-
const updateSignInLinks = async () => {
const location = await getLocation();
Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).forEach((el) => {
@@ -28,43 +17,7 @@ const updateSignInLinks = async () => {
});
};
-const initRemoveSubscriptionButtonHandlers = () => {
- Array.from(document.querySelectorAll('.js-jira-connect-remove-subscription')).forEach((el) => {
- el.addEventListener('click', function onRemoveSubscriptionClick(e) {
- e.preventDefault();
-
- const removePath = e.target.getAttribute('href');
- removeSubscription(removePath)
- .then(reqComplete)
- .catch((err) =>
- reqFailed(err.response.data, 'Failed to remove namespace. Please try again.'),
- );
- });
- });
-};
-
-const initAddSubscriptionFormHandler = () => {
- const formEl = document.querySelector('#add-subscription-form');
- if (!formEl) {
- return;
- }
-
- formEl.addEventListener('submit', function onAddSubscriptionForm(e) {
- e.preventDefault();
-
- const addPath = e.target.getAttribute('action');
- const namespace = (e.target.querySelector('#namespace-input') || {}).value;
-
- addSubscription(addPath, namespace)
- .then(reqComplete)
- .catch((err) => reqFailed(err.response.data, 'Failed to add namespace. Please try again.'));
- });
-};
-
export async function initJiraConnect() {
- initAddSubscriptionFormHandler();
- initRemoveSubscriptionButtonHandlers();
-
await updateSignInLinks();
const el = document.querySelector('.js-jira-connect-app');
@@ -76,14 +29,15 @@ export async function initJiraConnect() {
Vue.use(Translate);
Vue.use(GlFeatureFlagsPlugin);
- const { groupsPath, subscriptionsPath, usersPath } = el.dataset;
- AP.sizeToParent();
+ const { groupsPath, subscriptions, subscriptionsPath, usersPath } = el.dataset;
+ sizeToParent();
return new Vue({
el,
store,
provide: {
groupsPath,
+ subscriptions: JSON.parse(subscriptions),
subscriptionsPath,
usersPath,
},
diff --git a/app/assets/javascripts/jira_connect/utils.js b/app/assets/javascripts/jira_connect/utils.js
index 2a6c53ba42c..ecd1a31339a 100644
--- a/app/assets/javascripts/jira_connect/utils.js
+++ b/app/assets/javascripts/jira_connect/utils.js
@@ -1,6 +1,8 @@
import AccessorUtilities from '~/lib/utils/accessor';
import { ALERT_LOCALSTORAGE_KEY } from './constants';
+const isFunction = (fn) => typeof fn === 'function';
+
/**
* Persist alert data to localStorage.
*/
@@ -31,3 +33,41 @@ export const retrieveAlert = () => {
return JSON.parse(initialAlertJSON);
};
+
+export const getJwt = () => {
+ return new Promise((resolve) => {
+ if (isFunction(AP?.context?.getToken)) {
+ AP.context.getToken((token) => {
+ resolve(token);
+ });
+ } else {
+ resolve();
+ }
+ });
+};
+
+export const getLocation = () => {
+ return new Promise((resolve) => {
+ if (isFunction(AP?.getLocation)) {
+ AP.getLocation((location) => {
+ resolve(location);
+ });
+ } else {
+ resolve();
+ }
+ });
+};
+
+export const reloadPage = () => {
+ if (isFunction(AP?.navigator?.reload)) {
+ AP.navigator.reload();
+ } else {
+ window.location.reload();
+ }
+};
+
+export const sizeToParent = () => {
+ if (isFunction(AP?.sizeToParent)) {
+ AP.sizeToParent();
+ }
+};
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue
index eae6b5d5419..7f25ca8a94d 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/commit_block.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
import { GlLink } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -23,9 +22,9 @@ export default {
</script>
<template>
<div>
- <span class="font-weight-bold">{{ __('Commit') }}</span>
+ <span class="gl-font-weight-bold">{{ __('Commit') }}</span>
- <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit">
+ <gl-link :href="commit.commit_path" class="gl-text-blue-600!" data-testid="commit-sha">
{{ commit.short_id }}
</gl-link>
@@ -37,8 +36,8 @@ export default {
/>
<span v-if="mergeRequest">
- in
- <gl-link :href="mergeRequest.path" class="js-link-commit link-commit"
+ {{ __('in') }}
+ <gl-link :href="mergeRequest.path" class="gl-text-blue-600!" data-testid="link-commit"
>!{{ mergeRequest.iid }}</gl-link
>
</span>
diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue
index 488d838db52..00a570fe2f8 100644
--- a/app/assets/javascripts/jobs/components/job_container_item.vue
+++ b/app/assets/javascripts/jobs/components/job_container_item.vue
@@ -48,7 +48,7 @@ export default {
}"
>
<gl-link
- v-gl-tooltip
+ v-gl-tooltip:tooltip-container.left
:href="job.status.details_path"
:title="tooltipText"
class="js-job-link gl-display-flex gl-align-items-center"
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index ce4a85b35b7..ea50a11bed6 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -1,9 +1,15 @@
<script>
import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui';
import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { __, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
export default {
+ i18n: {
+ eraseLogButtonLabel: s__('Job|Erase job log'),
+ scrollToBottomButtonLabel: s__('Job|Scroll to bottom'),
+ scrollToTopButtonLabel: s__('Job|Scroll to top'),
+ showRawButtonLabel: s__('Job|Show complete raw'),
+ },
components: {
GlLink,
GlButton,
@@ -82,7 +88,8 @@ export default {
<gl-button
v-if="rawPath"
v-gl-tooltip.body
- :title="s__('Job|Show complete raw')"
+ :title="$options.i18n.showRawButtonLabel"
+ :aria-label="$options.i18n.showRawButtonLabel"
:href="rawPath"
data-testid="job-raw-link-controller"
icon="doc-text"
@@ -91,7 +98,8 @@ export default {
<gl-button
v-if="erasePath"
v-gl-tooltip.body
- :title="s__('Job|Erase job log')"
+ :title="$options.i18n.eraseLogButtonLabel"
+ :aria-label="$options.i18n.eraseLogButtonLabel"
:href="erasePath"
:data-confirm="__('Are you sure you want to erase this build?')"
class="gl-ml-3"
@@ -102,23 +110,25 @@ export default {
<!-- eo links -->
<!-- scroll buttons -->
- <div v-gl-tooltip :title="s__('Job|Scroll to top')" class="gl-ml-3">
+ <div v-gl-tooltip :title="$options.i18n.scrollToTopButtonLabel" class="gl-ml-3">
<gl-button
:disabled="isScrollTopDisabled"
class="btn-scroll"
data-testid="job-controller-scroll-top"
icon="scroll_up"
+ :aria-label="$options.i18n.scrollToTopButtonLabel"
@click="handleScrollToTop"
/>
</div>
- <div v-gl-tooltip :title="s__('Job|Scroll to bottom')" class="gl-ml-3">
+ <div v-gl-tooltip :title="$options.i18n.scrollToBottomButtonLabel" class="gl-ml-3">
<gl-button
:disabled="isScrollBottomDisabled"
class="js-scroll-bottom btn-scroll"
data-testid="job-controller-scroll-bottom"
icon="scroll_down"
:class="{ animate: isScrollingDown }"
+ :aria-label="$options.i18n.scrollToBottomButtonLabel"
@click="handleScrollToBottom"
/>
</div>
diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue
index a1f4f7abb77..d45012d2023 100644
--- a/app/assets/javascripts/jobs/components/manual_variables_form.vue
+++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue
@@ -43,6 +43,7 @@ export default {
variables: [],
key: '',
secretValue: '',
+ triggerBtnDisabled: false,
};
},
computed: {
@@ -98,6 +99,11 @@ export default {
1,
);
},
+ trigger() {
+ this.triggerBtnDisabled = true;
+
+ this.triggerManualJob(this.variables);
+ },
},
};
</script>
@@ -111,7 +117,12 @@ export default {
<div class="table-section section-50" role="rowheader">{{ s__('CiVariables|Value') }}</div>
</div>
- <div v-for="variable in variables" :key="variable.id" class="gl-responsive-table-row">
+ <div
+ v-for="variable in variables"
+ :key="variable.id"
+ class="gl-responsive-table-row"
+ data-testid="ci-variable-row"
+ >
<div class="table-section section-50">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div>
<div class="table-mobile-content gl-mr-3">
@@ -120,6 +131,7 @@ export default {
v-model="variable.key"
:placeholder="$options.i18n.keyPlaceholder"
class="ci-variable-body-item form-control"
+ data-testid="ci-variable-key"
/>
</div>
</div>
@@ -132,6 +144,7 @@ export default {
v-model="variable.secret_value"
:placeholder="$options.i18n.valuePlaceholder"
class="ci-variable-body-item form-control"
+ data-testid="ci-variable-value"
/>
</div>
</div>
@@ -143,6 +156,7 @@ export default {
category="tertiary"
icon="clear"
:aria-label="__('Delete variable')"
+ data-testid="delete-variable-btn"
@click="deleteVariable(variable.id)"
/>
</div>
@@ -175,14 +189,16 @@ export default {
</div>
</div>
<div class="d-flex gl-mt-3 justify-content-center">
- <p class="text-muted" v-html="helpText"></p>
+ <p class="text-muted" data-testid="form-help-text" v-html="helpText"></p>
</div>
<div class="d-flex justify-content-center">
<gl-button
variant="info"
category="primary"
:aria-label="__('Trigger manual job')"
- @click="triggerManualJob(variables)"
+ :disabled="triggerBtnDisabled"
+ data-testid="trigger-manual-job-btn"
+ @click="trigger"
>
{{ action.button_title }}
</gl-button>
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index fcf03dff34e..1b50006239c 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -49,7 +49,8 @@ export default {
return this.job.status && this.job.recoverable ? 'primary' : 'secondary';
},
hasArtifact() {
- return !isEmpty(this.job.artifact);
+ // the artifact object will always have a locked property
+ return Object.keys(this.job.artifact).length > 1;
},
hasTriggers() {
return !isEmpty(this.job.trigger);
diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
index b20d58b6ffe..98badb96ed7 100644
--- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
@@ -51,7 +51,9 @@ export default {
});
},
runnerId() {
- return `${this.job.runner.description} (#${this.job.runner.id})`;
+ const { id, short_sha: token, description } = this.job?.runner;
+
+ return `#${id} (${token}) ${description}`;
},
shouldRenderBlock() {
return Boolean(this.hasAnyDetail || this.hasTimeout || this.hasTags);
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue
index 18de849af88..36b0ad43b14 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue
@@ -44,13 +44,14 @@ export default {
</script>
<template>
<div class="dropdown">
- <div class="js-pipeline-info">
+ <div class="js-pipeline-info" data-testid="pipeline-info">
<ci-icon :status="pipeline.details.status" class="vertical-align-middle" />
<span class="font-weight-bold">{{ s__('Job|Pipeline') }}</span>
<gl-link
:href="pipeline.path"
class="js-pipeline-path link-commit"
+ data-testid="pipeline-path"
data-qa-selector="pipeline_path"
>#{{ pipeline.id }}</gl-link
>
@@ -58,13 +59,17 @@ export default {
{{ s__('Job|for') }}
<template v-if="isTriggeredByMergeRequest">
- <gl-link :href="pipeline.merge_request.path" class="link-commit ref-name js-mr-link"
+ <gl-link
+ :href="pipeline.merge_request.path"
+ class="link-commit ref-name"
+ data-testid="mr-link"
>!{{ pipeline.merge_request.iid }}</gl-link
>
{{ s__('Job|with') }}
<gl-link
:href="pipeline.merge_request.source_branch_path"
- class="link-commit ref-name js-source-branch-link"
+ class="link-commit ref-name"
+ data-testid="source-branch-link"
>{{ pipeline.merge_request.source_branch }}</gl-link
>
@@ -72,7 +77,8 @@ export default {
{{ s__('Job|into') }}
<gl-link
:href="pipeline.merge_request.target_branch_path"
- class="link-commit ref-name js-target-branch-link"
+ class="link-commit ref-name"
+ data-testid="target-branch-link"
>{{ pipeline.merge_request.target_branch }}</gl-link
>
</template>
diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
new file mode 100644
index 00000000000..d9e51b0345a
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
@@ -0,0 +1,52 @@
+query getJobs($fullPath: ID!, $statuses: [CiJobStatus!]) {
+ project(fullPath: $fullPath) {
+ jobs(first: 20, statuses: $statuses) {
+ pageInfo {
+ endCursor
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ }
+ nodes {
+ detailedStatus {
+ icon
+ label
+ text
+ tooltip
+ action {
+ buttonTitle
+ icon
+ method
+ path
+ title
+ }
+ }
+ id
+ refName
+ refPath
+ tags
+ shortSha
+ commitPath
+ pipeline {
+ id
+ path
+ user {
+ webPath
+ avatarUrl
+ }
+ }
+ stage {
+ name
+ }
+ name
+ duration
+ finishedAt
+ coverage
+ retryable
+ playable
+ cancelable
+ active
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/jobs/components/table/index.js b/app/assets/javascripts/jobs/components/table/index.js
new file mode 100644
index 00000000000..b6b3bb6d379
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/index.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue';
+import createDefaultClient from '~/lib/graphql';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export default (containerId = 'js-jobs-table') => {
+ const containerEl = document.getElementById(containerId);
+
+ if (!containerEl) {
+ return false;
+ }
+
+ const { fullPath, jobCounts, jobStatuses } = containerEl.dataset;
+
+ return new Vue({
+ el: containerEl,
+ apolloProvider,
+ provide: {
+ fullPath,
+ jobStatuses: JSON.parse(jobStatuses),
+ jobCounts: JSON.parse(jobCounts),
+ },
+ render(createElement) {
+ return createElement(JobsTableApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue
new file mode 100644
index 00000000000..32b26d45dfe
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlTable } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+const defaultTableClasses = {
+ tdClass: 'gl-p-5!',
+ thClass: 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!',
+};
+
+export default {
+ fields: [
+ {
+ key: 'status',
+ label: __('Status'),
+ ...defaultTableClasses,
+ },
+ {
+ key: 'job',
+ label: __('Job'),
+ ...defaultTableClasses,
+ },
+ {
+ key: 'pipeline',
+ label: __('Pipeline'),
+ ...defaultTableClasses,
+ },
+ {
+ key: 'stage',
+ label: __('Stage'),
+ ...defaultTableClasses,
+ },
+ {
+ key: 'name',
+ label: __('Name'),
+ ...defaultTableClasses,
+ },
+ {
+ key: 'duration',
+ label: __('Duration'),
+ ...defaultTableClasses,
+ },
+ {
+ key: 'coverage',
+ label: __('Coverage'),
+ ...defaultTableClasses,
+ },
+ {
+ key: 'actions',
+ label: '',
+ ...defaultTableClasses,
+ },
+ ],
+ components: {
+ GlTable,
+ },
+ props: {
+ jobs: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-table :items="jobs" :fields="$options.fields" />
+</template>
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
new file mode 100644
index 00000000000..55954e31654
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -0,0 +1,85 @@
+<script>
+import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
+import { __ } from '~/locale';
+import GetJobs from './graphql/queries/get_jobs.query.graphql';
+import JobsTable from './jobs_table.vue';
+import JobsTableTabs from './jobs_table_tabs.vue';
+
+export default {
+ i18n: {
+ errorMsg: __('There was an error fetching the jobs for your project.'),
+ },
+ components: {
+ GlAlert,
+ GlSkeletonLoader,
+ JobsTable,
+ JobsTableTabs,
+ },
+ inject: {
+ fullPath: {
+ default: '',
+ },
+ },
+ apollo: {
+ jobs: {
+ query: GetJobs,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update({ project }) {
+ return project?.jobs;
+ },
+ error() {
+ this.hasError = true;
+ },
+ },
+ },
+ data() {
+ return {
+ jobs: null,
+ hasError: false,
+ isAlertDismissed: false,
+ };
+ },
+ computed: {
+ shouldShowAlert() {
+ return this.hasError && !this.isAlertDismissed;
+ },
+ },
+ methods: {
+ fetchJobsByStatus(scope) {
+ this.$apollo.queries.jobs.refetch({ statuses: scope });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert
+ v-if="shouldShowAlert"
+ class="gl-mt-2"
+ variant="danger"
+ dismissible
+ @dismiss="isAlertDismissed = true"
+ >
+ {{ $options.i18n.errorMsg }}
+ </gl-alert>
+
+ <jobs-table-tabs @fetchJobsByStatus="fetchJobsByStatus" />
+
+ <div v-if="$apollo.loading" class="gl-mt-5">
+ <gl-skeleton-loader
+ preserve-aspect-ratio="none"
+ equal-width-lines
+ :lines="5"
+ :width="600"
+ :height="66"
+ />
+ </div>
+
+ <jobs-table v-else :jobs="jobs.nodes" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
new file mode 100644
index 00000000000..95d265fce60
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlBadge, GlTab, GlTabs } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlBadge,
+ GlTab,
+ GlTabs,
+ },
+ inject: {
+ jobCounts: {
+ default: {},
+ },
+ jobStatuses: {
+ default: {},
+ },
+ },
+ computed: {
+ tabs() {
+ return [
+ {
+ text: __('All'),
+ count: this.jobCounts.all,
+ scope: null,
+ testId: 'jobs-all-tab',
+ },
+ {
+ text: __('Pending'),
+ count: this.jobCounts.pending,
+ scope: this.jobStatuses.pending,
+ testId: 'jobs-pending-tab',
+ },
+ {
+ text: __('Running'),
+ count: this.jobCounts.running,
+ scope: this.jobStatuses.running,
+ testId: 'jobs-running-tab',
+ },
+ {
+ text: __('Finished'),
+ count: this.jobCounts.finished,
+ scope: [this.jobStatuses.success, this.jobStatuses.failed, this.jobStatuses.canceled],
+ testId: 'jobs-finished-tab',
+ },
+ ];
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-tabs>
+ <gl-tab
+ v-for="tab in tabs"
+ :key="tab.text"
+ :title-link-attributes="{ 'data-testid': tab.testId }"
+ @click="$emit('fetchJobsByStatus', tab.scope)"
+ >
+ <template #title>
+ <span>{{ tab.text }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+</template>
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 56e69ab9418..fb88e48c9a6 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, dot-notation, no-empty */
+/* eslint-disable func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, no-empty */
/* global Issuable */
/* global ListLabel */
@@ -7,7 +7,6 @@ import { difference, isEqual, escape, sortBy, template, union } from 'lodash';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { isScopedLabel } from '~/lib/utils/common_utils';
import boardsStore from './boards/stores/boards_store';
-import ModalStore from './boards/stores/modal_store';
import CreateLabelDropdown from './create_label';
import { deprecatedCreateFlash as flash } from './flash';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
@@ -313,7 +312,11 @@ export default class LabelsSelect {
return;
}
- if ($('html').hasClass('issue-boards-page')) {
+ if (
+ $('html')
+ .attr('class')
+ .match(/issue-boards-page|epic-boards-page/)
+ ) {
return;
}
if ($dropdown.hasClass('js-multiselect')) {
@@ -357,21 +360,7 @@ export default class LabelsSelect {
return;
}
- let boardsModel;
- if ($dropdown.closest('.add-issues-modal').length) {
- boardsModel = ModalStore.store.filter;
- }
-
- if (boardsModel) {
- if (label.isAny) {
- boardsModel['label_name'] = [];
- } else if ($el.hasClass('is-active')) {
- boardsModel['label_name'].push(label.title);
- }
-
- e.preventDefault();
- return;
- } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
if (!$dropdown.hasClass('js-multiselect')) {
selectedLabel = label.title;
return Issuable.filterResults($dropdown.closest('form'));
@@ -522,11 +511,15 @@ export default class LabelsSelect {
}
bindEvents() {
- return $('body').on('change', '.selected-issuable', this.onSelectCheckboxIssue);
+ return $('body').on(
+ 'change',
+ '.issuable-list input[type="checkbox"]',
+ this.onSelectCheckboxIssue,
+ );
}
// eslint-disable-next-line class-methods-use-this
onSelectCheckboxIssue() {
- if ($('.selected-issuable:checked').length) {
+ if ($('.issuable-list input[type="checkbox"]:checked').length) {
return;
}
return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text(__('Label'));
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index e090f9f6e8c..c720476f3bf 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -4,6 +4,7 @@ import { ApolloLink } from 'apollo-link';
import { BatchHttpLink } from 'apollo-link-batch-http';
import { createHttpLink } from 'apollo-link-http';
import { createUploadLink } from 'apollo-upload-client';
+import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
import csrf from '~/lib/utils/csrf';
import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
@@ -78,6 +79,7 @@ export default (resolvers = {}, config = {}) => {
requestCounterLink,
performanceBarLink,
new StartupJSLink(),
+ apolloCaptchaLink,
uploadsLink,
]),
cache: new InMemoryCache({
diff --git a/app/assets/javascripts/lib/utils/color_utils.js b/app/assets/javascripts/lib/utils/color_utils.js
index ff176f11867..da2c10076b1 100644
--- a/app/assets/javascripts/lib/utils/color_utils.js
+++ b/app/assets/javascripts/lib/utils/color_utils.js
@@ -43,3 +43,15 @@ export const validateHexColor = (color = '') => {
return /^#([0-9A-F]{3}){1,2}$/i.test(color);
};
+
+export function darkModeEnabled() {
+ const ideDarkThemes = ['dark', 'solarized-dark', 'monokai'];
+
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ const isWebIde = document.body.dataset.page.startsWith('ide:');
+
+ if (isWebIde) {
+ return ideDarkThemes.includes(window.gon?.user_color_scheme);
+ }
+ return document.body.classList.contains('gl-dark');
+}
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 73eadfe3cbe..fb257228597 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -681,6 +681,19 @@ export const roundOffFloat = (number, precision = 0) => {
};
/**
+ * Method to round values to the nearest half (0.5)
+ *
+ * Eg; roundToNearestHalf(3.141592) = 3, roundToNearestHalf(3.41592) = 3.5
+ *
+ * Refer to spec/javascripts/lib/utils/common_utils_spec.js for
+ * more supported examples.
+ *
+ * @param {Float} number
+ * @returns {Float|Number}
+ */
+export const roundToNearestHalf = (num) => Math.round(num * 2).toFixed() / 2;
+
+/**
* Method to round down values with decimal places
* with provided precision.
*
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 145b419f8f0..a509828815a 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -4,8 +4,6 @@ import { isString, mapValues, isNumber, reduce } from 'lodash';
import * as timeago from 'timeago.js';
import { languageCode, s__, __, n__ } from '../../locale';
-const MILLISECONDS_IN_HOUR = 60 * 60 * 1000;
-const MILLISECONDS_IN_DAY = 24 * MILLISECONDS_IN_HOUR;
const DAYS_IN_WEEK = 7;
window.timeago = timeago;
@@ -256,6 +254,37 @@ export const timeIntervalInWords = (intervalInSeconds) => {
: secondsText;
};
+/**
+ * Similar to `timeIntervalInWords`, but rounds the return value
+ * to 1/10th of the largest time unit. For example:
+ *
+ * 30 => 30 seconds
+ * 90 => 1.5 minutes
+ * 7200 => 2 hours
+ * 86400 => 1 day
+ * ... etc.
+ *
+ * The largest supported unit is "days".
+ *
+ * @param {Number} intervalInSeconds The time interval in seconds
+ * @returns {String} A humanized description of the time interval
+ */
+export const humanizeTimeInterval = (intervalInSeconds) => {
+ if (intervalInSeconds < 60 /* = 1 minute */) {
+ const seconds = Math.round(intervalInSeconds * 10) / 10;
+ return n__('%d second', '%d seconds', seconds);
+ } else if (intervalInSeconds < 3600 /* = 1 hour */) {
+ const minutes = Math.round(intervalInSeconds / 6) / 10;
+ return n__('%d minute', '%d minutes', minutes);
+ } else if (intervalInSeconds < 86400 /* = 1 day */) {
+ const hours = Math.round(intervalInSeconds / 360) / 10;
+ return n__('%d hour', '%d hours', hours);
+ }
+
+ const days = Math.round(intervalInSeconds / 8640) / 10;
+ return n__('%d day', '%d days', days);
+};
+
export const dateInWords = (date, abbreviated = false, hideYear = false) => {
if (!date) return date;
@@ -947,49 +976,6 @@ export const format24HourTimeStringFromInt = (time) => {
};
/**
- * A utility function which checks if two date ranges overlap.
- *
- * @param {Object} givenPeriodLeft - the first period to compare.
- * @param {Object} givenPeriodRight - the second period to compare.
- * @returns {Object} { daysOverlap: number of days the overlap is present, hoursOverlap: number of hours the overlap is present, overlapStartDate: the start date of the overlap in time format, overlapEndDate: the end date of the overlap in time format }
- * @throws {Error} Uncaught Error: Invalid period
- *
- * @example
- * getOverlapDateInPeriods(
- * { start: new Date(2021, 0, 11), end: new Date(2021, 0, 13) },
- * { start: new Date(2021, 0, 11), end: new Date(2021, 0, 14) }
- * ) => { daysOverlap: 2, hoursOverlap: 48, overlapStartDate: 1610323200000, overlapEndDate: 1610496000000 }
- *
- */
-export const getOverlapDateInPeriods = (givenPeriodLeft = {}, givenPeriodRight = {}) => {
- const leftStartTime = new Date(givenPeriodLeft.start).getTime();
- const leftEndTime = new Date(givenPeriodLeft.end).getTime();
- const rightStartTime = new Date(givenPeriodRight.start).getTime();
- const rightEndTime = new Date(givenPeriodRight.end).getTime();
-
- if (!(leftStartTime <= leftEndTime && rightStartTime <= rightEndTime)) {
- throw new Error(__('Invalid period'));
- }
-
- const isOverlapping = leftStartTime < rightEndTime && rightStartTime < leftEndTime;
-
- if (!isOverlapping) {
- return { daysOverlap: 0 };
- }
-
- const overlapStartDate = Math.max(leftStartTime, rightStartTime);
- const overlapEndDate = rightEndTime > leftEndTime ? leftEndTime : rightEndTime;
- const differenceInMs = overlapEndDate - overlapStartDate;
-
- return {
- hoursOverlap: Math.ceil(differenceInMs / MILLISECONDS_IN_HOUR),
- daysOverlap: Math.ceil(differenceInMs / MILLISECONDS_IN_DAY),
- overlapStartDate,
- overlapEndDate,
- };
-};
-
-/**
* A utility function that checks that the date is today
*
* @param {Date} date
diff --git a/app/assets/javascripts/lib/utils/forms.js b/app/assets/javascripts/lib/utils/forms.js
index 52e1323412d..b58aef15dda 100644
--- a/app/assets/javascripts/lib/utils/forms.js
+++ b/app/assets/javascripts/lib/utils/forms.js
@@ -1,3 +1,5 @@
+import { convertToCamelCase } from '~/lib/utils/text_utility';
+
export const serializeFormEntries = (entries) =>
entries.reduce((acc, { name, value }) => Object.assign(acc, { [name]: value }), {});
@@ -51,3 +53,95 @@ export const serializeFormObject = (form) =>
return acc;
}, []),
);
+
+/**
+ * Parse inputs of HTML forms generated by Rails.
+ *
+ * This can be helpful when mounting Vue components within Rails forms.
+ *
+ * If called with an HTML element like:
+ *
+ * ```html
+ * <input type="text" placeholder="Email" value="foo@bar.com" name="user[contact_info][email]" id="user_contact_info_email" data-js-name="contactInfoEmail">
+ * <input type="text" placeholder="Phone" value="(123) 456-7890" name="user[contact_info][phone]" id="user_contact_info_phone" data-js-name="contactInfoPhone">
+ * <input type="checkbox" name="user[interests][]" id="user_interests_vue" value="Vue" checked data-js-name="interests">
+ * <input type="checkbox" name="user[interests][]" id="user_interests_graphql" value="GraphQL" data-js-name="interests">
+ * ```
+ *
+ * It will return an object like:
+ *
+ * ```javascript
+ * {
+ * contactInfoEmail: {
+ * name: 'user[contact_info][email]',
+ * id: 'user_contact_info_email',
+ * value: 'foo@bar.com',
+ * placeholder: 'Email',
+ * },
+ * contactInfoPhone: {
+ * name: 'user[contact_info][phone]',
+ * id: 'user_contact_info_phone',
+ * value: '(123) 456-7890',
+ * placeholder: 'Phone',
+ * },
+ * interests: [
+ * {
+ * name: 'user[interests][]',
+ * id: 'user_interests_vue',
+ * value: 'Vue',
+ * checked: true,
+ * },
+ * {
+ * name: 'user[interests][]',
+ * id: 'user_interests_graphql',
+ * value: 'GraphQL',
+ * checked: false,
+ * },
+ * ],
+ * }
+ * ```
+ *
+ * @param {HTMLInputElement} mountEl
+ * @returns {Object} object with form fields data.
+ */
+export const parseRailsFormFields = (mountEl) => {
+ if (!mountEl) {
+ throw new TypeError('`mountEl` argument is required');
+ }
+
+ const inputs = mountEl.querySelectorAll('[name]');
+
+ return [...inputs].reduce((accumulator, input) => {
+ const fieldName = input.dataset.jsName;
+
+ if (!fieldName) {
+ return accumulator;
+ }
+
+ const fieldNameCamelCase = convertToCamelCase(fieldName);
+ const { id, placeholder, name, value, type, checked } = input;
+ const attributes = {
+ name,
+ id,
+ value,
+ ...(placeholder && { placeholder }),
+ };
+
+ // Store radio buttons and checkboxes as an array so they can be
+ // looped through and rendered in Vue
+ if (['radio', 'checkbox'].includes(type)) {
+ return {
+ ...accumulator,
+ [fieldNameCamelCase]: [
+ ...(accumulator[fieldNameCamelCase] || []),
+ { ...attributes, checked },
+ ],
+ };
+ }
+
+ return {
+ ...accumulator,
+ [fieldNameCamelCase]: attributes,
+ };
+ }, {});
+};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 345dfaf895b..1593a363dd1 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -232,7 +232,7 @@ export function insertMarkdownText({
.join('\n');
}
} else if (tag.indexOf(textPlaceholder) > -1) {
- textToInsert = tag.replace(textPlaceholder, selected);
+ textToInsert = tag.replace(textPlaceholder, selected.replace(/\\n/g, '\n'));
} else {
textToInsert = String(startChar) + tag + selected + (wrap ? tag : '');
}
@@ -322,7 +322,7 @@ export function updateTextForToolbarBtn($toolbarBtn) {
blockTag: $toolbarBtn.data('mdBlock'),
wrap: !$toolbarBtn.data('mdPrepend'),
select: $toolbarBtn.data('mdSelect'),
- tagContent: $toolbarBtn.data('mdTagContent'),
+ tagContent: $toolbarBtn.attr('data-md-tag-content'),
});
}
diff --git a/app/assets/javascripts/lib/utils/webpack.js b/app/assets/javascripts/lib/utils/webpack.js
index 07a4d2deb0b..a88f1bd82fc 100644
--- a/app/assets/javascripts/lib/utils/webpack.js
+++ b/app/assets/javascripts/lib/utils/webpack.js
@@ -11,10 +11,4 @@ export function resetServiceWorkersPublicPath() {
const relativeRootPath = (gon && gon.relative_url_root) || '';
const webpackAssetPath = joinPaths(relativeRootPath, '/assets/webpack/');
__webpack_public_path__ = webpackAssetPath; // eslint-disable-line babel/camelcase
-
- // monaco-editor-webpack-plugin currently (incorrectly) references the
- // public path as a property of `window`. Once this is fixed upstream we
- // can remove this line
- // see: https://github.com/Microsoft/monaco-editor-webpack-plugin/pull/63
- window.__webpack_public_path__ = webpackAssetPath; // eslint-disable-line
}
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 417baddc031..3f22bd36a4a 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -16,21 +16,16 @@ import { initRails } from '~/lib/utils/rails_ujs';
import * as popovers from '~/popovers';
import * as tooltips from '~/tooltips';
import initAlertHandler from './alert_handler';
-import { deprecatedCreateFlash as Flash, removeFlashClickListener } from './flash';
+import { removeFlashClickListener } from './flash';
import initTodoToggle from './header';
import initLayoutNav from './layout_nav';
-import {
- handleLocationHash,
- addSelectOnFocusBehaviour,
- getCspNonceValue,
-} from './lib/utils/common_utils';
+import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils';
import { localTimeAgo } from './lib/utils/datetime_utility';
import { getLocationHash, visitUrl } from './lib/utils/url_utility';
// everything else
import initFeatureHighlight from './feature_highlight';
import LazyLoader from './lazy_loader';
-import { __ } from './locale';
import initLogoAnimation from './logo';
import initFrequentItemDropdowns from './frequent_items';
import initBreadcrumbs from './breadcrumb';
@@ -49,29 +44,8 @@ applyGitLabUIConfig();
window.jQuery = jQuery;
window.$ = jQuery;
-// Add nonce to jQuery script handler
-jQuery.ajaxSetup({
- converters: {
- // eslint-disable-next-line @gitlab/require-i18n-strings, func-names
- 'text script': function (text) {
- jQuery.globalEval(text, { nonce: getCspNonceValue() });
- return text;
- },
- },
-});
-
-function disableJQueryAnimations() {
- $.fx.off = true;
-}
-
-// Disable jQuery animations
-if (gon?.disable_animations) {
- disableJQueryAnimations();
-}
-
// inject test utilities if necessary
if (process.env.NODE_ENV !== 'production' && gon?.test_env) {
- disableJQueryAnimations();
import(/* webpackMode: "eager" */ './test_utils/');
}
@@ -135,20 +109,6 @@ function deferredInitialisation() {
addSelectOnFocusBehaviour('.js-select-on-focus');
- $('.remove-row').on('ajax:success', function removeRowAjaxSuccessCallback() {
- tooltips.dispose(this);
-
- $(this).closest('li').addClass('gl-display-none!');
- });
-
- $('.js-remove-tr').on('ajax:before', function removeTRAjaxBeforeCallback() {
- $(this).hide();
- });
-
- $('.js-remove-tr').on('ajax:success', function removeTRAjaxSuccessCallback() {
- $(this).closest('tr').addClass('gl-display-none!');
- });
-
const glTooltipDelay = localStorage.getItem('gl-tooltip-delay');
const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0;
@@ -239,17 +199,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
- // eslint-disable-next-line no-jquery/no-ajax-events
- $(document).ajaxError((e, xhrObj) => {
- const ref = xhrObj.status;
-
- if (ref === 401) {
- Flash(__('You need to be logged in.'));
- } else if (ref === 404 || ref === 500) {
- Flash(__('Something went wrong on our end.'));
- }
- });
-
$('.navbar-toggler').on('click', () => {
$('.header-content').toggleClass('menu-expanded');
});
diff --git a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
index 83f266779f2..00973100e15 100644
--- a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
@@ -12,6 +12,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: ['namespace'],
props: {
memberId: {
type: Number,
@@ -19,7 +20,11 @@ export default {
},
},
computed: {
- ...mapState(['memberPath']),
+ ...mapState({
+ memberPath(state) {
+ return state[this.namespace].memberPath;
+ },
+ }),
approvePath() {
return this.memberPath.replace(/:id$/, `${this.memberId}/approve_access_request`);
},
diff --git a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
index 0bcc85157f1..91062c222f4 100644
--- a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
@@ -42,6 +42,7 @@ export default {
:member-id="member.id"
:message="message"
:title="s__('Member|Revoke invite')"
+ is-invite
/>
</div>
</action-button-group>
diff --git a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
index 3b87c29c1bc..fef7940eaa2 100644
--- a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
@@ -12,6 +12,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: ['namespace'],
props: {
groupLink: {
type: Object,
@@ -19,7 +20,11 @@ export default {
},
},
methods: {
- ...mapActions(['showRemoveGroupLinkModal']),
+ ...mapActions({
+ showRemoveGroupLinkModal(dispatch, payload) {
+ return dispatch(`${this.namespace}/showRemoveGroupLinkModal`, payload);
+ },
+ }),
},
};
</script>
diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
index cb71be39ebc..a477aedd233 100644
--- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
@@ -8,11 +8,17 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: ['namespace'],
props: {
memberId: {
type: Number,
required: true,
},
+ memberType: {
+ type: String,
+ required: false,
+ default: null,
+ },
message: {
type: String,
required: true,
@@ -31,12 +37,29 @@ export default {
required: false,
default: false,
},
+ isInvite: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ oncallSchedules: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
},
computed: {
- ...mapState(['memberPath']),
+ ...mapState({
+ memberPath(state) {
+ return state[this.namespace].memberPath;
+ },
+ }),
computedMemberPath() {
return this.memberPath.replace(':id', this.memberId);
},
+ stringifiedSchedules() {
+ return JSON.stringify(this.oncallSchedules);
+ },
},
};
</script>
@@ -50,8 +73,11 @@ export default {
:aria-label="title"
:icon="icon"
:data-member-path="computedMemberPath"
+ :data-member-type="memberType"
:data-is-access-request="isAccessRequest"
+ :data-is-invite="isInvite"
:data-message="message"
+ :data-oncall-schedules="stringifiedSchedules"
data-qa-selector="delete_member_button"
/>
</template>
diff --git a/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue
index 261a6279920..2173974c6f4 100644
--- a/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue
@@ -12,6 +12,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: ['namespace'],
props: {
memberId: {
type: Number,
@@ -19,7 +20,11 @@ export default {
},
},
computed: {
- ...mapState(['memberPath']),
+ ...mapState({
+ memberPath(state) {
+ return state[this.namespace].memberPath;
+ },
+ }),
resendPath() {
return this.memberPath.replace(/:id$/, `${this.memberId}/resend_invite`);
},
diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
index f779d1755a5..1e9f79927ea 100644
--- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
@@ -33,7 +33,7 @@ export default {
if (user) {
return sprintf(
- s__('Members|Are you sure you want to remove %{usersName} from "%{source}"'),
+ s__('Members|Are you sure you want to remove %{usersName} from "%{source}"?'),
{
usersName: user.name,
source: source.fullName,
@@ -42,12 +42,16 @@ export default {
}
return sprintf(
- s__('Members|Are you sure you want to remove this orphaned member from "%{source}"'),
+ s__('Members|Are you sure you want to remove this orphaned member from "%{source}"?'),
{
source: source.fullName,
},
);
},
+ oncallScheduleUserData() {
+ const { user: { name, oncallSchedules: schedules } = {} } = this.member;
+ return { name, schedules };
+ },
},
};
</script>
@@ -59,6 +63,8 @@ export default {
<remove-member-button
v-else
:member-id="member.id"
+ :member-type="member.type"
+ :oncall-schedules="oncallScheduleUserData"
:message="message"
:title="s__('Member|Remove member')"
/>
diff --git a/app/assets/javascripts/members/components/app.vue b/app/assets/javascripts/members/components/app.vue
index 27fceb7374e..585fabdf3ff 100644
--- a/app/assets/javascripts/members/components/app.vue
+++ b/app/assets/javascripts/members/components/app.vue
@@ -9,8 +9,16 @@ import MembersTable from './table/members_table.vue';
export default {
name: 'MembersApp',
components: { MembersTable, FilterSortContainer, GlAlert },
+ inject: ['namespace'],
computed: {
- ...mapState(['showError', 'errorMessage']),
+ ...mapState({
+ showError(state) {
+ return state[this.namespace].showError;
+ },
+ errorMessage(state) {
+ return state[this.namespace].errorMessage;
+ },
+ }),
},
watch: {
showError(value) {
@@ -23,7 +31,9 @@ export default {
},
methods: {
...mapMutations({
- hideError: HIDE_ERROR,
+ hideError(commit) {
+ return commit(`${this.namespace}/${HIDE_ERROR}`);
+ },
}),
},
};
diff --git a/app/assets/javascripts/members/components/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue
index 658fb43cecb..9687eacb036 100644
--- a/app/assets/javascripts/members/components/avatars/user_avatar.vue
+++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue
@@ -5,7 +5,6 @@ import {
GlBadge,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
-import { mapState } from 'vuex';
import { generateBadges } from 'ee_else_ce/members/utils';
import { glEmojiTag } from '~/emoji';
import { __ } from '~/locale';
@@ -24,6 +23,7 @@ export default {
directives: {
SafeHtml,
},
+ inject: ['canManageMembers'],
props: {
member: {
type: Object,
@@ -35,7 +35,6 @@ export default {
},
},
computed: {
- ...mapState(['canManageMembers']),
user() {
return this.member.user;
},
diff --git a/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue
index 812a8626949..419b7b83c0f 100644
--- a/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue
+++ b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue
@@ -6,8 +6,16 @@ import SortDropdown from './sort_dropdown.vue';
export default {
name: 'FilterSortContainer',
components: { MembersFilteredSearchBar, SortDropdown },
+ inject: ['namespace'],
computed: {
- ...mapState(['filteredSearchBar', 'tableSortableFields']),
+ ...mapState({
+ filteredSearchBar(state) {
+ return state[this.namespace].filteredSearchBar;
+ },
+ tableSortableFields(state) {
+ return state[this.namespace].tableSortableFields;
+ },
+ }),
showContainer() {
return this.filteredSearchBar.show || this.showSortDropdown;
},
diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
index 039ee9a0207..cc97d235a9c 100644
--- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
+++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
@@ -37,13 +37,18 @@ export default {
],
},
],
+ inject: ['namespace', 'sourceId', 'canManageMembers'],
data() {
return {
initialFilterValue: [],
};
},
computed: {
- ...mapState(['sourceId', 'filteredSearchBar', 'canManageMembers']),
+ ...mapState({
+ filteredSearchBar(state) {
+ return state[this.namespace].filteredSearchBar;
+ },
+ }),
tokens() {
return this.$options.availableTokens.filter((token) => {
if (
diff --git a/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue
index 9fa8772faf4..ce28283ccdf 100644
--- a/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue
+++ b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue
@@ -8,8 +8,16 @@ import { parseSortParam, buildSortHref } from '~/members/utils';
export default {
name: 'SortDropdown',
components: { GlSorting, GlSortingItem },
+ inject: ['namespace'],
computed: {
- ...mapState(['tableSortableFields', 'filteredSearchBar']),
+ ...mapState({
+ tableSortableFields(state) {
+ return state[this.namespace].tableSortableFields;
+ },
+ filteredSearchBar(state) {
+ return state[this.namespace].filteredSearchBar;
+ },
+ }),
sort() {
return parseSortParam(this.tableSortableFields);
},
diff --git a/app/assets/javascripts/members/components/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue
index a0f978d85cc..44178981136 100644
--- a/app/assets/javascripts/members/components/modals/leave_modal.vue
+++ b/app/assets/javascripts/members/components/modals/leave_modal.vue
@@ -3,6 +3,7 @@ import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { mapState } from 'vuex';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
+import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
import { LEAVE_MODAL_ID } from '../../constants';
export default {
@@ -19,10 +20,11 @@ export default {
csrf,
modalId: LEAVE_MODAL_ID,
modalContent: s__('Members|Are you sure you want to leave "%{source}"?'),
- components: { GlModal, GlForm, GlSprintf },
+ components: { GlModal, GlForm, GlSprintf, OncallSchedulesList },
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: ['namespace'],
props: {
member: {
type: Object,
@@ -30,13 +32,23 @@ export default {
},
},
computed: {
- ...mapState(['memberPath']),
+ ...mapState({
+ memberPath(state) {
+ return state[this.namespace].memberPath;
+ },
+ }),
leavePath() {
return this.memberPath.replace(/:id$/, 'leave');
},
modalTitle() {
return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.fullName });
},
+ schedules() {
+ return this.member.user?.oncallSchedules;
+ },
+ isPartOfOnCallSchedules() {
+ return this.schedules?.length;
+ },
},
methods: {
handlePrimary() {
@@ -53,7 +65,6 @@ export default {
:title="modalTitle"
:action-primary="$options.actionPrimary"
:action-cancel="$options.actionCancel"
- size="sm"
@primary="handlePrimary"
>
<gl-form ref="form" :action="leavePath" method="post">
@@ -63,6 +74,12 @@ export default {
</gl-sprintf>
</p>
+ <oncall-schedules-list
+ v-if="isPartOfOnCallSchedules"
+ :schedules="schedules"
+ :is-current-user="true"
+ />
+
<input type="hidden" name="_method" value="delete" />
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
</gl-form>
diff --git a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue
index 1ba6bf9aba6..b179ced46e1 100644
--- a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue
+++ b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue
@@ -22,8 +22,19 @@ export default {
},
modalId: REMOVE_GROUP_LINK_MODAL_ID,
components: { GlModal, GlSprintf, GlForm },
+ inject: ['namespace'],
computed: {
- ...mapState(['memberPath', 'groupLinkToRemove', 'removeGroupLinkModalVisible']),
+ ...mapState({
+ memberPath(state) {
+ return state[this.namespace].memberPath;
+ },
+ groupLinkToRemove(state) {
+ return state[this.namespace].groupLinkToRemove;
+ },
+ removeGroupLinkModalVisible(state) {
+ return state[this.namespace].removeGroupLinkModalVisible;
+ },
+ }),
groupLinkPath() {
return this.memberPath.replace(/:id$/, this.groupLinkToRemove?.id);
},
@@ -35,7 +46,11 @@ export default {
},
},
methods: {
- ...mapActions(['hideRemoveGroupLinkModal']),
+ ...mapActions({
+ hideRemoveGroupLinkModal(dispatch) {
+ return dispatch(`${this.namespace}/hideRemoveGroupLinkModal`);
+ },
+ }),
handlePrimary() {
this.$refs.form.$el.submit();
},
diff --git a/app/assets/javascripts/members/components/table/expiration_datepicker.vue b/app/assets/javascripts/members/components/table/expiration_datepicker.vue
index 0a8af81c1d1..9f6e8979102 100644
--- a/app/assets/javascripts/members/components/table/expiration_datepicker.vue
+++ b/app/assets/javascripts/members/components/table/expiration_datepicker.vue
@@ -7,6 +7,7 @@ import { s__ } from '~/locale';
export default {
name: 'ExpirationDatepicker',
components: { GlDatepicker },
+ inject: ['namespace'],
props: {
member: {
type: Object,
@@ -46,7 +47,11 @@ export default {
}
},
methods: {
- ...mapActions(['updateMemberExpiration']),
+ ...mapActions({
+ updateMemberExpiration(dispatch, payload) {
+ return dispatch(`${this.namespace}/updateMemberExpiration`, payload);
+ },
+ }),
handleInput(date) {
this.busy = true;
this.updateMemberExpiration({
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index 9a3edff19ff..236aeaef418 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -31,8 +31,19 @@ export default {
LdapOverrideConfirmationModal: () =>
import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'),
},
+ inject: ['namespace', 'currentUserId'],
computed: {
- ...mapState(['members', 'tableFields', 'tableAttrs', 'currentUserId']),
+ ...mapState({
+ members(state) {
+ return state[this.namespace].members;
+ },
+ tableFields(state) {
+ return state[this.namespace].tableFields;
+ },
+ tableAttrs(state) {
+ return state[this.namespace].tableAttrs;
+ },
+ }),
filteredFields() {
return FIELDS.filter(
(field) => this.tableFields.includes(field.key) && this.showField(field),
diff --git a/app/assets/javascripts/members/components/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue
index 1f537740f94..3436bcab2fc 100644
--- a/app/assets/javascripts/members/components/table/members_table_cell.vue
+++ b/app/assets/javascripts/members/components/table/members_table_cell.vue
@@ -1,5 +1,4 @@
<script>
-import { mapState } from 'vuex';
import { MEMBER_TYPES } from '../../constants';
import {
isGroup,
@@ -12,6 +11,7 @@ import {
export default {
name: 'MembersTableCell',
+ inject: ['currentUserId'],
props: {
member: {
type: Object,
@@ -19,7 +19,6 @@ export default {
},
},
computed: {
- ...mapState(['currentUserId']),
isGroup() {
return isGroup(this.member);
},
diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue
index 8ad45ab6920..f84ded427cd 100644
--- a/app/assets/javascripts/members/components/table/role_dropdown.vue
+++ b/app/assets/javascripts/members/components/table/role_dropdown.vue
@@ -11,6 +11,7 @@ export default {
GlDropdownItem,
LdapDropdownItem: () => import('ee_component/members/components/ldap/ldap_dropdown_item.vue'),
},
+ inject: ['namespace'],
props: {
member: {
type: Object,
@@ -44,7 +45,11 @@ export default {
}
},
methods: {
- ...mapActions(['updateMemberRole']),
+ ...mapActions({
+ updateMemberRole(dispatch, payload) {
+ return dispatch(`${this.namespace}/updateMemberRole`, payload);
+ },
+ }),
handleSelect(value, name) {
if (value === this.member.accessLevel.integerValue) {
return;
diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js
index fe174d9beb6..6376b3fa75a 100644
--- a/app/assets/javascripts/members/index.js
+++ b/app/assets/javascripts/members/index.js
@@ -8,6 +8,7 @@ import membersStore from './store';
export const initMembersApp = (
el,
{
+ namespace,
tableFields = [],
tableAttrs = {},
tableSortableFields = [],
@@ -22,22 +23,31 @@ export const initMembersApp = (
Vue.use(Vuex);
Vue.use(GlToast);
- const store = new Vuex.Store(
- membersStore({
- ...parseDataAttributes(el),
- currentUserId: gon.current_user_id || null,
- tableFields,
- tableAttrs,
- tableSortableFields,
- requestFormatter,
- filteredSearchBar,
- }),
- );
+ const { sourceId, canManageMembers, ...vuexStoreAttributes } = parseDataAttributes(el);
+
+ const store = new Vuex.Store({
+ modules: {
+ [namespace]: membersStore({
+ ...vuexStoreAttributes,
+ tableFields,
+ tableAttrs,
+ tableSortableFields,
+ requestFormatter,
+ filteredSearchBar,
+ }),
+ },
+ });
return new Vue({
el,
components: { App },
store,
+ provide: {
+ namespace,
+ currentUserId: gon.current_user_id || null,
+ sourceId,
+ canManageMembers,
+ },
render: (createElement) => createElement('app'),
});
};
diff --git a/app/assets/javascripts/members/store/index.js b/app/assets/javascripts/members/store/index.js
index 45f4eefffc9..6c371887a3f 100644
--- a/app/assets/javascripts/members/store/index.js
+++ b/app/assets/javascripts/members/store/index.js
@@ -3,6 +3,7 @@ import mutations from 'ee_else_ce/members/store/mutations';
import createState from 'ee_else_ce/members/store/state';
export default (initialState) => ({
+ namespaced: true,
state: createState(initialState),
actions,
mutations,
diff --git a/app/assets/javascripts/members/store/state.js b/app/assets/javascripts/members/store/state.js
index 23a7983adcc..4006b4b501d 100644
--- a/app/assets/javascripts/members/store/state.js
+++ b/app/assets/javascripts/members/store/state.js
@@ -1,8 +1,5 @@
export default ({
members,
- sourceId,
- currentUserId,
- canManageMembers,
tableFields,
tableAttrs,
tableSortableFields,
@@ -11,9 +8,6 @@ export default ({
filteredSearchBar,
}) => ({
members,
- sourceId,
- currentUserId,
- canManageMembers,
tableFields,
tableAttrs,
tableSortableFields,
diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
index 2c7c8038af5..7649c363daa 100644
--- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
+++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
@@ -1,8 +1,10 @@
<script>
import { debounce } from 'lodash';
+import { mapActions } from 'vuex';
import { deprecatedCreateFlash as flash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
+import { INTERACTIVE_RESOLVE_MODE } from '../constants';
export default {
props: {
@@ -10,14 +12,6 @@ export default {
type: Object,
required: true,
},
- onCancelDiscardConfirmation: {
- type: Function,
- required: true,
- },
- onAcceptDiscardConfirmation: {
- type: Function,
- required: true,
- },
},
data() {
return {
@@ -50,6 +44,7 @@ export default {
}
},
methods: {
+ ...mapActions(['setFileResolveMode', 'setPromptConfirmationState', 'updateFile']),
loadEditor() {
const EditorPromise = import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite');
const DataPromise = axios.get(this.file.content_path);
@@ -82,23 +77,24 @@ export default {
saveDiffResolution() {
this.saved = true;
- // This probably be better placed in the data provider
- /* eslint-disable vue/no-mutating-props */
- this.file.content = this.editor.getValue();
- this.file.resolveEditChanged = this.file.content !== this.originalContent;
- this.file.promptDiscardConfirmation = false;
- /* eslint-enable vue/no-mutating-props */
+ this.updateFile({
+ ...this.file,
+ content: this.editor.getValue(),
+ resolveEditChanged: this.file.content !== this.originalContent,
+ promptDiscardConfirmation: false,
+ });
},
resetEditorContent() {
if (this.fileLoaded) {
this.editor.setValue(this.originalContent);
}
},
- cancelDiscardConfirmation(file) {
- this.onCancelDiscardConfirmation(file);
- },
acceptDiscardConfirmation(file) {
- this.onAcceptDiscardConfirmation(file);
+ this.setPromptConfirmationState({ file, promptDiscardConfirmation: false });
+ this.setFileResolveMode({ file, mode: INTERACTIVE_RESOLVE_MODE });
+ },
+ cancelDiscardConfirmation(file) {
+ this.setPromptConfirmationState({ file, promptDiscardConfirmation: false });
},
},
};
diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
index 519fd53af1e..9721481e6be 100644
--- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
+++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
@@ -1,34 +1,41 @@
<script>
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
-import actionsMixin from '../mixins/line_conflict_actions';
+import { mapActions } from 'vuex';
+import syntaxHighlight from '~/syntax_highlight';
+import { SYNTAX_HIGHLIGHT_CLASS } from '../constants';
import utilsMixin from '../mixins/line_conflict_utils';
export default {
directives: {
SafeHtml,
},
- mixins: [utilsMixin, actionsMixin],
+ mixins: [utilsMixin],
+ SYNTAX_HIGHLIGHT_CLASS,
props: {
file: {
type: Object,
required: true,
},
},
+ mounted() {
+ syntaxHighlight(document.querySelectorAll(`.${SYNTAX_HIGHLIGHT_CLASS}`));
+ },
+ methods: {
+ ...mapActions(['handleSelected']),
+ },
};
</script>
<template>
- <table class="diff-wrap-lines code code-commit js-syntax-highlight">
- <tr
- v-for="line in file.inlineLines"
- :key="(line.isHeader ? line.id : line.new_line) + line.richText"
- class="line_holder diff-inline"
- >
+ <table :class="['diff-wrap-lines code code-commit', $options.SYNTAX_HIGHLIGHT_CLASS]">
+ <!-- Unfortunately there isn't a good key for these sections -->
+ <!-- eslint-disable vue/require-v-for-key -->
+ <tr v-for="line in file.inlineLines" class="line_holder diff-inline">
<template v-if="line.isHeader">
<td :class="lineCssClass(line)" class="diff-line-num header"></td>
<td :class="lineCssClass(line)" class="diff-line-num header"></td>
<td :class="lineCssClass(line)" class="line_content header">
<strong>{{ line.richText }}</strong>
- <button class="btn" @click="handleSelected(file, line.id, line.section)">
+ <button class="btn" @click="handleSelected({ file, line })">
{{ line.buttonTitle }}
</button>
</td>
diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
index e66f641f70d..7b1d947ccff 100644
--- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
+++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
@@ -1,32 +1,41 @@
<script>
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
-import actionsMixin from '../mixins/line_conflict_actions';
+import { mapActions } from 'vuex';
+import syntaxHighlight from '~/syntax_highlight';
+import { SYNTAX_HIGHLIGHT_CLASS } from '../constants';
import utilsMixin from '../mixins/line_conflict_utils';
export default {
directives: {
SafeHtml,
},
- mixins: [utilsMixin, actionsMixin],
+ mixins: [utilsMixin],
+ SYNTAX_HIGHLIGHT_CLASS,
props: {
file: {
type: Object,
required: true,
},
},
+ mounted() {
+ syntaxHighlight(document.querySelectorAll(`.${SYNTAX_HIGHLIGHT_CLASS}`));
+ },
+ methods: {
+ ...mapActions(['handleSelected']),
+ },
};
</script>
<template>
<!-- Unfortunately there isn't a good key for these sections -->
<!-- eslint-disable vue/require-v-for-key -->
- <table class="diff-wrap-lines code js-syntax-highlight">
+ <table :class="['diff-wrap-lines code', $options.SYNTAX_HIGHLIGHT_CLASS]">
<tr v-for="section in file.parallelLines" class="line_holder parallel">
<template v-for="line in section">
<template v-if="line.isHeader">
<td class="diff-line-num header" :class="lineCssClass(line)"></td>
<td class="line_content header" :class="lineCssClass(line)">
<strong>{{ line.richText }}</strong>
- <button class="btn" @click="handleSelected(file, line.id, line.section)">
+ <button class="btn" @click="handleSelected({ file, line })">
{{ line.buttonTitle }}
</button>
</td>
diff --git a/app/assets/javascripts/merge_conflicts/constants.js b/app/assets/javascripts/merge_conflicts/constants.js
index 6f3ee339e36..dddcc891e81 100644
--- a/app/assets/javascripts/merge_conflicts/constants.js
+++ b/app/assets/javascripts/merge_conflicts/constants.js
@@ -13,6 +13,7 @@ export const VIEW_TYPES = {
export const EDIT_RESOLVE_MODE = 'edit';
export const INTERACTIVE_RESOLVE_MODE = 'interactive';
export const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE;
+export const SYNTAX_HIGHLIGHT_CLASS = 'js-syntax-highlight';
export const HEAD_HEADER_TEXT = s__('MergeConflict|HEAD//our changes');
export const ORIGIN_HEADER_TEXT = s__('MergeConflict|origin//their changes');
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
index 16a7cfb2ba8..0509cf0afa1 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
@@ -1,14 +1,15 @@
<script>
import { GlSprintf } from '@gitlab/ui';
+import { mapGetters, mapState, mapActions } from 'vuex';
import { __ } from '~/locale';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import DiffFileEditor from './components/diff_file_editor.vue';
import InlineConflictLines from './components/inline_conflict_lines.vue';
import ParallelConflictLines from './components/parallel_conflict_lines.vue';
+import { INTERACTIVE_RESOLVE_MODE } from './constants';
/**
- * NOTE: Most of this component is directly using $root, rather than props or a better data store.
- * This is BAD and one shouldn't copy that behavior. Similarly a lot of the classes below should
+ * A lot of the classes below should
* be replaced with GitLab UI components.
*
* We are just doing it temporarily in order to migrate the template from HAML => Vue in an iterative manner
@@ -25,60 +26,88 @@ export default {
InlineConflictLines,
ParallelConflictLines,
},
- inject: ['mergeRequestPath', 'sourceBranchPath'],
+ inject: ['mergeRequestPath', 'sourceBranchPath', 'resolveConflictsPath'],
i18n: {
commitStatSummary: __('Showing %{conflict} between %{sourceBranch} and %{targetBranch}'),
resolveInfo: __(
'You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}',
),
},
+ computed: {
+ ...mapGetters([
+ 'getConflictsCountText',
+ 'isReadyToCommit',
+ 'getCommitButtonText',
+ 'fileTextTypePresent',
+ ]),
+ ...mapState(['isLoading', 'hasError', 'isParallel', 'conflictsData']),
+ commitMessage: {
+ get() {
+ return this.conflictsData.commitMessage;
+ },
+ set(value) {
+ this.updateCommitMessage(value);
+ },
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'setViewType',
+ 'submitResolvedConflicts',
+ 'setFileResolveMode',
+ 'setPromptConfirmationState',
+ 'updateCommitMessage',
+ ]),
+ onClickResolveModeButton(file, mode) {
+ if (mode === INTERACTIVE_RESOLVE_MODE && file.resolveEditChanged) {
+ this.setPromptConfirmationState({ file, promptDiscardConfirmation: true });
+ } else {
+ this.setFileResolveMode({ file, mode });
+ }
+ },
+ },
};
</script>
<template>
<div id="conflicts">
- <div v-if="$root.isLoading" class="loading">
+ <div v-if="isLoading" class="loading">
<div class="spinner spinner-md"></div>
</div>
- <div v-if="$root.hasError" class="nothing-here-block">
- {{ $root.conflictsData.errorMessage }}
+ <div v-if="hasError" class="nothing-here-block">
+ {{ conflictsData.errorMessage }}
</div>
- <template v-if="!$root.isLoading && !$root.hasError">
+ <template v-if="!isLoading && !hasError">
<div class="content-block oneline-block files-changed">
- <div v-if="$root.showDiffViewTypeSwitcher" class="inline-parallel-buttons">
+ <div v-if="fileTextTypePresent" class="inline-parallel-buttons">
<div class="btn-group">
<button
- :class="{ active: !$root.isParallel }"
+ :class="{ active: !isParallel }"
class="btn gl-button"
- @click="$root.handleViewTypeChange('inline')"
+ @click="setViewType('inline')"
>
{{ __('Inline') }}
</button>
<button
- :class="{ active: $root.isParallel }"
+ :class="{ active: isParallel }"
class="btn gl-button"
- @click="$root.handleViewTypeChange('parallel')"
+ data-testid="side-by-side"
+ @click="setViewType('parallel')"
>
{{ __('Side-by-side') }}
</button>
</div>
</div>
<div class="js-toggle-container">
- <div class="commit-stat-summary">
+ <div class="commit-stat-summary" data-testid="conflicts-count">
<gl-sprintf :message="$options.i18n.commitStatSummary">
<template #conflict>
- <strong class="cred">
- {{ $root.conflictsCountText }}
- </strong>
+ <strong class="cred">{{ getConflictsCountText }}</strong>
</template>
<template #sourceBranch>
- <strong class="ref-name">
- {{ $root.conflictsData.sourceBranch }}
- </strong>
+ <strong class="ref-name">{{ conflictsData.sourceBranch }}</strong>
</template>
<template #targetBranch>
- <strong class="ref-name">
- {{ $root.conflictsData.targetBranch }}
- </strong>
+ <strong class="ref-name">{{ conflictsData.targetBranch }}</strong>
</template>
</gl-sprintf>
</div>
@@ -87,12 +116,13 @@ export default {
<div class="files-wrapper">
<div class="files">
<div
- v-for="file in $root.conflictsData.files"
+ v-for="file in conflictsData.files"
:key="file.blobPath"
class="diff-file file-holder conflict"
+ data-testid="files"
>
<div class="js-file-title file-title file-title-flex-parent cursor-default">
- <div class="file-header-content">
+ <div class="file-header-content" data-testid="file-name">
<file-icon :file-name="file.filePath" :size="18" css-classes="gl-mr-2" />
<strong class="file-title-name">{{ file.filePath }}</strong>
</div>
@@ -102,7 +132,8 @@ export default {
:class="{ active: file.resolveMode === 'interactive' }"
class="btn gl-button"
type="button"
- @click="$root.onClickResolveModeButton(file, 'interactive')"
+ data-testid="interactive-button"
+ @click="onClickResolveModeButton(file, 'interactive')"
>
{{ __('Interactive mode') }}
</button>
@@ -110,7 +141,8 @@ export default {
:class="{ active: file.resolveMode === 'edit' }"
class="btn gl-button"
type="button"
- @click="$root.onClickResolveModeButton(file, 'edit')"
+ data-testid="inline-button"
+ @click="onClickResolveModeButton(file, 'edit')"
>
{{ __('Edit inline') }}
</button>
@@ -118,35 +150,23 @@ export default {
<a :href="file.blobPath" class="btn gl-button view-file">
<gl-sprintf :message="__('View file @ %{commitSha}')">
<template #commitSha>
- {{ $root.conflictsData.shortCommitSha }}
+ {{ conflictsData.shortCommitSha }}
</template>
</gl-sprintf>
</a>
</div>
</div>
<div class="diff-content diff-wrap-lines">
- <div
- v-show="
- !$root.isParallel && file.resolveMode === 'interactive' && file.type === 'text'
- "
- class="file-content"
- >
- <inline-conflict-lines :file="file" />
- </div>
- <div
- v-show="
- $root.isParallel && file.resolveMode === 'interactive' && file.type === 'text'
- "
- class="file-content"
- >
- <parallel-conflict-lines :file="file" />
- </div>
- <div v-show="file.resolveMode === 'edit' || file.type === 'text-editor'">
- <diff-file-editor
- :file="file"
- :on-accept-discard-confirmation="$root.acceptDiscardConfirmation"
- :on-cancel-discard-confirmation="$root.cancelDiscardConfirmation"
- />
+ <template v-if="file.resolveMode === 'interactive' && file.type === 'text'">
+ <div v-if="!isParallel" class="file-content">
+ <inline-conflict-lines :file="file" />
+ </div>
+ <div v-if="isParallel" class="file-content">
+ <parallel-conflict-lines :file="file" />
+ </div>
+ </template>
+ <div v-if="file.resolveMode === 'edit' || file.type === 'text-editor'">
+ <diff-file-editor :file="file" />
</div>
</div>
</div>
@@ -169,7 +189,7 @@ export default {
</template>
<template #branch_name>
<a class="ref-name" :href="sourceBranchPath">
- {{ $root.conflictsData.sourceBranch }}
+ {{ conflictsData.sourceBranch }}
</a>
</template>
</gl-sprintf>
@@ -183,7 +203,8 @@ export default {
<div class="max-width-marker"></div>
<textarea
id="commit-message"
- v-model="$root.conflictsData.commitMessage"
+ v-model="commitMessage"
+ data-testid="commit-message"
class="form-control js-commit-message"
rows="5"
></textarea>
@@ -195,12 +216,12 @@ export default {
<div class="row">
<div class="col-6">
<button
- :disabled="!$root.readyToCommit"
+ :disabled="!isReadyToCommit"
class="btn gl-button btn-success js-submit-button"
type="button"
- @click="$root.commit()"
+ @click="submitResolvedConflicts(resolveConflictsPath)"
>
- <span>{{ $root.commitButtonText }}</span>
+ <span>{{ getCommitButtonText }}</span>
</button>
</div>
<div class="col-6 text-right">
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_service.js b/app/assets/javascripts/merge_conflicts/merge_conflict_service.js
deleted file mode 100644
index 64d69159222..00000000000
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_service.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import axios from '../lib/utils/axios_utils';
-
-export default class MergeConflictsService {
- constructor(options) {
- this.conflictsPath = options.conflictsPath;
- this.resolveConflictsPath = options.resolveConflictsPath;
- }
-
- fetchConflictsData() {
- return axios.get(this.conflictsPath);
- }
-
- submitResolveConflicts(data) {
- return axios.post(this.resolveConflictsPath, data);
- }
-}
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
deleted file mode 100644
index fb3444262ea..00000000000
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
+++ /dev/null
@@ -1,432 +0,0 @@
-/* eslint-disable no-param-reassign, babel/camelcase, no-nested-ternary, no-continue */
-
-import $ from 'jquery';
-import Cookies from 'js-cookie';
-import Vue from 'vue';
-import { s__ } from '~/locale';
-
-((global) => {
- global.mergeConflicts = global.mergeConflicts || {};
-
- const diffViewType = Cookies.get('diff_view');
- const HEAD_HEADER_TEXT = s__('MergeConflict|HEAD//our changes');
- const ORIGIN_HEADER_TEXT = s__('MergeConflict|origin//their changes');
- const HEAD_BUTTON_TITLE = s__('MergeConflict|Use ours');
- const ORIGIN_BUTTON_TITLE = s__('MergeConflict|Use theirs');
- const INTERACTIVE_RESOLVE_MODE = 'interactive';
- const EDIT_RESOLVE_MODE = 'edit';
- const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE;
- const VIEW_TYPES = {
- INLINE: 'inline',
- PARALLEL: 'parallel',
- };
- const CONFLICT_TYPES = {
- TEXT: 'text',
- TEXT_EDITOR: 'text-editor',
- };
-
- global.mergeConflicts.mergeConflictsStore = {
- state: {
- isLoading: true,
- hasError: false,
- isSubmitting: false,
- isParallel: diffViewType === VIEW_TYPES.PARALLEL,
- diffViewType,
- conflictsData: {},
- },
-
- setConflictsData(data) {
- this.decorateFiles(data.files);
-
- this.state.conflictsData = {
- files: data.files,
- commitMessage: data.commit_message,
- sourceBranch: data.source_branch,
- targetBranch: data.target_branch,
- shortCommitSha: data.commit_sha.slice(0, 7),
- };
- },
-
- decorateFiles(files) {
- files.forEach((file) => {
- file.content = '';
- file.resolutionData = {};
- file.promptDiscardConfirmation = false;
- file.resolveMode = DEFAULT_RESOLVE_MODE;
- file.filePath = this.getFilePath(file);
- file.blobPath = file.blob_path;
-
- if (file.type === CONFLICT_TYPES.TEXT) {
- file.showEditor = false;
- file.loadEditor = false;
-
- this.setInlineLine(file);
- this.setParallelLine(file);
- } else if (file.type === CONFLICT_TYPES.TEXT_EDITOR) {
- file.showEditor = true;
- file.loadEditor = true;
- }
- });
- },
-
- setInlineLine(file) {
- file.inlineLines = [];
-
- file.sections.forEach((section) => {
- let currentLineType = 'new';
- const { conflict, lines, id } = section;
-
- if (conflict) {
- file.inlineLines.push(this.getHeadHeaderLine(id));
- }
-
- lines.forEach((line) => {
- const { type } = line;
-
- if ((type === 'new' || type === 'old') && currentLineType !== type) {
- currentLineType = type;
- file.inlineLines.push({ lineType: 'emptyLine', richText: '' });
- }
-
- this.decorateLineForInlineView(line, id, conflict);
- file.inlineLines.push(line);
- });
-
- if (conflict) {
- file.inlineLines.push(this.getOriginHeaderLine(id));
- }
- });
- },
-
- setParallelLine(file) {
- file.parallelLines = [];
- const linesObj = { left: [], right: [] };
-
- file.sections.forEach((section) => {
- const { conflict, lines, id } = section;
-
- if (conflict) {
- linesObj.left.push(this.getOriginHeaderLine(id));
- linesObj.right.push(this.getHeadHeaderLine(id));
- }
-
- lines.forEach((line) => {
- const { type } = line;
-
- if (conflict) {
- if (type === 'old') {
- linesObj.left.push(this.getLineForParallelView(line, id, 'conflict'));
- } else if (type === 'new') {
- linesObj.right.push(this.getLineForParallelView(line, id, 'conflict', true));
- }
- } else {
- const lineType = type || 'context';
-
- linesObj.left.push(this.getLineForParallelView(line, id, lineType));
- linesObj.right.push(this.getLineForParallelView(line, id, lineType, true));
- }
- });
-
- this.checkLineLengths(linesObj);
- });
-
- for (let i = 0, len = linesObj.left.length; i < len; i += 1) {
- file.parallelLines.push([linesObj.right[i], linesObj.left[i]]);
- }
- },
-
- setLoadingState(state) {
- this.state.isLoading = state;
- },
-
- setErrorState(state) {
- this.state.hasError = state;
- },
-
- setFailedRequest(message) {
- this.state.hasError = true;
- this.state.conflictsData.errorMessage = message;
- },
-
- getConflictsCount() {
- if (!this.state.conflictsData.files.length) {
- return 0;
- }
-
- const { files } = this.state.conflictsData;
- let count = 0;
-
- files.forEach((file) => {
- if (file.type === CONFLICT_TYPES.TEXT) {
- file.sections.forEach((section) => {
- if (section.conflict) {
- count += 1;
- }
- });
- } else {
- count += 1;
- }
- });
-
- return count;
- },
-
- getConflictsCountText() {
- const count = this.getConflictsCount();
- const text = count > 1 ? s__('MergeConflict|conflicts') : s__('MergeConflict|conflict');
-
- return `${count} ${text}`;
- },
-
- setViewType(viewType) {
- this.state.diffView = viewType;
- this.state.isParallel = viewType === VIEW_TYPES.PARALLEL;
-
- Cookies.set('diff_view', viewType);
- },
-
- getHeadHeaderLine(id) {
- return {
- id,
- richText: HEAD_HEADER_TEXT,
- buttonTitle: HEAD_BUTTON_TITLE,
- type: 'new',
- section: 'head',
- isHeader: true,
- isHead: true,
- isSelected: false,
- isUnselected: false,
- };
- },
-
- decorateLineForInlineView(line, id, conflict) {
- const { type } = line;
- line.id = id;
- line.hasConflict = conflict;
- line.isHead = type === 'new';
- line.isOrigin = type === 'old';
- line.hasMatch = type === 'match';
- line.richText = line.rich_text;
- line.isSelected = false;
- line.isUnselected = false;
- },
-
- getLineForParallelView(line, id, lineType, isHead) {
- const { old_line, new_line, rich_text } = line;
- const hasConflict = lineType === 'conflict';
-
- return {
- id,
- lineType,
- hasConflict,
- isHead: hasConflict && isHead,
- isOrigin: hasConflict && !isHead,
- hasMatch: lineType === 'match',
- lineNumber: isHead ? new_line : old_line,
- section: isHead ? 'head' : 'origin',
- richText: rich_text,
- isSelected: false,
- isUnselected: false,
- };
- },
-
- getOriginHeaderLine(id) {
- return {
- id,
- richText: ORIGIN_HEADER_TEXT,
- buttonTitle: ORIGIN_BUTTON_TITLE,
- type: 'old',
- section: 'origin',
- isHeader: true,
- isOrigin: true,
- isSelected: false,
- isUnselected: false,
- };
- },
-
- getFilePath(file) {
- const { old_path, new_path } = file;
- return old_path === new_path ? new_path : `${old_path} → ${new_path}`;
- },
-
- checkLineLengths(linesObj) {
- const { left, right } = linesObj;
-
- if (left.length !== right.length) {
- if (left.length > right.length) {
- const diff = left.length - right.length;
- for (let i = 0; i < diff; i += 1) {
- right.push({ lineType: 'emptyLine', richText: '' });
- }
- } else {
- const diff = right.length - left.length;
- for (let i = 0; i < diff; i += 1) {
- left.push({ lineType: 'emptyLine', richText: '' });
- }
- }
- }
- },
-
- setPromptConfirmationState(file, state) {
- file.promptDiscardConfirmation = state;
- },
-
- setFileResolveMode(file, mode) {
- if (mode === INTERACTIVE_RESOLVE_MODE) {
- file.showEditor = false;
- } else if (mode === EDIT_RESOLVE_MODE) {
- // Restore Interactive mode when switching to Edit mode
- file.showEditor = true;
- file.loadEditor = true;
- file.resolutionData = {};
-
- this.restoreFileLinesState(file);
- }
-
- file.resolveMode = mode;
- },
-
- restoreFileLinesState(file) {
- file.inlineLines.forEach((line) => {
- if (line.hasConflict || line.isHeader) {
- line.isSelected = false;
- line.isUnselected = false;
- }
- });
-
- file.parallelLines.forEach((lines) => {
- const left = lines[0];
- const right = lines[1];
- const isLeftMatch = left.hasConflict || left.isHeader;
- const isRightMatch = right.hasConflict || right.isHeader;
-
- if (isLeftMatch || isRightMatch) {
- left.isSelected = false;
- left.isUnselected = false;
- right.isSelected = false;
- right.isUnselected = false;
- }
- });
- },
-
- isReadyToCommit() {
- const { files } = this.state.conflictsData;
- const hasCommitMessage = $.trim(this.state.conflictsData.commitMessage).length;
- let unresolved = 0;
-
- for (let i = 0, l = files.length; i < l; i += 1) {
- const file = files[i];
-
- if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) {
- let numberConflicts = 0;
- const resolvedConflicts = Object.keys(file.resolutionData).length;
-
- // We only check for conflicts type 'text'
- // since conflicts `text_editor` can´t be resolved in interactive mode
- if (file.type === CONFLICT_TYPES.TEXT) {
- for (let j = 0, k = file.sections.length; j < k; j += 1) {
- if (file.sections[j].conflict) {
- numberConflicts += 1;
- }
- }
-
- if (resolvedConflicts !== numberConflicts) {
- unresolved += 1;
- }
- }
- } else if (file.resolveMode === EDIT_RESOLVE_MODE) {
- // Unlikely to happen since switching to Edit mode saves content automatically.
- // Checking anyway in case the save strategy changes in the future
- if (!file.content) {
- unresolved += 1;
- continue;
- }
- }
- }
-
- return !this.state.isSubmitting && hasCommitMessage && !unresolved;
- },
-
- getCommitButtonText() {
- const initial = s__('MergeConflict|Commit to source branch');
- const inProgress = s__('MergeConflict|Committing...');
-
- return this.state ? (this.state.isSubmitting ? inProgress : initial) : initial;
- },
-
- getCommitData() {
- let commitData = {};
-
- commitData = {
- commit_message: this.state.conflictsData.commitMessage,
- files: [],
- };
-
- this.state.conflictsData.files.forEach((file) => {
- const addFile = {
- old_path: file.old_path,
- new_path: file.new_path,
- };
-
- if (file.type === CONFLICT_TYPES.TEXT) {
- // Submit only one data for type of editing
- if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) {
- addFile.sections = file.resolutionData;
- } else if (file.resolveMode === EDIT_RESOLVE_MODE) {
- addFile.content = file.content;
- }
- } else if (file.type === CONFLICT_TYPES.TEXT_EDITOR) {
- addFile.content = file.content;
- }
-
- commitData.files.push(addFile);
- });
-
- return commitData;
- },
-
- handleSelected(file, sectionId, selection) {
- Vue.set(file.resolutionData, sectionId, selection);
-
- file.inlineLines.forEach((line) => {
- if (line.id === sectionId && (line.hasConflict || line.isHeader)) {
- this.markLine(line, selection);
- }
- });
-
- file.parallelLines.forEach((lines) => {
- const left = lines[0];
- const right = lines[1];
- const hasSameId = right.id === sectionId || left.id === sectionId;
- const isLeftMatch = left.hasConflict || left.isHeader;
- const isRightMatch = right.hasConflict || right.isHeader;
-
- if (hasSameId && (isLeftMatch || isRightMatch)) {
- this.markLine(left, selection);
- this.markLine(right, selection);
- }
- });
- },
-
- markLine(line, selection) {
- if (selection === 'head' && line.isHead) {
- line.isSelected = true;
- line.isUnselected = false;
- } else if (selection === 'origin' && line.isOrigin) {
- line.isSelected = true;
- line.isUnselected = false;
- } else {
- line.isSelected = false;
- line.isUnselected = true;
- }
- },
-
- setSubmitState(state) {
- this.state.isSubmitting = state;
- },
-
- fileTextTypePresent() {
- return this.state.conflictsData.files.some((f) => f.type === CONFLICT_TYPES.TEXT);
- },
- };
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
index 4b73dd317cd..cf02c6fbd6b 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
@@ -1,100 +1,32 @@
-import $ from 'jquery';
import Vue from 'vue';
-import { __ } from '~/locale';
-import { deprecatedCreateFlash as createFlash } from '../flash';
import initIssuableSidebar from '../init_issuable_sidebar';
-import './merge_conflict_store';
-import syntaxHighlight from '../syntax_highlight';
import MergeConflictsResolverApp from './merge_conflict_resolver_app.vue';
-import MergeConflictsService from './merge_conflict_service';
+import { createStore } from './store';
export default function initMergeConflicts() {
- const INTERACTIVE_RESOLVE_MODE = 'interactive';
const conflictsEl = document.querySelector('#conflicts');
- const { mergeConflictsStore } = gl.mergeConflicts;
- const mergeConflictsService = new MergeConflictsService({
- conflictsPath: conflictsEl.dataset.conflictsPath,
- resolveConflictsPath: conflictsEl.dataset.resolveConflictsPath,
- });
- const { sourceBranchPath, mergeRequestPath } = conflictsEl.dataset;
+ const {
+ sourceBranchPath,
+ mergeRequestPath,
+ conflictsPath,
+ resolveConflictsPath,
+ } = conflictsEl.dataset;
initIssuableSidebar();
+ const store = createStore();
+
return new Vue({
el: conflictsEl,
+ store,
provide: {
sourceBranchPath,
mergeRequestPath,
- },
- data: mergeConflictsStore.state,
- computed: {
- conflictsCountText() {
- return mergeConflictsStore.getConflictsCountText();
- },
- readyToCommit() {
- return mergeConflictsStore.isReadyToCommit();
- },
- commitButtonText() {
- return mergeConflictsStore.getCommitButtonText();
- },
- showDiffViewTypeSwitcher() {
- return mergeConflictsStore.fileTextTypePresent();
- },
+ resolveConflictsPath,
},
created() {
- mergeConflictsService
- .fetchConflictsData()
- .then(({ data }) => {
- if (data.type === 'error') {
- mergeConflictsStore.setFailedRequest(data.message);
- } else {
- mergeConflictsStore.setConflictsData(data);
- }
-
- mergeConflictsStore.setLoadingState(false);
-
- this.$nextTick(() => {
- syntaxHighlight($('.js-syntax-highlight'));
- });
- })
- .catch(() => {
- mergeConflictsStore.setLoadingState(false);
- mergeConflictsStore.setFailedRequest();
- });
- },
- methods: {
- handleViewTypeChange(viewType) {
- mergeConflictsStore.setViewType(viewType);
- },
- onClickResolveModeButton(file, mode) {
- if (mode === INTERACTIVE_RESOLVE_MODE && file.resolveEditChanged) {
- mergeConflictsStore.setPromptConfirmationState(file, true);
- return;
- }
-
- mergeConflictsStore.setFileResolveMode(file, mode);
- },
- acceptDiscardConfirmation(file) {
- mergeConflictsStore.setPromptConfirmationState(file, false);
- mergeConflictsStore.setFileResolveMode(file, INTERACTIVE_RESOLVE_MODE);
- },
- cancelDiscardConfirmation(file) {
- mergeConflictsStore.setPromptConfirmationState(file, false);
- },
- commit() {
- mergeConflictsStore.setSubmitState(true);
-
- mergeConflictsService
- .submitResolveConflicts(mergeConflictsStore.getCommitData())
- .then(({ data }) => {
- window.location.href = data.redirect_to;
- })
- .catch(() => {
- mergeConflictsStore.setSubmitState(false);
- createFlash(__('Failed to save merge conflicts resolutions. Please try again!'));
- });
- },
+ store.dispatch('fetchConflictsData', conflictsPath);
},
render(createElement) {
return createElement(MergeConflictsResolverApp);
diff --git a/app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js b/app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js
deleted file mode 100644
index 364ae2b2688..00000000000
--- a/app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js
+++ /dev/null
@@ -1,7 +0,0 @@
-export default {
- methods: {
- handleSelected(file, sectionId, selection) {
- gl.mergeConflicts.mergeConflictsStore.handleSelected(file, sectionId, selection);
- },
- },
-};
diff --git a/app/assets/javascripts/merge_conflicts/store/actions.js b/app/assets/javascripts/merge_conflicts/store/actions.js
index 8036e90c58c..df515c4ac1a 100644
--- a/app/assets/javascripts/merge_conflicts/store/actions.js
+++ b/app/assets/javascripts/merge_conflicts/store/actions.js
@@ -118,3 +118,8 @@ export const handleSelected = ({ commit, state, getters }, { file, line: { id, s
commit(types.UPDATE_FILE, { file: updated, index });
};
+
+export const updateFile = ({ commit, getters }, file) => {
+ const index = getters.getFileIndex(file);
+ commit(types.UPDATE_FILE, { file, index });
+};
diff --git a/app/assets/javascripts/merge_conflicts/store/getters.js b/app/assets/javascripts/merge_conflicts/store/getters.js
index 03e425fb478..54f3d6ec4bc 100644
--- a/app/assets/javascripts/merge_conflicts/store/getters.js
+++ b/app/assets/javascripts/merge_conflicts/store/getters.js
@@ -67,7 +67,7 @@ export const isReadyToCommit = (state) => {
}
}
- return !state.isSubmitting && hasCommitMessage && !unresolved;
+ return Boolean(!state.isSubmitting && hasCommitMessage && !unresolved);
};
export const getCommitButtonText = (state) => {
diff --git a/app/assets/javascripts/merge_request/components/status_box.vue b/app/assets/javascripts/merge_request/components/status_box.vue
index 5d2660d65e6..526aafc1def 100644
--- a/app/assets/javascripts/merge_request/components/status_box.vue
+++ b/app/assets/javascripts/merge_request/components/status_box.vue
@@ -13,7 +13,7 @@ const CLASSES = {
const STATUS = {
opened: [__('Open'), 'issue-open-m'],
locked: [__('Open'), 'issue-open-m'],
- closed: [__('Closed'), 'close'],
+ closed: [__('Closed'), 'issue-close'],
merged: [__('Merged'), 'git-merge'],
};
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 81b9db6b4d5..67b24793a65 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -264,7 +264,7 @@ export default class MergeRequestTabs {
}
}
- // Replaces the current Merge Request-specific action in the URL with a new one
+ // Replaces the current merge request-specific action in the URL with a new one
//
// If the action is "notes", the URL is reset to the standard
// `MergeRequests#show` route.
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index f4b60fc0961..b992eaff779 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -11,7 +11,6 @@ import boardsStore, {
boardStoreIssueSet,
boardStoreIssueDelete,
} from './boards/stores/boards_store';
-import ModalStore from './boards/stores/modal_store';
import axios from './lib/utils/axios_utils';
import { timeFor, parsePikadayDate, dateInWords } from './lib/utils/datetime_utility';
@@ -211,7 +210,7 @@ export default class MilestoneSelect {
const { e } = clickEvent;
let selected = clickEvent.selectedObj;
- let data, modalStoreFilter;
+ let data;
if (!selected) return;
if (options.handleClick) {
@@ -234,14 +233,7 @@ export default class MilestoneSelect {
return;
}
- if ($dropdown.closest('.add-issues-modal').length) {
- modalStoreFilter = ModalStore.store.filter;
- }
-
- if (modalStoreFilter) {
- modalStoreFilter[$dropdown.data('fieldName')] = selected.name;
- e.preventDefault();
- } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) {
return $dropdown.closest('form').submit();
diff --git a/app/assets/javascripts/mini_pipeline_graph_dropdown.js b/app/assets/javascripts/mini_pipeline_graph_dropdown.js
deleted file mode 100644
index 05f2f15fa9a..00000000000
--- a/app/assets/javascripts/mini_pipeline_graph_dropdown.js
+++ /dev/null
@@ -1,113 +0,0 @@
-import $ from 'jquery';
-import { deprecatedCreateFlash as flash } from './flash';
-import axios from './lib/utils/axios_utils';
-import { __ } from './locale';
-
-/**
- * In each pipelines table we have a mini pipeline graph for each pipeline.
- *
- * When we click in a pipeline stage, we need to make an API call to get the
- * builds list to render in a dropdown.
- *
- * The container should be the table element.
- *
- * The stage icon clicked needs to have the following HTML structure:
- * <div class="dropdown">
- * <button class="dropdown js-builds-dropdown-button" data-toggle="dropdown"></button>
- * <div class="js-builds-dropdown-container dropdown-menu"></div>
- * </div>
- */
-
-export default class MiniPipelineGraph {
- constructor(opts = {}) {
- this.container = opts.container || '';
- this.dropdownListSelector = '.js-builds-dropdown-container';
- this.getBuildsList = this.getBuildsList.bind(this);
- }
-
- /**
- * Adds the event listener when the dropdown is opened.
- * All dropdown events are fired at the .dropdown-menu's parent element.
- */
- bindEvents() {
- $(document)
- .off('shown.bs.dropdown', this.container)
- .on('shown.bs.dropdown', this.container, this.getBuildsList);
- }
-
- /**
- * When the user right clicks or cmd/ctrl + click in the job name
- * the dropdown should not be closed and the link should open in another tab,
- * so we stop propagation of the click event inside the dropdown.
- *
- * Since this component is rendered multiple times per page we need to guarantee we only
- * target the click event of this component.
- */
- stopDropdownClickPropagation() {
- $(document).on(
- 'click',
- `${this.container} .js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item`,
- (e) => {
- e.stopPropagation();
- },
- );
- }
-
- /**
- * For the clicked stage, renders the given data in the dropdown list.
- *
- * @param {HTMLElement} stageContainer
- * @param {Object} data
- */
- renderBuildsList(stageContainer, data) {
- const dropdownContainer = stageContainer.parentElement.querySelector(
- `${this.dropdownListSelector} .js-builds-dropdown-list ul`,
- );
-
- dropdownContainer.innerHTML = data;
- }
-
- /**
- * For the clicked stage, gets the list of builds.
- *
- * All dropdown events have a relatedTarget property,
- * whose value is the toggling anchor element.
- *
- * @param {Object} e bootstrap dropdown event
- * @return {Promise}
- */
- getBuildsList(e) {
- const button = e.relatedTarget;
- const endpoint = button.dataset.stageEndpoint;
-
- this.renderBuildsList(button, '');
- this.toggleLoading(button);
-
- axios
- .get(endpoint)
- .then(({ data }) => {
- this.toggleLoading(button);
- this.renderBuildsList(button, data.html);
- this.stopDropdownClickPropagation();
- })
- .catch(() => {
- this.toggleLoading(button);
- if ($(button).parent().hasClass('open')) {
- $(button).dropdown('toggle');
- }
- flash(__('An error occurred while fetching the builds.'), 'alert');
- });
- }
-
- /**
- * Toggles the visibility of the loading icon.
- *
- * @param {HTMLElement} stageContainer
- * @return {type}
- */
- toggleLoading(stageContainer) {
- stageContainer.parentElement
- .querySelector(`${this.dropdownListSelector} .js-builds-dropdown-loading`)
- .classList.toggle('hidden');
- }
-}
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
index 3c423bea368..05b5b760f0a 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -14,6 +14,7 @@ import { debounce } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import invalidUrl from '~/lib/utils/invalid_url';
import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import { timeRanges } from '~/vue_shared/constants';
@@ -24,6 +25,9 @@ import DashboardsDropdown from './dashboards_dropdown.vue';
import RefreshButton from './refresh_button.vue';
export default {
+ i18n: {
+ metricsSettings: s__('Metrics|Metrics Settings'),
+ },
components: {
GlIcon,
GlButton,
@@ -282,7 +286,8 @@ export default {
data-testid="metrics-settings-button"
icon="settings"
:href="operationsSettingsPath"
- :title="s__('Metrics|Metrics Settings')"
+ :title="$options.i18n.metricsSettings"
+ :aria-label="$options.i18n.metricsSettings"
/>
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue
index 847339e814a..e5f0206bb8b 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue
@@ -10,6 +10,7 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
+import { s__ } from '~/locale';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import { timeRanges } from '~/vue_shared/constants';
import DashboardPanel from './dashboard_panel.vue';
@@ -24,6 +25,9 @@ metrics:
`;
export default {
+ i18n: {
+ refreshButtonLabel: s__('Metrics|Refresh Prometheus data'),
+ },
components: {
GlCard,
GlForm,
@@ -191,7 +195,8 @@ export default {
v-gl-tooltip
data-testid="previewRefreshButton"
icon="retry"
- :title="s__('Metrics|Refresh Prometheus data')"
+ :title="$options.i18n.refreshButtonLabel"
+ :aria-label="$options.i18n.refreshButtonLabel"
@click="onRefresh"
/>
<dashboard-panel :graph-data="panelPreviewGraphData" />
diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue
index 627af202028..1765a2f3d5d 100644
--- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue
+++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue
@@ -23,7 +23,7 @@ export default {
},
},
radioVals: {
- /* Use the default branch (e.g. master) */
+ /* Use the default branch (e.g. main) */
DEFAULT: 'DEFAULT',
/* Create a new branch */
NEW: 'NEW',
diff --git a/app/assets/javascripts/monitoring/components/links_section.vue b/app/assets/javascripts/monitoring/components/links_section.vue
index 3f9f57d4ac1..fb5ab12916e 100644
--- a/app/assets/javascripts/monitoring/components/links_section.vue
+++ b/app/assets/javascripts/monitoring/components/links_section.vue
@@ -15,7 +15,7 @@ export default {
<template>
<div
ref="linksSection"
- class="gl-sm-display-flex gl-flex-sm-wrap gl-mt-5 gl-p-3 gl-bg-gray-10 border gl-rounded-base links-section"
+ class="gl-sm-display-flex gl-sm-flex-wrap gl-mt-5 gl-p-3 gl-bg-gray-10 border gl-rounded-base links-section"
>
<div
v-for="(link, key) in links"
diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue
index 3daf5b38933..0b80043a92c 100644
--- a/app/assets/javascripts/monitoring/components/refresh_button.vue
+++ b/app/assets/javascripts/monitoring/components/refresh_button.vue
@@ -9,7 +9,7 @@ import {
} from '@gitlab/ui';
import Visibility from 'visibilityjs';
import { mapActions } from 'vuex';
-import { n__, __ } from '~/locale';
+import { n__, __, s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -45,6 +45,9 @@ const makeInterval = (length = 0, unit = 's') => {
};
export default {
+ i18n: {
+ refreshDashboard: s__('Metrics|Refresh dashboard'),
+ },
components: {
GlButtonGroup,
GlButton,
@@ -148,7 +151,8 @@ export default {
v-gl-tooltip
class="gl-flex-grow-1"
variant="default"
- :title="s__('Metrics|Refresh dashboard')"
+ :title="$options.i18n.refreshDashboard"
+ :aria-label="$options.i18n.refreshDashboard"
icon="retry"
@click="refresh"
/>
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index 9a93e90c2bb..d85fd10be45 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -25,6 +25,9 @@ export default () => {
return {
noteableData,
+ endpoints: {
+ metadata: notesDataset.endpointMetadata,
+ },
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: JSON.parse(notesDataset.notesData),
helpPagePath: notesDataset.helpPagePath,
@@ -54,6 +57,9 @@ export default () => {
},
created() {
this.setActiveTab(window.mrTabs.getCurrentAction());
+ this.setEndpoints(this.endpoints);
+
+ this.fetchMrMetadata();
},
mounted() {
this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge');
@@ -65,7 +71,7 @@ export default () => {
window.mrTabs.eventHub.$off('MergeRequestTabChange', this.setActiveTab);
},
methods: {
- ...mapActions(['setActiveTab']),
+ ...mapActions(['setActiveTab', 'setEndpoints', 'fetchMrMetadata']),
updateDiscussionTabCounter() {
this.notesCountBadge.text(this.discussionTabCounter);
},
diff --git a/app/assets/javascripts/mr_notes/stores/actions.js b/app/assets/javascripts/mr_notes/stores/actions.js
index 426c6a00d5e..bc66d1dd68f 100644
--- a/app/assets/javascripts/mr_notes/stores/actions.js
+++ b/app/assets/javascripts/mr_notes/stores/actions.js
@@ -1,7 +1,32 @@
+import axios from '~/lib/utils/axios_utils';
+
import types from './mutation_types';
-export default {
- setActiveTab({ commit }, tab) {
- commit(types.SET_ACTIVE_TAB, tab);
- },
-};
+export function setActiveTab({ commit }, tab) {
+ commit(types.SET_ACTIVE_TAB, tab);
+}
+
+export function setEndpoints({ commit }, endpoints) {
+ commit(types.SET_ENDPOINTS, endpoints);
+}
+
+export function setMrMetadata({ commit }, metadata) {
+ commit(types.SET_MR_METADATA, metadata);
+}
+
+export function fetchMrMetadata({ dispatch, state }) {
+ if (state.endpoints?.metadata) {
+ axios
+ .get(state.endpoints.metadata)
+ .then((response) => {
+ dispatch('setMrMetadata', response.data);
+ })
+ .catch(() => {
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/324740
+ // We can't even do a simple console warning here because
+ // the pipeline will fail. However, the issue above will
+ // eventually handle errors appropriately.
+ // console.warn('Failed to load MR Metadata for the Overview tab.');
+ });
+ }
+}
diff --git a/app/assets/javascripts/mr_notes/stores/modules/index.js b/app/assets/javascripts/mr_notes/stores/modules/index.js
index c28e666943b..52e12ba664c 100644
--- a/app/assets/javascripts/mr_notes/stores/modules/index.js
+++ b/app/assets/javascripts/mr_notes/stores/modules/index.js
@@ -1,10 +1,12 @@
-import actions from '../actions';
+import * as actions from '../actions';
import getters from '../getters';
import mutations from '../mutations';
export default () => ({
state: {
+ endpoints: {},
activeTab: null,
+ mrMetadata: {},
},
actions,
getters,
diff --git a/app/assets/javascripts/mr_notes/stores/mutation_types.js b/app/assets/javascripts/mr_notes/stores/mutation_types.js
index 105104361cf..88cf6e48988 100644
--- a/app/assets/javascripts/mr_notes/stores/mutation_types.js
+++ b/app/assets/javascripts/mr_notes/stores/mutation_types.js
@@ -1,3 +1,5 @@
export default {
SET_ACTIVE_TAB: 'SET_ACTIVE_TAB',
+ SET_ENDPOINTS: 'SET_ENDPOINTS',
+ SET_MR_METADATA: 'SET_MR_METADATA',
};
diff --git a/app/assets/javascripts/mr_notes/stores/mutations.js b/app/assets/javascripts/mr_notes/stores/mutations.js
index 8175aa9488f..6af6adb4e18 100644
--- a/app/assets/javascripts/mr_notes/stores/mutations.js
+++ b/app/assets/javascripts/mr_notes/stores/mutations.js
@@ -4,4 +4,10 @@ export default {
[types.SET_ACTIVE_TAB](state, tab) {
Object.assign(state, { activeTab: tab });
},
+ [types.SET_ENDPOINTS](state, endpoints) {
+ Object.assign(state, { endpoints });
+ },
+ [types.SET_MR_METADATA](state, metadata) {
+ Object.assign(state, { mrMetadata: metadata });
+ },
};
diff --git a/app/assets/javascripts/namespaces/cascading_settings/components/lock_popovers.vue b/app/assets/javascripts/namespaces/cascading_settings/components/lock_popovers.vue
new file mode 100644
index 00000000000..8de6e910bb6
--- /dev/null
+++ b/app/assets/javascripts/namespaces/cascading_settings/components/lock_popovers.vue
@@ -0,0 +1,77 @@
+<script>
+import { GlPopover, GlSprintf, GlLink } from '@gitlab/ui';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+export default {
+ name: 'LockPopovers',
+ components: {
+ GlPopover,
+ GlSprintf,
+ GlLink,
+ },
+ data() {
+ return {
+ targets: [],
+ };
+ },
+ mounted() {
+ this.targets = [...document.querySelectorAll('.js-cascading-settings-lock-popover-target')].map(
+ (el) => {
+ const {
+ dataset: { popoverData },
+ } = el;
+
+ const {
+ lockedByAncestor,
+ lockedByApplicationSetting,
+ ancestorNamespace,
+ } = convertObjectPropsToCamelCase(JSON.parse(popoverData || '{}'), { deep: true });
+
+ return {
+ el,
+ lockedByAncestor,
+ lockedByApplicationSetting,
+ ancestorNamespace,
+ };
+ },
+ );
+ },
+};
+</script>
+
+<template>
+ <div>
+ <template
+ v-for="(
+ { el, lockedByApplicationSetting, lockedByAncestor, ancestorNamespace }, index
+ ) in targets"
+ >
+ <gl-popover
+ v-if="lockedByApplicationSetting || lockedByAncestor"
+ :key="index"
+ :target="el"
+ placement="top"
+ >
+ <template #title>{{ s__('CascadingSettings|Setting enforced') }}</template>
+ <p data-testid="cascading-settings-lock-popover">
+ <template v-if="lockedByApplicationSetting">{{
+ s__('CascadingSettings|This setting has been enforced by an instance admin.')
+ }}</template>
+
+ <gl-sprintf
+ v-else-if="lockedByAncestor && ancestorNamespace"
+ :message="
+ s__('CascadingSettings|This setting has been enforced by an owner of %{link}.')
+ "
+ >
+ <template #link>
+ <gl-link :href="ancestorNamespace.path" class="gl-font-sm">{{
+ ancestorNamespace.fullName
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-popover>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/namespaces/cascading_settings/index.js b/app/assets/javascripts/namespaces/cascading_settings/index.js
new file mode 100644
index 00000000000..3e44d1e9e2d
--- /dev/null
+++ b/app/assets/javascripts/namespaces/cascading_settings/index.js
@@ -0,0 +1,15 @@
+import Vue from 'vue';
+import LockPopovers from './components/lock_popovers.vue';
+
+export const initCascadingSettingsLockPopovers = () => {
+ const el = document.querySelector('.js-cascading-settings-lock-popovers');
+
+ if (!el) return false;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(LockPopovers);
+ },
+ });
+};
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index e4cde0d4ff3..c09db6851e5 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -37,6 +37,11 @@ const katexRegexString = `(
.replace(/\s/g, '')
.trim();
+function deHTMLify(t) {
+ // get some specific characters back, that are allowed for KaTex rendering
+ const text = t.replace(/&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>');
+ return text;
+}
function renderKatex(t) {
let text = t;
let numInline = 0; // number of successfull converted math formulas
@@ -57,9 +62,7 @@ function renderKatex(t) {
while (matches !== null) {
try {
- const renderedKatex = katex.renderToString(
- matches[0].replace(/\$/g, '').replace(/&#39;/g, "'"),
- ); // get the tick ' back again from HTMLified string
+ const renderedKatex = katex.renderToString(deHTMLify(matches[0].replace(/\$/g, '')));
text = `${text.replace(matches[0], ` ${renderedKatex}`)}`;
} catch {
numInline -= 1;
@@ -68,7 +71,7 @@ function renderKatex(t) {
}
} else {
try {
- text = katex.renderToString(matches[2].replace(/&#39;/g, "'"));
+ text = katex.renderToString(deHTMLify(matches[2]));
} catch (error) {
numInline -= 1;
}
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 8ed40f36103..b5c59f34e87 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -22,7 +22,7 @@ import syntaxHighlight from '~/syntax_highlight';
import Autosave from './autosave';
import loadAwardsHandler from './awards_handler';
import CommentTypeToggle from './comment_type_toggle';
-import { deprecatedCreateFlash as Flash } from './flash';
+import createFlash from './flash';
import { defaultAutocompleteConfig } from './gfm_auto_complete';
import GLForm from './gl_form';
import axios from './lib/utils/axios_utils';
@@ -106,7 +106,7 @@ export default class Notes {
this.collapseLongCommitList();
this.setViewType(view);
- // We are in the Merge Requests page so we need another edit form for Changes tab
+ // We are in the merge requests page so we need another edit form for Changes tab
if (getPagePath(1) === 'merge_requests') {
$('.note-edit-form').clone().addClass('mr-note-edit-form').insertAfter('.note-edit-form');
}
@@ -399,7 +399,11 @@ export default class Notes {
if (noteEntity.commands_changes && Object.keys(noteEntity.commands_changes).length > 0) {
$notesList.find('.system-note.being-posted').remove();
}
- this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline.get(0));
+ this.addFlash({
+ message: noteEntity.errors.commands_only,
+ type: 'notice',
+ parent: this.parentTimeline.get(0),
+ });
this.refresh();
}
return;
@@ -620,20 +624,21 @@ export default class Notes {
} else if ($form.hasClass('js-discussion-note-form')) {
formParentTimeline = $form.closest('.discussion-notes').find('.notes');
}
- return this.addFlash(
- __(
+ return this.addFlash({
+ message: __(
'Your comment could not be submitted! Please check your network connection and try again.',
),
- 'alert',
- formParentTimeline.get(0),
- );
+ type: 'alert',
+ parent: formParentTimeline.get(0),
+ });
}
updateNoteError() {
- // eslint-disable-next-line no-new
- new Flash(
- __('Your comment could not be updated! Please check your network connection and try again.'),
- );
+ createFlash({
+ message: __(
+ 'Your comment could not be updated! Please check your network connection and try again.',
+ ),
+ });
}
/**
@@ -1289,7 +1294,7 @@ export default class Notes {
}
addFlash(...flashParams) {
- this.flashContainer = new Flash(...flashParams);
+ this.flashContainer = createFlash(...flashParams);
}
clearFlash() {
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 08d7c745791..79d8ce78329 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -84,6 +84,7 @@ export default {
'getNoteableDataByProp',
'getNotesData',
'openState',
+ 'hasDrafts',
]),
...mapState(['isToggleStateButtonLoading']),
isNoteTypeComment() {
@@ -171,6 +172,9 @@ export default {
endpoint() {
return this.getNoteableData.create_note_path;
},
+ draftEndpoint() {
+ return this.getNotesData.draftsPath;
+ },
issuableTypeTitle() {
return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
? this.$options.i18n.mergeRequest
@@ -214,12 +218,15 @@ export default {
this.errors = [this.$options.i18n.GENERIC_UNSUBMITTABLE_NETWORK];
}
},
- handleSave(withIssueAction) {
+ handleSaveDraft() {
+ this.handleSave({ isDraft: true });
+ },
+ handleSave({ withIssueAction = false, isDraft = false } = {}) {
this.errors = [];
if (this.note.length) {
const noteData = {
- endpoint: this.endpoint,
+ endpoint: isDraft ? this.draftEndpoint : this.endpoint,
data: {
note: {
noteable_type: this.noteableType,
@@ -229,6 +236,7 @@ export default {
},
merge_request_diff_head_sha: this.getNoteableData.diff_head_sha,
},
+ isDraft,
};
if (this.noteType === constants.DISCUSSION) {
@@ -392,62 +400,82 @@ export default {
</markdown-field>
</comment-field-layout>
<div class="note-form-actions">
- <gl-form-checkbox
- v-if="confidentialNotesEnabled && canSetConfidential"
- v-model="noteIsConfidential"
- class="gl-mb-6"
- data-testid="confidential-note-checkbox"
- >
- {{ $options.i18n.confidential }}
- <gl-icon
- v-gl-tooltip:tooltipcontainer.bottom
- name="question"
- :size="16"
- :title="$options.i18n.confidentialVisibility"
- class="gl-text-gray-500"
- />
- </gl-form-checkbox>
- <gl-dropdown
- split
- :text="commentButtonTitle"
- class="gl-mr-3 js-comment-button js-comment-submit-button comment-type-dropdown"
- category="primary"
- variant="success"
- :disabled="disableSubmitButton"
- data-testid="comment-button"
- data-qa-selector="comment_button"
- :data-track-label="trackingLabel"
- data-track-event="click_button"
- @click="handleSave()"
- >
- <gl-dropdown-item
- is-check-item
- :is-checked="isNoteTypeComment"
- :selected="isNoteTypeComment"
- @click="setNoteTypeToComment"
+ <template v-if="hasDrafts">
+ <gl-button
+ :disabled="disableSubmitButton"
+ data-testid="add-to-review-button"
+ type="submit"
+ category="primary"
+ variant="success"
+ @click.prevent="handleSaveDraft()"
+ >{{ __('Add to review') }}</gl-button
+ >
+ <gl-button
+ :disabled="disableSubmitButton"
+ data-testid="add-comment-now-button"
+ category="secondary"
+ @click.prevent="handleSave()"
+ >{{ __('Add comment now') }}</gl-button
+ >
+ </template>
+ <template v-else>
+ <gl-form-checkbox
+ v-if="confidentialNotesEnabled && canSetConfidential"
+ v-model="noteIsConfidential"
+ class="gl-mb-6"
+ data-testid="confidential-note-checkbox"
>
- <strong>{{ $options.i18n.submitButton.comment }}</strong>
- <p class="gl-m-0">{{ commentDescription }}</p>
- </gl-dropdown-item>
- <gl-dropdown-divider />
- <gl-dropdown-item
- is-check-item
- :is-checked="isNoteTypeDiscussion"
- :selected="isNoteTypeDiscussion"
- data-qa-selector="discussion_menu_item"
- @click="setNoteTypeToDiscussion"
+ {{ $options.i18n.confidential }}
+ <gl-icon
+ v-gl-tooltip:tooltipcontainer.bottom
+ name="question"
+ :size="16"
+ :title="$options.i18n.confidentialVisibility"
+ class="gl-text-gray-500"
+ />
+ </gl-form-checkbox>
+ <gl-dropdown
+ split
+ :text="commentButtonTitle"
+ class="gl-mr-3 js-comment-button js-comment-submit-button comment-type-dropdown"
+ category="primary"
+ variant="confirm"
+ :disabled="disableSubmitButton"
+ data-testid="comment-button"
+ data-qa-selector="comment_button"
+ :data-track-label="trackingLabel"
+ data-track-event="click_button"
+ @click="handleSave()"
>
- <strong>{{ $options.i18n.submitButton.startThread }}</strong>
- <p class="gl-m-0">{{ startDiscussionDescription }}</p>
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="isNoteTypeComment"
+ :selected="isNoteTypeComment"
+ @click="setNoteTypeToComment"
+ >
+ <strong>{{ $options.i18n.submitButton.comment }}</strong>
+ <p class="gl-m-0">{{ commentDescription }}</p>
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="isNoteTypeDiscussion"
+ :selected="isNoteTypeDiscussion"
+ data-qa-selector="discussion_menu_item"
+ @click="setNoteTypeToDiscussion"
+ >
+ <strong>{{ $options.i18n.submitButton.startThread }}</strong>
+ <p class="gl-m-0">{{ startDiscussionDescription }}</p>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </template>
<gl-button
v-if="canToggleIssueState"
:loading="isToggleStateButtonLoading"
:class="[actionButtonClassNames, 'btn-comment btn-comment-and-close']"
:disabled="isSubmitting"
data-testid="close-reopen-button"
- @click="handleSave(true)"
+ @click="handleSave({ withIssueAction: true })"
>{{ issueActionButtonTitle }}</gl-button
>
</div>
diff --git a/app/assets/javascripts/notes/components/discussion_navigator.vue b/app/assets/javascripts/notes/components/discussion_navigator.vue
index fa3c900c337..7e8bb75902b 100644
--- a/app/assets/javascripts/notes/components/discussion_navigator.vue
+++ b/app/assets/javascripts/notes/components/discussion_navigator.vue
@@ -1,6 +1,11 @@
<script>
/* global Mousetrap */
import 'mousetrap';
+import {
+ keysFor,
+ MR_NEXT_UNRESOLVED_DISCUSSION,
+ MR_PREVIOUS_UNRESOLVED_DISCUSSION,
+} from '~/behaviors/shortcuts/keybindings';
import eventHub from '~/notes/event_hub';
import discussionNavigation from '~/notes/mixins/discussion_navigation';
@@ -10,12 +15,12 @@ export default {
eventHub.$on('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion);
},
mounted() {
- Mousetrap.bind('n', this.jumpToNextDiscussion);
- Mousetrap.bind('p', this.jumpToPreviousDiscussion);
+ Mousetrap.bind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION), this.jumpToNextDiscussion);
+ Mousetrap.bind(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION), this.jumpToPreviousDiscussion);
},
beforeDestroy() {
- Mousetrap.unbind('n');
- Mousetrap.unbind('p');
+ Mousetrap.unbind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION));
+ Mousetrap.unbind(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION));
eventHub.$off('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion);
},
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index 0f74d78c8e0..dfe2763d8bd 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -121,6 +121,7 @@ export default {
:is="componentName(firstNote)"
:note="componentData(firstNote)"
:line="line || diffLine"
+ :discussion-file="discussion.diff_file"
:commit="commit"
:help-page-path="helpPagePath"
:show-reply-button="userCanReply"
@@ -167,6 +168,7 @@ export default {
v-for="(note, index) in discussion.notes"
:key="note.id"
:note="componentData(note)"
+ :discussion-file="discussion.diff_file"
:help-page-path="helpPagePath"
:line="diffLine"
:discussion-root="index === 0"
diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
index cace382ccd6..5f429cbf462 100644
--- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
+++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
@@ -1,7 +1,11 @@
<script>
import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import { s__ } from '~/locale';
export default {
+ i18n: {
+ buttonLabel: s__('MergeRequests|Resolve this thread in a new issue'),
+ },
name: 'ResolveWithIssueButton',
components: {
GlButton,
@@ -23,7 +27,8 @@ export default {
<gl-button
v-gl-tooltip
:href="url"
- :title="s__('MergeRequests|Resolve this thread in a new issue')"
+ :title="$options.i18n.buttonLabel"
+ :aria-label="$options.i18n.buttonLabel"
class="new-issue-for-discussion discussion-create-issue-btn"
icon="issue-new"
/>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index ed6701b34e8..24399e669a6 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -13,6 +13,12 @@ import { splitCamelCase } from '../../lib/utils/text_utility';
import ReplyButton from './note_actions/reply_button.vue';
export default {
+ i18n: {
+ addReactionLabel: __('Add reaction'),
+ editCommentLabel: __('Edit comment'),
+ deleteCommentLabel: __('Delete comment'),
+ moreActionsLabel: __('More actions'),
+ },
name: 'NoteActions',
components: {
GlIcon,
@@ -119,9 +125,11 @@ export default {
type: Boolean,
required: true,
},
+ // This can be undefined when `canAwardEmoji` is false
awardPath: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
},
computed: {
@@ -301,9 +309,9 @@ export default {
category="tertiary"
variant="default"
size="small"
- title="Add reaction"
+ :title="$options.i18n.addReactionLabel"
+ :aria-label="$options.i18n.addReactionLabel"
data-position="right"
- :aria-label="__('Add reaction')"
>
<span class="reaction-control-icon reaction-control-icon-neutral">
<gl-icon name="slight-smile" />
@@ -325,32 +333,35 @@ export default {
<gl-button
v-if="canEdit"
v-gl-tooltip
- title="Edit comment"
+ :title="$options.i18n.editCommentLabel"
+ :aria-label="$options.i18n.editCommentLabel"
icon="pencil"
size="small"
category="tertiary"
- class="note-action-button js-note-edit btn btn-transparent"
+ class="note-action-button js-note-edit"
data-qa-selector="note_edit_button"
@click="onEdit"
/>
<gl-button
v-if="showDeleteAction"
v-gl-tooltip
- title="Delete comment"
+ :title="$options.i18n.deleteCommentLabel"
+ :aria-label="$options.i18n.deleteCommentLabel"
size="small"
icon="remove"
category="tertiary"
- class="note-action-button js-note-delete btn btn-transparent"
+ class="note-action-button js-note-delete"
@click="onDelete"
/>
<div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions">
<gl-button
v-gl-tooltip
- title="More actions"
+ :title="$options.i18n.moreActionsLabel"
+ :aria-label="$options.i18n.moreActionsLabel"
icon="ellipsis_v"
size="small"
category="tertiary"
- class="note-action-button more-actions-toggle btn btn-transparent"
+ class="note-action-button more-actions-toggle"
data-toggle="dropdown"
@click="closeTooltip"
/>
diff --git a/app/assets/javascripts/notes/components/note_attachment.vue b/app/assets/javascripts/notes/components/note_attachment.vue
index b20facc4032..c49f3e2de99 100644
--- a/app/assets/javascripts/notes/components/note_attachment.vue
+++ b/app/assets/javascripts/notes/components/note_attachment.vue
@@ -24,7 +24,7 @@ export default {
target="_blank"
rel="noopener noreferrer"
>
- <img :src="attachment.url" class="note-image-attach" />
+ <img :src="attachment.url" class="note-image-attach col-lg-4" />
</a>
<div class="attachment">
<a
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index d74ade15de1..a70bac94b71 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -60,6 +60,11 @@ export default {
required: false,
default: null,
},
+ lines: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
note: {
type: Object,
required: false,
@@ -333,6 +338,7 @@ export default {
:help-page-path="helpPagePath"
:show-suggest-popover="showSuggestPopover"
:textarea-value="updatedNoteBody"
+ :lines="lines"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
>
<template #textarea>
@@ -384,7 +390,7 @@ export default {
<gl-button
:disabled="isDisabled"
category="primary"
- variant="success"
+ variant="confirm"
class="gl-mr-3"
data-qa-selector="start_review_button"
@click="handleAddToReview"
@@ -418,7 +424,7 @@ export default {
<gl-button
:disabled="isDisabled"
category="primary"
- variant="success"
+ variant="confirm"
data-qa-selector="reply_comment_button"
class="gl-mr-3 js-vue-issue-save js-comment-button"
@click="handleUpdate()"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 185f4a70367..0feb77be653 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -48,6 +48,11 @@ export default {
required: false,
default: null,
},
+ discussionFile: {
+ type: Object,
+ required: false,
+ default: null,
+ },
helpPagePath: {
type: String,
required: false,
@@ -86,7 +91,7 @@ export default {
isRequesting: false,
isResolving: false,
commentLineStart: {},
- resolveAsThread: this.glFeatures.removeResolveNote,
+ resolveAsThread: true,
};
},
computed: {
@@ -139,14 +144,9 @@ export default {
return this.note.isDraft;
},
canResolve() {
- if (this.glFeatures.removeResolveNote && !this.discussionRoot) return false;
+ if (!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)
- );
+ return this.note.current_user.can_resolve_discussion;
},
lineRange() {
return this.note.position?.line_range;
@@ -172,12 +172,18 @@ export default {
return commentLineOptions(lines, this.commentLineStart, this.line.line_code);
},
diffFile() {
+ let fileResolvedFromAvailableSource;
+
if (this.commentLineStart.line_code) {
const lineCode = this.commentLineStart.line_code.split('_')[0];
- return this.getDiffFileByHash(lineCode);
+ fileResolvedFromAvailableSource = this.getDiffFileByHash(lineCode);
+ }
+
+ if (!fileResolvedFromAvailableSource && this.discussionFile) {
+ fileResolvedFromAvailableSource = this.discussionFile;
}
- return null;
+ return fileResolvedFromAvailableSource || null;
},
},
created() {
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 58cfd150659..433f75a752d 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -3,8 +3,10 @@ import { mapGetters, mapActions } from 'vuex';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import { __ } from '~/locale';
import initUserPopovers from '~/user_popovers';
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import OrderedLayout from '~/vue_shared/components/ordered_layout.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import draftNote from '../../batch_comments/components/draft_note.vue';
import { deprecatedCreateFlash as Flash } from '../../flash';
import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility';
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
@@ -32,6 +34,8 @@ export default {
discussionFilterNote,
OrderedLayout,
SidebarSubscription,
+ draftNote,
+ TimelineEntryItem,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -276,6 +280,9 @@ export default {
<ul id="notes-list" class="notes main-notes-list timeline">
<template v-for="discussion in allDiscussions">
<skeleton-loading-container v-if="discussion.isSkeletonNote" :key="discussion.id" />
+ <timeline-entry-item v-else-if="discussion.isDraft" :key="discussion.id">
+ <draft-note :draft="discussion" />
+ </timeline-entry-item>
<template v-else-if="discussion.isPlaceholderNote">
<placeholder-system-note
v-if="discussion.placeholderType === $options.systemNote"
diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue
index ed1f456c174..92c39fbb9f0 100644
--- a/app/assets/javascripts/notes/components/sort_discussion.vue
+++ b/app/assets/javascripts/notes/components/sort_discussion.vue
@@ -49,18 +49,17 @@ export default {
</script>
<template>
- <div class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile">
+ <div
+ data-testid="sort-discussion-filter"
+ class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile"
+ >
<local-storage-sync
:value="sortDirection"
:storage-key="storageKey"
:persist="persistSortOrder"
@input="setDiscussionSortDirection({ direction: $event })"
/>
- <gl-dropdown
- :text="dropdownText"
- data-testid="sort-discussion-filter"
- class="js-dropdown-text full-width-mobile"
- >
+ <gl-dropdown :text="dropdownText" class="js-dropdown-text full-width-mobile">
<gl-dropdown-item
v-for="{ text, key, cls } in $options.SORT_OPTIONS"
:key="key"
diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js
index baada4c5ce8..27ed8e203b0 100644
--- a/app/assets/javascripts/notes/mixins/resolvable.js
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -1,24 +1,11 @@
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);
- }
-
- return resolved;
+ return Boolean(this.discussion.resolved);
}
return this.note.resolved;
@@ -47,7 +34,7 @@ export default {
let endpoint =
discussion && this.discussion ? this.discussion.resolve_path : `${this.note.path}/resolve`;
- if (this.glFeatures.removeResolveNote && this.discussionResolvePath) {
+ if (this.discussionResolvePath) {
endpoint = this.discussionResolvePath;
}
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 43d99937b8d..39f66063cfb 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -2,7 +2,23 @@ import { flattenDeep, clone } from 'lodash';
import * as constants from '../constants';
import { collapseSystemNotes } from './collapse_utils';
-export const discussions = (state) => {
+const getDraftComments = (state) => {
+ if (!state.batchComments) {
+ return [];
+ }
+
+ return state.batchComments.drafts
+ .filter((draft) => !draft.file_path && !draft.discussion_id)
+ .map((x) => ({
+ ...x,
+ // Treat a top-level draft note as individual_note so it's not included in
+ // expand/collapse threads
+ individual_note: true,
+ }))
+ .sort((a, b) => a.id - b.id);
+};
+
+export const discussions = (state, getters, rootState) => {
let discussionsInState = clone(state.discussions);
// NOTE: not testing bc will be removed when backend is finished.
@@ -22,11 +38,15 @@ export const discussions = (state) => {
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
}
+ discussionsInState = collapseSystemNotes(discussionsInState);
+
+ discussionsInState = discussionsInState.concat(getDraftComments(rootState));
+
if (state.discussionSortOrder === constants.DESC) {
discussionsInState = discussionsInState.reverse();
}
- return collapseSystemNotes(discussionsInState);
+ return discussionsInState;
};
export const convertedDisscussionIds = (state) => state.convertedDisscussionIds;
@@ -257,3 +277,6 @@ export const commentsDisabled = (state) => state.commentsDisabled;
export const suggestionsCount = (state, getters) =>
Object.values(getters.notesById).filter((n) => n.suggestions.length).length;
+
+export const hasDrafts = (state, getters, rootState, rootGetters) =>
+ Boolean(rootGetters['batchComments/hasDrafts']);
diff --git a/app/assets/javascripts/operation_settings/components/metrics_settings.vue b/app/assets/javascripts/operation_settings/components/metrics_settings.vue
index 0c0bbb744b3..a24612e4680 100644
--- a/app/assets/javascripts/operation_settings/components/metrics_settings.vue
+++ b/app/assets/javascripts/operation_settings/components/metrics_settings.vue
@@ -31,7 +31,9 @@ export default {
<template>
<section class="settings no-animate">
<div class="settings-header">
- <h4 class="js-section-header">
+ <h4
+ class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
+ >
{{ s__('MetricsSettings|Metrics dashboard') }}
</h4>
<gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
diff --git a/app/assets/javascripts/packages/list/components/package_search.vue b/app/assets/javascripts/packages/list/components/package_search.vue
index cd61d323d83..2e183b1b978 100644
--- a/app/assets/javascripts/packages/list/components/package_search.vue
+++ b/app/assets/javascripts/packages/list/components/package_search.vue
@@ -2,7 +2,8 @@
import { mapState, mapActions } from 'vuex';
import { __, s__ } from '~/locale';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
-import getTableHeaders from '../utils';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
+import { sortableFields } from '../utils';
import PackageTypeToken from './tokens/package_type_token.vue';
export default {
@@ -16,7 +17,7 @@ export default {
operators: [{ value: '=', description: __('is'), default: 'true' }],
},
],
- components: { RegistrySearch },
+ components: { RegistrySearch, UrlSync },
computed: {
...mapState({
isGroupPage: (state) => state.config.isGroupPage,
@@ -24,7 +25,7 @@ export default {
filter: (state) => state.filter,
}),
sortableFields() {
- return getTableHeaders(this.isGroupPage);
+ return sortableFields(this.isGroupPage);
},
},
methods: {
@@ -38,13 +39,18 @@ export default {
</script>
<template>
- <registry-search
- :filter="filter"
- :sorting="sorting"
- :tokens="$options.tokens"
- :sortable-fields="sortableFields"
- @sorting:changed="updateSorting"
- @filter:changed="setFilter"
- @filter:submit="$emit('update')"
- />
+ <url-sync>
+ <template #default="{ updateQuery }">
+ <registry-search
+ :filter="filter"
+ :sorting="sorting"
+ :tokens="$options.tokens"
+ :sortable-fields="sortableFields"
+ @sorting:changed="updateSorting"
+ @filter:changed="setFilter"
+ @filter:submit="$emit('update')"
+ @query:changed="updateQuery"
+ />
+ </template>
+ </url-sync>
</template>
diff --git a/app/assets/javascripts/packages/list/components/package_title.vue b/app/assets/javascripts/packages/list/components/package_title.vue
index 6176e15ffd4..426ad150ea9 100644
--- a/app/assets/javascripts/packages/list/components/package_title.vue
+++ b/app/assets/javascripts/packages/list/components/package_title.vue
@@ -11,25 +11,25 @@ export default {
MetadataItem,
},
props: {
- packagesCount: {
+ count: {
type: Number,
required: false,
default: null,
},
- packageHelpUrl: {
+ helpUrl: {
type: String,
required: true,
},
},
computed: {
showPackageCount() {
- return Number.isInteger(this.packagesCount);
+ return Number.isInteger(this.count);
},
packageAmountText() {
- return n__(`%d Package`, `%d Packages`, this.packagesCount);
+ return n__(`%d Package`, `%d Packages`, this.count);
},
infoMessages() {
- return [{ text: LIST_INTRO_TEXT, link: this.packageHelpUrl }];
+ return [{ text: LIST_INTRO_TEXT, link: this.helpUrl }];
},
},
i18n: {
diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages/list/components/packages_list_app.vue
index a609dfebedf..4c5fb0ee7c9 100644
--- a/app/assets/javascripts/packages/list/components/packages_list_app.vue
+++ b/app/assets/javascripts/packages/list/components/packages_list_app.vue
@@ -5,9 +5,9 @@ import createFlash from '~/flash';
import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
+import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants';
-import PackageSearch from './package_search.vue';
-import PackageTitle from './package_title.vue';
import PackageList from './packages_list.vue';
export default {
@@ -16,8 +16,38 @@ export default {
GlLink,
GlSprintf,
PackageList,
- PackageTitle,
- PackageSearch,
+ PackageTitle: () =>
+ import(/* webpackChunkName: 'package_registry_components' */ './package_title.vue'),
+ PackageSearch: () =>
+ import(/* webpackChunkName: 'package_registry_components' */ './package_search.vue'),
+ InfrastructureTitle: () =>
+ import(
+ /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue'
+ ),
+ InfrastructureSearch: () =>
+ import(
+ /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue'
+ ),
+ },
+ inject: {
+ titleComponent: {
+ from: 'titleComponent',
+ default: 'PackageTitle',
+ },
+ searchComponent: {
+ from: 'searchComponent',
+ default: 'PackageSearch',
+ },
+ emptyPageTitle: {
+ from: 'emptyPageTitle',
+ default: s__('PackageRegistry|There are no packages yet'),
+ },
+ noResultsText: {
+ from: 'noResultsText',
+ default: s__(
+ 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
+ ),
+ },
},
computed: {
...mapState({
@@ -30,22 +60,32 @@ export default {
}),
emptySearch() {
return (
- this.filter.filter((f) => f.type !== 'filtered-search-term' || f.value?.data).length === 0
+ this.filter.filter((f) => f.type !== FILTERED_SEARCH_TERM || f.value?.data).length === 0
);
},
emptyStateTitle() {
return this.emptySearch
- ? s__('PackageRegistry|There are no packages yet')
+ ? this.emptyPageTitle
: s__('PackageRegistry|Sorry, your filter produced no results');
},
},
mounted() {
+ const queryParams = getQueryParams(window.document.location.search);
+ const { sorting, filters } = extractFilterAndSorting(queryParams);
+ this.setSorting(sorting);
+ this.setFilter(filters);
this.requestPackagesList();
this.checkDeleteAlert();
},
methods: {
- ...mapActions(['requestPackagesList', 'requestDeletePackage', 'setSelectedType']),
+ ...mapActions([
+ 'requestPackagesList',
+ 'requestDeletePackage',
+ 'setSelectedType',
+ 'setSorting',
+ 'setFilter',
+ ]),
onPageChanged(page) {
return this.requestPackagesList({ page });
},
@@ -65,24 +105,21 @@ export default {
},
i18n: {
widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
- noResults: s__(
- 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
- ),
},
};
</script>
<template>
<div>
- <package-title :package-help-url="packageHelpUrl" :packages-count="packagesCount" />
- <package-search @update="requestPackagesList" />
+ <component :is="titleComponent" :help-url="packageHelpUrl" :count="packagesCount" />
+ <component :is="searchComponent" @update="requestPackagesList" />
<package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
<template #empty-state>
<gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
<template #description>
<gl-sprintf v-if="!emptySearch" :message="$options.i18n.widenFilters" />
- <gl-sprintf v-else :message="$options.i18n.noResults">
+ <gl-sprintf v-else :message="noResultsText">
<template #noPackagesLink="{ content }">
<gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
</template>
diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js
index 25a55200df2..b4fe3c70dea 100644
--- a/app/assets/javascripts/packages/list/constants.js
+++ b/app/assets/javascripts/packages/list/constants.js
@@ -82,6 +82,10 @@ export const PACKAGE_TYPES = [
title: s__('PackageRegistry|PyPI'),
type: PackageType.PYPI,
},
+ {
+ title: s__('PackageRegistry|RubyGems'),
+ type: PackageType.RUBYGEMS,
+ },
];
export const LIST_TITLE_TEXT = s__('PackageRegistry|Package Registry');
diff --git a/app/assets/javascripts/packages/list/packages_list_app_bundle.js b/app/assets/javascripts/packages/list/packages_list_app_bundle.js
index 58b09c1ebd1..2911cf70a33 100644
--- a/app/assets/javascripts/packages/list/packages_list_app_bundle.js
+++ b/app/assets/javascripts/packages/list/packages_list_app_bundle.js
@@ -1,11 +1,8 @@
import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
import Translate from '~/vue_shared/translate';
import PackagesListApp from './components/packages_list_app.vue';
import { createStore } from './stores';
-Vue.use(VueApollo);
Vue.use(Translate);
export default () => {
@@ -13,14 +10,9 @@ export default () => {
const store = createStore();
store.dispatch('setInitialState', el.dataset);
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
-
return new Vue({
el,
store,
- apolloProvider,
components: {
PackagesListApp,
},
diff --git a/app/assets/javascripts/packages/list/utils.js b/app/assets/javascripts/packages/list/utils.js
index ee89d3cdefe..537b30d2ca4 100644
--- a/app/assets/javascripts/packages/list/utils.js
+++ b/app/assets/javascripts/packages/list/utils.js
@@ -1,7 +1,7 @@
import { LIST_KEY_PROJECT, SORT_FIELDS } from './constants';
-export default (isGroupPage) =>
- SORT_FIELDS.filter((f) => f.key !== LIST_KEY_PROJECT || isGroupPage);
+export const sortableFields = (isGroupPage) =>
+ SORT_FIELDS.filter((f) => f.orderBy !== LIST_KEY_PROJECT || isGroupPage);
/**
* A small util function that works out if the delete action has deleted the
diff --git a/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue b/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue
new file mode 100644
index 00000000000..105f7bbe132
--- /dev/null
+++ b/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ name: 'PackageIconAndName',
+ components: {
+ GlIcon,
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-icon name="package" class="gl-ml-3 gl-mr-2" />
+ <span><slot></slot></span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages/shared/components/package_list_row.vue
index 172b356227a..4de4c191e51 100644
--- a/app/assets/javascripts/packages/shared/components/package_list_row.vue
+++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlIcon, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
+import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { getPackageTypeLabel } from '../utils';
@@ -11,7 +11,6 @@ export default {
name: 'PackageListRow',
components: {
GlButton,
- GlIcon,
GlLink,
GlSprintf,
GlTruncate,
@@ -19,11 +18,23 @@ export default {
PackagePath,
PublishMethod,
ListItem,
+ PackageIconAndName: () =>
+ import(/* webpackChunkName: 'package_registry_components' */ './package_icon_and_name.vue'),
+ InfrastructureIconAndName: () =>
+ import(
+ /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue'
+ ),
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
+ inject: {
+ iconComponent: {
+ from: 'iconComponent',
+ default: 'PackageIconAndName',
+ },
+ },
props: {
packageEntity: {
type: Object,
@@ -94,10 +105,9 @@ export default {
</gl-sprintf>
</div>
- <div v-if="showPackageType" class="d-flex align-items-center" data-testid="package-type">
- <gl-icon name="package" class="gl-ml-3 gl-mr-2" />
- <span>{{ packageType }}</span>
- </div>
+ <component :is="iconComponent" v-if="showPackageType">
+ {{ packageType }}
+ </component>
<package-path v-if="hasProjectLink" :path="packageEntity.project_path" />
</div>
diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js
index c0f7f150337..f7de31c2c86 100644
--- a/app/assets/javascripts/packages/shared/constants.js
+++ b/app/assets/javascripts/packages/shared/constants.js
@@ -7,6 +7,7 @@ export const PackageType = {
NUGET: 'nuget',
PYPI: 'pypi',
COMPOSER: 'composer',
+ RUBYGEMS: 'rubygems',
GENERIC: 'generic',
};
diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js
index d34372e89b6..bd35a47ca4d 100644
--- a/app/assets/javascripts/packages/shared/utils.js
+++ b/app/assets/javascripts/packages/shared/utils.js
@@ -10,19 +10,21 @@ export const beautifyPath = (path) => (path ? path.split('/').join(' / ') : '');
export const getPackageTypeLabel = (packageType) => {
switch (packageType) {
case PackageType.CONAN:
- return s__('PackageType|Conan');
+ return s__('PackageRegistry|Conan');
case PackageType.MAVEN:
- return s__('PackageType|Maven');
+ return s__('PackageRegistry|Maven');
case PackageType.NPM:
- return s__('PackageType|npm');
+ return s__('PackageRegistry|npm');
case PackageType.NUGET:
- return s__('PackageType|NuGet');
+ return s__('PackageRegistry|NuGet');
case PackageType.PYPI:
- return s__('PackageType|PyPI');
+ return s__('PackageRegistry|PyPI');
+ case PackageType.RUBYGEMS:
+ return s__('PackageRegistry|RubyGems');
case PackageType.COMPOSER:
- return s__('PackageType|Composer');
+ return s__('PackageRegistry|Composer');
case PackageType.GENERIC:
- return s__('PackageType|Generic');
+ return s__('PackageRegistry|Generic');
default:
return null;
}
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue
new file mode 100644
index 00000000000..3100a1a7296
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ name: 'InfrastructureIconAndName',
+ components: {
+ GlIcon,
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-icon name="infrastructure-registry" class="gl-ml-3 gl-mr-2" />
+ <span>{{ s__('InfrastructureRegistry|Terraform') }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue
new file mode 100644
index 00000000000..4928da862ea
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue
@@ -0,0 +1,45 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { LIST_KEY_PACKAGE_TYPE } from '~/packages/list/constants';
+import { sortableFields } from '~/packages/list/utils';
+import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
+
+export default {
+ components: { RegistrySearch, UrlSync },
+ computed: {
+ ...mapState({
+ isGroupPage: (state) => state.config.isGroupPage,
+ sorting: (state) => state.sorting,
+ filter: (state) => state.filter,
+ }),
+ sortableFields() {
+ return sortableFields(this.isGroupPage).filter((h) => h.orderBy !== LIST_KEY_PACKAGE_TYPE);
+ },
+ },
+ methods: {
+ ...mapActions(['setSorting', 'setFilter']),
+ updateSorting(newValue) {
+ this.setSorting(newValue);
+ this.$emit('update');
+ },
+ },
+};
+</script>
+
+<template>
+ <url-sync>
+ <template #default="{ updateQuery }">
+ <registry-search
+ :filter="filter"
+ :sorting="sorting"
+ :tokens="[]"
+ :sortable-fields="sortableFields"
+ @sorting:changed="updateSorting"
+ @filter:changed="setFilter"
+ @filter:submit="$emit('update')"
+ @query:changed="updateQuery"
+ />
+ </template>
+ </url-sync>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue
new file mode 100644
index 00000000000..2a479c65d0c
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue
@@ -0,0 +1,53 @@
+<script>
+import { s__, n__ } from '~/locale';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+
+export default {
+ name: 'InfrastructureTitle',
+ components: {
+ TitleArea,
+ MetadataItem,
+ },
+ props: {
+ count: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ helpUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ showModuleCount() {
+ return Number.isInteger(this.count);
+ },
+ moduleAmountText() {
+ return n__(`%d Module`, `%d Modules`, this.count);
+ },
+ infoMessages() {
+ return [{ text: this.$options.i18n.LIST_INTRO_TEXT, link: this.helpUrl }];
+ },
+ },
+ i18n: {
+ LIST_TITLE_TEXT: s__('InfrastructureRegistry|Infrastructure Registry'),
+ LIST_INTRO_TEXT: s__(
+ 'InfrastructureRegistry|Publish and share your modules. %{docLinkStart}More information%{docLinkEnd}',
+ ),
+ },
+};
+</script>
+
+<template>
+ <title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages">
+ <template #metadata-amount>
+ <metadata-item
+ v-if="showModuleCount"
+ icon="infrastructure-registry"
+ :text="moduleAmountText"
+ />
+ </template>
+ </title-area>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js
new file mode 100644
index 00000000000..88ee8a4200e
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import { s__ } from '~/locale';
+import PackagesListApp from '~/packages/list/components/packages_list_app.vue';
+import { createStore } from '~/packages/list/stores';
+import Translate from '~/vue_shared/translate';
+
+Vue.use(Translate);
+
+export default () => {
+ const el = document.getElementById('js-vue-packages-list');
+ const store = createStore();
+ store.dispatch('setInitialState', el.dataset);
+
+ return new Vue({
+ el,
+ store,
+ components: {
+ PackagesListApp,
+ },
+ provide: {
+ titleComponent: 'InfrastructureTitle',
+ searchComponent: 'InfrastructureSearch',
+ iconComponent: 'InfrastructureIconAndName',
+ emptyPageTitle: s__('InfrastructureRegistry|You have no Terraform modules in your project'),
+ noResultsText: s__(
+ 'InfrastructureRegistry|Terraform modules are the main way to package and reuse resource configurations with Terraform. Learn more about how to %{noPackagesLinkStart}create Terraform modules%{noPackagesLinkEnd} in GitLab.',
+ ),
+ },
+ render(createElement) {
+ return createElement('packages-list-app');
+ },
+ });
+};
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
index d4f51b83e1e..faacabb44ce 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
@@ -2,6 +2,7 @@
import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui';
import {
+ MAVEN_TOGGLE_LABEL,
MAVEN_TITLE,
MAVEN_SETTINGS_SUBTITLE,
MAVEN_DUPLICATES_ALLOWED_DISABLED,
@@ -15,6 +16,7 @@ import {
export default {
name: 'MavenSettings',
i18n: {
+ MAVEN_TOGGLE_LABEL,
MAVEN_TITLE,
MAVEN_SETTINGS_SUBTITLE,
MAVEN_SETTING_EXCEPTION_TITLE,
@@ -80,6 +82,8 @@ export default {
<div class="gl-display-flex">
<gl-toggle
data-qa-selector="allow_duplicates_toggle"
+ :label="$options.i18n.MAVEN_TOGGLE_LABEL"
+ label-position="hidden"
:value="mavenDuplicatesAllowed"
@change="update($options.modelNames.MAVEN_DUPLICATES_ALLOWED, $event)"
/>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
index 72bec74060c..d52a6a626f9 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -8,6 +8,7 @@ export const PACKAGE_SETTINGS_DESCRIPTION = s__(
export const MAVEN_TITLE = s__('PackageRegistry|Maven');
export const MAVEN_SETTINGS_SUBTITLE = s__('PackageRegistry|Settings for Maven packages');
+export const MAVEN_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates');
export const MAVEN_DUPLICATES_ALLOWED_DISABLED = s__(
'PackageRegistry|%{boldStart}Do not allow duplicates%{boldEnd} - Packages with the same name and version are rejected.',
);
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants.js b/app/assets/javascripts/packages_and_registries/shared/constants.js
new file mode 100644
index 00000000000..55b5816cc5a
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/shared/constants.js
@@ -0,0 +1 @@
+export const FILTERED_SEARCH_TERM = 'filtered-search-term';
diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js
new file mode 100644
index 00000000000..cc5c7ce82bf
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/shared/utils.js
@@ -0,0 +1,29 @@
+import { queryToObject } from '~/lib/utils/url_utility';
+import { FILTERED_SEARCH_TERM } from './constants';
+
+export const getQueryParams = (query) => queryToObject(query, { gatherArrays: true });
+
+export const keyValueToFilterToken = (type, data) => ({ type, value: { data } });
+
+export const searchArrayToFilterTokens = (search) =>
+ search.map((s) => keyValueToFilterToken(FILTERED_SEARCH_TERM, s));
+
+export const extractFilterAndSorting = (queryObject) => {
+ const { type, search, sort, orderBy } = queryObject;
+ const filters = [];
+ const sorting = {};
+
+ if (type) {
+ filters.push(keyValueToFilterToken('type', type));
+ }
+ if (search) {
+ filters.push(...searchArrayToFilterTokens(search));
+ }
+ if (sort) {
+ sorting.sort = sort;
+ }
+ if (orderBy) {
+ sorting.orderBy = orderBy;
+ }
+ return { filters, sorting };
+};
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
index f78d2b0dbd3..3ad9d80b4f2 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -8,20 +8,21 @@ const ENDLESS_SCROLL_BOTTOM_PX = 400;
const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000;
export default {
- init(
+ init({
limit = 0,
preload = false,
disable = false,
prepareData = $.noop,
- callback = $.noop,
+ successCallback = $.noop,
+ errorCallback = $.noop,
container = '',
- ) {
- this.url = $('.content_list').data('href') || removeParams(['limit', 'offset']);
+ } = {}) {
this.limit = limit;
this.offset = parseInt(getParameterByName('offset'), 10) || this.limit;
this.disable = disable;
this.prepareData = prepareData;
- this.callback = callback;
+ this.successCallback = successCallback;
+ this.errorCallback = errorCallback;
this.loading = $(`${container} .loading`).first();
if (preload) {
this.offset = 0;
@@ -32,8 +33,10 @@ export default {
getOld() {
this.loading.show();
+ const url = $('.content_list').data('href') || removeParams(['limit', 'offset']);
+
axios
- .get(this.url, {
+ .get(url, {
params: {
limit: this.limit,
offset: this.offset,
@@ -41,7 +44,7 @@ export default {
})
.then(({ data }) => {
this.append(data.count, this.prepareData(data.html));
- this.callback();
+ this.successCallback();
// keep loading until we've filled the viewport height
if (!this.disable && !this.isScrollable()) {
@@ -50,7 +53,8 @@ export default {
this.loading.hide();
}
})
- .catch(() => this.loading.hide());
+ .catch((err) => this.errorCallback(err))
+ .finally(() => this.loading.hide());
},
append(count, html) {
diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js
index 0a4311ec73a..a88d35796f7 100644
--- a/app/assets/javascripts/pages/admin/abuse_reports/index.js
+++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js
@@ -1,5 +1,8 @@
+import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
import UsersSelect from '~/users_select';
import AbuseReports from './abuse_reports';
new AbuseReports(); /* eslint-disable-line no-new */
new UsersSelect(); /* eslint-disable-line no-new */
+
+document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior);
diff --git a/app/assets/javascripts/pages/admin/admin.js b/app/assets/javascripts/pages/admin/admin.js
index 2732fc191be..6b7bfbf217d 100644
--- a/app/assets/javascripts/pages/admin/admin.js
+++ b/app/assets/javascripts/pages/admin/admin.js
@@ -1,16 +1,6 @@
import $ from 'jquery';
import { refreshCurrentPage } from '../../lib/utils/url_utility';
-function showDenylistType() {
- if ($('input[name="denylist_type"]:checked').val() === 'file') {
- $('.js-denylist-file').show();
- $('.js-denylist-raw').hide();
- } else {
- $('.js-denylist-file').hide();
- $('.js-denylist-raw').show();
- }
-}
-
export default function adminInit() {
$('input#user_force_random_password').on('change', function randomPasswordClick() {
const $elems = $('#user_password, #user_password_confirmation');
@@ -27,7 +17,4 @@ export default function adminInit() {
});
$('li.project_member, li.group_member').on('ajax:success', refreshCurrentPage);
-
- $("input[name='denylist_type']").on('click', showDenylistType);
- showDenylistType();
}
diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_checkbox.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_checkbox.vue
new file mode 100644
index 00000000000..2217792d7f3
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_checkbox.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlFormCheckbox } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFormCheckbox,
+ },
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ helpText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: Boolean,
+ required: true,
+ },
+ dataQaSelector: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <input :name="name" type="hidden" :value="value ? '1' : '0'" data-testid="input" />
+
+ <gl-form-checkbox
+ :checked="value"
+ :data-qa-selector="dataQaSelector"
+ @input="$emit('input', $event)"
+ >
+ <span data-testid="label">{{ label }}</span>
+ <template v-if="helpText" #help>
+ <span data-testid="helpText">{{ helpText }}</span>
+ </template>
+ </gl-form-checkbox>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
new file mode 100644
index 00000000000..9850113d4be
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
@@ -0,0 +1,417 @@
+<script>
+import {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlSprintf,
+ GlLink,
+ GlModal,
+} from '@gitlab/ui';
+import { toSafeInteger } from 'lodash';
+import csrf from '~/lib/utils/csrf';
+import { __, s__, sprintf } from '~/locale';
+import SignupCheckbox from './signup_checkbox.vue';
+
+const DENYLIST_TYPE_RAW = 'raw';
+const DENYLIST_TYPE_FILE = 'file';
+
+export default {
+ csrf,
+ DENYLIST_TYPE_RAW,
+ DENYLIST_TYPE_FILE,
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlSprintf,
+ GlLink,
+ SignupCheckbox,
+ GlModal,
+ },
+ inject: [
+ 'host',
+ 'settingsPath',
+ 'signupEnabled',
+ 'requireAdminApprovalAfterUserSignup',
+ 'sendUserConfirmationEmail',
+ 'minimumPasswordLength',
+ 'minimumPasswordLengthMin',
+ 'minimumPasswordLengthMax',
+ 'minimumPasswordLengthHelpLink',
+ 'domainAllowlistRaw',
+ 'newUserSignupsCap',
+ 'domainDenylistEnabled',
+ 'denylistTypeRawSelected',
+ 'domainDenylistRaw',
+ 'emailRestrictionsEnabled',
+ 'supportedSyntaxLinkUrl',
+ 'emailRestrictions',
+ 'afterSignUpText',
+ ],
+ data() {
+ return {
+ showModal: false,
+ form: {
+ signupEnabled: this.signupEnabled,
+ requireAdminApproval: this.requireAdminApprovalAfterUserSignup,
+ sendConfirmationEmail: this.sendUserConfirmationEmail,
+ minimumPasswordLength: this.minimumPasswordLength,
+ minimumPasswordLengthMin: this.minimumPasswordLengthMin,
+ minimumPasswordLengthMax: this.minimumPasswordLengthMax,
+ minimumPasswordLengthHelpLink: this.minimumPasswordLengthHelpLink,
+ domainAllowlistRaw: this.domainAllowlistRaw,
+ userCap: this.newUserSignupsCap,
+ domainDenylistEnabled: this.domainDenylistEnabled,
+ denylistType: this.denylistTypeRawSelected
+ ? this.$options.DENYLIST_TYPE_RAW
+ : this.$options.DENYLIST_TYPE_FILE,
+ domainDenylistRaw: this.domainDenylistRaw,
+ emailRestrictionsEnabled: this.emailRestrictionsEnabled,
+ supportedSyntaxLinkUrl: this.supportedSyntaxLinkUrl,
+ emailRestrictions: this.emailRestrictions,
+ afterSignUpText: this.afterSignUpText,
+ },
+ };
+ },
+ computed: {
+ isOldUserCapUnlimited() {
+ // User cap is set to unlimited if no value is provided in the field
+ return this.newUserSignupsCap === '';
+ },
+ isNewUserCapUnlimited() {
+ // User cap is set to unlimited if no value is provided in the field
+ return this.form.userCap === '';
+ },
+ hasUserCapChangedFromUnlimitedToLimited() {
+ return this.isOldUserCapUnlimited && !this.isNewUserCapUnlimited;
+ },
+ hasUserCapChangedFromLimitedToUnlimited() {
+ return !this.isOldUserCapUnlimited && this.isNewUserCapUnlimited;
+ },
+ hasUserCapBeenIncreased() {
+ if (this.hasUserCapChangedFromUnlimitedToLimited) {
+ return false;
+ }
+
+ const oldValueAsInteger = toSafeInteger(this.newUserSignupsCap);
+ const newValueAsInteger = toSafeInteger(this.form.userCap);
+
+ return this.hasUserCapChangedFromLimitedToUnlimited || newValueAsInteger > oldValueAsInteger;
+ },
+ canUsersBeAccidentallyApproved() {
+ const hasUserCapBeenToggledOff =
+ this.requireAdminApprovalAfterUserSignup && !this.form.requireAdminApproval;
+
+ return this.hasUserCapBeenIncreased || hasUserCapBeenToggledOff;
+ },
+ signupEnabledHelpText() {
+ const text = sprintf(
+ s__(
+ 'ApplicationSettings|When enabled, any user visiting %{host} will be able to create an account.',
+ ),
+ {
+ host: this.host,
+ },
+ );
+
+ return text;
+ },
+ requireAdminApprovalHelpText() {
+ const text = sprintf(
+ s__(
+ 'ApplicationSettings|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: this.host,
+ },
+ );
+
+ return text;
+ },
+ },
+ watch: {
+ showModal(value) {
+ if (value === true) {
+ this.$refs[this.$options.modal.id].show();
+ } else {
+ this.$refs[this.$options.modal.id].hide();
+ }
+ },
+ },
+ methods: {
+ submitButtonHandler() {
+ if (this.canUsersBeAccidentallyApproved) {
+ this.showModal = true;
+
+ return;
+ }
+
+ this.submitForm();
+ },
+ submitForm() {
+ this.$refs.form.submit();
+ },
+ modalHideHandler() {
+ this.showModal = false;
+ },
+ },
+ i18n: {
+ buttonText: s__('ApplicationSettings|Save changes'),
+ signupEnabledLabel: s__('ApplicationSettings|Sign-up enabled'),
+ requireAdminApprovalLabel: s__('ApplicationSettings|Require admin approval for new sign-ups'),
+ sendConfirmationEmailLabel: s__('ApplicationSettings|Send confirmation email on sign-up'),
+ minimumPasswordLengthLabel: s__(
+ 'ApplicationSettings|Minimum password length (number of characters)',
+ ),
+ domainAllowListLabel: s__('ApplicationSettings|Allowed domains for sign-ups'),
+ domainAllowListDescription: s__(
+ 'ApplicationSettings|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',
+ ),
+ userCapLabel: s__('ApplicationSettings|User cap'),
+ userCapDescription: s__(
+ 'ApplicationSettings|Once the instance reaches the user cap, any user who is added or requests access will have to be approved by an admin. Leave the field empty for unlimited.',
+ ),
+ domainDenyListGroupLabel: s__('ApplicationSettings|Domain denylist'),
+ domainDenyListLabel: s__('ApplicationSettings|Enable domain denylist for sign ups'),
+ domainDenyListTypeFileLabel: s__('ApplicationSettings|Upload denylist file'),
+ domainDenyListTypeRawLabel: s__('ApplicationSettings|Enter denylist manually'),
+ domainDenyListFileLabel: s__('ApplicationSettings|Denylist file'),
+ domainDenyListFileDescription: s__(
+ 'ApplicationSettings|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.',
+ ),
+ domainDenyListListLabel: s__('ApplicationSettings|Denied domains for sign-ups'),
+ domainDenyListListDescription: s__(
+ 'ApplicationSettings|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',
+ ),
+ domainPlaceholder: s__('ApplicationSettings|domain.com'),
+ emailRestrictionsEnabledGroupLabel: s__('ApplicationSettings|Email restrictions'),
+ emailRestrictionsEnabledLabel: s__(
+ 'ApplicationSettings|Enable email restrictions for sign ups',
+ ),
+ emailRestrictionsGroupLabel: s__('ApplicationSettings|Email restrictions for sign-ups'),
+ afterSignUpTextGroupLabel: s__('ApplicationSettings|After sign up text'),
+ afterSignUpTextGroupDescription: s__('ApplicationSettings|Markdown enabled'),
+ },
+ modal: {
+ id: 'signup-settings-modal',
+ actionPrimary: {
+ text: s__('ApplicationSettings|Approve users'),
+ attributes: {
+ variant: 'confirm',
+ },
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ title: s__('ApplicationSettings|Approve all users in the pending approval status?'),
+ text: s__(
+ 'ApplicationSettings|By making this change, you will automatically approve all users in pending approval status.',
+ ),
+ },
+};
+</script>
+
+<template>
+ <form
+ ref="form"
+ accept-charset="UTF-8"
+ data-testid="form"
+ method="post"
+ :action="settingsPath"
+ enctype="multipart/form-data"
+ >
+ <input type="hidden" name="utf8" value="✓" />
+ <input type="hidden" name="_method" value="patch" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+
+ <section class="gl-mb-8">
+ <signup-checkbox
+ v-model="form.signupEnabled"
+ class="gl-mb-5"
+ name="application_setting[signup_enabled]"
+ :help-text="signupEnabledHelpText"
+ :label="$options.i18n.signupEnabledLabel"
+ data-qa-selector="signup_enabled_checkbox"
+ />
+
+ <signup-checkbox
+ v-model="form.requireAdminApproval"
+ class="gl-mb-5"
+ name="application_setting[require_admin_approval_after_user_signup]"
+ :help-text="requireAdminApprovalHelpText"
+ :label="$options.i18n.requireAdminApprovalLabel"
+ data-qa-selector="require_admin_approval_after_user_signup_checkbox"
+ data-testid="require-admin-approval-checkbox"
+ />
+
+ <signup-checkbox
+ v-model="form.sendConfirmationEmail"
+ class="gl-mb-5"
+ name="application_setting[send_user_confirmation_email]"
+ :label="$options.i18n.sendConfirmationEmailLabel"
+ />
+
+ <gl-form-group
+ :label="$options.i18n.userCapLabel"
+ :description="$options.i18n.userCapDescription"
+ >
+ <gl-form-input
+ v-model="form.userCap"
+ type="text"
+ name="application_setting[new_user_signups_cap]"
+ data-testid="user-cap-input"
+ />
+ </gl-form-group>
+
+ <gl-form-group :label="$options.i18n.minimumPasswordLengthLabel">
+ <gl-form-input
+ v-model="form.minimumPasswordLength"
+ :min="form.minimumPasswordLengthMin"
+ :max="form.minimumPasswordLengthMax"
+ type="number"
+ name="application_setting[minimum_password_length]"
+ />
+
+ <gl-sprintf
+ :message="
+ s__(
+ 'ApplicationSettings|See GitLab\'s %{linkStart}Password Policy Guidelines%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="form.minimumPasswordLengthHelpLink" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-form-group>
+
+ <gl-form-group
+ :description="$options.i18n.domainAllowListDescription"
+ :label="$options.i18n.domainAllowListLabel"
+ >
+ <textarea
+ v-model="form.domainAllowlistRaw"
+ :placeholder="$options.i18n.domainPlaceholder"
+ rows="8"
+ class="form-control gl-form-input"
+ name="application_setting[domain_allowlist_raw]"
+ ></textarea>
+ </gl-form-group>
+
+ <gl-form-group :label="$options.i18n.domainDenyListGroupLabel">
+ <signup-checkbox
+ v-model="form.domainDenylistEnabled"
+ name="application_setting[domain_denylist_enabled]"
+ :label="$options.i18n.domainDenyListLabel"
+ />
+ </gl-form-group>
+
+ <gl-form-radio-group v-model="form.denylistType" name="denylist_type" class="gl-mb-5">
+ <gl-form-radio :value="$options.DENYLIST_TYPE_FILE">{{
+ $options.i18n.domainDenyListTypeFileLabel
+ }}</gl-form-radio>
+ <gl-form-radio :value="$options.DENYLIST_TYPE_RAW">{{
+ $options.i18n.domainDenyListTypeRawLabel
+ }}</gl-form-radio>
+ </gl-form-radio-group>
+
+ <gl-form-group
+ v-if="form.denylistType === $options.DENYLIST_TYPE_FILE"
+ :description="$options.i18n.domainDenyListFileDescription"
+ :label="$options.i18n.domainDenyListFileLabel"
+ label-for="domain-denylist-file-input"
+ data-testid="domain-denylist-file-input-group"
+ >
+ <input
+ id="domain-denylist-file-input"
+ class="form-control gl-form-input"
+ type="file"
+ accept=".txt,.conf"
+ name="application_setting[domain_denylist_file]"
+ />
+ </gl-form-group>
+
+ <gl-form-group
+ v-if="form.denylistType !== $options.DENYLIST_TYPE_FILE"
+ :description="$options.i18n.domainDenyListListDescription"
+ :label="$options.i18n.domainDenyListListLabel"
+ data-testid="domain-denylist-raw-input-group"
+ >
+ <textarea
+ v-model="form.domainDenylistRaw"
+ :placeholder="$options.i18n.domainPlaceholder"
+ rows="8"
+ class="form-control gl-form-input"
+ name="application_setting[domain_denylist_raw]"
+ ></textarea>
+ </gl-form-group>
+
+ <gl-form-group :label="$options.i18n.emailRestrictionsEnabledGroupLabel">
+ <signup-checkbox
+ v-model="form.emailRestrictionsEnabled"
+ name="application_setting[email_restrictions_enabled]"
+ :label="$options.i18n.emailRestrictionsEnabledLabel"
+ />
+ </gl-form-group>
+
+ <gl-form-group :label="$options.i18n.emailRestrictionsGroupLabel">
+ <textarea
+ v-model="form.emailRestrictions"
+ rows="4"
+ class="form-control gl-form-input"
+ name="application_setting[email_restrictions]"
+ ></textarea>
+
+ <gl-sprintf
+ :message="
+ s__(
+ 'ApplicationSettings|Restricts sign-ups for email addresses that match the given regex. See the %{linkStart}supported syntax%{linkEnd} for more information.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="form.supportedSyntaxLinkUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-form-group>
+
+ <gl-form-group
+ :label="$options.i18n.afterSignUpTextGroupLabel"
+ :description="$options.i18n.afterSignUpTextGroupDescription"
+ >
+ <textarea
+ v-model="form.afterSignUpText"
+ rows="4"
+ class="form-control gl-form-input"
+ name="application_setting[after_sign_up_text]"
+ ></textarea>
+ </gl-form-group>
+ </section>
+
+ <gl-button
+ data-qa-selector="save_changes_button"
+ variant="confirm"
+ @click.prevent="submitButtonHandler"
+ >
+ {{ $options.i18n.buttonText }}
+ </gl-button>
+
+ <gl-modal
+ :ref="$options.modal.id"
+ :modal-id="$options.modal.id"
+ :action-cancel="$options.modal.actionCancel"
+ :action-primary="$options.modal.actionPrimary"
+ :title="$options.modal.title"
+ @primary="submitForm"
+ @hide="modalHideHandler"
+ >
+ {{ $options.modal.text }}
+ </gl-modal>
+ </form>
+</template>
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 eda1a9d3599..c48d99da990 100644
--- a/app/assets/javascripts/pages/admin/application_settings/general/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js
@@ -1,27 +1,9 @@
-import Vue from 'vue';
-import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
import initUserInternalRegexPlaceholder from '../account_and_limits';
+import initGitpod from '../gitpod';
+import initSignupRestrictions from '../signup_restrictions';
(() => {
initUserInternalRegexPlaceholder();
-
- const el = document.querySelector('#js-gitpod-settings-help-text');
- if (!el) {
- return;
- }
-
- const { message, messageUrl } = el.dataset;
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- render(createElement) {
- return createElement(IntegrationHelpText, {
- props: {
- message,
- messageUrl,
- },
- });
- },
- });
+ initGitpod();
+ initSignupRestrictions();
})();
diff --git a/app/assets/javascripts/pages/admin/application_settings/gitpod.js b/app/assets/javascripts/pages/admin/application_settings/gitpod.js
new file mode 100644
index 00000000000..74e46617d52
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/gitpod.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
+
+export default function initGitpod() {
+ const el = document.querySelector('#js-gitpod-settings-help-text');
+
+ if (!el) {
+ return false;
+ }
+
+ const { message, messageUrl } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(IntegrationHelpText, {
+ props: {
+ message,
+ messageUrl,
+ },
+ });
+ },
+ });
+}
diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js
index e3c6b0f6f5b..a6e3a7dc08a 100644
--- a/app/assets/javascripts/pages/admin/application_settings/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/index.js
@@ -4,9 +4,7 @@ import initSearchSettings from '~/search_settings';
import selfMonitor from '~/self_monitor';
import initSettingsPanels from '~/settings_panels';
-if (gon.features?.ciInstanceVariablesUi) {
- initVariableList('js-instance-variables');
-}
+initVariableList('js-instance-variables');
selfMonitor();
// Initialize expandable settings panels
initSettingsPanels();
diff --git a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js
new file mode 100644
index 00000000000..70b896f6372
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import SignupForm from './general/components/signup_form.vue';
+import { getParsedDataset } from './utils';
+
+export default function initSignupRestrictions(elementSelector = '#js-signup-form') {
+ const el = document.querySelector(elementSelector);
+
+ if (!el) {
+ return false;
+ }
+
+ const parsedDataset = getParsedDataset({
+ dataset: el.dataset,
+ booleanAttributes: [
+ 'signupEnabled',
+ 'requireAdminApprovalAfterUserSignup',
+ 'sendUserConfirmationEmail',
+ 'domainDenylistEnabled',
+ 'denylistTypeRawSelected',
+ 'emailRestrictionsEnabled',
+ ],
+ });
+
+ return new Vue({
+ el,
+ provide: {
+ ...parsedDataset,
+ },
+ render: (createElement) => createElement(SignupForm),
+ });
+}
diff --git a/app/assets/javascripts/pages/admin/application_settings/utils.js b/app/assets/javascripts/pages/admin/application_settings/utils.js
new file mode 100644
index 00000000000..5462a13d523
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/utils.js
@@ -0,0 +1,21 @@
+import { includes } from 'lodash';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+/**
+ * Returns a new dataset that has all the values of keys indicated in
+ * booleanAttributes transformed by the parseBoolean() helper function
+ *
+ * @param {Object}
+ * @returns {Object}
+ */
+export const getParsedDataset = ({ dataset = {}, booleanAttributes = [] } = {}) => {
+ const parsedDataset = {};
+
+ Object.keys(dataset).forEach((key) => {
+ parsedDataset[key] = includes(booleanAttributes, key)
+ ? parseBoolean(dataset[key])
+ : dataset[key];
+ });
+
+ return parsedDataset;
+};
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/index.js
index d6cc6a850eb..b7db6443658 100644
--- a/app/assets/javascripts/pages/admin/broadcast_messages/index.js
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/index.js
@@ -1,3 +1,7 @@
+import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
import initBroadcastMessagesForm from './broadcast_message';
-document.addEventListener('DOMContentLoaded', initBroadcastMessagesForm);
+document.addEventListener('DOMContentLoaded', () => {
+ initBroadcastMessagesForm();
+ initDeprecatedRemoveRowBehavior();
+});
diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js
index 94f7cfd55be..1630cfb8253 100644
--- a/app/assets/javascripts/pages/admin/groups/new/index.js
+++ b/app/assets/javascripts/pages/admin/groups/new/index.js
@@ -2,9 +2,9 @@ import initFilePickers from '~/file_pickers';
import BindInOut from '../../../../behaviors/bind_in_out';
import Group from '../../../../group';
-document.addEventListener('DOMContentLoaded', () => {
+(() => {
BindInOut.initAll();
initFilePickers();
return new Group();
-});
+})();
diff --git a/app/assets/javascripts/pages/admin/labels/edit/index.js b/app/assets/javascripts/pages/admin/labels/edit/index.js
index 5de1d4d6344..f7c25347e75 100644
--- a/app/assets/javascripts/pages/admin/labels/edit/index.js
+++ b/app/assets/javascripts/pages/admin/labels/edit/index.js
@@ -1,3 +1,3 @@
import Labels from '../../../../labels';
-document.addEventListener('DOMContentLoaded', () => new Labels());
+new Labels(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/admin/labels/index/index.js b/app/assets/javascripts/pages/admin/labels/index/index.js
new file mode 100644
index 00000000000..e5ab5d43bbf
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/labels/index/index.js
@@ -0,0 +1,3 @@
+import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
+
+document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior);
diff --git a/app/assets/javascripts/pages/admin/labels/new/index.js b/app/assets/javascripts/pages/admin/labels/new/index.js
index 5de1d4d6344..f7c25347e75 100644
--- a/app/assets/javascripts/pages/admin/labels/new/index.js
+++ b/app/assets/javascripts/pages/admin/labels/new/index.js
@@ -1,3 +1,3 @@
import Labels from '../../../../labels';
-document.addEventListener('DOMContentLoaded', () => new Labels());
+new Labels(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/admin/runners/index.js b/app/assets/javascripts/pages/admin/runners/index/index.js
index 45ed3ac6bd8..45ed3ac6bd8 100644
--- a/app/assets/javascripts/pages/admin/runners/index.js
+++ b/app/assets/javascripts/pages/admin/runners/index/index.js
diff --git a/app/assets/javascripts/pages/admin/runners/show/index.js b/app/assets/javascripts/pages/admin/runners/show/index.js
new file mode 100644
index 00000000000..d1853772fda
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/runners/show/index.js
@@ -0,0 +1,3 @@
+import { initRunnerDetail } from '~/runner/runner_details';
+
+initRunnerDetail();
diff --git a/app/assets/javascripts/pages/admin/services/edit/index.js b/app/assets/javascripts/pages/admin/services/edit/index.js
index 3d692ef4dcc..b8080ddff77 100644
--- a/app/assets/javascripts/pages/admin/services/edit/index.js
+++ b/app/assets/javascripts/pages/admin/services/edit/index.js
@@ -1,6 +1,4 @@
import IntegrationSettingsForm from '~/integrations/integration_settings_form';
-document.addEventListener('DOMContentLoaded', () => {
- const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
- integrationSettingsForm.init();
-});
+const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+integrationSettingsForm.init();
diff --git a/app/assets/javascripts/pages/admin/spam_logs/index.js b/app/assets/javascripts/pages/admin/spam_logs/index.js
new file mode 100644
index 00000000000..e5ab5d43bbf
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/spam_logs/index.js
@@ -0,0 +1,3 @@
+import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
+
+document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior);
diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
index d2b83f980d7..20407334b3f 100644
--- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
+++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
@@ -119,8 +119,8 @@ export default {
<gl-button @click="onCancel">{{ s__('Cancel') }}</gl-button>
<gl-button
:disabled="!canSubmit"
- category="primary"
- variant="warning"
+ category="secondary"
+ variant="danger"
@click="onSecondaryAction"
>
{{ secondaryAction }}
diff --git a/app/assets/javascripts/pages/admin/users/new/index.js b/app/assets/javascripts/pages/admin/users/new/index.js
index 7b7d4c169ef..34c10e44f4c 100644
--- a/app/assets/javascripts/pages/admin/users/new/index.js
+++ b/app/assets/javascripts/pages/admin/users/new/index.js
@@ -1,51 +1,3 @@
-import $ from 'jquery';
+import { setupInternalUserRegexHandler } from '~/admin/users/new';
-export default class UserInternalRegexHandler {
- constructor() {
- this.regexPattern = $('[data-user-internal-regex-pattern]').data('user-internal-regex-pattern');
- if (this.regexPattern && this.regexPattern !== '') {
- this.regexOptions = $('[data-user-internal-regex-options]').data(
- 'user-internal-regex-options',
- );
- this.external = $('#user_external');
- this.warningMessage = $('#warning_external_automatically_set');
- this.addListenerToEmailField();
- this.addListenerToUserExternalCheckbox();
- }
- }
-
- addListenerToEmailField() {
- $('#user_email').on('input', (event) => {
- this.setExternalCheckbox(event.currentTarget.value);
- });
- }
-
- addListenerToUserExternalCheckbox() {
- this.external.on('click', () => {
- this.warningMessage.addClass('hidden');
- });
- }
-
- isEmailInternal(email) {
- const regex = new RegExp(this.regexPattern, this.regexOptions);
- return regex.test(email);
- }
-
- setExternalCheckbox(email) {
- const isChecked = this.external.prop('checked');
- if (this.isEmailInternal(email)) {
- if (isChecked) {
- this.external.prop('checked', false);
- this.warningMessage.removeClass('hidden');
- }
- } else if (!isChecked) {
- this.external.prop('checked', true);
- this.warningMessage.addClass('hidden');
- }
- }
-}
-
-document.addEventListener('DOMContentLoaded', () => {
- // eslint-disable-next-line
- new UserInternalRegexHandler();
-});
+setupInternalUserRegexHandler();
diff --git a/app/assets/javascripts/pages/dashboard/activity/index.js b/app/assets/javascripts/pages/dashboard/activity/index.js
index 1b887cad496..8b7c36a0976 100644
--- a/app/assets/javascripts/pages/dashboard/activity/index.js
+++ b/app/assets/javascripts/pages/dashboard/activity/index.js
@@ -1,3 +1,4 @@
import Activities from '~/activities';
-document.addEventListener('DOMContentLoaded', () => new Activities());
+// eslint-disable-next-line no-new
+new Activities();
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index d53cd405504..42341436b55 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -1,6 +1,8 @@
/* eslint-disable class-methods-use-this, no-unneeded-ternary */
import $ from 'jquery';
+import { getGroups } from '~/api/groups_api';
+import { getProjects } from '~/api/projects_api';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { deprecatedCreateFlash as flash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
@@ -41,14 +43,37 @@ export default class Todos {
}
initFilters() {
- this.initFilterDropdown($('.js-group-search'), 'group_id', ['text']);
- this.initFilterDropdown($('.js-project-search'), 'project_id', ['text']);
+ this.initAjaxFilterDropdown(getGroups, $('.js-group-search'), 'group_id');
+ this.initAjaxFilterDropdown(getProjects, $('.js-project-search'), 'project_id');
this.initFilterDropdown($('.js-type-search'), 'type');
this.initFilterDropdown($('.js-action-search'), 'action_id');
return new UsersSelect();
}
+ initAjaxFilterDropdown(apiMethod, $dropdown, fieldName) {
+ initDeprecatedJQueryDropdown($dropdown, {
+ fieldName,
+ selectable: true,
+ filterable: true,
+ filterRemote: true,
+ data(search, callback) {
+ return apiMethod(search, {}, (data) => {
+ callback(
+ data.map((d) => ({
+ id: d.id,
+ text: d.full_name || d.name_with_namespace,
+ })),
+ );
+ });
+ },
+ clicked: () => {
+ const $formEl = $dropdown.closest('form.filter-form');
+ $formEl.submit();
+ },
+ });
+ }
+
initFilterDropdown($dropdown, fieldName, searchFields) {
initDeprecatedJQueryDropdown($dropdown, {
fieldName,
@@ -58,12 +83,6 @@ export default class Todos {
data: $dropdown.data('data'),
clicked: () => {
const $formEl = $dropdown.closest('form.filter-form');
- const mutexDropdowns = {
- group_id: 'project_id',
- project_id: 'group_id',
- };
-
- $formEl.find(`input[name="${mutexDropdowns[fieldName]}"]`).remove();
$formEl.submit();
},
});
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index 176d2406751..49b9822795c 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -4,6 +4,7 @@ import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import initFilePickers from '~/file_pickers';
import TransferDropdown from '~/groups/transfer_dropdown';
import groupsSelect from '~/groups_select';
+import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import projectSelect from '~/project_select';
import initSearchSettings from '~/search_settings';
@@ -26,6 +27,7 @@ document.addEventListener('DOMContentLoaded', () => {
projectSelect();
initSearchSettings();
+ initCascadingSettingsLockPopovers();
return new TransferDropdown();
});
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index ab70fa572ba..b0a70055835 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -8,6 +8,7 @@ import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigg
import { s__ } from '~/locale';
import memberExpirationDate from '~/member_expiration_date';
import { initMembersApp } from '~/members';
+import { MEMBER_TYPES } from '~/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
import UsersSelect from '~/users_select';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
@@ -29,6 +30,7 @@ function mountRemoveMemberModal() {
const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-group-members-list'), {
+ namespace: MEMBER_TYPES.user,
tableFields: SHARED_FIELDS.concat(['source', 'granted']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'],
@@ -43,6 +45,7 @@ initMembersApp(document.querySelector('.js-group-members-list'), {
});
initMembersApp(document.querySelector('.js-group-group-links-list'), {
+ namespace: MEMBER_TYPES.group,
tableFields: SHARED_FIELDS.concat('granted'),
tableAttrs: {
table: { 'data-qa-selector': 'groups_list' },
@@ -51,6 +54,7 @@ initMembersApp(document.querySelector('.js-group-group-links-list'), {
requestFormatter: groupLinkRequestFormatter,
});
initMembersApp(document.querySelector('.js-group-invited-members-list'), {
+ namespace: MEMBER_TYPES.invite,
tableFields: SHARED_FIELDS.concat('invited'),
requestFormatter: groupMemberRequestFormatter,
filteredSearchBar: {
@@ -62,6 +66,7 @@ initMembersApp(document.querySelector('.js-group-invited-members-list'), {
},
});
initMembersApp(document.querySelector('.js-group-access-requests-list'), {
+ namespace: MEMBER_TYPES.accessRequest,
tableFields: SHARED_FIELDS.concat('requested'),
requestFormatter: groupMemberRequestFormatter,
});
diff --git a/app/assets/javascripts/pages/groups/labels/index/index.js b/app/assets/javascripts/pages/groups/labels/index/index.js
index 87d522d7654..95c2c7cd7d0 100644
--- a/app/assets/javascripts/pages/groups/labels/index/index.js
+++ b/app/assets/javascripts/pages/groups/labels/index/index.js
@@ -1,3 +1,5 @@
+import initDeleteLabelModal from '~/delete_label_modal';
import initLabels from '~/init_labels';
initLabels();
+initDeleteLabelModal();
diff --git a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js b/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js
index 1d68ccd724d..301e0b4f7a2 100644
--- a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js
+++ b/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js
@@ -1,7 +1,12 @@
+import { buildApiUrl } from '~/api/api_utils';
import axios from '~/lib/utils/axios_utils';
-const rootUrl = gon.relative_url_root;
+const NAMESPACE_EXISTS_PATH = '/api/:version/namespaces/:id/exists';
-export default function fetchGroupPathAvailability(groupPath) {
- return axios.get(`${rootUrl}/users/${groupPath}/suggests`);
+export default function fetchGroupPathAvailability(groupPath, parentId) {
+ const url = buildApiUrl(NAMESPACE_EXISTS_PATH).replace(':id', encodeURIComponent(groupPath));
+
+ return axios.get(url, {
+ params: { parent_id: parentId },
+ });
}
diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js
index 89dccea2812..a0ff98645fb 100644
--- a/app/assets/javascripts/pages/groups/new/group_path_validator.js
+++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js
@@ -8,6 +8,7 @@ import fetchGroupPathAvailability from './fetch_group_path_availability';
const debounceTimeoutDuration = 1000;
const invalidInputClass = 'gl-field-error-outline';
const successInputClass = 'gl-field-success-outline';
+const parentIdSelector = 'group_parent_id';
const successMessageSelector = '.validation-success';
const pendingMessageSelector = '.validation-pending';
const unavailableMessageSelector = '.validation-error';
@@ -20,9 +21,10 @@ export default class GroupPathValidator extends InputValidator {
const container = opts.container || '';
const validateElements = document.querySelectorAll(`${container} .js-validate-group-path`);
+ const parentIdElement = document.getElementById(parentIdSelector);
this.debounceValidateInput = debounce((inputDomElement) => {
- GroupPathValidator.validateGroupPathInput(inputDomElement);
+ GroupPathValidator.validateGroupPathInput(inputDomElement, parentIdElement);
}, debounceTimeoutDuration);
validateElements.forEach((element) =>
@@ -37,13 +39,14 @@ export default class GroupPathValidator extends InputValidator {
this.debounceValidateInput(inputDomElement);
}
- static validateGroupPathInput(inputDomElement) {
+ static validateGroupPathInput(inputDomElement, parentIdElement) {
const groupPath = inputDomElement.value;
+ const parentId = parentIdElement.value;
if (inputDomElement.checkValidity() && groupPath.length > 1) {
GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector);
- fetchGroupPathAvailability(groupPath)
+ fetchGroupPathAvailability(groupPath, parentId)
.then(({ data }) => data)
.then((data) => {
GroupPathValidator.setInputState(inputDomElement, !data.exists);
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index 322ad2c79e7..569b5afd676 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -5,10 +5,8 @@ import Group from '~/group';
import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
import GroupPathValidator from './group_path_validator';
-const parentId = $('#group_parent_id');
-if (!parentId.val()) {
- new GroupPathValidator(); // eslint-disable-line no-new
-}
+new GroupPathValidator(); // eslint-disable-line no-new
+
BindInOut.initAll();
initFilePickers();
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 0c3fdcf3e75..636eea5d7ac 100644
--- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
@@ -4,7 +4,6 @@ import initSharedRunnersForm from '~/group_settings/mount_shared_runners';
import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
-import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
// Initialize expandable settings panels
@@ -21,5 +20,3 @@ initSharedRunnersForm();
initVariableList();
initInstallRunner();
-
-initSearchSettings();
diff --git a/app/assets/javascripts/pages/groups/settings/index.js b/app/assets/javascripts/pages/groups/settings/index.js
new file mode 100644
index 00000000000..cb787c60002
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/settings/index.js
@@ -0,0 +1,5 @@
+import initRevokeButton from '~/deploy_tokens/init_revoke_button';
+import initSearchSettings from '~/search_settings';
+
+initSearchSettings();
+initRevokeButton();
diff --git a/app/assets/javascripts/pages/groups/settings/integrations/index.js b/app/assets/javascripts/pages/groups/settings/integrations/index.js
new file mode 100644
index 00000000000..53068f72d3f
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/settings/integrations/index.js
@@ -0,0 +1,3 @@
+import initIntegrationsList from '~/integrations/index';
+
+initIntegrationsList();
diff --git a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js b/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js
index d13bf026777..3b922622d2c 100644
--- a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js
+++ b/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js
@@ -1,6 +1,3 @@
import bundle from '~/packages_and_registries/settings/group/bundle';
-import initSearchSettings from '~/search_settings';
bundle();
-
-document.addEventListener('DOMContentLoaded', initSearchSettings);
diff --git a/app/assets/javascripts/pages/groups/settings/repository/show/index.js b/app/assets/javascripts/pages/groups/settings/repository/show/index.js
index 2c9867653de..92405f205cb 100644
--- a/app/assets/javascripts/pages/groups/settings/repository/show/index.js
+++ b/app/assets/javascripts/pages/groups/settings/repository/show/index.js
@@ -1,10 +1,7 @@
import DueDateSelectors from '~/due_date_select';
-import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
// Initialize expandable settings panels
initSettingsPanels();
new DueDateSelectors(); // eslint-disable-line no-new
-
-initSearchSettings();
diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js
index a24c6ca7754..b5441127797 100644
--- a/app/assets/javascripts/pages/profiles/show/index.js
+++ b/app/assets/javascripts/pages/profiles/show/index.js
@@ -7,81 +7,78 @@ import { __ } from '~/locale';
import EmojiMenu from './emoji_menu';
const defaultStatusEmoji = 'speech_balloon';
+const toggleEmojiMenuButtonSelector = '.js-toggle-emoji-menu';
+const toggleEmojiMenuButton = document.querySelector(toggleEmojiMenuButtonSelector);
+const statusEmojiField = document.getElementById('js-status-emoji-field');
+const statusMessageField = document.getElementById('js-status-message-field');
-document.addEventListener('DOMContentLoaded', () => {
- const toggleEmojiMenuButtonSelector = '.js-toggle-emoji-menu';
- const toggleEmojiMenuButton = document.querySelector(toggleEmojiMenuButtonSelector);
- const statusEmojiField = document.getElementById('js-status-emoji-field');
- const statusMessageField = document.getElementById('js-status-message-field');
+const toggleNoEmojiPlaceholder = (isVisible) => {
+ const placeholderElement = document.getElementById('js-no-emoji-placeholder');
+ placeholderElement.classList.toggle('hidden', !isVisible);
+};
- const toggleNoEmojiPlaceholder = (isVisible) => {
- const placeholderElement = document.getElementById('js-no-emoji-placeholder');
- placeholderElement.classList.toggle('hidden', !isVisible);
- };
+const findStatusEmoji = () => toggleEmojiMenuButton.querySelector('gl-emoji');
+const removeStatusEmoji = () => {
+ const statusEmoji = findStatusEmoji();
+ if (statusEmoji) {
+ statusEmoji.remove();
+ }
+};
- const findStatusEmoji = () => toggleEmojiMenuButton.querySelector('gl-emoji');
- const removeStatusEmoji = () => {
- const statusEmoji = findStatusEmoji();
- if (statusEmoji) {
- statusEmoji.remove();
- }
- };
+const selectEmojiCallback = (emoji, emojiTag) => {
+ statusEmojiField.value = emoji;
+ toggleNoEmojiPlaceholder(false);
+ removeStatusEmoji();
+ toggleEmojiMenuButton.innerHTML += emojiTag;
+};
- const selectEmojiCallback = (emoji, emojiTag) => {
- statusEmojiField.value = emoji;
- toggleNoEmojiPlaceholder(false);
- removeStatusEmoji();
- toggleEmojiMenuButton.innerHTML += emojiTag;
- };
-
- const clearEmojiButton = document.getElementById('js-clear-user-status-button');
- clearEmojiButton.addEventListener('click', () => {
- statusEmojiField.value = '';
- statusMessageField.value = '';
- removeStatusEmoji();
- toggleNoEmojiPlaceholder(true);
- });
+const clearEmojiButton = document.getElementById('js-clear-user-status-button');
+clearEmojiButton.addEventListener('click', () => {
+ statusEmojiField.value = '';
+ statusMessageField.value = '';
+ removeStatusEmoji();
+ toggleNoEmojiPlaceholder(true);
+});
- const emojiAutocomplete = new GfmAutoComplete();
- emojiAutocomplete.setup($(statusMessageField), { emojis: true });
+const emojiAutocomplete = new GfmAutoComplete();
+emojiAutocomplete.setup($(statusMessageField), { emojis: true });
- const userNameInput = document.getElementById('user_name');
- userNameInput.addEventListener('input', () => {
- const EMOJI_REGEX = emojiRegex();
- if (EMOJI_REGEX.test(userNameInput.value)) {
- // set field to invalid so it gets detected by GlFieldErrors
- userNameInput.setCustomValidity(__('Invalid field'));
- } else {
- userNameInput.setCustomValidity('');
- }
- });
+const userNameInput = document.getElementById('user_name');
+userNameInput.addEventListener('input', () => {
+ const EMOJI_REGEX = emojiRegex();
+ if (EMOJI_REGEX.test(userNameInput.value)) {
+ // set field to invalid so it gets detected by GlFieldErrors
+ userNameInput.setCustomValidity(__('Invalid field'));
+ } else {
+ userNameInput.setCustomValidity('');
+ }
+});
- Emoji.initEmojiMap()
- .then(() => {
- const emojiMenu = new EmojiMenu(
- Emoji,
- toggleEmojiMenuButtonSelector,
- 'js-status-emoji-menu',
- selectEmojiCallback,
- );
- emojiMenu.bindEvents();
+Emoji.initEmojiMap()
+ .then(() => {
+ const emojiMenu = new EmojiMenu(
+ Emoji,
+ toggleEmojiMenuButtonSelector,
+ 'js-status-emoji-menu',
+ selectEmojiCallback,
+ );
+ emojiMenu.bindEvents();
- const defaultEmojiTag = Emoji.glEmojiTag(defaultStatusEmoji);
- statusMessageField.addEventListener('input', () => {
- const hasStatusMessage = statusMessageField.value.trim() !== '';
- const statusEmoji = findStatusEmoji();
- if (hasStatusMessage && statusEmoji) {
- return;
- }
+ const defaultEmojiTag = Emoji.glEmojiTag(defaultStatusEmoji);
+ statusMessageField.addEventListener('input', () => {
+ const hasStatusMessage = statusMessageField.value.trim() !== '';
+ const statusEmoji = findStatusEmoji();
+ if (hasStatusMessage && statusEmoji) {
+ return;
+ }
- if (hasStatusMessage) {
- toggleNoEmojiPlaceholder(false);
- toggleEmojiMenuButton.innerHTML += defaultEmojiTag;
- } else if (statusEmoji.dataset.name === defaultStatusEmoji) {
- toggleNoEmojiPlaceholder(true);
- removeStatusEmoji();
- }
- });
- })
- .catch(() => createFlash(__('Failed to load emoji list.')));
-});
+ if (hasStatusMessage) {
+ toggleNoEmojiPlaceholder(false);
+ toggleEmojiMenuButton.innerHTML += defaultEmojiTag;
+ } else if (statusEmoji.dataset.name === defaultStatusEmoji) {
+ toggleNoEmojiPlaceholder(true);
+ removeStatusEmoji();
+ }
+ });
+ })
+ .catch(() => createFlash(__('Failed to load emoji list.')));
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 10bac6d60c2..fc2702b8c37 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -5,10 +5,29 @@ import GpgBadges from '~/gpg_badges';
import initBlob from '~/pages/projects/init_blob';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
import '~/sourcegraph/load';
-new BlobViewer(); // eslint-disable-line no-new
-initBlob();
+const viewBlobEl = document.querySelector('#js-view-blob-app');
+
+if (viewBlobEl) {
+ const { blobPath } = viewBlobEl.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: viewBlobEl,
+ render(createElement) {
+ return createElement(BlobContentViewer, {
+ props: {
+ path: blobPath,
+ },
+ });
+ },
+ });
+} else {
+ new BlobViewer(); // eslint-disable-line no-new
+ initBlob();
+}
const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
const statusLink = document.querySelector('.commit-actions .ci-status-link');
diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js
index 72861855c5a..27ec746ad02 100644
--- a/app/assets/javascripts/pages/projects/branches/index/index.js
+++ b/app/assets/javascripts/pages/projects/branches/index/index.js
@@ -1,7 +1,16 @@
+import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
+import BranchSortDropdown from '~/branches/branch_sort_dropdown';
import DeleteModal from '~/branches/branches_delete_modal';
import initDiverganceGraph from '~/branches/divergence_graph';
AjaxLoadingSpinner.init();
new DeleteModal(); // eslint-disable-line no-new
-initDiverganceGraph(document.querySelector('.js-branch-list').dataset.divergingCountsEndpoint);
+
+const { divergingCountsEndpoint, defaultBranch } = document.querySelector(
+ '.js-branch-list',
+).dataset;
+
+initDiverganceGraph(divergingCountsEndpoint, defaultBranch);
+BranchSortDropdown();
+initDeprecatedRemoveRowBehavior();
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
index 7112b23775d..288d6711682 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -13,6 +13,7 @@ import {
GlFormRadioGroup,
GlFormSelect,
} from '@gitlab/ui';
+import { kebabCase } from 'lodash';
import { buildApiUrl } from '~/api/api_utils';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
@@ -145,6 +146,10 @@ export default {
this.fork.visibility = visibility;
}
},
+ // eslint-disable-next-line func-names
+ 'fork.name': function (newVal) {
+ this.fork.slug = kebabCase(newVal);
+ },
},
mounted() {
this.fetchNamespaces();
@@ -213,6 +218,7 @@ export default {
id="fork-url"
v-model="selectedNamespace"
data-testid="fork-url-input"
+ data-qa-selector="fork_namespace_dropdown"
required
>
<template slot="first">
@@ -286,6 +292,7 @@ export default {
category="primary"
variant="confirm"
data-testid="submit-button"
+ data-qa-selector="fork_project_button"
:loading="isSaving"
>
{{ s__('ForkProject|Fork project') }}
diff --git a/app/assets/javascripts/pages/projects/hooks/index.js b/app/assets/javascripts/pages/projects/hooks/index.js
new file mode 100644
index 00000000000..2a120a690ef
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/hooks/index.js
@@ -0,0 +1,3 @@
+import initSearchSettings from '~/search_settings';
+
+initSearchSettings();
diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js
index 4e35f28ab06..c0da0069a99 100644
--- a/app/assets/javascripts/pages/projects/issues/form.js
+++ b/app/assets/javascripts/pages/projects/issues/form.js
@@ -5,6 +5,7 @@ import IssuableForm from 'ee_else_ce/issuable_form';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import GLForm from '~/gl_form';
import initSuggestions from '~/issuable_suggestions';
+import initIssuableTypeSelector from '~/issuable_type_selector';
import LabelsSelect from '~/labels_select';
import MilestoneSelect from '~/milestone_select';
import IssuableTemplateSelectors from '~/templates/issuable_template_selectors';
@@ -20,4 +21,5 @@ export default () => {
});
initSuggestions();
+ initIssuableTypeSelector();
};
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
index 366f8dc61bc..85489ae8687 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -20,7 +20,12 @@ initFilteredSearch({
useDefaultState: true,
});
-new IssuableIndex(ISSUABLE_INDEX.ISSUE);
+if (gon.features?.vueIssuesList) {
+ new IssuableIndex();
+} else {
+ new IssuableIndex(ISSUABLE_INDEX.ISSUE);
+}
+
new ShortcutsNavigation();
new UsersSelect();
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 992bf3c54ff..2b679a83eac 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -3,6 +3,8 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import initIssuableSidebar from '~/init_issuable_sidebar';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
+import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
+import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { IssuableType } from '~/issuable_show/constants';
import Issue from '~/issue';
import '~/notes/index';
@@ -34,6 +36,8 @@ export default function initShowIssue() {
initIssueHeaderActions(store);
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
+ initInviteMembersModal();
+ initInviteMembersTrigger();
import(/* webpackChunkName: 'design_management' */ '~/design_management')
.then((module) => module.default())
@@ -42,10 +46,18 @@ export default function initShowIssue() {
new ZenMode(); // eslint-disable-line no-new
if (issueType !== IssuableType.TestCase) {
+ const awardEmojiEl = document.getElementById('js-vue-awards-block');
+
new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new
initIssuableSidebar();
- loadAwardsHandler();
+ if (awardEmojiEl) {
+ import('~/emoji/awards_app')
+ .then((m) => m.default(awardEmojiEl))
+ .catch(() => {});
+ } else {
+ loadAwardsHandler();
+ }
initInviteMemberModal();
initInviteMemberTrigger();
}
diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js
index 681d151b77f..75194499a7f 100644
--- a/app/assets/javascripts/pages/projects/jobs/index/index.js
+++ b/app/assets/javascripts/pages/projects/jobs/index/index.js
@@ -1,17 +1,23 @@
import Vue from 'vue';
+import initJobsTable from '~/jobs/components/table';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
-const remainingTimeElements = document.querySelectorAll('.js-remaining-time');
-remainingTimeElements.forEach(
- (el) =>
- new Vue({
- el,
- render(h) {
- return h(GlCountdown, {
- props: {
- endDateString: el.dateTime,
- },
- });
- },
- }),
-);
+if (gon.features?.jobsTableVue) {
+ initJobsTable();
+} else {
+ const remainingTimeElements = document.querySelectorAll('.js-remaining-time');
+
+ remainingTimeElements.forEach(
+ (el) =>
+ new Vue({
+ el,
+ render(h) {
+ return h(GlCountdown, {
+ props: {
+ endDateString: el.dateTime,
+ },
+ });
+ },
+ }),
+ );
+}
diff --git a/app/assets/javascripts/pages/projects/jobs/show/index.js b/app/assets/javascripts/pages/projects/jobs/show/index.js
index d57dbeb1242..6fef057dee0 100644
--- a/app/assets/javascripts/pages/projects/jobs/show/index.js
+++ b/app/assets/javascripts/pages/projects/jobs/show/index.js
@@ -1,3 +1,3 @@
import initJobDetails from '~/jobs';
-document.addEventListener('DOMContentLoaded', initJobDetails);
+initJobDetails();
diff --git a/app/assets/javascripts/pages/projects/labels/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js
index 9f782c07101..94ab0d64de4 100644
--- a/app/assets/javascripts/pages/projects/labels/index/index.js
+++ b/app/assets/javascripts/pages/projects/labels/index/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import initDeleteLabelModal from '~/delete_label_modal';
import initLabels from '~/init_labels';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import Translate from '~/vue_shared/translate';
@@ -9,6 +10,7 @@ Vue.use(Translate);
const initLabelIndex = () => {
initLabels();
+ initDeleteLabelModal();
const onRequestFinished = ({ labelUrl, successful }) => {
const button = document.querySelector(
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue
index 32ca623ca45..ef9e13f7ccf 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue
@@ -1,11 +1,17 @@
<script>
-import { GlLink } from '@gitlab/ui';
-import { ACTION_LABELS } from '../constants';
+import { GlProgressBar, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { ACTION_LABELS, ACTION_SECTIONS } from '../constants';
+import LearnGitlabSectionCard from './learn_gitlab_section_card.vue';
export default {
- components: { GlLink },
+ components: { GlProgressBar, GlSprintf, LearnGitlabSectionCard },
i18n: {
- ACTION_LABELS,
+ title: s__('LearnGitLab|Learn GitLab'),
+ description: s__(
+ 'LearnGitLab|Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.',
+ ),
+ percentageCompleted: s__(`LearnGitLab|%{percentage}%{percentSymbol} completed`),
},
props: {
actions: {
@@ -13,15 +19,49 @@ export default {
type: Object,
},
},
+ maxValue: Object.keys(ACTION_LABELS).length,
+ sections: Object.keys(ACTION_SECTIONS),
+ computed: {
+ progressValue() {
+ return Object.values(this.actions).filter((a) => a.completed).length;
+ },
+ progressPercentage() {
+ return Math.round((this.progressValue / this.$options.maxValue) * 100);
+ },
+ },
+ methods: {
+ actionsFor(section) {
+ const actions = Object.fromEntries(
+ Object.entries(this.actions).filter(
+ ([action]) => ACTION_LABELS[action].section === section,
+ ),
+ );
+ return actions;
+ },
+ },
};
</script>
<template>
- <ul>
- <li v-for="(value, action) in actions" :key="action">
- <span v-if="value.completed">{{ $options.i18n.ACTION_LABELS[action].title }}</span>
- <span v-else>
- <gl-link :href="value.url">{{ $options.i18n.ACTION_LABELS[action].title }}</gl-link>
- </span>
- </li>
- </ul>
+ <div>
+ <div class="row">
+ <div class="gl-mb-7 gl-ml-5">
+ <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
+ <p class="gl-text-gray-700 gl-mb-0">{{ $options.i18n.description }}</p>
+ </div>
+ </div>
+ <div class="gl-mb-3">
+ <p class="gl-text-gray-500 gl-mb-2" data-testid="completion-percentage">
+ <gl-sprintf :message="$options.i18n.percentageCompleted">
+ <template #percentage>{{ progressPercentage }}</template>
+ <template #percentSymbol>%</template>
+ </gl-sprintf>
+ </p>
+ <gl-progress-bar :value="progressValue" :max="$options.maxValue" />
+ </div>
+ <div class="row row-cols-1 row-cols-md-3 gl-mt-5">
+ <div v-for="section in $options.sections" :key="section" class="col gl-mb-6">
+ <learn-gitlab-section-card :section="section" :actions="actionsFor(section)" />
+ </div>
+ </div>
+ </div>
</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue
index 230054ff76e..8f92ce95dbf 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue
@@ -1,5 +1,6 @@
<script>
import { GlProgressBar, GlSprintf } from '@gitlab/ui';
+import { pick } from 'lodash';
import { s__ } from '~/locale';
import { ACTION_LABELS } from '../constants';
import LearnGitlabInfoCard from './learn_gitlab_info_card.vue';
@@ -42,7 +43,7 @@ export default {
infoProps(action) {
return {
...this.actions[action],
- ...ACTION_LABELS[action],
+ ...pick(ACTION_LABELS[action], ['title', 'actionLabel', 'description', 'trialRequired']),
};
},
progressValue() {
@@ -96,6 +97,9 @@ export default {
<div class="row row-cols-2 row-cols-md-3 row-cols-lg-4">
<div class="col gl-mb-6">
+ <learn-gitlab-info-card v-bind="infoProps('issueCreated')" />
+ </div>
+ <div class="col gl-mb-6">
<learn-gitlab-info-card v-bind="infoProps('mergeRequestCreated')" />
</div>
</div>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
index 3d2a8eed9d4..6cd3bbc359b 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
@@ -61,7 +61,7 @@ export default {
<div
class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content"
>
- <img :src="svg" />
+ <img :src="svg" :alt="actionLabel" />
<h6>{{ title }}</h6>
<p class="gl-font-sm gl-text-gray-700">{{ description }}</p>
<gl-link :href="url" target="_blank">{{ actionLabel }}</gl-link>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue
new file mode 100644
index 00000000000..db694a66afd
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlCard } from '@gitlab/ui';
+import { imagePath } from '~/lib/utils/common_utils';
+import { ACTION_LABELS, ACTION_SECTIONS } from '../constants';
+
+import LearnGitlabSectionLink from './learn_gitlab_section_link.vue';
+
+export default {
+ name: 'LearnGitlabSectionCard',
+ components: { GlCard, LearnGitlabSectionLink },
+ i18n: {
+ ...ACTION_SECTIONS,
+ },
+ props: {
+ section: {
+ required: true,
+ type: String,
+ },
+ actions: {
+ required: true,
+ type: Object,
+ },
+ },
+ computed: {
+ sortedActions() {
+ return Object.entries(this.actions).sort(
+ (a1, a2) => ACTION_LABELS[a1[0]].position - ACTION_LABELS[a2[0]].position,
+ );
+ },
+ },
+ methods: {
+ svg(section) {
+ return imagePath(`learn_gitlab/section_${section}.svg`);
+ },
+ },
+};
+</script>
+<template>
+ <gl-card class="gl-pt-0 learn-gitlab-section-card">
+ <div class="learn-gitlab-section-card-header">
+ <img :src="svg(section)" />
+ <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n[section].title }}</h2>
+ <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n[section].description }}</p>
+ </div>
+ <learn-gitlab-section-link
+ v-for="[action, value] in sortedActions"
+ :key="action"
+ :action="action"
+ :value="value"
+ />
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
new file mode 100644
index 00000000000..6f51c7372fd
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
@@ -0,0 +1,43 @@
+<script>
+import { GlLink, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { ACTION_LABELS } from '../constants';
+
+export default {
+ name: 'LearnGitlabSectionLink',
+ components: { GlLink, GlIcon },
+ i18n: {
+ ACTION_LABELS,
+ trialOnly: s__('LearnGitlab|Trial only'),
+ },
+ props: {
+ action: {
+ required: true,
+ type: String,
+ },
+ value: {
+ required: true,
+ type: Object,
+ },
+ },
+ computed: {
+ trialOnly() {
+ return ACTION_LABELS[this.action].trialRequired;
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-mb-4">
+ <span v-if="value.completed" class="gl-text-green-500">
+ <gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" />
+ {{ $options.i18n.ACTION_LABELS[action].title }}
+ </span>
+ <span v-else>
+ <gl-link :href="value.url">{{ $options.i18n.ACTION_LABELS[action].title }}</gl-link>
+ </span>
+ <span v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only">
+ - {{ $options.i18n.trialOnly }}
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
index 80f04b0cf44..9e204aa6746 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
@@ -5,6 +5,8 @@ export const ACTION_LABELS = {
title: s__('LearnGitLab|Create or import a repository'),
actionLabel: s__('LearnGitLab|Create or import a repository'),
description: s__('LearnGitLab|Create or import your first repository into your new project.'),
+ section: 'workspace',
+ position: 1,
},
userAdded: {
title: s__('LearnGitLab|Invite your colleagues'),
@@ -12,16 +14,22 @@ export const ACTION_LABELS = {
description: s__(
'LearnGitLab|GitLab works best as a team. Invite your colleague to enjoy all features.',
),
+ section: 'workspace',
+ position: 0,
},
pipelineCreated: {
title: s__('LearnGitLab|Set up CI/CD'),
actionLabel: s__('LearnGitLab|Set-up CI/CD'),
description: s__('LearnGitLab|Save time by automating your integration and deployment tasks.'),
+ section: 'workspace',
+ position: 2,
},
trialStarted: {
title: s__('LearnGitLab|Start a free Ultimate trial'),
actionLabel: s__('LearnGitLab|Try GitLab Ultimate for free'),
description: s__('LearnGitLab|Try all GitLab features for 30 days, no credit card required.'),
+ section: 'workspace',
+ position: 3,
},
codeOwnersEnabled: {
title: s__('LearnGitLab|Add code owners'),
@@ -30,21 +38,59 @@ export const ACTION_LABELS = {
'LearnGitLab|Prevent unexpected changes to important assets by assigning ownership of files and paths.',
),
trialRequired: true,
+ section: 'workspace',
+ position: 4,
},
requiredMrApprovalsEnabled: {
title: s__('LearnGitLab|Add merge request approval'),
actionLabel: s__('LearnGitLab|Enable require merge approvals'),
description: s__('LearnGitLab|Route code reviews to the right reviewers, every time.'),
trialRequired: true,
+ section: 'workspace',
+ position: 5,
},
mergeRequestCreated: {
title: s__('LearnGitLab|Submit a merge request'),
actionLabel: s__('LearnGitLab|Submit a merge request (MR)'),
description: s__('LearnGitLab|Review and edit proposed changes to source code.'),
+ section: 'plan',
+ position: 1,
},
securityScanEnabled: {
- title: s__('LearnGitLab|Run a security scan'),
- actionLabel: s__('LearnGitLab|Run a Security scan'),
+ title: s__('LearnGitLab|Run a Security scan using CI/CD'),
+ actionLabel: s__('LearnGitLab|Run a Security scan using CI/CD'),
description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'),
+ section: 'deploy',
+ position: 1,
+ },
+ issueCreated: {
+ title: s__('LearnGitLab|Create an issue'),
+ actionLabel: s__('LearnGitLab|Create an issue'),
+ description: s__(
+ 'LearnGitLab|Create/import issues (tickets) to collaborate on ideas and plan work.',
+ ),
+ section: 'plan',
+ position: 0,
+ },
+};
+
+export const ACTION_SECTIONS = {
+ workspace: {
+ title: s__('LearnGitLab|Set up your workspace'),
+ description: s__(
+ "LearnGitLab|Complete these tasks first so you can enjoy GitLab's features to their fullest:",
+ ),
+ },
+ plan: {
+ title: s__('LearnGitLab|Plan and execute'),
+ description: s__(
+ 'LearnGitLab|Create a workflow for your new workspace, and learn how GitLab features work together:',
+ ),
+ },
+ deploy: {
+ title: s__('LearnGitLab|Deploy'),
+ description: s__(
+ 'LearnGitLab|Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure:',
+ ),
},
};
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
index d4d5e9f2711..a5118e3529a 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -5,21 +5,33 @@ import initPipelines from '~/commit/pipelines/pipelines_bundle';
import initIssuableSidebar from '~/init_issuable_sidebar';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
+import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
+import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { handleLocationHash } from '~/lib/utils/common_utils';
import StatusBox from '~/merge_request/components/status_box.vue';
import initSourcegraph from '~/sourcegraph';
import ZenMode from '~/zen_mode';
export default function initMergeRequestShow() {
+ const awardEmojiEl = document.getElementById('js-vue-awards-block');
+
new ZenMode(); // eslint-disable-line no-new
initIssuableSidebar();
initPipelines();
new ShortcutsIssuable(true); // eslint-disable-line no-new
handleLocationHash();
initSourcegraph();
- loadAwardsHandler();
+ if (awardEmojiEl) {
+ import('~/emoji/awards_app')
+ .then((m) => m.default(awardEmojiEl))
+ .catch(() => {});
+ } else {
+ loadAwardsHandler();
+ }
initInviteMemberModal();
initInviteMemberTrigger();
+ initInviteMembersModal();
+ initInviteMembersTrigger();
const el = document.querySelector('.js-mr-status-box');
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/pages/projects/packages/infrastructure_registry/index/index.js b/app/assets/javascripts/pages/projects/packages/infrastructure_registry/index/index.js
new file mode 100644
index 00000000000..dfb750eca41
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/packages/infrastructure_registry/index/index.js
@@ -0,0 +1,3 @@
+import initList from '~/packages_and_registries/infrastructure_registry/list_app_bundle';
+
+initList();
diff --git a/app/assets/javascripts/pages/projects/pages/index.js b/app/assets/javascripts/pages/projects/pages/index.js
new file mode 100644
index 00000000000..2a120a690ef
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pages/index.js
@@ -0,0 +1,3 @@
+import initSearchSettings from '~/search_settings';
+
+initSearchSettings();
diff --git a/app/assets/javascripts/pages/projects/path_locks/index.js b/app/assets/javascripts/pages/projects/path_locks/index.js
new file mode 100644
index 00000000000..e5ab5d43bbf
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/path_locks/index.js
@@ -0,0 +1,3 @@
+import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
+
+document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior);
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index 3b19231720a..159c619e16c 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -139,7 +139,7 @@ export default {
v-model="cronInterval"
:placeholder="__('Define a custom pattern with cron syntax')"
:name="inputNameAttribute"
- class="form-control inline cron-interval-input"
+ class="form-control inline cron-interval-input gl-form-input"
type="text"
required="true"
@input="onCustomInput"
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index da8dc527d79..91f376060f8 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -123,10 +123,19 @@ export default class Project {
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).split('#')[0];
+ // Since the current ref in renderRow is outdated on page changes
+ // (To be addressed in: https://gitlab.com/gitlab-org/gitlab/-/issues/327085)
+ // We are deciphering the current ref from the dropdown data instead
+ const currentRef = $dropdown.data('ref');
+ // The split and startWith is to ensure an exact word match
+ // and avoid partial match ie. currentRef is "dev" and loc is "development"
+ const splitPathAfterRefPortion = loc.split(currentRef)[1];
+ const doesPathContainRef = splitPathAfterRefPortion?.startsWith('/');
+
+ if (doesPathContainRef) {
+ // We are ignoring the url containing the ref portion
+ // and plucking the thereafter portion to reconstructure the url that is correct
+ const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0];
selectedUrl.searchParams.set('path', targetPath);
selectedUrl.hash = window.location.hash;
}
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index 4aea5614bfb..471798d2931 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -7,6 +7,7 @@ import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigg
import { s__ } from '~/locale';
import memberExpirationDate from '~/member_expiration_date';
import { initMembersApp } from '~/members';
+import { MEMBER_TYPES } from '~/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
import { projectMemberRequestFormatter } from '~/projects/members/utils';
import UsersSelect from '~/users_select';
@@ -42,6 +43,7 @@ new UsersSelect(); // eslint-disable-line no-new
const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-project-members-list'), {
+ namespace: MEMBER_TYPES.user,
tableFields: SHARED_FIELDS.concat(['source', 'granted']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'],
@@ -56,6 +58,7 @@ initMembersApp(document.querySelector('.js-project-members-list'), {
});
initMembersApp(document.querySelector('.js-project-group-links-list'), {
+ namespace: MEMBER_TYPES.group,
tableFields: SHARED_FIELDS.concat('granted'),
tableAttrs: {
table: { 'data-qa-selector': 'groups_list' },
@@ -72,11 +75,13 @@ initMembersApp(document.querySelector('.js-project-group-links-list'), {
});
initMembersApp(document.querySelector('.js-project-invited-members-list'), {
+ namespace: MEMBER_TYPES.invite,
tableFields: SHARED_FIELDS.concat('invited'),
requestFormatter: projectMemberRequestFormatter,
});
initMembersApp(document.querySelector('.js-project-access-requests-list'), {
+ namespace: MEMBER_TYPES.accessRequest,
tableFields: SHARED_FIELDS.concat('requested'),
requestFormatter: projectMemberRequestFormatter,
});
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 b7e8d4b03ac..be9259ec3ca 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
@@ -6,7 +6,6 @@ import initDeployFreeze from '~/deploy_freeze';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle';
import registrySettingsApp from '~/registry/settings/registry_settings_bundle';
-import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
document.addEventListener('DOMContentLoaded', () => {
@@ -43,6 +42,4 @@ document.addEventListener('DOMContentLoaded', () => {
}
initInstallRunner();
-
- initSearchSettings();
});
diff --git a/app/assets/javascripts/pages/projects/settings/index.js b/app/assets/javascripts/pages/projects/settings/index.js
new file mode 100644
index 00000000000..cb787c60002
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/index.js
@@ -0,0 +1,5 @@
+import initRevokeButton from '~/deploy_tokens/init_revoke_button';
+import initSearchSettings from '~/search_settings';
+
+initSearchSettings();
+initRevokeButton();
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 bf9ccdbf9a8..01ad87160c5 100644
--- a/app/assets/javascripts/pages/projects/settings/integrations/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/integrations/show/index.js
@@ -1,4 +1,7 @@
+import initIntegrationsList from '~/integrations/index';
import PersistentUserCallout from '~/persistent_user_callout';
const callout = document.querySelector('.js-webhooks-moved-alert');
PersistentUserCallout.factory(callout);
+
+initIntegrationsList();
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 4a800ab150d..3a46241e2eb 100644
--- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
@@ -3,7 +3,6 @@ import mountErrorTrackingForm from '~/error_tracking_settings';
import mountGrafanaIntegration from '~/grafana_integration';
import initIncidentsSettings from '~/incidents_settings';
import mountOperationSettings from '~/operation_settings';
-import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
initIncidentsSettings();
@@ -14,7 +13,3 @@ if (!IS_EE) {
initSettingsPanels();
}
mountAlertsSettings(document.querySelector('.js-alerts-settings'));
-
-document.addEventListener('DOMContentLoaded', () => {
- initSearchSettings();
-});
diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
index c7bcbb83051..e90954c14c5 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
@@ -1,5 +1,4 @@
import MirrorRepos from '~/mirrors/mirror_repos';
-import initSearchSettings from '~/search_settings';
import initForm from '../form';
document.addEventListener('DOMContentLoaded', () => {
@@ -7,6 +6,4 @@ document.addEventListener('DOMContentLoaded', () => {
const mirrorReposContainer = document.querySelector('.js-mirror-settings');
if (mirrorReposContainer) new MirrorRepos(mirrorReposContainer).init();
-
- initSearchSettings();
});
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
index d62df77ad2c..c110c1d4d62 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
@@ -12,6 +12,11 @@ export default {
event: 'change',
},
props: {
+ label: {
+ type: String,
+ required: false,
+ default: '',
+ },
name: {
type: String,
required: false,
@@ -82,6 +87,8 @@ export default {
class="gl-mr-3"
:value="featureEnabled"
:disabled="disabledInput"
+ :label="label"
+ label-position="hidden"
@change="toggleFeature"
/>
<div class="select-wrapper gl-flex-fill-1">
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 0b58cb4731d..0b7b4c0ded1 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
@@ -22,6 +22,21 @@ const PAGE_FEATURE_ACCESS_LEVEL = s__('ProjectSettings|Everyone');
export default {
i18n: {
...CVE_ID_REQUEST_BUTTON_I18N,
+ analyticsLabel: s__('ProjectSettings|Analytics'),
+ containerRegistryLabel: s__('ProjectSettings|Container registry'),
+ forksLabel: s__('ProjectSettings|Forks'),
+ issuesLabel: s__('ProjectSettings|Issues'),
+ lfsLabel: s__('ProjectSettings|Git Large File Storage (LFS)'),
+ mergeRequestsLabel: s__('ProjectSettings|Merge requests'),
+ operationsLabel: s__('ProjectSettings|Operations'),
+ packagesLabel: s__('ProjectSettings|Packages'),
+ pagesLabel: s__('ProjectSettings|Pages'),
+ ciCdLabel: s__('CI/CD'),
+ repositoryLabel: s__('ProjectSettings|Repository'),
+ requirementsLabel: s__('ProjectSettings|Requirements'),
+ securityAndComplianceLabel: s__('ProjectSettings|Security & Compliance'),
+ snippetsLabel: s__('ProjectSettings|Snippets'),
+ wikiLabel: s__('ProjectSettings|Wiki'),
},
components: {
@@ -423,11 +438,12 @@ export default {
>
<project-setting-row
ref="issues-settings"
- :label="s__('ProjectSettings|Issues')"
+ :label="$options.i18n.issuesLabel"
:help-text="s__('ProjectSettings|Lightweight issue tracking system.')"
>
<project-feature-setting
v-model="issuesAccessLevel"
+ :label="$options.i18n.issuesLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][issues_access_level]"
/>
@@ -440,6 +456,8 @@ export default {
v-model="cveIdRequestEnabled"
class="gl-my-2"
:disabled="cveIdRequestIsDisabled"
+ :label="$options.i18n.cve_request_toggle_label"
+ label-position="hidden"
name="project[project_setting_attributes][cve_id_request_enabled]"
data-testid="cve_id_request_toggle"
/>
@@ -447,11 +465,12 @@ export default {
</project-setting-row>
<project-setting-row
ref="repository-settings"
- :label="s__('ProjectSettings|Repository')"
+ :label="$options.i18n.repositoryLabel"
:help-text="repositoryHelpText"
>
<project-feature-setting
v-model="repositoryAccessLevel"
+ :label="$options.i18n.repositoryLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][repository_access_level]"
/>
@@ -459,11 +478,12 @@ export default {
<div class="project-feature-setting-group gl-pl-7 gl-sm-pl-5">
<project-setting-row
ref="merge-request-settings"
- :label="s__('ProjectSettings|Merge requests')"
+ :label="$options.i18n.mergeRequestsLabel"
:help-text="s__('ProjectSettings|Submit changes to be merged upstream.')"
>
<project-feature-setting
v-model="mergeRequestsAccessLevel"
+ :label="$options.i18n.mergeRequestsLabel"
:options="repoFeatureAccessLevelOptions"
:disabled-input="!repositoryEnabled"
name="project[project_feature_attributes][merge_requests_access_level]"
@@ -471,33 +491,22 @@ export default {
</project-setting-row>
<project-setting-row
ref="fork-settings"
- :label="s__('ProjectSettings|Forks')"
+ :label="$options.i18n.forksLabel"
:help-text="s__('ProjectSettings|Users can copy the repository to a new project.')"
>
<project-feature-setting
v-model="forkingAccessLevel"
+ :label="$options.i18n.forksLabel"
:options="featureAccessLevelOptions"
:disabled-input="!repositoryEnabled"
name="project[project_feature_attributes][forking_access_level]"
/>
</project-setting-row>
<project-setting-row
- ref="pipeline-settings"
- :label="s__('ProjectSettings|Pipelines')"
- :help-text="s__('ProjectSettings|Build, test, and deploy your changes.')"
- >
- <project-feature-setting
- v-model="buildsAccessLevel"
- :options="repoFeatureAccessLevelOptions"
- :disabled-input="!repositoryEnabled"
- name="project[project_feature_attributes][builds_access_level]"
- />
- </project-setting-row>
- <project-setting-row
v-if="registryAvailable"
ref="container-registry-settings"
:help-path="registryHelpPath"
- :label="s__('ProjectSettings|Container registry')"
+ :label="$options.i18n.containerRegistryLabel"
:help-text="
s__('ProjectSettings|Every project can have its own space to store its Docker images')
"
@@ -513,6 +522,8 @@ export default {
v-model="containerRegistryEnabled"
class="gl-my-2"
:disabled="!repositoryEnabled"
+ :label="$options.i18n.containerRegistryLabel"
+ label-position="hidden"
name="project[container_registry_enabled]"
/>
</project-setting-row>
@@ -520,7 +531,7 @@ export default {
v-if="lfsAvailable"
ref="git-lfs-settings"
:help-path="lfsHelpPath"
- :label="s__('ProjectSettings|Git Large File Storage (LFS)')"
+ :label="$options.i18n.lfsLabel"
:help-text="
s__('ProjectSettings|Manages large files such as audio, video, and graphics files.')
"
@@ -529,6 +540,8 @@ export default {
v-model="lfsEnabled"
class="gl-my-2"
:disabled="!repositoryEnabled"
+ :label="$options.i18n.lfsLabel"
+ label-position="hidden"
name="project[lfs_enabled]"
/>
<p v-if="!lfsEnabled && lfsObjectsExist">
@@ -553,7 +566,7 @@ export default {
v-if="packagesAvailable"
ref="package-settings"
:help-path="packagesHelpPath"
- :label="s__('ProjectSettings|Packages')"
+ :label="$options.i18n.packagesLabel"
:help-text="
s__('ProjectSettings|Every project can have its own space to store its packages.')
"
@@ -562,17 +575,33 @@ export default {
v-model="packagesEnabled"
class="gl-my-2"
:disabled="!repositoryEnabled"
+ :label="$options.i18n.packagesLabel"
+ label-position="hidden"
name="project[packages_enabled]"
/>
</project-setting-row>
</div>
<project-setting-row
+ ref="pipeline-settings"
+ :label="$options.i18n.ciCdLabel"
+ :help-text="s__('ProjectSettings|Build, test, and deploy your changes.')"
+ >
+ <project-feature-setting
+ v-model="buildsAccessLevel"
+ :label="$options.i18n.ciCdLabel"
+ :options="repoFeatureAccessLevelOptions"
+ :disabled-input="!repositoryEnabled"
+ name="project[project_feature_attributes][builds_access_level]"
+ />
+ </project-setting-row>
+ <project-setting-row
ref="analytics-settings"
- :label="s__('ProjectSettings|Analytics')"
+ :label="$options.i18n.analyticsLabel"
:help-text="s__('ProjectSettings|View project analytics.')"
>
<project-feature-setting
v-model="analyticsAccessLevel"
+ :label="$options.i18n.analyticsLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][analytics_access_level]"
/>
@@ -580,43 +609,47 @@ export default {
<project-setting-row
v-if="requirementsAvailable"
ref="requirements-settings"
- :label="s__('ProjectSettings|Requirements')"
+ :label="$options.i18n.requirementsLabel"
:help-text="s__('ProjectSettings|Requirements management system.')"
>
<project-feature-setting
v-model="requirementsAccessLevel"
+ :label="$options.i18n.requirementsLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][requirements_access_level]"
/>
</project-setting-row>
<project-setting-row
- :label="s__('ProjectSettings|Security & Compliance')"
+ :label="$options.i18n.securityAndComplianceLabel"
:help-text="s__('ProjectSettings|Security & Compliance for this project')"
>
<project-feature-setting
v-model="securityAndComplianceAccessLevel"
+ :label="$options.i18n.securityAndComplianceLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][security_and_compliance_access_level]"
/>
</project-setting-row>
<project-setting-row
ref="wiki-settings"
- :label="s__('ProjectSettings|Wiki')"
+ :label="$options.i18n.wikiLabel"
:help-text="s__('ProjectSettings|Pages for project documentation.')"
>
<project-feature-setting
v-model="wikiAccessLevel"
+ :label="$options.i18n.wikiLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][wiki_access_level]"
/>
</project-setting-row>
<project-setting-row
ref="snippet-settings"
- :label="s__('ProjectSettings|Snippets')"
+ :label="$options.i18n.snippetsLabel"
:help-text="s__('ProjectSettings|Share code with others outside the project.')"
>
<project-feature-setting
v-model="snippetsAccessLevel"
+ :label="$options.i18n.snippetsLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][snippets_access_level]"
/>
@@ -625,26 +658,28 @@ export default {
v-if="pagesAvailable && pagesAccessControlEnabled"
ref="pages-settings"
:help-path="pagesHelpPath"
- :label="s__('ProjectSettings|Pages')"
+ :label="$options.i18n.pagesLabel"
:help-text="
s__('ProjectSettings|With GitLab Pages you can host your static websites on GitLab.')
"
>
<project-feature-setting
v-model="pagesAccessLevel"
+ :label="$options.i18n.pagesLabel"
:options="pagesFeatureAccessLevelOptions"
name="project[project_feature_attributes][pages_access_level]"
/>
</project-setting-row>
<project-setting-row
ref="operations-settings"
- :label="s__('ProjectSettings|Operations')"
+ :label="$options.i18n.operationsLabel"
:help-text="
s__('ProjectSettings|Configure your project resources and monitor their health.')
"
>
<project-feature-setting
v-model="operationsAccessLevel"
+ :label="$options.i18n.operationsLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][operations_access_level]"
/>
diff --git a/app/assets/javascripts/pages/projects/tags/index/index.js b/app/assets/javascripts/pages/projects/tags/index/index.js
index 98560c1193b..9e48dd9e463 100644
--- a/app/assets/javascripts/pages/projects/tags/index/index.js
+++ b/app/assets/javascripts/pages/projects/tags/index/index.js
@@ -1,3 +1,4 @@
+import TagSortDropdown from '~/tags';
import { initRemoveTag } from '../remove_tag';
initRemoveTag({
@@ -5,3 +6,4 @@ initRemoveTag({
document.querySelector(`[data-path="${path}"]`).closest('.js-tag-list').remove();
},
});
+TagSortDropdown();
diff --git a/app/assets/javascripts/pages/shared/mount_runner_instructions.js b/app/assets/javascripts/pages/shared/mount_runner_instructions.js
index 51028e585b8..e83c73edfde 100644
--- a/app/assets/javascripts/pages/shared/mount_runner_instructions.js
+++ b/app/assets/javascripts/pages/shared/mount_runner_instructions.js
@@ -1,7 +1,7 @@
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';
+import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
Vue.use(VueApollo);
@@ -10,7 +10,6 @@ export function initInstallRunner(componentId = 'js-install-runner') {
if (installRunnerEl) {
const defaultClient = createDefaultClient();
- const { projectPath, groupPath } = installRunnerEl.dataset;
const apolloProvider = new VueApollo({
defaultClient,
@@ -20,12 +19,8 @@ export function initInstallRunner(componentId = 'js-install-runner') {
new Vue({
el: installRunnerEl,
apolloProvider,
- provide: {
- projectPath,
- groupPath,
- },
render(createElement) {
- return createElement(InstallRunnerInstructions);
+ return createElement(RunnerInstructions);
},
});
}
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
new file mode 100644
index 00000000000..6afc33ec8a5
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -0,0 +1,253 @@
+<script>
+import { GlForm, GlIcon, GlLink, GlButton, GlSprintf } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import { setUrlFragment } from '~/lib/utils/url_utility';
+import { __, s__, sprintf } from '~/locale';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+
+const MARKDOWN_LINK_TEXT = {
+ markdown: '[Link Title](page-slug)',
+ rdoc: '{Link title}[link:page-slug]',
+ asciidoc: 'link:page-slug[Link title]',
+ org: '[[page-slug]]',
+};
+
+export default {
+ components: {
+ GlForm,
+ GlSprintf,
+ GlIcon,
+ GlLink,
+ GlButton,
+ MarkdownField,
+ },
+ inject: ['formatOptions', 'pageInfo'],
+ data() {
+ return {
+ title: this.pageInfo.title?.trim() || '',
+ format: this.pageInfo.format || 'markdown',
+ content: this.pageInfo.content?.trim() || '',
+ commitMessage: '',
+ };
+ },
+ computed: {
+ csrfToken() {
+ return csrf.token;
+ },
+ formAction() {
+ return this.pageInfo.persisted ? this.pageInfo.path : this.pageInfo.createPath;
+ },
+ helpPath() {
+ return setUrlFragment(
+ this.pageInfo.helpPath,
+ this.pageInfo.persisted ? 'move-a-wiki-page' : 'create-a-new-wiki-page',
+ );
+ },
+ commitMessageI18n() {
+ return this.pageInfo.persisted
+ ? s__('WikiPage|Update %{pageTitle}')
+ : s__('WikiPage|Create %{pageTitle}');
+ },
+ linkExample() {
+ return MARKDOWN_LINK_TEXT[this.format];
+ },
+ submitButtonText() {
+ if (this.pageInfo.persisted) return __('Save changes');
+ return s__('WikiPage|Create page');
+ },
+ cancelFormPath() {
+ if (this.pageInfo.persisted) return this.pageInfo.path;
+ return this.pageInfo.wikiPath;
+ },
+ wikiSpecificMarkdownHelpPath() {
+ return setUrlFragment(this.pageInfo.markdownHelpPath, 'wiki-specific-markdown');
+ },
+ },
+ mounted() {
+ this.updateCommitMessage();
+ },
+ methods: {
+ handleFormSubmit() {
+ window.removeEventListener('beforeunload', this.onBeforeUnload);
+ },
+
+ handleContentChange() {
+ window.addEventListener('beforeunload', this.onBeforeUnload);
+ },
+
+ onBeforeUnload() {
+ return '';
+ },
+
+ updateCommitMessage() {
+ if (!this.title) return;
+
+ // Replace hyphens with spaces
+ const newTitle = this.title.replace(/-+/g, ' ');
+
+ const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: newTitle }, false);
+ this.commitMessage = newCommitMessage;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form
+ :action="formAction"
+ method="post"
+ class="wiki-form common-note-form gl-mt-3 js-quick-submit"
+ @submit="handleFormSubmit"
+ >
+ <input :value="csrfToken" type="hidden" name="authenticity_token" />
+ <input v-if="pageInfo.persisted" type="hidden" name="_method" value="put" />
+ <input
+ :v-if="pageInfo.persisted"
+ type="hidden"
+ name="wiki[last_commit_sha]"
+ :value="pageInfo.lastCommitSha"
+ />
+ <div class="form-group row">
+ <div class="col-sm-2 col-form-label">
+ <label class="control-label-full-width" for="wiki_title">{{ s__('WikiPage|Title') }}</label>
+ </div>
+ <div class="col-sm-10">
+ <input
+ id="wiki_title"
+ v-model.trim="title"
+ name="wiki[title]"
+ type="text"
+ class="form-control"
+ data-qa-selector="wiki_title_textbox"
+ :required="true"
+ :autofocus="!pageInfo.persisted"
+ :placeholder="s__('WikiPage|Page title')"
+ @input="updateCommitMessage"
+ />
+ <span class="gl-display-inline-block gl-max-w-full gl-mt-2 gl-text-gray-600">
+ <gl-icon class="gl-mr-n1" name="bulb" />
+ {{
+ pageInfo.persisted
+ ? s__(
+ 'WikiPage|Tip: You can move this page by adding the path to the beginning of the title.',
+ )
+ : s__(
+ 'WikiPage|Tip: You can specify the full path for the new file. We will automatically create any missing directories.',
+ )
+ }}
+ <gl-link :href="helpPath" target="_blank" data-testid="wiki-title-help-link"
+ ><gl-icon name="question-o" /> {{ __('More Information.') }}</gl-link
+ >
+ </span>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-sm-2 col-form-label">
+ <label class="control-label-full-width" for="wiki_format">{{
+ s__('WikiPage|Format')
+ }}</label>
+ </div>
+ <div class="col-sm-10">
+ <select id="wiki_format" v-model="format" class="form-control" name="wiki[format]">
+ <option v-for="(key, label) of formatOptions" :key="key" :value="key">
+ {{ label }}
+ </option>
+ </select>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-sm-2 col-form-label">
+ <label class="control-label-full-width" for="wiki_content">{{
+ s__('WikiPage|Content')
+ }}</label>
+ </div>
+ <div class="col-sm-10">
+ <markdown-field
+ :markdown-preview-path="pageInfo.markdownPreviewPath"
+ :can-attach-file="true"
+ :enable-autocomplete="true"
+ :textarea-value="content"
+ :markdown-docs-path="pageInfo.markdownHelpPath"
+ :uploads-path="pageInfo.uploadsPath"
+ class="bordered-box"
+ >
+ <template #textarea>
+ <textarea
+ id="wiki_content"
+ ref="textarea"
+ v-model.trim="content"
+ name="wiki[content]"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ data-qa-selector="wiki_content_textarea"
+ :autofocus="pageInfo.persisted"
+ :aria-label="s__('WikiPage|Content')"
+ :placeholder="s__('WikiPage|Write your content or drag files here…')"
+ @input="handleContentChange"
+ >
+ </textarea>
+ </template>
+ </markdown-field>
+ <div class="clearfix"></div>
+ <div class="error-alert"></div>
+
+ <div class="form-text gl-text-gray-600">
+ <gl-sprintf
+ :message="
+ s__(
+ 'WikiPage|To link to a (new) page, simply type %{linkExample}. More examples are in the %{linkStart}documentation%{linkEnd}.',
+ )
+ "
+ >
+ <template #linkExample
+ ><code>{{ linkExample }}</code></template
+ >
+ <template
+ #link="// eslint-disable-next-line vue/no-template-shadow
+ { content }"
+ ><gl-link
+ :href="wikiSpecificMarkdownHelpPath"
+ target="_blank"
+ data-testid="wiki-markdown-help-link"
+ >{{ content }}</gl-link
+ ></template
+ >
+ </gl-sprintf>
+ </div>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-sm-2 col-form-label">
+ <label class="control-label-full-width" for="wiki_message">{{
+ s__('WikiPage|Commit message')
+ }}</label>
+ </div>
+ <div class="col-sm-10">
+ <input
+ id="wiki_message"
+ v-model.trim="commitMessage"
+ name="wiki[message]"
+ type="text"
+ class="form-control"
+ data-qa-selector="wiki_message_textbox"
+ :placeholder="s__('WikiPage|Commit message')"
+ />
+ </div>
+ </div>
+ <div class="form-actions">
+ <gl-button
+ category="primary"
+ variant="confirm"
+ type="submit"
+ data-qa-selector="wiki_submit_button"
+ data-testid="wiki-submit-button"
+ :disabled="!content || !title"
+ >{{ submitButtonText }}</gl-button
+ >
+ <gl-button :href="cancelFormPath" class="float-right" data-testid="wiki-cancel-button">{{
+ __('Cancel')
+ }}</gl-button>
+ </div>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/pages/shared/wikis/index.js b/app/assets/javascripts/pages/shared/wikis/index.js
index c382a372260..c04cd0b3fa4 100644
--- a/app/assets/javascripts/pages/shared/wikis/index.js
+++ b/app/assets/javascripts/pages/shared/wikis/index.js
@@ -1,12 +1,14 @@
import $ from 'jquery';
import Vue from 'vue';
import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import csrf from '~/lib/utils/csrf';
import Translate from '~/vue_shared/translate';
import GLForm from '../../../gl_form';
import ZenMode from '../../../zen_mode';
import deleteWikiModal from './components/delete_wiki_modal.vue';
import wikiAlert from './components/wiki_alert.vue';
+import wikiForm from './components/wiki_form.vue';
import Wikis from './wikis';
const createModalVueApp = () => {
@@ -61,7 +63,28 @@ const createAlertVueApp = () => {
}
};
+const createWikiFormApp = () => {
+ const el = document.getElementById('js-wiki-form');
+
+ if (el) {
+ const { pageInfo, formatOptions } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ provide: {
+ formatOptions: JSON.parse(formatOptions),
+ pageInfo: convertObjectPropsToCamelCase(JSON.parse(pageInfo)),
+ },
+ render(createElement) {
+ return createElement(wikiForm);
+ },
+ });
+ }
+};
+
export default () => {
createModalVueApp();
createAlertVueApp();
+ createWikiFormApp();
};
diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js
index 4b4d2f7d238..7d0b0c90c8d 100644
--- a/app/assets/javascripts/pages/shared/wikis/wikis.js
+++ b/app/assets/javascripts/pages/shared/wikis/wikis.js
@@ -1,15 +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)',
- rdoc: '{Link title}[link:page-slug]',
- asciidoc: 'link:page-slug[Link title]',
- org: '[[page-slug]]',
-};
-
const TRACKING_EVENT_NAME = 'view_wiki_page';
const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-1';
@@ -23,78 +15,11 @@ export default class Wikis {
sidebarToggles[i].addEventListener('click', (e) => this.handleToggleSidebar(e));
}
- this.isNewWikiPage = Boolean(document.querySelector('.js-new-wiki-page'));
- this.editTitleInput = document.querySelector('form.wiki-form #wiki_title');
- this.commitMessageInput = document.querySelector('form.wiki-form #wiki_message');
- this.submitButton = document.querySelector('.js-wiki-btn-submit');
- this.commitMessageI18n = this.isNewWikiPage
- ? s__('WikiPageCreate|Create %{pageTitle}')
- : s__('WikiPageEdit|Update %{pageTitle}');
-
- if (this.editTitleInput) {
- // Initialize the commit message on load
- if (this.editTitleInput.value) this.setWikiCommitMessage(this.editTitleInput.value);
-
- // Set the commit message as the page title is changed
- this.editTitleInput.addEventListener('keyup', (e) => this.handleWikiTitleChange(e));
- }
-
window.addEventListener('resize', () => this.renderSidebar());
this.renderSidebar();
- const changeFormatSelect = document.querySelector('#wiki_format');
- const linkExample = document.querySelector('.js-markup-link-example');
-
- if (changeFormatSelect) {
- changeFormatSelect.addEventListener('change', (e) => {
- linkExample.innerHTML = MARKDOWN_LINK_TEXT[e.target.value];
- });
- }
-
- this.wikiTextarea = document.querySelector('form.wiki-form #wiki_content');
- const wikiForm = document.querySelector('form.wiki-form');
-
- if (this.wikiTextarea) {
- this.wikiTextarea.addEventListener('input', () => this.handleWikiContentChange());
-
- wikiForm.addEventListener('submit', () => {
- window.onbeforeunload = null;
- });
- }
-
Wikis.trackPageView();
Wikis.showToasts();
-
- this.updateSubmitButton();
- }
-
- handleWikiContentChange() {
- this.updateSubmitButton();
-
- window.onbeforeunload = () => '';
- }
-
- handleWikiTitleChange(e) {
- this.updateSubmitButton();
- this.setWikiCommitMessage(e.target.value);
- }
-
- updateSubmitButton() {
- if (!this.wikiTextarea) return;
-
- const isEnabled = Boolean(this.wikiTextarea.value.trim() && this.editTitleInput.value.trim());
- if (isEnabled) this.submitButton.removeAttribute('disabled');
- else this.submitButton.setAttribute('disabled', 'true');
- }
-
- setWikiCommitMessage(rawTitle) {
- let title = rawTitle;
-
- // Replace hyphens with spaces
- if (title) title = title.replace(/-+/g, ' ');
-
- const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: title }, false);
- this.commitMessageInput.value = newCommitMessage;
}
handleToggleSidebar(e) {
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index 3ff455fad32..d236dc4610a 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -39,7 +39,7 @@ function formatTooltipText({ date, count }) {
if (count > 0) {
contribText = n__('%d contribution', '%d contributions', count);
}
- return `${contribText}<br />${dateDayName} ${dateText}`;
+ return `${contribText}<br /><span class="gl-text-gray-300">${dateDayName} ${dateText}</span>`;
}
const initColorKey = () => d3.scaleLinear().range(['#acd5f2', '#254e77']).domain([0, 3]);
diff --git a/app/assets/javascripts/performance/constants.js b/app/assets/javascripts/performance/constants.js
index 4ac758550e0..98b2e4238c1 100644
--- a/app/assets/javascripts/performance/constants.js
+++ b/app/assets/javascripts/performance/constants.js
@@ -74,4 +74,4 @@ export const PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION =
// (defined in: app/services/ci/prometheus_metrics/observe_histograms_service.rb)
export const PIPELINES_DETAIL_LINK_DURATION = 'pipeline_graph_link_calculation_duration_seconds';
export const PIPELINES_DETAIL_LINKS_TOTAL = 'pipeline_graph_links_total';
-export const PIPELINES_DETAIL_LINKS_JOB_RATIO = 'pipeline_graph_link_per_job_ratio';
+export const PIPELINES_DETAIL_LINKS_JOB_RATIO = 'pipeline_graph_links_per_job_ratio';
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index 9bf77239a6b..e5b26a00c4c 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -1,5 +1,8 @@
<script>
-import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
+import { GlButton, GlModal, GlModalDirective, GlSegmentedControl } from '@gitlab/ui';
+
+import { s__ } from '~/locale';
+import { sortOrders, sortOrderOptions } from '../constants';
import RequestWarning from './request_warning.vue';
export default {
@@ -7,6 +10,7 @@ export default {
RequestWarning,
GlButton,
GlModal,
+ GlSegmentedControl,
},
directives: {
'gl-modal': GlModalDirective,
@@ -39,6 +43,7 @@ export default {
data() {
return {
openedBacktraces: [],
+ sortOrder: sortOrders.DURATION,
};
},
computed: {
@@ -48,13 +53,43 @@ export default {
metricDetails() {
return this.currentRequest.details[this.metric];
},
+ metricDetailsSummary() {
+ const summary = {};
+
+ if (!this.metricDetails.summaryOptions?.hideTotal) {
+ summary[s__('Total')] = this.metricDetails.calls;
+ }
+
+ if (!this.metricDetails.summaryOptions?.hideDuration) {
+ summary[s__('PerformanceBar|Total duration')] = this.metricDetails.duration;
+ }
+
+ return { ...summary, ...(this.metricDetails.summary || {}) };
+ },
metricDetailsLabel() {
- return this.metricDetails.duration
- ? `${this.metricDetails.duration} / ${this.metricDetails.calls}`
- : this.metricDetails.calls;
+ if (this.metricDetails.duration && this.metricDetails.calls) {
+ return `${this.metricDetails.duration} / ${this.metricDetails.calls}`;
+ } else if (this.metricDetails.calls) {
+ return this.metricDetails.calls;
+ }
+
+ return '0';
+ },
+ displaySortOrder() {
+ return (
+ this.metricDetails.details.length !== 0 &&
+ this.metricDetails.details.every((item) => item.start)
+ );
},
detailsList() {
- return this.metricDetails.details;
+ return this.metricDetails.details.map((item, index) => ({ ...item, id: index }));
+ },
+ sortedList() {
+ if (this.sortOrder === sortOrders.CHRONOLOGICAL) {
+ return this.detailsList.slice().sort(this.sortDetailChronologically);
+ }
+
+ return this.detailsList.slice().sort(this.sortDetailByDuration);
},
warnings() {
return this.metricDetails.warnings || [];
@@ -82,7 +117,17 @@ export default {
itemHasOpenedBacktrace(toggledIndex) {
return this.openedBacktraces.find((openedIndex) => openedIndex === toggledIndex) >= 0;
},
+ changeSortOrder(order) {
+ this.sortOrder = order;
+ },
+ sortDetailByDuration(a, b) {
+ return a.duration < b.duration ? 1 : -1;
+ },
+ sortDetailChronologically(a, b) {
+ return a.start < b.start ? -1 : 1;
+ },
},
+ sortOrderOptions,
};
</script>
<template>
@@ -93,18 +138,41 @@ export default {
data-qa-selector="detailed_metric_content"
>
<gl-button v-gl-modal="modalId" class="gl-mr-2" type="button" variant="link">
- <span class="gl-text-blue-300 gl-font-weight-bold">{{ metricDetailsLabel }}</span>
+ <span
+ class="gl-text-blue-200 gl-font-weight-bold"
+ data-testid="performance-bar-details-label"
+ >
+ {{ metricDetailsLabel }}
+ </span>
</gl-button>
<gl-modal :modal-id="modalId" :title="header" size="lg" footer-class="d-none" scrollable>
+ <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
+ <div class="gl-display-flex gl-align-items-center" data-testid="performance-bar-summary">
+ <div v-for="(value, name) in metricDetailsSummary" :key="name" class="gl-pr-8">
+ <div v-if="value" data-testid="performance-bar-summary-item">
+ <div>{{ name }}</div>
+ <div class="gl-font-size-h1 gl-font-weight-bold">{{ value }}</div>
+ </div>
+ </div>
+ </div>
+ <gl-segmented-control
+ v-if="displaySortOrder"
+ data-testid="performance-bar-sort-order"
+ :options="$options.sortOrderOptions"
+ :checked="sortOrder"
+ @input="changeSortOrder"
+ />
+ </div>
+ <hr />
<table class="table gl-table">
- <template v-if="detailsList.length">
- <tr v-for="(item, index) in detailsList" :key="index">
- <td>
+ <template v-if="sortedList.length">
+ <tr v-for="item in sortedList" :key="item.id">
+ <td data-testid="performance-item-duration">
<span v-if="item.duration">{{
sprintf(__('%{duration}ms'), { duration: item.duration })
}}</span>
</td>
- <td>
+ <td data-testid="performance-item-content">
<div>
<div
v-for="(key, keyIndex) in keys"
@@ -121,12 +189,12 @@ export default {
variant="default"
icon="ellipsis_h"
size="small"
- :selected="itemHasOpenedBacktrace(index)"
+ :selected="itemHasOpenedBacktrace(item.id)"
:aria-label="__('Toggle backtrace')"
- @click="toggleBacktrace(index)"
+ @click="toggleBacktrace(item.id)"
/>
</div>
- <pre v-if="itemHasOpenedBacktrace(index)" class="backtrace-row mt-2">{{
+ <pre v-if="itemHasOpenedBacktrace(item.id)" class="backtrace-row gl-mt-3">{{
item.backtrace
}}</pre>
</div>
@@ -135,7 +203,7 @@ export default {
</template>
<template v-else>
<tr>
- <td>
+ <td data-testid="performance-bar-empty-detail-notice">
{{ sprintf(__('No %{header} for this request.'), { header: header.toLowerCase() }) }}
</td>
</tr>
@@ -146,7 +214,7 @@ export default {
<div></div>
</template>
</gl-modal>
- {{ title }}
+ <span class="gl-text-white">{{ title }}</span>
<request-warning :html-id="htmlId" :warnings="warnings" />
</div>
</template>
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index 6b446eb6073..ebe9c4eee2f 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -40,7 +40,7 @@ export default {
metric: 'active-record',
title: 'pg',
header: s__('PerformanceBar|SQL queries'),
- keys: ['sql', 'cached', 'db_role'],
+ keys: ['sql', 'cached', 'transaction', 'db_role'],
},
{
metric: 'bullet',
@@ -69,6 +69,7 @@ export default {
},
{
metric: 'external-http',
+ title: 'external',
header: s__('PerformanceBar|External Http calls'),
keys: ['label', 'code', 'proxy', 'error'],
},
@@ -135,7 +136,7 @@ export default {
<div id="peek-view-host" class="view">
<span
v-if="hasHost"
- class="current-host"
+ class="current-host gl-text-white"
:class="{ canary: currentRequest.details.host.canary }"
>
<span v-html="birdEmoji"></span>
@@ -156,16 +157,18 @@ export default {
id="peek-view-trace"
class="view"
>
- <a class="gl-text-blue-300" :href="currentRequest.details.tracing.tracing_url">{{
- s__('PerformanceBar|trace')
+ <a class="gl-text-blue-200" :href="currentRequest.details.tracing.tracing_url">{{
+ s__('PerformanceBar|Trace')
}}</a>
</div>
- <add-request v-on="$listeners" />
<div v-if="currentRequest.details" id="peek-download" class="view">
- <a class="gl-text-blue-300" :download="downloadName" :href="downloadPath">{{
+ <a class="gl-text-blue-200" :download="downloadName" :href="downloadPath">{{
s__('PerformanceBar|Download')
}}</a>
</div>
+ <a v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{
+ s__('PerformanceBar|Stats')
+ }}</a>
<request-selector
v-if="currentRequest"
:current-request="currentRequest"
@@ -173,9 +176,7 @@ export default {
class="ml-auto"
@change-current-request="changeCurrentRequest"
/>
- <div v-if="statsUrl" id="peek-stats" class="view">
- <a class="gl-text-blue-300" :href="statsUrl">{{ s__('PerformanceBar|Stats') }}</a>
- </div>
+ <add-request v-on="$listeners" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
index 5666e038f02..75fb7bbc5c5 100644
--- a/app/assets/javascripts/performance_bar/components/request_selector.vue
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -58,12 +58,12 @@ export default {
<span v-if="request.hasWarnings">(!)</span>
</option>
</select>
- <span v-if="requestsWithWarnings.length">
+ <span v-if="requestsWithWarnings.length" class="gl-cursor-default">
<span id="performance-bar-request-selector-warning" v-html="glEmojiTag('warning')"></span>
<gl-popover
+ placement="bottom"
target="performance-bar-request-selector-warning"
:content="warningMessage"
- triggers="hover focus"
/>
</span>
</div>
diff --git a/app/assets/javascripts/performance_bar/components/request_warning.vue b/app/assets/javascripts/performance_bar/components/request_warning.vue
index b61e1e5b7a9..7fe6b088ebb 100644
--- a/app/assets/javascripts/performance_bar/components/request_warning.vue
+++ b/app/assets/javascripts/performance_bar/components/request_warning.vue
@@ -35,8 +35,8 @@ export default {
};
</script>
<template>
- <span v-if="hasWarnings">
+ <span v-if="hasWarnings" class="gl-cursor-default">
<span :id="htmlId" v-html="glEmojiTag('warning')"></span>
- <gl-popover :target="htmlId" :content="warningMessage" triggers="hover focus" />
+ <gl-popover placement="bottom" :target="htmlId" :content="warningMessage" />
</span>
</template>
diff --git a/app/assets/javascripts/performance_bar/constants.js b/app/assets/javascripts/performance_bar/constants.js
new file mode 100644
index 00000000000..9659383edd9
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/constants.js
@@ -0,0 +1,17 @@
+import { s__ } from '~/locale';
+
+export const sortOrders = {
+ DURATION: 'duration',
+ CHRONOLOGICAL: 'chronological',
+};
+
+export const sortOrderOptions = [
+ {
+ value: sortOrders.DURATION,
+ text: s__('PerformanceBar|Sort by duration'),
+ },
+ {
+ value: sortOrders.CHRONOLOGICAL,
+ text: s__('PerformanceBar|Sort chronologically'),
+ },
+];
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index 51b6108868f..d8aab25a6a8 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -1,6 +1,7 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import Vue from 'vue';
import axios from '~/lib/utils/axios_utils';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { s__ } from '~/locale';
import Translate from '~/vue_shared/translate';
import initPerformanceBarLog from './performance_bar_log';
@@ -75,40 +76,53 @@ const initPerformanceBar = (el) => {
const resourceEntries = performance.getEntriesByType('resource');
let durationString = '';
+ let summary = {};
if (navigationEntries.length > 0) {
- durationString = `${Math.round(navigationEntries[0].responseEnd)} | `;
- durationString += `${Math.round(paintEntries[1].startTime)} | `;
- durationString += ` ${Math.round(navigationEntries[0].domContentLoadedEventEnd)}`;
+ const backend = Math.round(navigationEntries[0].responseEnd);
+ const firstContentfulPaint = Math.round(paintEntries[1].startTime);
+ const domContentLoaded = Math.round(navigationEntries[0].domContentLoadedEventEnd);
+
+ summary = {
+ [s__('PerformanceBar|Backend')]: backend,
+ [s__('PerformanceBar|First Contentful Paint')]: firstContentfulPaint,
+ [s__('PerformanceBar|DOM Content Loaded')]: domContentLoaded,
+ };
+
+ durationString = `${backend} | ${firstContentfulPaint} | ${domContentLoaded}`;
}
let newEntries = resourceEntries.map(this.transformResourceEntry);
- this.updateFrontendPerformanceMetrics(durationString, newEntries);
+ this.updateFrontendPerformanceMetrics(durationString, summary, newEntries);
if ('PerformanceObserver' in window) {
// We start observing for more incoming timings
const observer = new PerformanceObserver((list) => {
newEntries = newEntries.concat(list.getEntries().map(this.transformResourceEntry));
- this.updateFrontendPerformanceMetrics(durationString, newEntries);
+ this.updateFrontendPerformanceMetrics(durationString, summary, newEntries);
});
observer.observe({ entryTypes: ['resource'] });
}
}
},
- updateFrontendPerformanceMetrics(durationString, requestEntries) {
+ updateFrontendPerformanceMetrics(durationString, summary, requestEntries) {
this.store.setRequestDetailsData(this.requestId, 'total', {
duration: durationString,
calls: requestEntries.length,
details: requestEntries,
+ summaryOptions: {
+ hideDuration: true,
+ },
+ summary,
});
},
transformResourceEntry(entry) {
- const nf = new Intl.NumberFormat();
return {
+ start: entry.startTime,
name: entry.name.replace(document.location.origin, ''),
duration: Math.round(entry.duration),
- size: entry.transferSize ? `${nf.format(entry.transferSize)} bytes` : 'cached',
+ size: entry.transferSize ? numberToHumanSize(entry.transferSize) : 'cached',
};
},
},
diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
index 9d12d228d35..51a8eb5ca69 100644
--- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
+++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
@@ -47,10 +47,15 @@ export default class PerformanceBarStore {
}
canTrackRequest(requestUrl) {
- return (
- requestUrl.endsWith('/api/graphql') ||
- this.requests.filter((request) => request.url === requestUrl).length < 2
- );
+ // We want to store at most 2 unique requests per URL, as additional
+ // requests to the same URL probably aren't very interesting.
+ //
+ // GraphQL requests are the exception: because all GraphQL requests
+ // go to the same URL, we set a higher limit of 10 to allow
+ // capturing different queries a page may make.
+ const requestsLimit = requestUrl.endsWith('/api/graphql') ? 10 : 2;
+
+ return this.requests.filter((request) => request.url === requestUrl).length < requestsLimit;
}
static truncateUrl(requestUrl) {
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index c177fe25985..cadcab16f16 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -7,7 +7,9 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-buy-pipeline-minutes-notification-callout',
'.js-token-expiry-callout',
'.js-registration-enabled-callout',
+ '.js-service-templates-deprecated-callout',
'.js-new-user-signups-cap-reached',
+ '.js-eoa-bronze-plan-banner',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue
new file mode 100644
index 00000000000..7b33d98bca0
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue
@@ -0,0 +1,42 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { CODE_SNIPPET_SOURCES, CODE_SNIPPET_SOURCE_SETTINGS } from './constants';
+
+export default {
+ name: 'CodeSnippetAlert',
+ components: {
+ GlAlert,
+ },
+ inject: ['configurationPaths'],
+ props: {
+ source: {
+ type: String,
+ required: true,
+ validator: (source) => CODE_SNIPPET_SOURCES.includes(source),
+ },
+ },
+ computed: {
+ settings() {
+ return CODE_SNIPPET_SOURCE_SETTINGS[this.source];
+ },
+ configurationPath() {
+ return this.configurationPaths[this.source];
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert
+ variant="tip"
+ :title="__('Code snippet copied. Insert it in the correct location in the YAML file.')"
+ :dismiss-label="__('Dismiss')"
+ :primary-button-link="settings.docsPath"
+ :primary-button-text="__('Read documentation')"
+ :secondary-button-link="configurationPath"
+ :secondary-button-text="__('Go back to configuration')"
+ v-on="$listeners"
+ >
+ {{ __('Before inserting code, be sure to read the comment that separated each code group.') }}
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js
new file mode 100644
index 00000000000..582fdfea6c9
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js
@@ -0,0 +1,11 @@
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export const CODE_SNIPPET_SOURCE_URL_PARAM = 'code_snippet_copied_from';
+export const CODE_SNIPPET_SOURCE_API_FUZZING = 'api_fuzzing';
+export const CODE_SNIPPET_SOURCES = [CODE_SNIPPET_SOURCE_API_FUZZING];
+export const CODE_SNIPPET_SOURCE_SETTINGS = {
+ [CODE_SNIPPET_SOURCE_API_FUZZING]: {
+ datasetKey: 'apiFuzzingConfigurationPath',
+ docsPath: helpPagePath('user/application_security/api_fuzzing/index'),
+ },
+};
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
index b088678fee8..f6e88738002 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
@@ -124,7 +124,7 @@ export default {
type="submit"
class="js-no-auto-disable"
category="primary"
- variant="success"
+ variant="confirm"
:disabled="submitDisabled"
:loading="isSaving"
>
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
index f36b22f33c3..455990f2791 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
@@ -1,22 +1,15 @@
<script>
-import { GlAlert, GlIcon } from '@gitlab/ui';
+import { GlIcon } from '@gitlab/ui';
import { uniqueId } from 'lodash';
-import { __, s__ } from '~/locale';
-import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
-import { DEFAULT, INVALID_CI_CONFIG } from '~/pipelines/constants';
+import { s__ } from '~/locale';
import EditorLite from '~/vue_shared/components/editor_lite.vue';
export default {
i18n: {
viewOnlyMessage: s__('Pipelines|Merged YAML is view only'),
},
- errorTexts: {
- [INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'),
- [DEFAULT]: __('An unknown error occurred.'),
- },
components: {
EditorLite,
- GlAlert,
GlIcon,
},
inject: ['ciConfigPath'],
@@ -32,69 +25,30 @@ export default {
};
},
computed: {
- failure() {
- switch (this.failureType) {
- case INVALID_CI_CONFIG:
- return this.$options.errorTexts[INVALID_CI_CONFIG];
- default:
- return this.$options.errorTexts[DEFAULT];
- }
- },
fileGlobalId() {
return `${this.ciConfigPath}-${uniqueId()}`;
},
- hasError() {
- return this.failureType;
- },
- isInvalidConfiguration() {
- return this.ciConfigData.status === CI_CONFIG_STATUS_INVALID;
- },
mergedYaml() {
return this.ciConfigData.mergedYaml;
},
},
- watch: {
- ciConfigData: {
- immediate: true,
- handler() {
- if (this.isInvalidConfiguration) {
- this.reportFailure(INVALID_CI_CONFIG);
- } else if (this.hasError) {
- this.resetFailure();
- }
- },
- },
- },
- methods: {
- reportFailure(errorType) {
- this.failureType = errorType;
- },
- resetFailure() {
- this.failureType = null;
- },
- },
};
</script>
<template>
<div>
- <gl-alert v-if="hasError" variant="danger" :dismissible="false">
- {{ failure }}
- </gl-alert>
- <div v-else>
- <div class="gl-display-flex gl-align-items-center">
- <gl-icon :size="18" name="lock" use-deprecated-sizes class="gl-text-gray-500 gl-mr-3" />
- {{ $options.i18n.viewOnlyMessage }}
- </div>
- <div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1">
- <editor-lite
- ref="editor"
- :value="mergedYaml"
- :file-name="ciConfigPath"
- :file-global-id="fileGlobalId"
- :editor-options="{ readOnly: true }"
- v-on="$listeners"
- />
- </div>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-icon :size="16" name="lock" class="gl-text-gray-500 gl-mr-3" />
+ {{ $options.i18n.viewOnlyMessage }}
+ </div>
+ <div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1">
+ <editor-lite
+ ref="editor"
+ :value="mergedYaml"
+ :file-name="ciConfigPath"
+ :file-global-id="fileGlobalId"
+ :editor-options="{ readOnly: true }"
+ v-on="$listeners"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
index 872da88d3e6..a3410d7b837 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
@@ -27,7 +27,7 @@ export default {
registerCiSchema() {
const editorInstance = this.$refs.editor.getEditor();
- editorInstance.use(new CiSchemaExtension());
+ editorInstance.use(new CiSchemaExtension({ instance: editorInstance }));
editorInstance.registerCiSchema({
projectPath: this.projectPath,
projectNamespace: this.projectNamespace,
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
new file mode 100644
index 00000000000..b3eba0fcc19
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { DEFAULT_FAILURE } from '~/pipeline_editor/constants';
+import getAvailableBranches from '~/pipeline_editor/graphql/queries/available_branches.graphql';
+import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.graphql';
+
+export default {
+ i18n: {
+ title: s__('Branches'),
+ fetchError: s__('Unable to fetch branch list for this project.'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlIcon,
+ },
+ inject: ['projectFullPath'],
+ apollo: {
+ branches: {
+ query: getAvailableBranches,
+ variables() {
+ return {
+ projectFullPath: this.projectFullPath,
+ };
+ },
+ update(data) {
+ return data.project?.repository?.branches || [];
+ },
+ error() {
+ this.$emit('showError', {
+ type: DEFAULT_FAILURE,
+ reasons: [this.$options.i18n.fetchError],
+ });
+ },
+ },
+ currentBranch: {
+ query: getCurrentBranch,
+ },
+ },
+ computed: {
+ hasBranchList() {
+ return this.branches?.length > 0;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown v-if="hasBranchList" class="gl-ml-2" :text="currentBranch" icon="branch">
+ <gl-dropdown-section-header>
+ {{ this.$options.i18n.title }}
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="branch in branches"
+ :key="branch.name"
+ :is-checked="currentBranch === branch.name"
+ :is-check-item="true"
+ >
+ <gl-icon name="check" class="gl-visibility-hidden" />
+ {{ branch.name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
new file mode 100644
index 00000000000..a945fc542a5
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
@@ -0,0 +1,21 @@
+<script>
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import BranchSwitcher from './branch_switcher.vue';
+
+export default {
+ components: {
+ BranchSwitcher,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ computed: {
+ showBranchSwitcher() {
+ return this.glFeatures.pipelineEditorBranchSwitcher;
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-mb-5">
+ <branch-switcher v-if="showBranchSwitcher" v-on="$listeners" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
index 7a35e31e9ce..fefa784f060 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
@@ -31,22 +31,18 @@ export default {
},
mixins: [glFeatureFlagsMixin()],
props: {
- ciFileContent: {
- type: String,
- required: true,
- },
ciConfigData: {
type: Object,
required: true,
},
- isCiConfigDataLoading: {
+ isNewCiConfigFile: {
type: Boolean,
required: true,
},
},
computed: {
showPipelineStatus() {
- return this.glFeatures.pipelineStatusForPipelineEditor;
+ return this.glFeatures.pipelineStatusForPipelineEditor && !this.isNewCiConfigFile;
},
// make sure corners are rounded correctly depending on if
// pipeline status is rendered
@@ -61,11 +57,6 @@ export default {
<template>
<div class="gl-mb-5">
<pipeline-status v-if="showPipelineStatus" :class="$options.pipelineStatusClasses" />
- <validation-segment
- :class="validationStyling"
- :loading="isCiConfigDataLoading"
- :ci-file-content="ciFileContent"
- :ci-config="ciConfigData"
- />
+ <validation-segment :class="validationStyling" :ci-config="ciConfigData" />
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
index b1ea464be99..4a92e106da1 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -1,9 +1,11 @@
<script>
import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { truncateSha } from '~/lib/utils/text_utility';
import { s__ } from '~/locale';
import getCommitSha from '~/pipeline_editor/graphql/queries/client/commit_sha.graphql';
import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql';
+import { toggleQueryPollingByVisibility } from '~/pipelines/components/graph/utils';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
const POLL_INTERVAL = 10000;
@@ -38,13 +40,11 @@ export default {
};
},
update: (data) => {
- const { id, commitPath = '', shortSha = '', detailedStatus = {} } =
- data.project?.pipeline || {};
+ const { id, commitPath = '', detailedStatus = {} } = data.project?.pipeline || {};
return {
id,
commitPath,
- shortSha,
detailedStatus,
};
},
@@ -61,24 +61,34 @@ export default {
},
computed: {
hasPipelineData() {
- return Boolean(this.$apollo.queries.pipeline?.id);
+ return Boolean(this.pipeline?.id);
},
- isQueryLoading() {
- return this.$apollo.queries.pipeline.loading && !this.hasPipelineData;
+ pipelineId() {
+ return getIdFromGraphQLId(this.pipeline.id);
+ },
+ showLoadingState() {
+ // the query is set to poll regularly, so if there is no pipeline data
+ // (e.g. pipeline is null during fetch when the pipeline hasn't been
+ // triggered yet), we can just show the loading state until the pipeline
+ // details are ready to be fetched
+ return this.$apollo.queries.pipeline.loading || (!this.hasPipelineData && !this.hasError);
+ },
+ shortSha() {
+ return truncateSha(this.commitSha);
},
status() {
return this.pipeline.detailedStatus;
},
- pipelineId() {
- return getIdFromGraphQLId(this.pipeline.id);
- },
+ },
+ mounted() {
+ toggleQueryPollingByVisibility(this.$apollo.queries.pipeline, POLL_INTERVAL);
},
};
</script>
<template>
<div class="gl-white-space-nowrap gl-max-w-full">
- <template v-if="isQueryLoading">
+ <template v-if="showLoadingState">
<gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" />
<span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span>
</template>
@@ -88,7 +98,7 @@ export default {
</template>
<template v-else>
<a :href="status.detailsPath" class="gl-mr-auto">
- <ci-icon :status="status" :size="18" />
+ <ci-icon :status="status" :size="16" />
</a>
<span class="gl-font-weight-bold">
<gl-sprintf :message="$options.i18n.pipelineInfo">
@@ -110,7 +120,7 @@ export default {
target="_blank"
data-testid="pipeline-commit"
>
- {{ pipeline.shortSha }}
+ {{ shortSha }}
</gl-link>
</template>
</gl-sprintf>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
index 541ab74b177..d1534655a00 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
@@ -1,8 +1,13 @@
<script>
import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
+import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.graphql';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
-import { CI_CONFIG_STATUS_VALID } from '../../constants';
+import {
+ EDITOR_APP_STATUS_EMPTY,
+ EDITOR_APP_STATUS_LOADING,
+ EDITOR_APP_STATUS_VALID,
+} from '../../constants';
export const i18n = {
empty: __(
@@ -29,47 +34,51 @@ export default {
},
},
props: {
- ciFileContent: {
- type: String,
- required: true,
- },
ciConfig: {
type: Object,
required: false,
default: () => ({}),
},
- loading: {
- type: Boolean,
- required: false,
- default: false,
+ },
+ apollo: {
+ appStatus: {
+ query: getAppStatus,
},
},
computed: {
isEmpty() {
- return !this.ciFileContent;
+ return this.appStatus === EDITOR_APP_STATUS_EMPTY;
+ },
+ isLoading() {
+ return this.appStatus === EDITOR_APP_STATUS_LOADING;
},
isValid() {
- return this.ciConfig?.status === CI_CONFIG_STATUS_VALID;
+ return this.appStatus === EDITOR_APP_STATUS_VALID;
},
icon() {
- if (this.isValid || this.isEmpty) {
- return 'check';
+ switch (this.appStatus) {
+ case EDITOR_APP_STATUS_EMPTY:
+ return 'check';
+ case EDITOR_APP_STATUS_VALID:
+ return 'check';
+ default:
+ return 'warning-solid';
}
- return 'warning-solid';
},
message() {
- if (this.isEmpty) {
- return this.$options.i18n.empty;
- } else if (this.isValid) {
- return this.$options.i18n.valid;
- }
-
- // Only display first error as a reason
const [reason] = this.ciConfig?.errors || [];
- if (reason) {
- return sprintf(this.$options.i18n.invalidWithReason, { reason }, false);
+
+ switch (this.appStatus) {
+ case EDITOR_APP_STATUS_EMPTY:
+ return this.$options.i18n.empty;
+ case EDITOR_APP_STATUS_VALID:
+ return this.$options.i18n.valid;
+ default:
+ // Only display first error as a reason
+ return this.ciConfig?.errors.length > 0
+ ? sprintf(this.$options.i18n.invalidWithReason, { reason }, false)
+ : this.$options.i18n.invalid;
}
- return this.$options.i18n.invalid;
},
},
};
@@ -77,7 +86,7 @@ export default {
<template>
<div>
- <template v-if="loading">
+ <template v-if="isLoading">
<gl-loading-icon inline />
{{ $options.i18n.loading }}
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue
index b27ab9a39d3..f1cf5630fbf 100644
--- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue
+++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue
@@ -1,6 +1,5 @@
<script>
import { flatten } from 'lodash';
-import { CI_CONFIG_STATUS_VALID } from '../../constants';
import CiLintResults from './ci_lint_results.vue';
export default {
@@ -13,15 +12,16 @@ export default {
},
},
props: {
+ isValid: {
+ type: Boolean,
+ required: true,
+ },
ciConfig: {
type: Object,
required: true,
},
},
computed: {
- isValid() {
- return this.ciConfig?.status === CI_CONFIG_STATUS_VALID;
- },
stages() {
return this.ciConfig?.stages || [];
},
@@ -45,9 +45,9 @@ export default {
<template>
<ci-lint-results
- :valid="isValid"
- :jobs="jobs"
:errors="ciConfig.errors"
+ :is-valid="isValid"
+ :jobs="jobs"
:lint-help-page-path="lintHelpPagePath"
/>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
index 5d9697c9427..7f6dce05b6e 100644
--- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
+++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
@@ -42,34 +42,34 @@ export default {
CiLintResultsParam,
},
props: {
- valid: {
- type: Boolean,
- required: true,
- },
- jobs: {
- type: Array,
- required: false,
- default: () => [],
- },
errors: {
type: Array,
required: false,
default: () => [],
},
- warnings: {
- type: Array,
- required: false,
- default: () => [],
- },
dryRun: {
type: Boolean,
required: false,
default: false,
},
+ isValid: {
+ type: Boolean,
+ required: true,
+ },
+ jobs: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
lintHelpPagePath: {
type: String,
required: true,
},
+ warnings: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
data() {
return {
@@ -78,7 +78,7 @@ export default {
},
computed: {
status() {
- return this.valid ? this.$options.correct : this.$options.incorrect;
+ return this.isValid ? this.$options.correct : this.$options.incorrect;
},
shouldShowTable() {
return this.errors.length === 0;
diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
index 3bdcf383bee..5acb3355b23 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -1,15 +1,20 @@
<script>
-import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import { s__ } from '~/locale';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
- CI_CONFIG_STATUS_INVALID,
CREATE_TAB,
+ EDITOR_APP_STATUS_EMPTY,
+ EDITOR_APP_STATUS_ERROR,
+ EDITOR_APP_STATUS_INVALID,
+ EDITOR_APP_STATUS_LOADING,
+ EDITOR_APP_STATUS_VALID,
LINT_TAB,
MERGED_TAB,
VISUALIZE_TAB,
} from '../constants';
+import getAppStatus from '../graphql/queries/client/app_status.graphql';
import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue';
import TextEditor from './editor/text_editor.vue';
import CiLint from './lint/ci_lint.vue';
@@ -21,6 +26,17 @@ export default {
tabGraph: s__('Pipelines|Visualize'),
tabLint: s__('Pipelines|Lint'),
tabMergedYaml: s__('Pipelines|View merged YAML'),
+ empty: {
+ visualization: s__(
+ 'PipelineEditor|The pipeline visualization is displayed when the CI/CD configuration file has valid syntax.',
+ ),
+ lint: s__(
+ 'PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty.',
+ ),
+ merge: s__(
+ 'PipelineEditor|The merged YAML view is displayed when the CI/CD configuration file has valid syntax.',
+ ),
+ },
},
errorTexts: {
loadMergedYaml: s__('Pipelines|Could not load merged YAML content'),
@@ -37,7 +53,6 @@ export default {
EditorTab,
GlAlert,
GlLoadingIcon,
- GlTab,
GlTabs,
PipelineGraph,
TextEditor,
@@ -52,17 +67,28 @@ export default {
type: String,
required: true,
},
- isCiConfigDataLoading: {
- type: Boolean,
- required: false,
- default: false,
+ },
+ apollo: {
+ appStatus: {
+ query: getAppStatus,
},
},
computed: {
- hasMergedYamlLoadError() {
- return (
- !this.ciConfigData?.mergedYaml && this.ciConfigData.status !== CI_CONFIG_STATUS_INVALID
- );
+ hasAppError() {
+ // Not an invalid config and with `mergedYaml` data missing
+ return this.appStatus === EDITOR_APP_STATUS_ERROR;
+ },
+ isEmpty() {
+ return this.appStatus === EDITOR_APP_STATUS_EMPTY;
+ },
+ isInvalid() {
+ return this.appStatus === EDITOR_APP_STATUS_INVALID;
+ },
+ isValid() {
+ return this.appStatus === EDITOR_APP_STATUS_VALID;
+ },
+ isLoading() {
+ return this.appStatus === EDITOR_APP_STATUS_LOADING;
},
},
methods: {
@@ -83,39 +109,48 @@ export default {
>
<text-editor :value="ciFileContent" v-on="$listeners" />
</editor-tab>
- <gl-tab
+ <editor-tab
v-if="glFeatures.ciConfigVisualizationTab"
class="gl-mb-3"
+ :empty-message="$options.i18n.empty.visualization"
+ :is-empty="isEmpty"
+ :is-invalid="isInvalid"
:title="$options.i18n.tabGraph"
lazy
data-testid="visualization-tab"
@click="setCurrentTab($options.tabConstants.VISUALIZE_TAB)"
>
- <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" />
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
<pipeline-graph v-else :pipeline-data="ciConfigData" />
- </gl-tab>
+ </editor-tab>
<editor-tab
class="gl-mb-3"
+ :empty-message="$options.i18n.empty.lint"
+ :is-empty="isEmpty"
:title="$options.i18n.tabLint"
data-testid="lint-tab"
@click="setCurrentTab($options.tabConstants.LINT_TAB)"
>
- <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" />
- <ci-lint v-else :ci-config="ciConfigData" />
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
+ <ci-lint v-else :is-valid="isValid" :ci-config="ciConfigData" />
</editor-tab>
- <gl-tab
+ <editor-tab
v-if="glFeatures.ciConfigMergedTab"
class="gl-mb-3"
+ :empty-message="$options.i18n.empty.merge"
+ :keep-component-mounted="false"
+ :is-empty="isEmpty"
+ :is-invalid="isInvalid"
:title="$options.i18n.tabMergedYaml"
lazy
data-testid="merged-tab"
@click="setCurrentTab($options.tabConstants.MERGED_TAB)"
>
- <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" />
- <gl-alert v-else-if="hasMergedYamlLoadError" variant="danger" :dismissible="false">
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
+ <gl-alert v-else-if="hasAppError" variant="danger" :dismissible="false">
{{ $options.errorTexts.loadMergedYaml }}
</gl-alert>
<ci-config-merged-preview v-else :ci-config-data="ciConfigData" v-on="$listeners" />
- </gl-tab>
+ </editor-tab>
</gl-tabs>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue
index b0acd3ca2ee..7c032441a04 100644
--- a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue
+++ b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue
@@ -1,6 +1,6 @@
<script>
-import { GlTab } from '@gitlab/ui';
-
+import { GlAlert, GlTab } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
/**
* Wrapper of <gl-tab> to optionally lazily render this tab's content
* when its shown **without dismounting after its hidden**.
@@ -10,10 +10,10 @@ import { GlTab } from '@gitlab/ui';
* API is the same as <gl-tab>, for example:
*
* <gl-tabs>
- * <editor-tab title="Tab 1" :lazy="true">
+ * <editor-tab title="Tab 1" lazy>
* lazily mounted content (gets mounted if this is first tab)
* </editor-tab>
- * <editor-tab title="Tab 2" :lazy="true">
+ * <editor-tab title="Tab 2" lazy>
* lazily mounted content
* </editor-tab>
* <editor-tab title="Tab 3">
@@ -25,10 +25,26 @@ import { GlTab } from '@gitlab/ui';
* so it's contents are not dismounted.
*
* lazy is "false" by default, as in <gl-tab>.
+ *
+ * It is also possible to pass the `isEmpty` and or `isInvalid` to let
+ * the tab component handle that state on its own. For example:
+ *
+ * * <gl-tabs>
+ * <editor-tab-with-status title="Tab 1" :is-empty="isEmpty" :is-invalid="isInvalid">
+ * ...
+ * </editor-tab-with-status>
+ * Will be the same as normal, except it will only render the slot component
+ * if the status is not empty and not invalid. In any of these 2 cases, it will render
+ * a generic component and avoid mounting whatever it received in the slot.
+ * </gl-tabs>
*/
export default {
+ i18n: {
+ invalid: __('Your CI/CD configuration syntax is invalid. View Lint tab for more details.'),
+ },
components: {
+ GlAlert,
GlTab,
// Use a small renderless component to know when the tab content mounts because:
// - gl-tab always gets mounted, even if lazy is `true`. See:
@@ -40,29 +56,63 @@ export default {
},
inheritAttrs: false,
props: {
+ emptyMessage: {
+ type: String,
+ required: false,
+ default: s__(
+ 'PipelineEditor|This tab will be usable when the CI/CD configuration file is populated with valid syntax.',
+ ),
+ },
+ isEmpty: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
+ isInvalid: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
lazy: {
type: Boolean,
required: false,
default: false,
},
+ keepComponentMounted: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
isLazy: this.lazy,
};
},
+ computed: {
+ slots() {
+ return Object.keys(this.$slots);
+ },
+ },
methods: {
onContentMounted() {
// When a child is first mounted make the entire tab
- // permanently mounted by setting 'lazy' to false.
- this.isLazy = false;
+ // permanently mounted by setting 'lazy' to false unless
+ // explicitly opted out.
+ if (this.keepComponentMounted) {
+ this.isLazy = false;
+ }
},
},
};
</script>
<template>
<gl-tab :lazy="isLazy" v-bind="$attrs" v-on="$listeners">
- <slot v-for="slot in Object.keys($slots)" :slot="slot" :name="slot"></slot>
- <mount-spy @hook:mounted="onContentMounted" />
+ <gl-alert v-if="isEmpty" variant="tip">{{ emptyMessage }}</gl-alert>
+ <gl-alert v-else-if="isInvalid" variant="danger">{{ $options.i18n.invalid }}</gl-alert>
+ <template v-else>
+ <slot v-for="slot in slots" :name="slot"></slot>
+ <mount-spy @hook:mounted="onContentMounted" />
+ </template>
</gl-tab>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index 353deafe770..8d0ec6c3e2d 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -1,5 +1,14 @@
-export const CI_CONFIG_STATUS_VALID = 'VALID';
+// Values for CI_CONFIG_STATUS_* comes from lint graphQL
export const CI_CONFIG_STATUS_INVALID = 'INVALID';
+export const CI_CONFIG_STATUS_VALID = 'VALID';
+
+// Values for EDITOR_APP_STATUS_* are frontend specifics and
+// represent the global state of the pipeline editor app.
+export const EDITOR_APP_STATUS_EMPTY = 'EMPTY';
+export const EDITOR_APP_STATUS_ERROR = 'ERROR';
+export const EDITOR_APP_STATUS_INVALID = CI_CONFIG_STATUS_INVALID;
+export const EDITOR_APP_STATUS_LOADING = 'LOADING';
+export const EDITOR_APP_STATUS_VALID = CI_CONFIG_STATUS_VALID;
export const COMMIT_FAILURE = 'COMMIT_FAILURE';
export const COMMIT_SUCCESS = 'COMMIT_SUCCESS';
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql
new file mode 100644
index 00000000000..f162bb11d47
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql
@@ -0,0 +1,9 @@
+query getAvailableBranches($projectFullPath: ID!) {
+ project(fullPath: $projectFullPath) @client {
+ repository {
+ branches {
+ name
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql
new file mode 100644
index 00000000000..938f36c7d5c
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql
@@ -0,0 +1,3 @@
+query getAppStatus {
+ appStatus @client
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
index 7cc7f92fb60..d3a7387ad2d 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
@@ -1,10 +1,9 @@
query getPipeline($fullPath: ID!, $sha: String!) {
- project(fullPath: $fullPath) @client {
+ project(fullPath: $fullPath) {
pipeline(sha: $sha) {
commitPath
id
iid
- shortSha
status
detailedStatus {
detailsPath
diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
index 13f6200693b..caa2a65d424 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
+++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
@@ -11,25 +11,19 @@ export const resolvers = {
}),
};
},
-
/* eslint-disable @gitlab/require-i18n-strings */
project() {
return {
__typename: 'Project',
- pipeline: {
- __typename: 'Pipeline',
- commitPath: `/-/commit/aabbccdd`,
- id: 'gid://gitlab/Ci::Pipeline/118',
- iid: '28',
- shortSha: 'aabbccdd',
- status: 'SUCCESS',
- detailedStatus: {
- __typename: 'DetailedStatus',
- detailsPath: '/root/sample-ci-project/-/pipelines/118"',
- group: 'success',
- icon: 'status_success',
- text: 'passed',
- },
+ repository: {
+ __typename: 'Repository',
+ branches: [
+ { __typename: 'Branch', name: 'master' },
+ { __typename: 'Branch', name: 'main' },
+ { __typename: 'Branch', name: 'develop' },
+ { __typename: 'Branch', name: 'production' },
+ { __typename: 'Branch', name: 'test' },
+ ],
},
};
},
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index b17ec2d5c25..8a1e26f9bff 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -3,6 +3,9 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
+import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants';
+import getCommitSha from './graphql/queries/client/commit_sha.graphql';
+import getCurrentBranch from './graphql/queries/client/current_branch.graphql';
import { resolvers } from './graphql/resolvers';
import typeDefs from './graphql/typedefs.graphql';
import PipelineEditorApp from './pipeline_editor_app.vue';
@@ -35,15 +38,30 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
ymlHelpPagePath,
} = el?.dataset;
+ const configurationPaths = Object.fromEntries(
+ Object.entries(CODE_SNIPPET_SOURCE_SETTINGS).map(([source, { datasetKey }]) => [
+ source,
+ el.dataset[datasetKey],
+ ]),
+ );
+
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(resolvers, { typeDefs }),
});
+ const { cache } = apolloProvider.clients.defaultClient;
- apolloProvider.clients.defaultClient.cache.writeData({
+ cache.writeQuery({
+ query: getCurrentBranch,
data: {
currentBranch: initialBranchName || defaultBranch,
+ },
+ });
+
+ cache.writeQuery({
+ query: getCommitSha,
+ data: {
commitSha,
},
});
@@ -61,6 +79,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectPath,
projectNamespace,
ymlHelpPagePath,
+ configurationPaths,
},
render(h) {
return h(PipelineEditorApp);
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index c1168979e9f..e0fb38004ec 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -1,14 +1,29 @@
<script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import httpStatusCodes from '~/lib/utils/http_status';
+import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
+import CodeSnippetAlert from './components/code_snippet_alert/code_snippet_alert.vue';
+import {
+ CODE_SNIPPET_SOURCE_URL_PARAM,
+ CODE_SNIPPET_SOURCES,
+} from './components/code_snippet_alert/constants';
import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue';
import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue';
-import { COMMIT_FAILURE, COMMIT_SUCCESS, DEFAULT_FAILURE, LOAD_FAILURE_UNKNOWN } from './constants';
+import {
+ COMMIT_FAILURE,
+ COMMIT_SUCCESS,
+ DEFAULT_FAILURE,
+ EDITOR_APP_STATUS_EMPTY,
+ EDITOR_APP_STATUS_ERROR,
+ EDITOR_APP_STATUS_LOADING,
+ LOAD_FAILURE_UNKNOWN,
+} from './constants';
import getBlobContent from './graphql/queries/blob_content.graphql';
import getCiConfigData from './graphql/queries/ci_config.graphql';
+import getAppStatus from './graphql/queries/client/app_status.graphql';
import getCurrentBranch from './graphql/queries/client/current_branch.graphql';
import getIsNewCiConfigFile from './graphql/queries/client/is_new_ci_config_file.graphql';
import PipelineEditorHome from './pipeline_editor_home.vue';
@@ -20,6 +35,7 @@ export default {
GlLoadingIcon,
PipelineEditorEmptyState,
PipelineEditorHome,
+ CodeSnippetAlert,
},
inject: {
ciConfigPath: {
@@ -32,7 +48,6 @@ export default {
data() {
return {
ciConfigData: {},
- // Success and failure state
failureType: null,
failureReasons: [],
showStartScreen: false,
@@ -43,8 +58,10 @@ export default {
showFailureAlert: false,
showSuccessAlert: false,
successType: null,
+ codeSnippetCopiedFrom: '',
};
},
+
apollo: {
initialCiFileContent: {
query: getBlobContent,
@@ -77,8 +94,7 @@ export default {
},
ciConfigData: {
query: getCiConfigData,
- // If content is not loaded, we can't lint the data
- skip: ({ currentCiFileContent }) => {
+ skip({ currentCiFileContent }) {
return !currentCiFileContent;
},
variables() {
@@ -94,9 +110,20 @@ export default {
return { ...ciConfig, stages };
},
+ result({ data }) {
+ this.setAppStatus(data?.ciConfig?.status || EDITOR_APP_STATUS_ERROR);
+ },
error() {
this.reportFailure(LOAD_FAILURE_UNKNOWN);
},
+ watchLoading(isLoading) {
+ if (isLoading) {
+ this.setAppStatus(EDITOR_APP_STATUS_LOADING);
+ }
+ },
+ },
+ appStatus: {
+ query: getAppStatus,
},
currentBranch: {
query: getCurrentBranch,
@@ -115,6 +142,9 @@ export default {
isCiConfigDataLoading() {
return this.$apollo.queries.ciConfigData.loading;
},
+ isEmpty() {
+ return this.currentCiFileContent === '';
+ },
failure() {
switch (this.failureType) {
case LOAD_FAILURE_UNKNOWN:
@@ -159,6 +189,16 @@ export default {
successTexts: {
[COMMIT_SUCCESS]: __('Your changes have been successfully committed.'),
},
+ watch: {
+ isEmpty(flag) {
+ if (flag) {
+ this.setAppStatus(EDITOR_APP_STATUS_EMPTY);
+ }
+ },
+ },
+ created() {
+ this.parseCodeSnippetSourceParam();
+ },
methods: {
handleBlobContentError(error = {}) {
const { networkError } = error;
@@ -170,6 +210,7 @@ export default {
response?.status === httpStatusCodes.NOT_FOUND ||
response?.status === httpStatusCodes.BAD_REQUEST
) {
+ this.setAppStatus(EDITOR_APP_STATUS_EMPTY);
this.showStartScreen = true;
} else {
this.reportFailure(LOAD_FAILURE_UNKNOWN);
@@ -183,6 +224,8 @@ export default {
this.showSuccessAlert = false;
},
reportFailure(type, reasons = []) {
+ this.setAppStatus(EDITOR_APP_STATUS_ERROR);
+
window.scrollTo({ top: 0, behavior: 'smooth' });
this.showFailureAlert = true;
this.failureType = type;
@@ -196,6 +239,9 @@ export default {
resetContent() {
this.currentCiFileContent = this.lastCommittedContent;
},
+ setAppStatus(appStatus) {
+ this.$apollo.getClient().writeQuery({ query: getAppStatus, data: { appStatus } });
+ },
setNewEmptyCiConfigFile() {
this.$apollo
.getClient()
@@ -220,6 +266,20 @@ export default {
// if the user has made changes to the file that are unsaved.
this.lastCommittedContent = this.currentCiFileContent;
},
+ parseCodeSnippetSourceParam() {
+ const [codeSnippetCopiedFrom] = getParameterValues(CODE_SNIPPET_SOURCE_URL_PARAM);
+ if (codeSnippetCopiedFrom && CODE_SNIPPET_SOURCES.includes(codeSnippetCopiedFrom)) {
+ this.codeSnippetCopiedFrom = codeSnippetCopiedFrom;
+ window.history.replaceState(
+ {},
+ document.title,
+ removeParams([CODE_SNIPPET_SOURCE_URL_PARAM]),
+ );
+ }
+ },
+ dismissCodeSnippetAlert() {
+ this.codeSnippetCopiedFrom = '';
+ },
},
};
</script>
@@ -232,19 +292,35 @@ export default {
@createEmptyConfigFile="setNewEmptyCiConfigFile"
/>
<div v-else>
- <gl-alert v-if="showSuccessAlert" :variant="success.variant" @dismiss="dismissSuccess">
+ <code-snippet-alert
+ v-if="codeSnippetCopiedFrom"
+ :source="codeSnippetCopiedFrom"
+ class="gl-mb-5"
+ @dismiss="dismissCodeSnippetAlert"
+ />
+ <gl-alert
+ v-if="showSuccessAlert"
+ :variant="success.variant"
+ class="gl-mb-5"
+ @dismiss="dismissSuccess"
+ >
{{ success.text }}
</gl-alert>
- <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="dismissFailure">
+ <gl-alert
+ v-if="showFailureAlert"
+ :variant="failure.variant"
+ class="gl-mb-5"
+ @dismiss="dismissFailure"
+ >
{{ failure.text }}
<ul v-if="failureReasons.length" class="gl-mb-0">
<li v-for="reason in failureReasons" :key="reason">{{ reason }}</li>
</ul>
</gl-alert>
<pipeline-editor-home
- :is-ci-config-data-loading="isCiConfigDataLoading"
:ci-config-data="ciConfigData"
:ci-file-content="currentCiFileContent"
+ :is-new-ci-config-file="isNewCiConfigFile"
@commit="updateOnCommit"
@resetContent="resetContent"
@showError="showErrorAlert"
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index ef46040153f..adba55f9f4b 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -1,5 +1,6 @@
<script>
import CommitSection from './components/commit/commit_section.vue';
+import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue';
import PipelineEditorHeader from './components/header/pipeline_editor_header.vue';
import PipelineEditorTabs from './components/pipeline_editor_tabs.vue';
import { TABS_WITH_COMMIT_FORM, CREATE_TAB } from './constants';
@@ -7,6 +8,7 @@ import { TABS_WITH_COMMIT_FORM, CREATE_TAB } from './constants';
export default {
components: {
CommitSection,
+ PipelineEditorFileNav,
PipelineEditorHeader,
PipelineEditorTabs,
},
@@ -19,7 +21,7 @@ export default {
type: String,
required: true,
},
- isCiConfigDataLoading: {
+ isNewCiConfigFile: {
type: Boolean,
required: true,
},
@@ -44,15 +46,14 @@ export default {
<template>
<div>
+ <pipeline-editor-file-nav v-on="$listeners" />
<pipeline-editor-header
- :ci-file-content="ciFileContent"
:ci-config-data="ciConfigData"
- :is-ci-config-data-loading="isCiConfigDataLoading"
+ :is-new-ci-config-file="isNewCiConfigFile"
/>
<pipeline-editor-tabs
:ci-config-data="ciConfigData"
:ci-file-content="ciFileContent"
- :is-ci-config-data-loading="isCiConfigDataLoading"
v-on="$listeners"
@set-current-tab="setCurrentTab"
/>
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 ff6a354f673..e44d80ee9d1 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
@@ -33,6 +33,7 @@ const i18n = {
submitErrorTitle: s__('Pipeline|Pipeline cannot be run.'),
warningTitle: __('The form contains the following warning:'),
maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
+ removeVariableLabel: s__('CiVariables|Remove variable'),
};
export default {
@@ -416,15 +417,17 @@ export default {
data-testid="remove-ci-variable-row"
variant="danger"
category="secondary"
+ :aria-label="$options.i18n.removeVariableLabel"
@click="removeVariable(index)"
>
<gl-icon class="gl-mr-0! gl-display-none gl-md-display-block" name="clear" />
- <span class="gl-md-display-none">{{ s__('CiVariables|Remove variable') }}</span>
+ <span class="gl-md-display-none">{{ $options.i18n.removeVariableLabel }}</span>
</gl-button>
<gl-button
v-else
class="gl-md-ml-3 gl-mb-3 gl-display-none gl-md-display-block gl-visibility-hidden"
icon="clear"
+ :aria-label="$options.i18n.removeVariableLabel"
/>
</template>
</div>
@@ -441,18 +444,16 @@ export default {
</gl-sprintf></template
>
</gl-form-group>
- <div
- class="gl-border-t-solid gl-border-gray-100 gl-border-t-1 gl-p-5 gl-bg-gray-10 gl-display-flex gl-justify-content-space-between"
- >
+ <div class="gl-pt-5 gl-display-flex">
<gl-button
type="submit"
category="primary"
- variant="success"
- class="js-no-auto-disable"
+ variant="confirm"
+ class="js-no-auto-disable gl-mr-3"
data-qa-selector="run_pipeline_button"
data-testid="run_pipeline_button"
:disabled="submitted"
- >{{ s__('Pipeline|Run Pipeline') }}</gl-button
+ >{{ s__('Pipeline|Run pipeline') }}</gl-button
>
<gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button>
</div>
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue
index e44dedfe2ee..16fb931ec2b 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag.vue
@@ -50,6 +50,10 @@ export default {
};
},
update(data) {
+ if (!data?.project?.pipeline) {
+ return this.graphData;
+ }
+
const {
stages: { nodes: stages },
} = data.project.pipeline;
diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js
index caa269f5095..dd9cdae518f 100644
--- a/app/assets/javascripts/pipelines/components/graph/constants.js
+++ b/app/assets/javascripts/pipelines/components/graph/constants.js
@@ -10,3 +10,12 @@ export const ONE_COL_WIDTH = 180;
export const REST = 'rest';
export const GRAPHQL = 'graphql';
+
+export const STAGE_VIEW = 'stage';
+export const LAYER_VIEW = 'layer';
+export const VIEW_TYPE_KEY = 'pipeline_graph_view_type';
+
+export const SINGLE_JOB = 'single_job';
+export const JOB_DROPDOWN = 'job_dropdown';
+
+export const IID_FAILURE = 'missing_iid';
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 363226a0d85..63048777724 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,10 +1,11 @@
<script>
+import { reportToSentry } from '../../utils';
import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue';
import LinksLayer from '../graph_shared/links_layer.vue';
-import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH } from './constants';
+import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from './constants';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import StageColumnComponent from './stage_column_component.vue';
-import { reportToSentry, validateConfigPaths } from './utils';
+import { validateConfigPaths } from './utils';
export default {
name: 'PipelineGraph',
@@ -24,11 +25,20 @@ export default {
type: Object,
required: true,
},
+ viewType: {
+ type: String,
+ required: true,
+ },
isLinkedPipeline: {
type: Boolean,
required: false,
default: false,
},
+ pipelineLayers: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
type: {
type: String,
required: false,
@@ -44,6 +54,7 @@ export default {
data() {
return {
hoveredJobName: '',
+ hoveredSourceJobName: '',
highlightedJobs: [],
measurements: {
width: 0,
@@ -62,8 +73,8 @@ export default {
downstreamPipelines() {
return this.hasDownstreamPipelines ? this.pipeline.downstream : [];
},
- graph() {
- return this.pipeline.stages;
+ layout() {
+ return this.isStageView ? this.pipeline.stages : this.generateColumnsFromLayersList();
},
hasDownstreamPipelines() {
return Boolean(this.pipeline?.downstream?.length > 0);
@@ -71,12 +82,21 @@ export default {
hasUpstreamPipelines() {
return Boolean(this.pipeline?.upstream?.length > 0);
},
+ isStageView() {
+ return this.viewType === STAGE_VIEW;
+ },
metricsConfig() {
return {
path: this.configPaths.metricsPath,
collectMetrics: true,
};
},
+ shouldHideLinks() {
+ return this.isStageView;
+ },
+ shouldShowStageName() {
+ return !this.isStageView;
+ },
// The show downstream check prevents showing redundant linked columns
showDownstreamPipelines() {
return (
@@ -100,6 +120,26 @@ export default {
this.getMeasurements();
},
methods: {
+ generateColumnsFromLayersList() {
+ return this.pipelineLayers.map((layers, idx) => {
+ /*
+ look up the groups in each layer,
+ then add each set of layer groups to a stage-like object
+ */
+
+ const groups = layers.map((id) => {
+ const { stageIdx, groupIdx } = this.pipeline.stagesLookup[id];
+ return this.pipeline.stages?.[stageIdx]?.groups?.[groupIdx];
+ });
+
+ return {
+ name: '',
+ id: `layer-${idx}`,
+ status: { action: null },
+ groups: groups.filter(Boolean),
+ };
+ });
+ },
getMeasurements() {
this.measurements = {
width: this.$refs[this.containerId].scrollWidth,
@@ -112,6 +152,9 @@ export default {
setJob(jobName) {
this.hoveredJobName = jobName;
},
+ setSourceJob(jobName) {
+ this.hoveredSourceJobName = jobName;
+ },
slidePipelineContainer() {
this.$refs.mainPipelineContainer.scrollBy({
left: ONE_COL_WIDTH,
@@ -146,31 +189,35 @@ export default {
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:type="$options.pipelineTypeConstants.UPSTREAM"
+ :view-type="viewType"
@error="onError"
/>
</template>
<template #main>
<div :id="containerId" :ref="containerId">
<links-layer
- :pipeline-data="graph"
+ :pipeline-data="layout"
:pipeline-id="pipeline.id"
:container-id="containerId"
:container-measurements="measurements"
:highlighted-job="hoveredJobName"
:metrics-config="metricsConfig"
- :never-show-links="true"
+ :never-show-links="shouldHideLinks"
+ :view-type="viewType"
default-link-color="gl-stroke-transparent"
@error="onError"
@highlightedJobsChange="updateHighlightedJobs"
>
<stage-column-component
- v-for="stage in graph"
- :key="stage.name"
- :title="stage.name"
- :groups="stage.groups"
- :action="stage.status.action"
+ v-for="column in layout"
+ :key="column.id || column.name"
+ :name="column.name"
+ :groups="column.groups"
+ :action="column.status.action"
:highlighted-jobs="highlightedJobs"
+ :show-stage-name="shouldShowStageName"
:job-hovered="hoveredJobName"
+ :source-job-hovered="hoveredSourceJobName"
:pipeline-expanded="pipelineExpanded"
:pipeline-id="pipeline.id"
@refreshPipelineGraph="$emit('refreshPipelineGraph')"
@@ -188,7 +235,8 @@ export default {
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:type="$options.pipelineTypeConstants.DOWNSTREAM"
- @downstreamHovered="setJob"
+ :view-type="viewType"
+ @downstreamHovered="setSourceJob"
@pipelineExpandToggle="togglePipelineExpanded"
@scrollContainer="slidePipelineContainer"
@error="onError"
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue
index abbf8df6eed..39d0fa8a8ca 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue
@@ -2,10 +2,10 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { escape, capitalize } from 'lodash';
import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
+import { reportToSentry } from '../../utils';
import { UPSTREAM, DOWNSTREAM, MAIN } from './constants';
import LinkedPipelinesColumnLegacy from './linked_pipelines_column_legacy.vue';
import StageColumnComponentLegacy from './stage_column_component_legacy.vue';
-import { reportToSentry } from './utils';
export default {
name: 'PipelineGraphLegacy',
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
index 962f2ca2a4c..0bc6d883245 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -2,11 +2,16 @@
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import { __ } from '~/locale';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants';
+import { reportToSentry } from '../../utils';
+import { listByLayers } from '../parsing_utils';
+import { IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants';
import PipelineGraph from './graph_component.vue';
+import GraphViewSelector from './graph_view_selector.vue';
import {
getQueryHeaders,
- reportToSentry,
serializeLoadErrors,
toggleQueryPollingByVisibility,
unwrapPipelineData,
@@ -17,8 +22,11 @@ export default {
components: {
GlAlert,
GlLoadingIcon,
+ GraphViewSelector,
+ LocalStorageSync,
PipelineGraph,
},
+ mixins: [glFeatureFlagMixin()],
inject: {
graphqlResourceEtag: {
default: '',
@@ -35,13 +43,18 @@ export default {
},
data() {
return {
- pipeline: null,
alertType: null,
+ currentViewType: STAGE_VIEW,
+ pipeline: null,
+ pipelineLayers: null,
showAlert: false,
};
},
errorTexts: {
[DRAW_FAILURE]: __('An error occurred while drawing job relationship links.'),
+ [IID_FAILURE]: __(
+ 'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.',
+ ),
[LOAD_FAILURE]: __('We are currently unable to fetch data for this pipeline.'),
[DEFAULT]: __('An unknown error occurred while loading this graph.'),
},
@@ -58,6 +71,9 @@ export default {
iid: this.pipelineIid,
};
},
+ skip() {
+ return !(this.pipelineProjectPath && this.pipelineIid);
+ },
update(data) {
/*
This check prevents the pipeline from being overwritten
@@ -98,6 +114,11 @@ export default {
text: this.$options.errorTexts[DRAW_FAILURE],
variant: 'danger',
};
+ case IID_FAILURE:
+ return {
+ text: this.$options.errorTexts[IID_FAILURE],
+ variant: 'info',
+ };
case LOAD_FAILURE:
return {
text: this.$options.errorTexts[LOAD_FAILURE],
@@ -123,14 +144,28 @@ export default {
*/
return this.$apollo.queries.pipeline.loading && !this.pipeline;
},
+ showGraphViewSelector() {
+ return Boolean(this.glFeatures.pipelineGraphLayersView && this.pipeline?.usesNeeds);
+ },
},
mounted() {
+ if (!this.pipelineIid) {
+ this.reportFailure({ type: IID_FAILURE, skipSentry: true });
+ }
+
toggleQueryPollingByVisibility(this.$apollo.queries.pipeline);
},
errorCaptured(err, _vm, info) {
reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
},
methods: {
+ getPipelineLayers() {
+ if (this.currentViewType === LAYER_VIEW && !this.pipelineLayers) {
+ this.pipelineLayers = listByLayers(this.pipeline);
+ }
+
+ return this.pipelineLayers;
+ },
hideAlert() {
this.showAlert = false;
this.alertType = null;
@@ -147,7 +182,11 @@ export default {
}
},
/* eslint-enable @gitlab/require-i18n-strings */
+ updateViewType(type) {
+ this.currentViewType = type;
+ },
},
+ viewTypeKey: VIEW_TYPE_KEY,
};
</script>
<template>
@@ -155,11 +194,24 @@ export default {
<gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert">
{{ alert.text }}
</gl-alert>
+ <local-storage-sync
+ :storage-key="$options.viewTypeKey"
+ :value="currentViewType"
+ @input="updateViewType"
+ >
+ <graph-view-selector
+ v-if="showGraphViewSelector"
+ :type="currentViewType"
+ @updateViewType="updateViewType"
+ />
+ </local-storage-sync>
<gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" />
<pipeline-graph
v-if="pipeline"
:config-paths="configPaths"
:pipeline="pipeline"
+ :pipeline-layers="getPipelineLayers()"
+ :view-type="currentViewType"
@error="reportFailure"
@refreshPipelineGraph="refreshPipelineGraph"
/>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
new file mode 100644
index 00000000000..f33e6290e37
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
@@ -0,0 +1,85 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { STAGE_VIEW, LAYER_VIEW } from './constants';
+
+export default {
+ name: 'GraphViewSelector',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlSprintf,
+ },
+ props: {
+ type: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ currentViewType: STAGE_VIEW,
+ };
+ },
+ i18n: {
+ labelText: __('Order jobs by'),
+ },
+ views: {
+ [STAGE_VIEW]: {
+ type: STAGE_VIEW,
+ text: {
+ primary: __('Stage'),
+ secondary: __('View the jobs grouped into stages'),
+ },
+ },
+ [LAYER_VIEW]: {
+ type: LAYER_VIEW,
+ text: {
+ primary: __('%{codeStart}needs:%{codeEnd} relationships'),
+ secondary: __('View what jobs are needed for a job to run'),
+ },
+ },
+ },
+ computed: {
+ currentDropdownText() {
+ return this.$options.views[this.type].text.primary;
+ },
+ },
+ methods: {
+ itemClick(type) {
+ this.$emit('updateViewType', type);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center gl-my-4">
+ <span>{{ $options.i18n.labelText }}</span>
+ <gl-dropdown data-testid="pipeline-view-selector" class="gl-ml-4">
+ <template #button-content>
+ <gl-sprintf :message="currentDropdownText">
+ <template #code="{ content }">
+ <code> {{ content }} </code>
+ </template>
+ </gl-sprintf>
+ <gl-icon class="gl-px-2" name="angle-down" :size="16" />
+ </template>
+ <gl-dropdown-item
+ v-for="view in $options.views"
+ :key="view.type"
+ :secondary-text="view.text.secondary"
+ @click="itemClick(view.type)"
+ >
+ <b>
+ <gl-sprintf :message="view.text.primary">
+ <template #code="{ content }">
+ <code> {{ content }} </code>
+ </template>
+ </gl-sprintf>
+ </b>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
index f6aee8c5fcf..6451605a222 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -1,8 +1,7 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { reportToSentry } from '../../utils';
+import { JOB_DROPDOWN, SINGLE_JOB } from './constants';
import JobItem from './job_item.vue';
-import { reportToSentry } from './utils';
/**
* Renders the dropdown for the pipeline graph.
@@ -11,12 +10,8 @@ import { reportToSentry } from './utils';
*
*/
export default {
- directives: {
- GlTooltip: GlTooltipDirective,
- },
components: {
JobItem,
- CiIcon,
},
props: {
group: {
@@ -28,6 +23,15 @@ export default {
required: false,
default: -1,
},
+ stageName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ jobItemTypes: {
+ jobDropdown: JOB_DROPDOWN,
+ singleJob: SINGLE_JOB,
},
computed: {
computedJobId() {
@@ -51,22 +55,20 @@ export default {
<template>
<div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright">
<button
- v-gl-tooltip.hover="{ boundary: 'viewport' }"
- :title="tooltipText"
type="button"
data-toggle="dropdown"
data-display="static"
- class="dropdown-menu-toggle build-content gl-build-content"
+ class="dropdown-menu-toggle build-content gl-build-content gl-pipeline-job-width! gl-pr-4!"
>
<div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
- <span class="gl-display-flex gl-align-items-center gl-min-w-0">
- <ci-icon :status="group.status" :size="24" class="gl-line-height-0" />
- <span class="gl-text-truncate mw-70p gl-pl-3">
- {{ group.name }}
- </span>
- </span>
+ <job-item
+ :type="$options.jobItemTypes.jobDropdown"
+ :group-tooltip="tooltipText"
+ :job="group"
+ :stage-name="stageName"
+ />
- <span class="gl-font-weight-100 gl-font-size-lg"> {{ group.size }} </span>
+ <div class="gl-font-weight-100 gl-font-size-lg gl-ml-n4">{{ group.size }}</div>
</div>
</button>
@@ -77,6 +79,7 @@ export default {
<job-item
:dropdown-length="group.size"
:job="job"
+ :type="$options.jobItemTypes.singleJob"
css-class-job-name="mini-pipeline-graph-dropdown-item"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 46ef0457d40..6584d89d87c 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -3,11 +3,12 @@ import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { sprintf } from '~/locale';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { reportToSentry } from '../../utils';
+import ActionComponent from '../jobs_shared/action_component.vue';
+import JobNameComponent from '../jobs_shared/job_name_component.vue';
import { accessValue } from './accessors';
-import ActionComponent from './action_component.vue';
-import { REST } from './constants';
-import JobNameComponent from './job_name_component.vue';
-import { reportToSentry } from './utils';
+import { REST, SINGLE_JOB } from './constants';
/**
* Renders the badge for the pipeline graph and the job's dropdown.
@@ -38,6 +39,7 @@ export default {
hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500',
components: {
ActionComponent,
+ CiIcon,
JobNameComponent,
GlLink,
},
@@ -65,6 +67,11 @@ export default {
required: false,
default: Infinity,
},
+ groupTooltip: {
+ type: String,
+ required: false,
+ default: '',
+ },
jobHovered: {
type: String,
required: false,
@@ -80,24 +87,55 @@ export default {
required: false,
default: -1,
},
+ sourceJobHovered: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ stageName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ type: {
+ type: String,
+ required: false,
+ default: SINGLE_JOB,
+ },
},
computed: {
boundary() {
return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
},
+ computedJobId() {
+ return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : '';
+ },
detailsPath() {
return accessValue(this.dataMethod, 'detailsPath', this.status);
},
hasDetails() {
return accessValue(this.dataMethod, 'hasDetails', this.status);
},
- computedJobId() {
- return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : '';
+ isSingleItem() {
+ return this.type === SINGLE_JOB;
+ },
+ nameComponent() {
+ return this.hasDetails ? 'gl-link' : 'div';
+ },
+ showStageName() {
+ return Boolean(this.stageName);
},
status() {
return this.job && this.job.status ? this.job.status : {};
},
+ testId() {
+ return this.hasDetails ? 'job-with-link' : 'job-without-link';
+ },
tooltipText() {
+ if (this.groupTooltip) {
+ return this.groupTooltip;
+ }
+
const textBuilder = [];
const { name: jobName } = this.job;
@@ -129,7 +167,7 @@ export default {
return this.job.status && this.job.status.action && this.job.status.action.path;
},
relatedDownstreamHovered() {
- return this.job.name === this.jobHovered;
+ return this.job.name === this.sourceJobHovered;
},
relatedDownstreamExpanded() {
return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded;
@@ -147,6 +185,17 @@ export default {
hideTooltips() {
this.$root.$emit(BV_HIDE_TOOLTIP);
},
+ jobItemClick(evt) {
+ if (this.isSingleItem) {
+ /*
+ This is so the jobDropdown still toggles. Issue to refactor:
+ https://gitlab.com/gitlab-org/gitlab/-/issues/267117
+ */
+ evt.stopPropagation();
+ }
+
+ this.hideTooltips();
+ },
pipelineActionRequestComplete() {
this.$emit('pipelineActionRequestComplete');
},
@@ -156,40 +205,45 @@ export default {
<template>
<div
:id="computedJobId"
- class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between"
+ class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between gl-w-full"
data-qa-selector="job_item_container"
>
- <gl-link
- v-if="hasDetails"
- v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
- :href="detailsPath"
- :title="tooltipText"
- :class="jobClasses"
- class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none"
- data-testid="job-with-link"
- @click.stop="hideTooltips"
- @mouseout="hideTooltips"
- >
- <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
- </gl-link>
-
- <div
- v-else
- v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
+ <component
+ :is="nameComponent"
+ v-gl-tooltip="{
+ boundary: 'viewport',
+ placement: 'bottom',
+ customClass: 'gl-pointer-events-none',
+ }"
:title="tooltipText"
:class="jobClasses"
- class="js-job-component-tooltip non-details-job-component menu-item"
- data-testid="job-without-link"
+ :href="detailsPath"
+ class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none gl-w-full"
+ :data-testid="testId"
+ @click="jobItemClick"
@mouseout="hideTooltips"
>
- <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
- </div>
+ <div class="ci-job-name-component gl-display-flex gl-align-items-center">
+ <ci-icon :size="24" :status="job.status" class="gl-line-height-0" />
+ <div class="gl-pl-3 gl-display-flex gl-flex-direction-column gl-w-full">
+ <div class="gl-text-truncate mw-70p gl-line-height-normal">{{ job.name }}</div>
+ <div
+ v-if="showStageName"
+ data-testid="stage-name-in-job"
+ class="gl-text-truncate mw-70p gl-font-sm gl-text-gray-500 gl-line-height-normal"
+ >
+ {{ stageName }}
+ </div>
+ </div>
+ </div>
+ </component>
<action-component
v-if="hasAction"
:tooltip-text="status.action.title"
:link="status.action.path"
:action-icon="status.action.icon"
+ class="gl-mr-1"
data-qa-selector="action_button"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index add7b3445f7..3f746731e34 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -3,9 +3,9 @@ import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon, GlBadge } from '@g
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
+import { reportToSentry } from '../../utils';
import { accessValue } from './accessors';
import { DOWNSTREAM, REST, UPSTREAM } from './constants';
-import { reportToSentry } from './utils';
export default {
directives: {
@@ -183,6 +183,7 @@ export default {
class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!"
:class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`"
:icon="expandedIcon"
+ :aria-label="__('Expand pipeline')"
data-testid="expand-pipeline-button"
data-qa-selector="expand_pipeline_button"
@click="onClickLinkedPipeline"
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 b55a77a3c4f..7f772e35e55 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -1,11 +1,12 @@
<script>
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import { LOAD_FAILURE } from '../../constants';
-import { ONE_COL_WIDTH, UPSTREAM } from './constants';
+import { reportToSentry } from '../../utils';
+import { listByLayers } from '../parsing_utils';
+import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW } from './constants';
import LinkedPipeline from './linked_pipeline.vue';
import {
getQueryHeaders,
- reportToSentry,
serializeLoadErrors,
toggleQueryPollingByVisibility,
unwrapPipelineData,
@@ -35,11 +36,16 @@ export default {
type: String,
required: true,
},
+ viewType: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
currentPipeline: null,
loadingPipelineId: null,
+ pipelineLayers: {},
pipelineExpanded: false,
};
},
@@ -123,6 +129,13 @@ export default {
toggleQueryPollingByVisibility(this.$apollo.queries.currentPipeline);
},
+ getPipelineLayers(id) {
+ if (this.viewType === LAYER_VIEW && !this.pipelineLayers[id]) {
+ this.pipelineLayers[id] = listByLayers(this.currentPipeline);
+ }
+
+ return this.pipelineLayers[id];
+ },
isExpanded(id) {
return Boolean(this.currentPipeline?.id && id === this.currentPipeline.id);
},
@@ -203,7 +216,9 @@ export default {
class="d-inline-block gl-mt-n2"
:config-paths="configPaths"
:pipeline="currentPipeline"
+ :pipeline-layers="getPipelineLayers(pipeline.id)"
:is-linked-pipeline="true"
+ :view-type="viewType"
/>
</div>
</li>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue
index 0d1ff94c275..39baeb6e1c3 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue
@@ -1,7 +1,7 @@
<script>
+import { reportToSentry } from '../../utils';
import { UPSTREAM } from './constants';
import LinkedPipeline from './linked_pipeline.vue';
-import { reportToSentry } from './utils';
export default {
components: {
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index 0a762563114..fa2f381c8a4 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,12 +1,13 @@
<script>
import { capitalize, escape, isEmpty } from 'lodash';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { reportToSentry } from '../../utils';
import MainGraphWrapper from '../graph_shared/main_graph_wrapper.vue';
+import ActionComponent from '../jobs_shared/action_component.vue';
import { accessValue } from './accessors';
-import ActionComponent from './action_component.vue';
import { GRAPHQL } from './constants';
import JobGroupDropdown from './job_group_dropdown.vue';
import JobItem from './job_item.vue';
-import { reportToSentry } from './utils';
export default {
components: {
@@ -15,17 +16,18 @@ export default {
JobItem,
MainGraphWrapper,
},
+ mixins: [glFeatureFlagMixin()],
props: {
groups: {
type: Array,
required: true,
},
- pipelineId: {
- type: Number,
+ name: {
+ type: String,
required: true,
},
- title: {
- type: String,
+ pipelineId: {
+ type: Number,
required: true,
},
action: {
@@ -48,6 +50,16 @@ export default {
required: false,
default: () => ({}),
},
+ showStageName: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ sourceJobHovered: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
titleClasses: [
'gl-font-weight-bold',
@@ -57,8 +69,23 @@ export default {
'gl-pl-3',
],
computed: {
+ /*
+ currentGroups and filteredGroups are part of
+ a test to hunt down a bug
+ (see: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57142).
+
+ They should be removed when the bug is rectified.
+ */
+ currentGroups() {
+ return this.glFeatures.pipelineFilterJobs ? this.filteredGroups : this.groups;
+ },
+ filteredGroups() {
+ return this.groups.map((group) => {
+ return { ...group, jobs: group.jobs.filter(Boolean) };
+ });
+ },
formattedTitle() {
- return capitalize(escape(this.title));
+ return capitalize(escape(this.name));
},
hasAction() {
return !isEmpty(this.action);
@@ -80,6 +107,18 @@ export default {
isFadedOut(jobName) {
return this.highlightedJobs.length > 1 && !this.highlightedJobs.includes(jobName);
},
+ isParallel(group) {
+ return group.size > 1 && group.jobs.length > 1;
+ },
+ singleJobExists(group) {
+ const firstJobDefined = Boolean(group.jobs?.[0]);
+
+ if (!firstJobDefined) {
+ reportToSentry('stage_column_component', 'undefined_job_hunt');
+ }
+
+ return group.size === 1 && firstJobDefined;
+ },
},
};
</script>
@@ -104,7 +143,7 @@ export default {
</template>
<template #jobs>
<div
- v-for="group in groups"
+ v-for="group in currentGroups"
:id="groupId(group)"
:key="getGroupId(group)"
data-testid="stage-column-group"
@@ -113,17 +152,23 @@ export default {
@mouseleave="$emit('jobHover', '')"
>
<job-item
- v-if="group.size === 1"
+ v-if="singleJobExists(group)"
:job="group.jobs[0]"
:job-hovered="jobHovered"
+ :source-job-hovered="sourceJobHovered"
:pipeline-expanded="pipelineExpanded"
:pipeline-id="pipelineId"
+ :stage-name="showStageName ? group.stageName : ''"
css-class-job-name="gl-build-content"
:class="{ 'gl-opacity-3': isFadedOut(group.name) }"
@pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
/>
- <div v-else :class="{ 'gl-opacity-3': isFadedOut(group.name) }">
- <job-group-dropdown :group="group" :pipeline-id="pipelineId" />
+ <div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }">
+ <job-group-dropdown
+ :group="group"
+ :stage-name="showStageName ? group.stageName : ''"
+ :pipeline-id="pipelineId"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue
index 2cee2fbbd8f..cbaf07c05cf 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue
@@ -1,10 +1,10 @@
<script>
import { isEmpty, escape } from 'lodash';
import stageColumnMixin from '../../mixins/stage_column_mixin';
-import ActionComponent from './action_component.vue';
+import { reportToSentry } from '../../utils';
+import ActionComponent from '../jobs_shared/action_component.vue';
import JobGroupDropdown from './job_group_dropdown.vue';
import JobItem from './job_item.vue';
-import { reportToSentry } from './utils';
export default {
components: {
diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js
index b9a8e2638bc..373aa6bf9a1 100644
--- a/app/assets/javascripts/pipelines/components/graph/utils.js
+++ b/app/assets/javascripts/pipelines/components/graph/utils.js
@@ -1,7 +1,6 @@
-import * as Sentry from '@sentry/browser';
import Visibility from 'visibilityjs';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { unwrapStagesWithNeeds } from '../unwrapping_utils';
+import { unwrapStagesWithNeedsAndLookup } from '../unwrapping_utils';
const addMulti = (mainPipelineProjectPath, linkedPipeline) => {
return {
@@ -24,13 +23,6 @@ const getQueryHeaders = (etagResource) => {
};
};
-const reportToSentry = (component, failureType) => {
- Sentry.withScope((scope) => {
- scope.setTag('component', component);
- Sentry.captureException(failureType);
- });
-};
-
const serializeGqlErr = (gqlError) => {
const { locations = [], message = '', path = [] } = gqlError;
@@ -94,12 +86,13 @@ const unwrapPipelineData = (mainPipelineProjectPath, data) => {
stages: { nodes: stages },
} = pipeline;
- const nodes = unwrapStagesWithNeeds(stages);
+ const { stages: updatedStages, lookup } = unwrapStagesWithNeedsAndLookup(stages);
return {
...pipeline,
id: getIdFromGraphQLId(pipeline.id),
- stages: nodes,
+ stages: updatedStages,
+ stagesLookup: lookup,
upstream: upstream
? [upstream].map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId)
: [],
@@ -113,7 +106,6 @@ const validateConfigPaths = (value) => value.graphqlResourceEtag?.length > 0;
export {
getQueryHeaders,
- reportToSentry,
serializeGqlErr,
serializeLoadErrors,
toggleQueryPollingByVisibility,
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/api.js b/app/assets/javascripts/pipelines/components/graph_shared/api.js
index 04ac15ae24c..49cd04d11e9 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/api.js
+++ b/app/assets/javascripts/pipelines/components/graph_shared/api.js
@@ -1,5 +1,5 @@
import axios from '~/lib/utils/axios_utils';
-import { reportToSentry } from '../graph/utils';
+import { reportToSentry } from '../../utils';
export const reportPerformance = (path, stats) => {
axios.post(path, stats).catch((err) => {
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
index fad57084992..0ed5b8a5f09 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
+++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
@@ -10,8 +10,8 @@ import {
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
import { DRAW_FAILURE } from '../../constants';
-import { createJobsHash, generateJobNeedsDict } from '../../utils';
-import { reportToSentry } from '../graph/utils';
+import { createJobsHash, generateJobNeedsDict, reportToSentry } from '../../utils';
+import { STAGE_VIEW } from '../graph/constants';
import { parseData } from '../parsing_utils';
import { reportPerformance } from './api';
import { generateLinksData } from './drawing_utils';
@@ -55,11 +55,17 @@ export default {
required: false,
default: '',
},
+ viewType: {
+ type: String,
+ required: false,
+ default: STAGE_VIEW,
+ },
},
data() {
return {
links: [],
needsObject: null,
+ parsedData: {},
};
},
computed: {
@@ -109,6 +115,15 @@ export default {
highlightedJobs(jobs) {
this.$emit('highlightedJobsChange', jobs);
},
+ viewType() {
+ /*
+ We need to wait a tick so that the layout reflows
+ before the links refresh.
+ */
+ this.$nextTick(() => {
+ this.refreshLinks();
+ });
+ },
},
errorCaptured(err, _vm, info) {
reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
@@ -167,14 +182,17 @@ export default {
this.beginPerfMeasure();
try {
const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups);
- const parsedData = parseData(arrayOfJobs);
- this.links = generateLinksData(parsedData, this.containerId, `-${this.pipelineId}`);
+ this.parsedData = parseData(arrayOfJobs);
+ this.refreshLinks();
} catch (err) {
this.$emit('error', { type: DRAW_FAILURE, reportToSentry: false });
reportToSentry(this.$options.name, err);
}
this.finishPerfMeasureAndSend();
},
+ refreshLinks() {
+ this.links = generateLinksData(this.parsedData, this.containerId, `-${this.pipelineId}`);
+ },
getLinkClasses(link) {
return [
this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : this.defaultLinkColor,
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
index 42eab13b0bd..8dbab245f44 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
+++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
@@ -11,7 +11,7 @@ import {
PIPELINES_DETAIL_LINKS_JOB_RATIO,
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
-import { reportToSentry } from '../graph/utils';
+import { reportToSentry } from '../../utils';
import { parseData } from '../parsing_utils';
import { reportPerformance } from './api';
import LinksInner from './links_inner.vue';
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 4ce43b92c93..d8e7b83a8c1 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -8,6 +8,7 @@ import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutatio
import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql';
import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql';
import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql';
+import { getQueryHeaders } from './graph/utils';
const DELETE_MODAL_ID = 'pipeline-delete-modal';
const POLL_INTERVAL = 10000;
@@ -34,7 +35,9 @@ export default {
[DEFAULT]: __('An unknown error occurred.'),
},
inject: {
- // Receive `fullProject` and `pipelinesPath`
+ graphqlResourceEtag: {
+ default: '',
+ },
paths: {
default: {},
},
@@ -47,6 +50,9 @@ export default {
},
apollo: {
pipeline: {
+ context() {
+ return getQueryHeaders(this.graphqlResourceEtag);
+ },
query: getPipelineQuery,
variables() {
return {
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
index 1df693704d4..3972c126673 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
@@ -5,7 +5,7 @@ import axios from '~/lib/utils/axios_utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { dasherize } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
-import { reportToSentry } from './utils';
+import { reportToSentry } from '../../utils';
/**
* Renders either a cancel, retry or play icon button and handles the post request
diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue
index fffd8e1818a..fffd8e1818a 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue
diff --git a/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue
new file mode 100644
index 00000000000..6982586ab12
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlBanner, GlLink, GlSprintf } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import DismissPipelineNotification from '../../graphql/mutations/dismiss_pipeline_notification.graphql';
+import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql';
+
+const featureName = 'pipeline_needs_banner';
+const enumFeatureName = featureName.toUpperCase();
+
+export default {
+ i18n: {
+ title: __('View job dependencies in the pipeline graph!'),
+ description: __(
+ 'You can now group jobs in the pipeline graph based on which jobs are configured to run first, if you use the %{codeStart}needs:%{codeEnd} keyword to establish job dependencies in your CI/CD pipelines. %{linkStart}Learn how to speed up your pipeline with needs.%{linkEnd}',
+ ),
+ buttonText: __('Provide feedback'),
+ },
+ components: {
+ GlBanner,
+ GlLink,
+ GlSprintf,
+ },
+ apollo: {
+ callouts: {
+ query: getUserCallouts,
+ update(data) {
+ return data?.currentUser?.callouts?.nodes.map((c) => c.featureName);
+ },
+ error() {
+ this.hasError = true;
+ },
+ },
+ },
+ inject: ['dagDocPath'],
+ data() {
+ return {
+ callouts: [],
+ dismissedAlert: false,
+ hasError: false,
+ };
+ },
+ computed: {
+ showBanner() {
+ return (
+ !this.$apollo.queries.callouts?.loading &&
+ !this.hasError &&
+ !this.dismissedAlert &&
+ !this.callouts.includes(enumFeatureName)
+ );
+ },
+ },
+ methods: {
+ handleClose() {
+ this.dismissedAlert = true;
+ try {
+ this.$apollo.mutate({
+ mutation: DismissPipelineNotification,
+ variables: {
+ featureName,
+ },
+ });
+ } catch {
+ createFlash(__('There was a problem dismissing this notification.'));
+ }
+ },
+ },
+};
+</script>
+<template>
+ <gl-banner
+ v-if="showBanner"
+ :title="$options.i18n.title"
+ :button-text="$options.i18n.buttonText"
+ button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/327688"
+ variant="introduction"
+ @close="handleClose"
+ >
+ <p>
+ <gl-sprintf :message="$options.i18n.description">
+ <template #link="{ content }">
+ <gl-link :href="dagDocPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-banner>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js
index 9c97fa832d0..f5ab869633b 100644
--- a/app/assets/javascripts/pipelines/components/parsing_utils.js
+++ b/app/assets/javascripts/pipelines/components/parsing_utils.js
@@ -1,4 +1,5 @@
import { uniqWith, isEqual } from 'lodash';
+import { createSankey } from './dag/drawing_utils';
/*
The following functions are the main engine in transforming the data as
@@ -144,3 +145,28 @@ export const getMaxNodes = (nodes) => {
export const removeOrphanNodes = (sankeyfiedNodes) => {
return sankeyfiedNodes.filter((node) => node.sourceLinks.length || node.targetLinks.length);
};
+
+/*
+ This utility accepts unwrapped pipeline data in the format returned from
+ our standard pipeline GraphQL query and returns a list of names by layer
+ for the layer view. It can be combined with the stageLookup on the pipeline
+ to generate columns by layer.
+*/
+
+export const listByLayers = ({ stages }) => {
+ const arrayOfJobs = stages.flatMap(({ groups }) => groups);
+ const parsedData = parseData(arrayOfJobs);
+ const dataWithLayers = createSankey()(parsedData);
+
+ return dataWithLayers.nodes.reduce((acc, { layer, name }) => {
+ /* sort groups by layer */
+
+ if (!acc[layer]) {
+ acc[layer] = [];
+ }
+
+ acc[layer].push(name);
+
+ return acc;
+ }, []);
+};
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 51a95612d3f..01baf0a42d5 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
@@ -10,6 +10,10 @@ export default {
type: String,
required: true,
},
+ pipelineId: {
+ type: Number,
+ required: true,
+ },
isHighlighted: {
type: Boolean,
required: false,
@@ -32,6 +36,9 @@ export default {
},
},
computed: {
+ id() {
+ return `${this.jobName}-${this.pipelineId}`;
+ },
jobPillClasses() {
return [
{ 'gl-opacity-3': this.isFadedOut },
@@ -52,7 +59,7 @@ export default {
<template>
<tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
<div
- :id="jobName"
+ :id="id"
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"
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 707d6966e77..3ba0d7d0120 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
@@ -1,11 +1,8 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale';
-import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
-import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants';
-import { createJobsHash, generateJobNeedsDict } from '../../utils';
-import { generateLinksData } from '../graph_shared/drawing_utils';
-import { parseData } from '../parsing_utils';
+import { DRAW_FAILURE, DEFAULT } from '../../constants';
+import LinksLayer from '../graph_shared/links_layer.vue';
import JobPill from './job_pill.vue';
import StagePill from './stage_pill.vue';
@@ -13,18 +10,16 @@ export default {
components: {
GlAlert,
JobPill,
+ LinksLayer,
StagePill,
},
CONTAINER_REF: 'PIPELINE_GRAPH_CONTAINER_REF',
- CONTAINER_ID: 'pipeline-graph-container',
+ BASE_CONTAINER_ID: 'pipeline-graph-container',
+ PIPELINE_ID: 0,
STROKE_WIDTH: 2,
errorTexts: {
[DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
[DEFAULT]: __('An unknown error occurred.'),
- [EMPTY_PIPELINE_DATA]: __(
- 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.',
- ),
- [INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'),
},
props: {
pipelineData: {
@@ -36,33 +31,16 @@ export default {
return {
failureType: null,
highlightedJob: null,
- links: [],
- needsObject: null,
- height: 0,
- width: 0,
+ highlightedJobs: [],
+ measurements: {
+ height: 0,
+ width: 0,
+ },
};
},
computed: {
- hideGraph() {
- // We won't even try to render the graph with these condition
- // because it would cause additional errors down the line for the user
- // which is confusing.
- return this.isPipelineDataEmpty || this.isInvalidCiConfig;
- },
- pipelineStages() {
- return this.pipelineData?.stages || [];
- },
- isPipelineDataEmpty() {
- return !this.isInvalidCiConfig && this.pipelineStages.length === 0;
- },
- isInvalidCiConfig() {
- return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID;
- },
- hasError() {
- return this.failureType;
- },
- hasHighlightedJob() {
- return Boolean(this.highlightedJob);
+ containerId() {
+ return `${this.$options.BASE_CONTAINER_ID}-${this.$options.PIPELINE_ID}`;
},
failure() {
switch (this.failureType) {
@@ -72,18 +50,6 @@ export default {
variant: 'danger',
dismissible: true,
};
- case EMPTY_PIPELINE_DATA:
- return {
- text: this.$options.errorTexts[EMPTY_PIPELINE_DATA],
- variant: 'tip',
- dismissible: false,
- };
- case INVALID_CI_CONFIG:
- return {
- text: this.$options.errorTexts[INVALID_CI_CONFIG],
- variant: 'danger',
- dismissible: false,
- };
default:
return {
text: this.$options.errorTexts[DEFAULT],
@@ -92,56 +58,32 @@ export default {
};
}
},
- viewBox() {
- return [0, 0, this.width, this.height];
+ hasError() {
+ return this.failureType;
},
- highlightedJobs() {
- // If you are hovering on a job, then the jobs we want to highlight are:
- // The job you are currently hovering + all of its needs.
- return [this.highlightedJob, ...this.needsObject[this.highlightedJob]];
+ hasHighlightedJob() {
+ return Boolean(this.highlightedJob);
},
- highlightedLinks() {
- // If you are hovering on a job, then the links we want to highlight are:
- // All the links whose `source` and `target` are highlighted jobs.
- if (this.hasHighlightedJob) {
- const filteredLinks = this.links.filter((link) => {
- return (
- this.highlightedJobs.includes(link.source) && this.highlightedJobs.includes(link.target)
- );
- });
-
- return filteredLinks.map((link) => link.ref);
- }
-
- return [];
+ pipelineStages() {
+ return this.pipelineData?.stages || [];
},
},
watch: {
pipelineData: {
immediate: true,
handler() {
- if (this.isPipelineDataEmpty) {
- this.reportFailure(EMPTY_PIPELINE_DATA);
- } else if (this.isInvalidCiConfig) {
- this.reportFailure(INVALID_CI_CONFIG);
- } else {
- this.$nextTick(() => {
- this.computeGraphDimensions();
- this.prepareLinkData();
- });
- }
+ this.$nextTick(() => {
+ this.computeGraphDimensions();
+ });
},
},
},
methods: {
- prepareLinkData() {
- try {
- const arrayOfJobs = this.pipelineStages.flatMap(({ groups }) => groups);
- const parsedData = parseData(arrayOfJobs);
- this.links = generateLinksData(parsedData, this.$options.CONTAINER_ID);
- } catch {
- this.reportFailure(DRAW_FAILURE);
- }
+ computeGraphDimensions() {
+ this.measurements = {
+ width: this.$refs[this.$options.CONTAINER_REF].scrollWidth,
+ height: this.$refs[this.$options.CONTAINER_REF].scrollHeight,
+ };
},
getStageBackgroundClasses(index) {
const { length } = this.pipelineStages;
@@ -161,22 +103,14 @@ export default {
return '';
},
- highlightNeeds(uniqueJobId) {
- // The first time we hover, we create the object where
- // we store all the data to properly highlight the needs.
- if (!this.needsObject) {
- const jobs = createJobsHash(this.pipelineStages);
- this.needsObject = generateJobNeedsDict(jobs) ?? {};
- }
-
- this.highlightedJob = uniqueJobId;
+ isJobHighlighted(jobName) {
+ return this.highlightedJobs.includes(jobName);
},
- removeHighlightNeeds() {
- this.highlightedJob = null;
+ onError(error) {
+ this.reportFailure(error.type);
},
- computeGraphDimensions() {
- this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}`;
- this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}`;
+ removeHoveredJob() {
+ this.highlightedJob = null;
},
reportFailure(errorType) {
this.failureType = errorType;
@@ -184,17 +118,11 @@ export default {
resetFailure() {
this.failureType = null;
},
- isJobHighlighted(jobName) {
- return this.highlightedJobs.includes(jobName);
+ setHoveredJob(jobName) {
+ this.highlightedJob = jobName;
},
- isLinkHighlighted(linkRef) {
- return this.highlightedLinks.includes(linkRef);
- },
- getLinkClasses(link) {
- return [
- this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : 'gl-stroke-gray-200',
- { 'gl-opacity-3': this.hasHighlightedJob && !this.isLinkHighlighted(link.ref) },
- ];
+ updateHighlightedJobs(jobs) {
+ this.highlightedJobs = jobs;
},
},
};
@@ -209,50 +137,44 @@ export default {
>
{{ failure.text }}
</gl-alert>
- <div
- v-if="!hideGraph"
- :id="$options.CONTAINER_ID"
- :ref="$options.CONTAINER_REF"
- class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto gl-relative gl-py-7"
- data-testid="graph-container"
- >
- <svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute">
- <path
- v-for="link in links"
- :key="link.path"
- :ref="link.ref"
- :d="link.path"
- class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease"
- :class="getLinkClasses(link)"
- :stroke-width="$options.STROKE_WIDTH"
- />
- </svg>
- <div
- v-for="(stage, index) in pipelineStages"
- :key="`${stage.name}-${index}`"
- class="gl-flex-direction-column"
+ <div :id="containerId" :ref="$options.CONTAINER_REF" data-testid="graph-container">
+ <links-layer
+ :pipeline-data="pipelineStages"
+ :pipeline-id="$options.PIPELINE_ID"
+ :container-id="containerId"
+ :container-measurements="measurements"
+ :highlighted-job="highlightedJob"
+ @highlightedJobsChange="updateHighlightedJobs"
+ @error="onError"
>
<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="getStageBackgroundClasses(index)"
- data-testid="stage-background"
- >
- <stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" />
- </div>
- <div
- class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8"
+ v-for="(stage, index) in pipelineStages"
+ :key="`${stage.name}-${index}`"
+ class="gl-flex-direction-column"
>
- <job-pill
- v-for="group in stage.groups"
- :key="group.name"
- :job-name="group.name"
- :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)"
- :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)"
- @on-mouse-enter="highlightNeeds"
- @on-mouse-leave="removeHighlightNeeds"
- />
+ <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="getStageBackgroundClasses(index)"
+ data-testid="stage-background"
+ >
+ <stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" />
+ </div>
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8"
+ >
+ <job-pill
+ v-for="group in stage.groups"
+ :key="group.name"
+ :job-name="group.name"
+ :pipeline-id="$options.PIPELINE_ID"
+ :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)"
+ :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)"
+ @on-mouse-enter="setHoveredJob"
+ @on-mouse-leave="removeHoveredJob"
+ />
+ </div>
</div>
- </div>
+ </links-layer>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue
deleted file mode 100644
index 6c3a4a27606..00000000000
--- a/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue
+++ /dev/null
@@ -1,30 +0,0 @@
-<script>
-export default {
- name: 'PipelinesSvgState',
- props: {
- svgPath: {
- type: String,
- required: true,
- },
-
- message: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <div class="row empty-state">
- <div class="col-12">
- <div class="svg-content"><img :src="svgPath" /></div>
- </div>
-
- <div class="col-12 text-center">
- <div class="text-content">
- <h4>{{ message }}</h4>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
index f8107d288d9..c3bcfcb18fb 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
@@ -1,7 +1,9 @@
<script>
import { GlEmptyState } from '@gitlab/ui';
+import Experiment from '~/experimentation/components/experiment.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
+import PipelinesCiTemplates from './pipelines_ci_templates.vue';
export default {
i18n: {
@@ -15,6 +17,8 @@ export default {
name: 'PipelinesEmptyState',
components: {
GlEmptyState,
+ Experiment,
+ PipelinesCiTemplates,
},
props: {
emptyStateSvgPath: {
@@ -35,19 +39,26 @@ export default {
</script>
<template>
<div>
- <gl-empty-state
- v-if="canSetCi"
- :title="$options.i18n.title"
- :svg-path="emptyStateSvgPath"
- :description="$options.i18n.description"
- :primary-button-text="$options.i18n.btnText"
- :primary-button-link="ciHelpPagePath"
- />
- <gl-empty-state
- v-else
- title=""
- :svg-path="emptyStateSvgPath"
- :description="$options.i18n.noCiDescription"
- />
+ <experiment name="pipeline_empty_state_templates">
+ <template #control>
+ <gl-empty-state
+ v-if="canSetCi"
+ :title="$options.i18n.title"
+ :svg-path="emptyStateSvgPath"
+ :description="$options.i18n.description"
+ :primary-button-text="$options.i18n.btnText"
+ :primary-button-link="ciHelpPagePath"
+ />
+ <gl-empty-state
+ v-else
+ title=""
+ :svg-path="emptyStateSvgPath"
+ :description="$options.i18n.noCiDescription"
+ />
+ </template>
+ <template #candidate>
+ <pipelines-ci-templates />
+ </template>
+ </experiment>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue b/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue
new file mode 100644
index 00000000000..670fa398536
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue
@@ -0,0 +1,190 @@
+<script>
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
+import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
+import { sprintf } from '~/locale';
+import { reportToSentry } from '../../utils';
+import ActionComponent from '../jobs_shared/action_component.vue';
+import JobNameComponent from '../jobs_shared/job_name_component.vue';
+
+/**
+ * Renders the badge for the pipeline graph and the job's dropdown.
+ *
+ * The following object should be provided as `job`:
+ *
+ * {
+ * "id": 4256,
+ * "name": "test",
+ * "status": {
+ * "icon": "status_success",
+ * "text": "passed",
+ * "label": "passed",
+ * "group": "success",
+ * "tooltip": "passed",
+ * "details_path": "/root/ci-mock/builds/4256",
+ * "action": {
+ * "icon": "retry",
+ * "title": "Retry",
+ * "path": "/root/ci-mock/builds/4256/retry",
+ * "method": "post"
+ * }
+ * }
+ * }
+ */
+
+export default {
+ hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500',
+ components: {
+ ActionComponent,
+ JobNameComponent,
+ GlLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [delayedJobMixin],
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ cssClassJobName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ dropdownLength: {
+ type: Number,
+ required: false,
+ default: Infinity,
+ },
+ jobHovered: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ pipelineExpanded: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ pipelineId: {
+ type: Number,
+ required: false,
+ default: -1,
+ },
+ },
+ computed: {
+ boundary() {
+ return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
+ },
+ detailsPath() {
+ return this.status.details_path;
+ },
+ hasDetails() {
+ return this.status.has_details;
+ },
+ status() {
+ return this.job && this.job.status ? this.job.status : {};
+ },
+ tooltipText() {
+ const textBuilder = [];
+ const { name: jobName } = this.job;
+
+ if (jobName) {
+ textBuilder.push(jobName);
+ }
+
+ const { tooltip: statusTooltip } = this.status;
+ if (jobName && statusTooltip) {
+ textBuilder.push('-');
+ }
+
+ if (statusTooltip) {
+ if (this.isDelayedJob) {
+ textBuilder.push(sprintf(statusTooltip, { remainingTime: this.remainingTime }));
+ } else {
+ textBuilder.push(statusTooltip);
+ }
+ }
+
+ return textBuilder.join(' ');
+ },
+ /**
+ * Verifies if the provided job has an action path
+ *
+ * @return {Boolean}
+ */
+ hasAction() {
+ return this.job.status && this.job.status.action && this.job.status.action.path;
+ },
+ relatedDownstreamHovered() {
+ return this.job.name === this.jobHovered;
+ },
+ relatedDownstreamExpanded() {
+ return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded;
+ },
+ jobClasses() {
+ return this.relatedDownstreamHovered || this.relatedDownstreamExpanded
+ ? `${this.$options.hoverClass} ${this.cssClassJobName}`
+ : this.cssClassJobName;
+ },
+ },
+ errorCaptured(err, _vm, info) {
+ reportToSentry('pipelines_job_item', `pipelines_job_item error: ${err}, info: ${info}`);
+ },
+ methods: {
+ hideTooltips() {
+ this.$root.$emit(BV_HIDE_TOOLTIP);
+ },
+ pipelineActionRequestComplete() {
+ this.$emit('pipelineActionRequestComplete');
+ },
+ },
+};
+</script>
+<template>
+ <div
+ class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between"
+ data-qa-selector="job_item_container"
+ >
+ <gl-link
+ v-if="hasDetails"
+ v-gl-tooltip="{
+ boundary: 'viewport',
+ placement: 'bottom',
+ customClass: 'gl-pointer-events-none',
+ }"
+ :href="detailsPath"
+ :title="tooltipText"
+ :class="jobClasses"
+ class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none"
+ data-testid="job-with-link"
+ @click.stop="hideTooltips"
+ @mouseout="hideTooltips"
+ >
+ <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
+ </gl-link>
+
+ <div
+ v-else
+ v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
+ :title="tooltipText"
+ :class="jobClasses"
+ class="js-job-component-tooltip non-details-job-component menu-item"
+ data-testid="job-without-link"
+ @mouseout="hideTooltips"
+ >
+ <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
+ </div>
+
+ <action-component
+ v-if="hasAction"
+ :tooltip-text="status.action.title"
+ :link="status.action.path"
+ :action-icon="status.action.icon"
+ data-qa-selector="action_button"
+ @pipelineActionRequestComplete="pipelineActionRequestComplete"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
index cf0849751df..235126fea0c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
@@ -41,29 +41,29 @@ export default {
<template>
<div class="nav-controls">
<gl-button
- v-if="newPipelinePath"
- :href="newPipelinePath"
- variant="success"
- category="primary"
- class="js-run-pipeline"
- data-testid="run-pipeline-button"
- data-qa-selector="run_pipeline_button"
- >
- {{ s__('Pipelines|Run Pipeline') }}
- </gl-button>
-
- <gl-button
v-if="resetCachePath"
:loading="isResetCacheButtonLoading"
class="js-clear-cache"
data-testid="clear-cache-button"
@click="onClickResetCache"
>
- {{ s__('Pipelines|Clear Runner Caches') }}
+ {{ s__('Pipelines|Clear runner caches') }}
</gl-button>
<gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint" data-testid="ci-lint-button">
- {{ s__('Pipelines|CI Lint') }}
+ {{ s__('Pipelines|CI lint') }}
+ </gl-button>
+
+ <gl-button
+ v-if="newPipelinePath"
+ :href="newPipelinePath"
+ variant="confirm"
+ category="primary"
+ class="js-run-pipeline"
+ data-testid="run-pipeline-button"
+ data-qa-selector="run_pipeline_button"
+ >
+ {{ s__('Pipeline|Run pipeline') }}
</gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
index 05372010d0f..2b33467e948 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
@@ -36,7 +36,7 @@ export default {
};
</script>
<template>
- <div data-testid="widget-mini-pipeline-graph">
+ <div data-testid="pipeline-mini-graph">
<div
v-for="stage in stages"
:key="stage.name"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
index bdb7dd06620..bf992b84387 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
@@ -17,7 +17,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import eventHub from '../../event_hub';
-import JobItem from '../graph/job_item.vue';
+import JobItem from './job_item.vue';
export default {
components: {
@@ -103,7 +103,7 @@ export default {
<template>
<gl-dropdown
ref="dropdown"
- v-gl-tooltip.hover
+ v-gl-tooltip.hover.ds0
data-testid="mini-pipeline-graph-dropdown"
:title="stage.title"
variant="link"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
index c707b395192..0528e4c147c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
@@ -17,19 +17,11 @@ export default {
user() {
return this.pipeline.user;
},
- classes() {
- const triggererClass = 'pipeline-triggerer';
-
- if (this.glFeatures.newPipelinesTable) {
- return triggererClass;
- }
- return `table-section section-10 d-none d-md-block ${triggererClass}`;
- },
},
};
</script>
<template>
- <div :class="classes" data-testid="pipeline-triggerer">
+ <div class="pipeline-triggerer" data-testid="pipeline-triggerer">
<user-avatar-link
v-if="user"
:link-href="user.path"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
index 0de520a2ca7..d39e120dc6c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -49,19 +49,11 @@ export default {
autoDevopsHelpPath() {
return helpPagePath('topics/autodevops/index.md');
},
- classes() {
- const tagsClass = 'pipeline-tags';
-
- if (this.glFeatures.newPipelinesTable) {
- return tagsClass;
- }
- return `table-section section-10 d-none d-md-block ${tagsClass}`;
- },
},
};
</script>
<template>
- <div :class="classes" data-testid="pipeline-url-table-cell">
+ <div class="pipeline-tags" data-testid="pipeline-url-table-cell">
<gl-link
:href="pipeline.path"
data-testid="pipeline-url-link"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 19d93e7d083..f14a582d731 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
+import { GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui';
import { isEqual } from 'lodash';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { getParameterByName } from '~/lib/utils/common_utils';
@@ -10,7 +10,6 @@ import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../
import PipelinesMixin from '../../mixins/pipelines_mixin';
import PipelinesService from '../../services/pipelines_service';
import { validateParams } from '../../utils';
-import SvgBlankState from './blank_state.vue';
import EmptyState from './empty_state.vue';
import NavigationControls from './nav_controls.vue';
import PipelinesFilteredSearch from './pipelines_filtered_search.vue';
@@ -19,13 +18,13 @@ import PipelinesTableComponent from './pipelines_table.vue';
export default {
components: {
EmptyState,
+ GlEmptyState,
GlIcon,
GlLoadingIcon,
NavigationTabs,
NavigationControls,
PipelinesFilteredSearch,
PipelinesTableComponent,
- SvgBlankState,
TablePagination,
},
mixins: [PipelinesMixin],
@@ -314,6 +313,7 @@ export default {
</div>
<pipelines-filtered-search
+ v-if="stateToRender !== $options.stateMap.emptyState"
:project-id="projectId"
:params="validatedParams"
@filterPipelines="filterPipelines"
@@ -333,19 +333,19 @@ export default {
:can-set-ci="canCreatePipeline"
/>
- <svg-blank-state
+ <gl-empty-state
v-else-if="stateToRender === $options.stateMap.error"
:svg-path="errorStateSvgPath"
- :message="
+ :title="
s__(`Pipelines|There was an error fetching the pipelines.
Try again in a few moments or contact your support team.`)
"
/>
- <svg-blank-state
+ <gl-empty-state
v-else-if="stateToRender === $options.stateMap.emptyTab"
:svg-path="noPipelinesSvgPath"
- :message="emptyTabMessage"
+ :title="emptyTabMessage"
/>
<div v-else-if="stateToRender === $options.stateMap.tableList">
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
new file mode 100644
index 00000000000..c2ec8c57fd7
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
@@ -0,0 +1,143 @@
+<script>
+import { GlButton, GlCard, GlSprintf } from '@gitlab/ui';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+import { s__, sprintf } from '~/locale';
+import { HELLO_WORLD_TEMPLATE_KEY } from '../../constants';
+
+export default {
+ components: {
+ GlButton,
+ GlCard,
+ GlSprintf,
+ },
+ HELLO_WORLD_TEMPLATE_KEY,
+ i18n: {
+ cta: s__('Pipelines|Use template'),
+ testTemplates: {
+ title: s__('Pipelines|Use a sample CI/CD template'),
+ subtitle: s__(
+ 'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.',
+ ),
+ helloWorld: {
+ title: s__('Pipelines|“Hello world” with GitLab CI/CD'),
+ description: s__(
+ 'Pipelines|Get familiar with GitLab CI/CD syntax by starting with a simple pipeline that runs a “Hello world” script.',
+ ),
+ },
+ },
+ templates: {
+ title: s__('Pipelines|Use a CI/CD template'),
+ subtitle: s__(
+ "Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.",
+ ),
+ description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'),
+ },
+ },
+ inject: ['addCiYmlPath', 'suggestedCiTemplates'],
+ data() {
+ const templates = this.suggestedCiTemplates.map(({ name, logo }) => {
+ return {
+ name,
+ logo,
+ link: mergeUrlParams({ template: name }, this.addCiYmlPath),
+ description: sprintf(this.$options.i18n.templates.description, { name }),
+ };
+ });
+
+ return {
+ templates,
+ helloWorldTemplateUrl: mergeUrlParams(
+ { template: HELLO_WORLD_TEMPLATE_KEY },
+ this.addCiYmlPath,
+ ),
+ };
+ },
+ methods: {
+ trackEvent(template) {
+ const tracking = new ExperimentTracking('pipeline_empty_state_templates', {
+ label: template,
+ });
+ tracking.event('template_clicked');
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.testTemplates.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">
+ <gl-sprintf :message="$options.i18n.testTemplates.subtitle">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <div class="row gl-mb-8">
+ <div class="col-lg-3">
+ <gl-card>
+ <div class="gl-flex-direction-row">
+ <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div>
+ <div class="gl-mb-3">
+ <strong class="gl-text-gray-800 gl-mb-2">{{
+ $options.i18n.testTemplates.helloWorld.title
+ }}</strong>
+ </div>
+ <p class="gl-font-sm">{{ $options.i18n.testTemplates.helloWorld.description }}</p>
+ </div>
+
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="helloWorldTemplateUrl"
+ data-testid="test-template-link"
+ @click="trackEvent($options.HELLO_WORLD_TEMPLATE_KEY)"
+ >
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </gl-card>
+ </div>
+ </div>
+
+ <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.templates.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">{{ $options.i18n.templates.subtitle }}</p>
+
+ <ul class="gl-list-style-none gl-pl-0">
+ <li v-for="template in templates" :key="template.name">
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3"
+ >
+ <div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
+ <img
+ width="64"
+ height="64"
+ :src="template.logo"
+ class="gl-mr-6"
+ data-testid="template-logo"
+ />
+ <div class="gl-flex-direction-row">
+ <div class="gl-mb-3">
+ <strong class="gl-text-gray-800" data-testid="template-name">{{
+ template.name
+ }}</strong>
+ </div>
+ <p class="gl-mb-0 gl-font-sm" data-testid="template-description">
+ {{ template.description }}
+ </p>
+ </div>
+ </div>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="template.link"
+ data-testid="template-link"
+ @click="trackEvent(template.name)"
+ >
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </div>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index aa27aa7e50d..47fc7023222 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -1,7 +1,6 @@
<script>
import { GlTable, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../../event_hub';
import PipelineMiniGraph from './pipeline_mini_graph.vue';
import PipelineOperations from './pipeline_operations.vue';
@@ -10,7 +9,6 @@ import PipelineTriggerer from './pipeline_triggerer.vue';
import PipelineUrl from './pipeline_url.vue';
import PipelinesCommit from './pipelines_commit.vue';
import PipelinesStatusBadge from './pipelines_status_badge.vue';
-import PipelinesTableRowComponent from './pipelines_table_row.vue';
import PipelinesTimeago from './time_ago.vue';
const DEFAULT_TD_CLASS = 'gl-p-5!';
@@ -83,7 +81,6 @@ export default {
PipelineOperations,
PipelinesStatusBadge,
PipelineStopModal,
- PipelinesTableRowComponent,
PipelinesTimeago,
PipelineTriggerer,
PipelineUrl,
@@ -91,7 +88,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagMixin()],
props: {
pipelines: {
type: Array,
@@ -149,41 +145,7 @@ export default {
</script>
<template>
<div class="ci-table">
- <div v-if="!glFeatures.newPipelinesTable" data-testid="legacy-ci-table">
- <div class="gl-responsive-table-row table-row-header" role="row">
- <div class="table-section section-10 js-pipeline-status" role="rowheader">
- {{ s__('Pipeline|Status') }}
- </div>
- <div class="table-section section-10 js-pipeline-info pipeline-info" role="rowheader">
- {{ s__('Pipeline|Pipeline') }}
- </div>
- <div class="table-section section-10 js-triggerer-info triggerer-info" role="rowheader">
- {{ s__('Pipeline|Triggerer') }}
- </div>
- <div class="table-section section-20 js-pipeline-commit pipeline-commit" role="rowheader">
- {{ s__('Pipeline|Commit') }}
- </div>
- <div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader">
- {{ s__('Pipeline|Stages') }}
- </div>
- <div class="table-section section-15" role="rowheader"></div>
- <div class="table-section section-20" role="rowheader">
- <slot name="table-header-actions"></slot>
- </div>
- </div>
- <pipelines-table-row-component
- v-for="model in pipelines"
- :key="model.id"
- :pipeline="model"
- :pipeline-schedule-url="pipelineScheduleUrl"
- :update-graph-dropdown="updateGraphDropdown"
- :view-type="viewType"
- :canceling-pipeline="cancelingPipeline"
- />
- </div>
-
<gl-table
- v-else
:fields="$options.fields"
:items="pipelines"
tbody-tr-class="commit"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
deleted file mode 100644
index f684a0b0fcd..00000000000
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
+++ /dev/null
@@ -1,269 +0,0 @@
-<script>
-import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
-import CommitComponent from '~/vue_shared/components/commit.vue';
-import eventHub from '../../event_hub';
-import PipelineMiniGraph from './pipeline_mini_graph.vue';
-import PipelineTriggerer from './pipeline_triggerer.vue';
-import PipelineUrl from './pipeline_url.vue';
-import PipelinesArtifactsComponent from './pipelines_artifacts.vue';
-import PipelinesManualActionsComponent from './pipelines_manual_actions.vue';
-import PipelinesTimeago from './time_ago.vue';
-
-export default {
- i18n: {
- cancelTitle: __('Cancel'),
- redeployTitle: __('Retry'),
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- GlModalDirective,
- },
- components: {
- PipelinesManualActionsComponent,
- PipelinesArtifactsComponent,
- CommitComponent,
- PipelineMiniGraph,
- PipelineUrl,
- PipelineTriggerer,
- CiBadge,
- PipelinesTimeago,
- GlButton,
- },
- props: {
- pipeline: {
- type: Object,
- required: true,
- },
- pipelineScheduleUrl: {
- type: String,
- required: false,
- default: '',
- },
- updateGraphDropdown: {
- type: Boolean,
- required: false,
- default: false,
- },
- viewType: {
- type: String,
- required: true,
- },
- cancelingPipeline: {
- type: Number,
- required: false,
- default: null,
- },
- },
- data() {
- return {
- isRetrying: false,
- };
- },
- computed: {
- actions() {
- if (!this.pipeline || !this.pipeline.details) {
- return [];
- }
- const { details } = this.pipeline;
- return [...(details.manual_actions || []), ...(details.scheduled_actions || [])];
- },
- /**
- * If provided, returns the commit tag.
- * Needed to render the commit component column.
- *
- * This field needs a lot of verification, because of different possible cases:
- *
- * 1. person who is an author of a commit might be a GitLab user
- * 2. if person who is an author of a commit is a GitLab user, they can have a GitLab avatar
- * 3. If GitLab user does not have avatar they might have a Gravatar
- * 4. If committer is not a GitLab User they can have a Gravatar
- * 5. We do not have consistent API object in this case
- * 6. We should improve API and the code
- *
- * @returns {Object|Undefined}
- */
- commitAuthor() {
- let commitAuthorInformation;
-
- if (!this.pipeline || !this.pipeline.commit) {
- return null;
- }
-
- // 1. person who is an author of a commit might be a GitLab user
- if (this.pipeline.commit.author) {
- // 2. if person who is an author of a commit is a GitLab user
- // they can have a GitLab avatar
- if (this.pipeline.commit.author.avatar_url) {
- commitAuthorInformation = this.pipeline.commit.author;
-
- // 3. If GitLab user does not have avatar, they might have a Gravatar
- } else if (this.pipeline.commit.author_gravatar_url) {
- commitAuthorInformation = {
- ...this.pipeline.commit.author,
- avatar_url: this.pipeline.commit.author_gravatar_url,
- };
- }
- // 4. If committer is not a GitLab User, they can have a Gravatar
- } else {
- commitAuthorInformation = {
- avatar_url: this.pipeline.commit.author_gravatar_url,
- path: `mailto:${this.pipeline.commit.author_email}`,
- username: this.pipeline.commit.author_name,
- };
- }
-
- return commitAuthorInformation;
- },
- commitTag() {
- return this.pipeline?.ref?.tag;
- },
- commitRef() {
- return this.pipeline?.ref;
- },
- commitUrl() {
- return this.pipeline?.commit?.commit_path;
- },
- commitShortSha() {
- return this.pipeline?.commit?.short_id;
- },
- commitTitle() {
- return this.pipeline?.commit?.title;
- },
- pipelineStatus() {
- return this.pipeline?.details?.status ?? {};
- },
- hasStages() {
- return this.pipeline?.details?.stages?.length > 0;
- },
- displayPipelineActions() {
- return (
- this.pipeline.flags.retryable ||
- this.pipeline.flags.cancelable ||
- this.pipeline.details.manual_actions.length ||
- this.pipeline.details.artifacts.length
- );
- },
- isChildView() {
- return this.viewType === 'child';
- },
- isCancelling() {
- return this.cancelingPipeline === this.pipeline.id;
- },
- },
- watch: {
- pipeline() {
- this.isRetrying = false;
- },
- },
- methods: {
- handleCancelClick() {
- eventHub.$emit('openConfirmationModal', {
- pipeline: this.pipeline,
- endpoint: this.pipeline.cancel_path,
- });
- },
- handleRetryClick() {
- this.isRetrying = true;
- eventHub.$emit('retryPipeline', this.pipeline.retry_path);
- },
- handlePipelineActionRequestComplete() {
- // warn the pipelines table to update
- eventHub.$emit('refreshPipelinesTable');
- },
- },
-};
-</script>
-<template>
- <div class="commit gl-responsive-table-row">
- <div class="table-section section-10 commit-link">
- <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Status') }}</div>
- <div class="table-mobile-content">
- <ci-badge
- :status="pipelineStatus"
- :show-text="!isChildView"
- :icon-classes="'gl-vertical-align-middle!'"
- data-qa-selector="pipeline_commit_status"
- />
- </div>
- </div>
-
- <pipeline-url :pipeline="pipeline" :pipeline-schedule-url="pipelineScheduleUrl" />
- <pipeline-triggerer :pipeline="pipeline" />
-
- <div class="table-section section-wrap section-20">
- <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Commit') }}</div>
- <div class="table-mobile-content">
- <commit-component
- :tag="commitTag"
- :commit-ref="commitRef"
- :commit-url="commitUrl"
- :merge-request-ref="pipeline.merge_request"
- :short-sha="commitShortSha"
- :title="commitTitle"
- :author="commitAuthor"
- :show-ref-info="!isChildView"
- />
- </div>
- </div>
-
- <div class="table-section section-wrap section-15 stage-cell">
- <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Stages') }}</div>
- <div class="table-mobile-content">
- <pipeline-mini-graph
- v-if="hasStages"
- :stages="pipeline.details.stages"
- :update-dropdown="updateGraphDropdown"
- @pipelineActionRequestComplete="handlePipelineActionRequestComplete"
- />
- </div>
- </div>
-
- <pipelines-timeago class="gl-text-right" :pipeline="pipeline" />
-
- <div
- v-if="displayPipelineActions"
- class="table-section section-20 table-button-footer pipeline-actions"
- >
- <div class="btn-group table-action-buttons">
- <pipelines-manual-actions-component v-if="actions.length > 0" :actions="actions" />
-
- <pipelines-artifacts-component
- v-if="pipeline.details.artifacts.length"
- :artifacts="pipeline.details.artifacts"
- />
-
- <gl-button
- v-if="pipeline.flags.retryable"
- v-gl-tooltip.hover
- :aria-label="$options.i18n.redeployTitle"
- :title="$options.i18n.redeployTitle"
- :disabled="isRetrying"
- :loading="isRetrying"
- class="js-pipelines-retry-button"
- data-qa-selector="pipeline_retry_button"
- icon="repeat"
- variant="default"
- category="secondary"
- @click="handleRetryClick"
- />
-
- <gl-button
- v-if="pipeline.flags.cancelable"
- v-gl-tooltip.hover
- v-gl-modal-directive="'confirmation-modal'"
- :aria-label="$options.i18n.cancelTitle"
- :title="$options.i18n.cancelTitle"
- :loading="isCancelling"
- :disabled="isCancelling"
- icon="close"
- variant="danger"
- category="primary"
- class="js-pipelines-cancel-button"
- @click="handleCancelClick"
- />
- </div>
- </div>
- </div>
-</template>
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 543bdf94307..e6b03751350 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -22,6 +22,12 @@ export default {
finishedTime() {
return this.pipeline?.details?.finished_at;
},
+ skipped() {
+ return this.pipeline?.details?.status?.label === 'skipped';
+ },
+ stuck() {
+ return this.pipeline.flags.stuck;
+ },
durationFormatted() {
const date = new Date(this.duration * 1000);
@@ -42,46 +48,50 @@ export default {
return `${hh}:${mm}:${ss}`;
},
- legacySectionClass() {
- return !this.glFeatures.newPipelinesTable ? 'table-section section-15' : '';
- },
- legacyTableMobileClass() {
- return !this.glFeatures.newPipelinesTable ? 'table-mobile-content' : '';
- },
showInProgress() {
- return !this.duration && !this.finishedTime;
+ return !this.duration && !this.finishedTime && !this.skipped;
+ },
+ showSkipped() {
+ return !this.duration && !this.finishedTime && this.skipped;
},
},
};
</script>
<template>
- <div :class="legacySectionClass">
- <div v-if="!glFeatures.newPipelinesTable" class="table-mobile-header" role="rowheader">
- {{ s__('Pipeline|Duration') }}
- </div>
- <div :class="legacyTableMobileClass">
- <span v-if="showInProgress" data-testid="pipeline-in-progress">
- <gl-icon name="hourglass" class="gl-vertical-align-baseline! gl-mr-2" :size="12" />
- {{ s__('Pipeline|In progress') }}
- </span>
+ <div>
+ <span v-if="showInProgress" data-testid="pipeline-in-progress">
+ <gl-icon v-if="stuck" name="warning" class="gl-mr-2" :size="12" data-testid="warning-icon" />
+ <gl-icon
+ v-else
+ name="hourglass"
+ class="gl-vertical-align-baseline! gl-mr-2"
+ :size="12"
+ data-testid="hourglass-icon"
+ />
+ {{ s__('Pipeline|In progress') }}
+ </span>
+
+ <span v-if="showSkipped" data-testid="pipeline-skipped">
+ <gl-icon name="status_skipped_borderless" class="gl-mr-2" :size="16" />
+ {{ s__('Pipeline|Skipped') }}
+ </span>
- <p v-if="duration" class="duration">
- <gl-icon name="timer" class="gl-vertical-align-baseline!" :size="12" />
- {{ durationFormatted }}
- </p>
+ <p v-if="duration" class="duration">
+ <gl-icon name="timer" class="gl-vertical-align-baseline!" :size="12" />
+ {{ durationFormatted }}
+ </p>
- <p v-if="finishedTime" class="finished-at d-none d-md-block">
- <gl-icon name="calendar" class="gl-vertical-align-baseline!" :size="12" />
+ <p v-if="finishedTime" class="finished-at d-none d-md-block">
+ <gl-icon name="calendar" class="gl-vertical-align-baseline!" :size="12" />
- <time
- v-gl-tooltip
- :title="tooltipTitle(finishedTime)"
- data-placement="top"
- data-container="body"
- >
- {{ timeFormatted(finishedTime) }}
- </time>
- </p>
- </div>
+ <time
+ v-gl-tooltip
+ :title="tooltipTitle(finishedTime)"
+ data-placement="top"
+ data-container="body"
+ >
+ {{ timeFormatted(finishedTime) }}
+ </time>
+ </p>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
index d33d4e7dfd0..79b1b6af38b 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
@@ -72,6 +72,7 @@ export default {
size="small"
class="gl-mr-3 js-back-button"
icon="angle-left"
+ :aria-label="__('Go back')"
@click="onBackClick"
/>
diff --git a/app/assets/javascripts/pipelines/components/unwrapping_utils.js b/app/assets/javascripts/pipelines/components/unwrapping_utils.js
index 15073079c0a..2d24beb8323 100644
--- a/app/assets/javascripts/pipelines/components/unwrapping_utils.js
+++ b/app/assets/javascripts/pipelines/components/unwrapping_utils.js
@@ -1,15 +1,33 @@
+import { reportToSentry } from '../utils';
+
const unwrapGroups = (stages) => {
- return stages.map((stage) => {
+ return stages.map((stage, idx) => {
const {
groups: { nodes: groups },
} = stage;
- return { ...stage, groups };
+
+ /*
+ Being peformance conscious here means we don't want to spread and copy the
+ group value just to add one parameter.
+ */
+ /* eslint-disable no-param-reassign */
+ const groupsWithStageName = groups.map((group) => {
+ group.stageName = stage.name;
+ return group;
+ });
+ /* eslint-enable no-param-reassign */
+
+ return { node: { ...stage, groups: groupsWithStageName }, lookup: { stageIdx: idx } };
});
};
const unwrapNodesWithName = (jobArray, prop, field = 'name') => {
+ if (jobArray.length < 1) {
+ reportToSentry('unwrapping_utils', 'undefined_job_hunt, array empty from backend');
+ }
+
return jobArray.map((job) => {
- return { ...job, [prop]: job[prop].nodes.map((item) => item[field]) };
+ return { ...job, [prop]: job[prop].nodes.map((item) => item[field] || '') };
});
};
@@ -17,20 +35,34 @@ const unwrapJobWithNeeds = (denodedJobArray) => {
return unwrapNodesWithName(denodedJobArray, 'needs');
};
-const unwrapStagesWithNeeds = (denodedStages) => {
+const unwrapStagesWithNeedsAndLookup = (denodedStages) => {
const unwrappedNestedGroups = unwrapGroups(denodedStages);
- const nodes = unwrappedNestedGroups.map((node) => {
+ const lookupMap = {};
+
+ const nodes = unwrappedNestedGroups.map(({ node, lookup }) => {
const { groups } = node;
- const groupsWithJobs = groups.map((group) => {
+ const groupsWithJobs = groups.map((group, idx) => {
const jobs = unwrapJobWithNeeds(group.jobs.nodes);
+
+ lookupMap[group.name] = { ...lookup, groupIdx: idx };
return { ...group, jobs };
});
return { ...node, groups: groupsWithJobs };
});
- return nodes;
+ return { stages: nodes, lookup: lookupMap };
+};
+
+const unwrapStagesWithNeeds = (denodedStages) => {
+ return unwrapStagesWithNeedsAndLookup(denodedStages).stages;
};
-export { unwrapGroups, unwrapNodesWithName, unwrapJobWithNeeds, unwrapStagesWithNeeds };
+export {
+ unwrapGroups,
+ unwrapJobWithNeeds,
+ unwrapNodesWithName,
+ unwrapStagesWithNeeds,
+ unwrapStagesWithNeedsAndLookup,
+};
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 21b114825a6..01705e7726f 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -35,3 +35,6 @@ export const POST_FAILURE = 'post_failure';
export const UNSUPPORTED_DATA = 'unsupported_data';
export const CHILD_VIEW = 'child';
+
+// The key of the template is the same as the filename
+export const HELLO_WORLD_TEMPLATE_KEY = 'Hello-World';
diff --git a/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql
new file mode 100644
index 00000000000..e4fd55a28be
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql
@@ -0,0 +1,5 @@
+mutation DismissPipelineNotification($featureName: String!) {
+ userCalloutCreate(input: { featureName: $featureName }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql
index c73b186739e..887c217da41 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql
@@ -1,6 +1,7 @@
query getDagVisData($projectPath: ID!, $iid: ID!) {
project(fullPath: $projectPath) {
pipeline(iid: $iid) {
+ id
stages {
nodes {
name
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql
new file mode 100644
index 00000000000..12b391e41ac
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql
@@ -0,0 +1,13 @@
+query getUser {
+ currentUser {
+ id
+ __typename
+ callouts {
+ __typename
+ nodes {
+ __typename
+ featureName
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
index 2321728e30c..d9c9289f66e 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
@@ -190,7 +190,7 @@ export default {
.then(() => this.updateTable())
.catch(() => {
createFlash(
- __('An error occurred while trying to run a new pipeline for this Merge Request.'),
+ __('An error occurred while trying to run a new pipeline for this merge request.'),
);
})
.finally(() => this.store.toggleIsRunningPipeline(false));
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index c3444f38ea0..a2bc049c3c7 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -3,11 +3,15 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
import { __ } from '~/locale';
import Translate from '~/vue_shared/translate';
import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue';
-import { reportToSentry } from './components/graph/utils';
import TestReports from './components/test_reports/test_reports.vue';
import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import createDagApp from './pipeline_details_dag';
+import { createPipelinesDetailApp } from './pipeline_details_graph';
+import { createPipelineHeaderApp } from './pipeline_details_header';
+import { createPipelineNotificationApp } from './pipeline_details_notification';
+import { apolloProvider } from './pipeline_shared_client';
import createTestReportsStore from './stores/test_reports';
+import { reportToSentry } from './utils';
Vue.use(Translate);
@@ -15,6 +19,7 @@ const SELECTORS = {
PIPELINE_DETAILS: '.js-pipeline-details-vue',
PIPELINE_GRAPH: '#js-pipeline-graph-vue',
PIPELINE_HEADER: '#js-pipeline-header-vue',
+ PIPELINE_NOTIFICATION: '#js-pipeline-notification',
PIPELINE_TESTS: '#js-pipeline-tests-detail',
};
@@ -79,21 +84,28 @@ const createTestDetails = () => {
};
export default async function initPipelineDetailsBundle() {
- createTestDetails();
- createDagApp();
-
const canShowNewPipelineDetails =
gon.features.graphqlPipelineDetails || gon.features.graphqlPipelineDetailsUsers;
const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS);
- if (canShowNewPipelineDetails) {
+ try {
+ createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag);
+ } catch {
+ Flash(__('An error occurred while loading a section of this page.'));
+ }
+
+ if (gon.features.pipelineGraphLayersView) {
try {
- const { createPipelinesDetailApp } = await import(
- /* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph'
- );
+ createPipelineNotificationApp(SELECTORS.PIPELINE_NOTIFICATION, apolloProvider);
+ } catch {
+ Flash(__('An error occurred while loading a section of this page.'));
+ }
+ }
- createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, dataset);
+ if (canShowNewPipelineDetails) {
+ try {
+ createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset);
} catch {
Flash(__('An error occurred while loading the pipeline.'));
}
@@ -107,12 +119,6 @@ export default async function initPipelineDetailsBundle() {
createLegacyPipelinesDetailApp(mediator);
}
- 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.'));
- }
+ createDagApp(apolloProvider);
+ createTestDetails();
}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_dag.js b/app/assets/javascripts/pipelines/pipeline_details_dag.js
index 4ee0ad462d2..e2835ecc4d1 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_dag.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_dag.js
@@ -1,15 +1,10 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
import Dag from './components/dag/dag.vue';
Vue.use(VueApollo);
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
-});
-
-const createDagApp = () => {
+const createDagApp = (apolloProvider) => {
const el = document.querySelector('#js-pipeline-dag-vue');
if (!el) {
diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js
index 9eba39738dc..39c3c2ea5c5 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_graph.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js
@@ -1,23 +1,14 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
import { GRAPHQL } from './components/graph/constants';
import PipelineGraphWrapper from './components/graph/graph_component_wrapper.vue';
-import { reportToSentry } from './components/graph/utils';
+import { reportToSentry } from './utils';
Vue.use(VueApollo);
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- useGet: true,
- },
- ),
-});
-
const createPipelinesDetailApp = (
selector,
+ apolloProvider,
{ pipelineProjectPath, pipelineIid, metricsPath, graphqlResourceEtag } = {},
) => {
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js
index cba29acdb32..1c619768764 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_header.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_header.js
@@ -1,15 +1,10 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
import pipelineHeader from './components/header_component.vue';
Vue.use(VueApollo);
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
-});
-
-export const createPipelineHeaderApp = (elSelector) => {
+export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResourceEtag) => {
const el = document.querySelector(elSelector);
if (!el) {
@@ -27,6 +22,7 @@ export const createPipelineHeaderApp = (elSelector) => {
provide: {
paths: {
fullProject: fullPath,
+ graphqlResourceEtag,
pipelinesPath,
},
pipelineId,
diff --git a/app/assets/javascripts/pipelines/pipeline_details_notification.js b/app/assets/javascripts/pipelines/pipeline_details_notification.js
new file mode 100644
index 00000000000..be234e8972d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_notification.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import PipelineNotification from './components/notification/pipeline_notification.vue';
+
+Vue.use(VueApollo);
+
+export const createPipelineNotificationApp = (elSelector, apolloProvider) => {
+ const el = document.querySelector(elSelector);
+
+ if (!el) {
+ return;
+ }
+
+ const { dagDocPath } = el?.dataset;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ PipelineNotification,
+ },
+ provide: {
+ dagDocPath,
+ },
+ apolloProvider,
+ render(createElement) {
+ return createElement('pipeline-notification');
+ },
+ });
+};
diff --git a/app/assets/javascripts/pipelines/pipeline_shared_client.js b/app/assets/javascripts/pipelines/pipeline_shared_client.js
new file mode 100644
index 00000000000..c3be487caae
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_shared_client.js
@@ -0,0 +1,11 @@
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+export const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ useGet: true,
+ },
+ ),
+});
diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js
index 0e2e9785956..9ed4365ad75 100644
--- a/app/assets/javascripts/pipelines/pipelines_index.js
+++ b/app/assets/javascripts/pipelines/pipelines_index.js
@@ -27,6 +27,8 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
errorStateSvgPath,
noPipelinesSvgPath,
newPipelinePath,
+ addCiYmlPath,
+ suggestedCiTemplates,
canCreatePipeline,
hasGitlabCi,
ciLintPath,
@@ -37,6 +39,10 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
return new Vue({
el,
+ provide: {
+ addCiYmlPath,
+ suggestedCiTemplates: JSON.parse(suggestedCiTemplates),
+ },
data() {
return {
store: new PipelinesStore(),
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
index 22820fca43e..0a6c326fa3d 100644
--- a/app/assets/javascripts/pipelines/utils.js
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -1,3 +1,4 @@
+import * as Sentry from '@sentry/browser';
import { pickBy } from 'lodash';
import { createNodeDict } from './components/parsing_utils';
import { SUPPORTED_FILTER_PARAMETERS } from './constants';
@@ -65,3 +66,10 @@ export const generateJobNeedsDict = (jobs = {}) => {
return { ...acc, [value]: uniqueValues };
}, {});
};
+
+export const reportToSentry = (component, failureType) => {
+ Sentry.withScope((scope) => {
+ scope.setTag('component', component);
+ Sentry.captureException(failureType);
+ });
+};
diff --git a/app/assets/javascripts/projects/commit/components/commit_comments_button.vue b/app/assets/javascripts/projects/commit/components/commit_comments_button.vue
new file mode 100644
index 00000000000..67b5e1e512c
--- /dev/null
+++ b/app/assets/javascripts/projects/commit/components/commit_comments_button.vue
@@ -0,0 +1,42 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { n__ } from '~/locale';
+
+export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlButton,
+ },
+ props: {
+ commentsCount: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ tooltipText() {
+ return n__('%d comment on this commit', '%d comments on this commit', this.commentsCount);
+ },
+ showCommentButton() {
+ return this.commentsCount > 0;
+ },
+ },
+};
+</script>
+
+<template>
+ <span
+ v-if="showCommentButton"
+ v-gl-tooltip
+ class="gl-display-none gl-sm-display-inline-block"
+ tabindex="0"
+ :title="tooltipText"
+ data-testid="comment-button-wrapper"
+ >
+ <gl-button icon="comment" class="gl-mr-3" disabled>
+ {{ commentsCount }}
+ </gl-button>
+ </span>
+</template>
diff --git a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
new file mode 100644
index 00000000000..d96d1035ed0
--- /dev/null
+++ b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
@@ -0,0 +1,107 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlDropdownSectionHeader } from '@gitlab/ui';
+import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '../constants';
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ },
+ inject: {
+ newProjectTagPath: {
+ default: '',
+ },
+ emailPatchesPath: {
+ default: '',
+ },
+ plainDiffPath: {
+ default: '',
+ },
+ },
+ props: {
+ canRevert: {
+ type: Boolean,
+ required: true,
+ },
+ canCherryPick: {
+ type: Boolean,
+ required: true,
+ },
+ canTag: {
+ type: Boolean,
+ required: true,
+ },
+ canEmailPatches: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ showDivider() {
+ return this.canRevert || this.canCherryPick || this.canTag;
+ },
+ },
+ methods: {
+ showModal(modalId) {
+ eventHub.$emit(modalId);
+ },
+ },
+ openRevertModal: OPEN_REVERT_MODAL,
+ openCherryPickModal: OPEN_CHERRY_PICK_MODAL,
+};
+</script>
+
+<template>
+ <gl-dropdown
+ :text="__('Options')"
+ right
+ data-testid="commit-options-dropdown"
+ data-qa-selector="options_button"
+ class="gl-xs-w-full"
+ >
+ <gl-dropdown-item
+ v-if="canRevert"
+ data-testid="revert-link"
+ @click="showModal($options.openRevertModal)"
+ >
+ {{ s__('ChangeTypeAction|Revert') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="canCherryPick"
+ data-testid="cherry-pick-link"
+ data-qa-selector="cherry_pick_button"
+ @click="showModal($options.openCherryPickModal)"
+ >
+ {{ s__('ChangeTypeAction|Cherry-pick') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="canTag" :href="newProjectTagPath" data-testid="tag-link">
+ {{ s__('CreateTag|Tag') }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider v-if="showDivider" />
+ <gl-dropdown-section-header>
+ {{ __('Download') }}
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-if="canEmailPatches"
+ :href="emailPatchesPath"
+ download
+ rel="nofollow"
+ data-testid="email-patches-link"
+ data-qa-selector="email_patches"
+ >
+ {{ s__('DownloadCommit|Email Patches') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ :href="plainDiffPath"
+ download
+ rel="nofollow"
+ data-testid="plain-diff-link"
+ data-qa-selector="plain_diff"
+ >
+ {{ s__('DownloadCommit|Plain Diff') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue
index 30968d29cde..6eefa5f55e4 100644
--- a/app/assets/javascripts/projects/commit/components/form_modal.vue
+++ b/app/assets/javascripts/projects/commit/components/form_modal.vue
@@ -37,6 +37,11 @@ export default {
type: String,
required: true,
},
+ isCherryPick: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -47,6 +52,7 @@ export default {
{ variant: 'success' },
{ category: 'primary' },
{ 'data-testid': 'submit-commit' },
+ { 'data-qa-selector': 'submit_commit_button' },
],
},
actionCancel: {
@@ -110,7 +116,7 @@ export default {
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
<gl-form-group
- v-if="glFeatures.pickIntoProject"
+ v-if="glFeatures.pickIntoProject && isCherryPick"
:label="i18n.projectLabel"
label-for="start_project"
data-testid="dropdown-group"
diff --git a/app/assets/javascripts/projects/commit/components/form_trigger.vue b/app/assets/javascripts/projects/commit/components/form_trigger.vue
deleted file mode 100644
index 3561b5c2473..00000000000
--- a/app/assets/javascripts/projects/commit/components/form_trigger.vue
+++ /dev/null
@@ -1,35 +0,0 @@
-<script>
-import { GlLink } from '@gitlab/ui';
-import eventHub from '../event_hub';
-
-export default {
- components: {
- GlLink,
- },
- inject: {
- displayText: {
- default: '',
- },
- testId: {
- default: '',
- },
- },
- props: {
- openModal: {
- type: String,
- required: true,
- },
- },
- methods: {
- showModal() {
- eventHub.$emit(this.openModal);
- },
- },
-};
-</script>
-
-<template>
- <gl-link data-is-link="true" :data-testid="testId" @click="showModal">
- {{ displayText }}
- </gl-link>
-</template>
diff --git a/app/assets/javascripts/projects/commit/constants.js b/app/assets/javascripts/projects/commit/constants.js
index d6bb4e9483f..d553bca360e 100644
--- a/app/assets/javascripts/projects/commit/constants.js
+++ b/app/assets/javascripts/projects/commit/constants.js
@@ -2,10 +2,8 @@ import { s__, __ } from '~/locale';
export const OPEN_REVERT_MODAL = 'openRevertModal';
export const REVERT_MODAL_ID = 'revert-commit-modal';
-export const REVERT_LINK_TEST_ID = 'revert-commit-link';
export const OPEN_CHERRY_PICK_MODAL = 'openCherryPickModal';
export const CHERRY_PICK_MODAL_ID = 'cherry-pick-commit-modal';
-export const CHERRY_PICK_LINK_TEST_ID = 'cherry-pick-commit-link';
export const I18N_MODAL = {
startMergeRequest: s__('ChangeTypeAction|Start a %{newMergeRequest} with these changes'),
diff --git a/app/assets/javascripts/projects/commit/index.js b/app/assets/javascripts/projects/commit/index.js
index b5fdfc25236..d8d30c4332c 100644
--- a/app/assets/javascripts/projects/commit/index.js
+++ b/app/assets/javascripts/projects/commit/index.js
@@ -1,11 +1,11 @@
import initCherryPickCommitModal from './init_cherry_pick_commit_modal';
-import initCherryPickCommitTrigger from './init_cherry_pick_commit_trigger';
+import initCommitCommentsButton from './init_commit_comments_button';
+import initCommitOptionsDropdown from './init_commit_options_dropdown';
import initRevertCommitModal from './init_revert_commit_modal';
-import initRevertCommitTrigger from './init_revert_commit_trigger';
export default () => {
initRevertCommitModal();
- initRevertCommitTrigger();
initCherryPickCommitModal();
- initCherryPickCommitTrigger();
+ initCommitCommentsButton();
+ initCommitOptionsDropdown();
};
diff --git a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js
index ad31ad14b2a..47ee8237fea 100644
--- a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js
+++ b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js
@@ -51,6 +51,7 @@ export default function initInviteMembersModal() {
i18n: { ...I18N_CHERRY_PICK_MODAL, ...I18N_MODAL },
openModal: OPEN_CHERRY_PICK_MODAL,
modalId: CHERRY_PICK_MODAL_ID,
+ isCherryPick: true,
},
}),
});
diff --git a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_trigger.js b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_trigger.js
deleted file mode 100644
index 942451dc96a..00000000000
--- a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_trigger.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Vue from 'vue';
-import CommitFormTrigger from './components/form_trigger.vue';
-import { OPEN_CHERRY_PICK_MODAL, CHERRY_PICK_LINK_TEST_ID } from './constants';
-
-export default function initInviteMembersTrigger() {
- const el = document.querySelector('.js-cherry-pick-commit-trigger');
-
- if (!el) {
- return false;
- }
-
- const { displayText } = el.dataset;
-
- return new Vue({
- el,
- provide: { displayText, testId: CHERRY_PICK_LINK_TEST_ID },
- render: (createElement) =>
- createElement(CommitFormTrigger, { props: { openModal: OPEN_CHERRY_PICK_MODAL } }),
- });
-}
diff --git a/app/assets/javascripts/projects/commit/init_commit_comments_button.js b/app/assets/javascripts/projects/commit/init_commit_comments_button.js
new file mode 100644
index 00000000000..d70f7cb65f3
--- /dev/null
+++ b/app/assets/javascripts/projects/commit/init_commit_comments_button.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import CommitCommentsButton from './components/commit_comments_button.vue';
+
+export default function initCommitCommentsButton() {
+ const el = document.querySelector('#js-commit-comments-button');
+
+ if (!el) {
+ return false;
+ }
+
+ const { commentsCount } = el.dataset;
+
+ return new Vue({
+ el,
+ render: (createElement) =>
+ createElement(CommitCommentsButton, { props: { commentsCount: Number(commentsCount) } }),
+ });
+}
diff --git a/app/assets/javascripts/projects/commit/init_commit_options_dropdown.js b/app/assets/javascripts/projects/commit/init_commit_options_dropdown.js
new file mode 100644
index 00000000000..339918e7661
--- /dev/null
+++ b/app/assets/javascripts/projects/commit/init_commit_options_dropdown.js
@@ -0,0 +1,35 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import CommitOptionsDropdown from './components/commit_options_dropdown.vue';
+
+export default function initCommitOptionsDropdown() {
+ const el = document.querySelector('#js-commit-options-dropdown');
+
+ if (!el) {
+ return false;
+ }
+
+ const {
+ newProjectTagPath,
+ emailPatchesPath,
+ plainDiffPath,
+ canRevert,
+ canCherryPick,
+ canTag,
+ canEmailPatches,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: { newProjectTagPath, emailPatchesPath, plainDiffPath },
+ render: (createElement) =>
+ createElement(CommitOptionsDropdown, {
+ props: {
+ canRevert: parseBoolean(canRevert),
+ canCherryPick: parseBoolean(canCherryPick),
+ canTag: parseBoolean(canTag),
+ canEmailPatches: parseBoolean(canEmailPatches),
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/projects/commit/init_revert_commit_trigger.js b/app/assets/javascripts/projects/commit/init_revert_commit_trigger.js
deleted file mode 100644
index dc5168524ca..00000000000
--- a/app/assets/javascripts/projects/commit/init_revert_commit_trigger.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Vue from 'vue';
-import CommitFormTrigger from './components/form_trigger.vue';
-import { OPEN_REVERT_MODAL, REVERT_LINK_TEST_ID } from './constants';
-
-export default function initInviteMembersTrigger() {
- const el = document.querySelector('.js-revert-commit-trigger');
-
- if (!el) {
- return false;
- }
-
- const { displayText } = el.dataset;
-
- return new Vue({
- el,
- provide: { displayText, testId: REVERT_LINK_TEST_ID },
- render: (createElement) =>
- createElement(CommitFormTrigger, { props: { openModal: OPEN_REVERT_MODAL } }),
- });
-}
diff --git a/app/assets/javascripts/projects/commit/store/actions.js b/app/assets/javascripts/projects/commit/store/actions.js
index c72704303ca..2b25082eced 100644
--- a/app/assets/javascripts/projects/commit/store/actions.js
+++ b/app/assets/javascripts/projects/commit/store/actions.js
@@ -22,8 +22,8 @@ export const fetchBranches = ({ commit, dispatch, state }, query) => {
.get(state.branchesEndpoint, {
params: { search: query },
})
- .then(({ data }) => {
- commit(types.RECEIVE_BRANCHES_SUCCESS, data.Branches || []);
+ .then(({ data = [] }) => {
+ commit(types.RECEIVE_BRANCHES_SUCCESS, data.Branches?.length ? data.Branches : data);
})
.catch(() => {
createFlash({ message: PROJECT_BRANCHES_ERROR });
diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js
index 17c63ecf66b..69fe2d30489 100644
--- a/app/assets/javascripts/projects/commit_box/info/index.js
+++ b/app/assets/javascripts/projects/commit_box/info/index.js
@@ -1,27 +1,17 @@
import { fetchCommitMergeRequests } from '~/commit_merge_requests';
-import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
import { initCommitPipelineMiniGraph } from './init_commit_pipeline_mini_graph';
import { initDetailsButton } from './init_details_button';
import { loadBranches } from './load_branches';
-export const initCommitBoxInfo = (containerSelector = '.js-commit-box-info') => {
- const containerEl = document.querySelector(containerSelector);
-
+export const initCommitBoxInfo = () => {
// Display commit related branches
- loadBranches(containerEl);
+ loadBranches();
// Related merge requests to this commit
fetchCommitMergeRequests();
// Display pipeline mini graph for this commit
- // Feature flag ci_commit_pipeline_mini_graph_vue
- if (gon.features.ciCommitPipelineMiniGraphVue) {
- initCommitPipelineMiniGraph();
- } else {
- new MiniPipelineGraph({
- container: '.js-commit-pipeline-graph',
- }).bindEvents();
- }
+ initCommitPipelineMiniGraph();
initDetailsButton();
};
diff --git a/app/assets/javascripts/projects/commit_box/info/load_branches.js b/app/assets/javascripts/projects/commit_box/info/load_branches.js
index 8a0b2c30abe..d1136817cb3 100644
--- a/app/assets/javascripts/projects/commit_box/info/load_branches.js
+++ b/app/assets/javascripts/projects/commit_box/info/load_branches.js
@@ -2,7 +2,8 @@ import axios from 'axios';
import { sanitize } from '~/lib/dompurify';
import { __ } from '~/locale';
-export const loadBranches = (containerEl) => {
+export const loadBranches = (containerSelector = '.js-commit-box-info') => {
+ const containerEl = document.querySelector(containerSelector);
if (!containerEl) {
return;
}
diff --git a/app/assets/javascripts/projects/compare/components/app_legacy.vue b/app/assets/javascripts/projects/compare/components/app_legacy.vue
index c0ff58ee074..d3f09f7d69f 100644
--- a/app/assets/javascripts/projects/compare/components/app_legacy.vue
+++ b/app/assets/javascripts/projects/compare/components/app_legacy.vue
@@ -37,10 +37,22 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ from: this.paramsFrom,
+ to: this.paramsTo,
+ };
+ },
methods: {
onSubmit() {
this.$refs.form.submit();
},
+ onSwapRevision() {
+ [this.from, this.to] = [this.to, this.from]; // swaps 'from' and 'to'
+ },
+ onSelectRevision({ direction, revision }) {
+ this[direction] = revision; // direction is either 'from' or 'to'
+ },
},
};
</script>
@@ -57,19 +69,30 @@ export default {
:refs-project-path="refsProjectPath"
revision-text="Source"
params-name="to"
- :params-branch="paramsTo"
+ :params-branch="to"
+ data-testid="sourceRevisionDropdown"
+ @selectRevision="onSelectRevision"
/>
<div class="compare-ellipsis gl-display-inline" data-testid="ellipsis">...</div>
<revision-dropdown
:refs-project-path="refsProjectPath"
revision-text="Target"
params-name="from"
- :params-branch="paramsFrom"
+ :params-branch="from"
+ data-testid="targetRevisionDropdown"
+ @selectRevision="onSelectRevision"
/>
<gl-button category="primary" variant="success" class="gl-ml-3" @click="onSubmit">
{{ s__('CompareRevisions|Compare') }}
</gl-button>
<gl-button
+ data-testid="swapRevisionsButton"
+ class="btn btn-default gl-button gl-ml-3"
+ @click="onSwapRevision"
+ >
+ {{ s__('CompareRevisions|Swap revisions') }}
+ </gl-button>
+ <gl-button
v-if="projectMergeRequestPath"
:href="projectMergeRequestPath"
data-testid="projectMrButton"
diff --git a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
index 822dfc09d81..cb9d8b64b33 100644
--- a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
+++ b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
@@ -46,14 +46,7 @@ export default {
this.emitTargetProject(repo.name);
},
setDefaultRepo() {
- if (this.isSourceRevision) {
- this.selectedRepo = this.projectTo;
- return;
- }
-
- const [defaultTargetProject] = this.projectsFrom;
- this.emitTargetProject(defaultTargetProject.name);
- this.selectedRepo = defaultTargetProject;
+ this.selectedRepo = this.projectTo;
},
emitTargetProject(name) {
if (!this.isSourceRevision) {
diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
index a175af2f32e..d0b69344c12 100644
--- a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
+++ b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
@@ -1,10 +1,12 @@
<script>
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlDropdownSectionHeader } from '@gitlab/ui';
+import { debounce } from 'lodash';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
-const emptyDropdownText = s__('CompareRevisions|Select branch/tag');
+const EMPTY_DROPDOWN_TEXT = s__('CompareRevisions|Select branch/tag');
+const SEARCH_DEBOUNCE_MS = 300;
export default {
components: {
@@ -38,19 +40,11 @@ export default {
};
},
computed: {
- filteredBranches() {
- return this.branches.filter((branch) =>
- branch.toLowerCase().includes(this.searchTerm.toLowerCase()),
- );
+ hasBranches() {
+ return Boolean(this.branches?.length);
},
- hasFilteredBranches() {
- return this.filteredBranches.length;
- },
- filteredTags() {
- return this.tags.filter((tag) => tag.toLowerCase().includes(this.searchTerm.toLowerCase()));
- },
- hasFilteredTags() {
- return this.filteredTags.length;
+ hasTags() {
+ return Boolean(this.tags?.length);
},
},
watch: {
@@ -59,13 +53,34 @@ export default {
this.fetchBranchesAndTags(true);
}
},
+ searchTerm: debounce(function debounceSearch() {
+ this.searchBranchesAndTags();
+ }, SEARCH_DEBOUNCE_MS),
},
mounted() {
this.fetchBranchesAndTags();
},
methods: {
+ searchBranchesAndTags() {
+ return axios
+ .get(this.refsProjectPath, {
+ params: {
+ search: this.searchTerm,
+ },
+ })
+ .then(({ data }) => {
+ this.branches = data.Branches || [];
+ this.tags = data.Tags || [];
+ })
+ .catch(() => {
+ createFlash({
+ message: s__(
+ 'CompareRevisions|There was an error while searching the branch/tag list. Please try again.',
+ ),
+ });
+ });
+ },
fetchBranchesAndTags(reset = false) {
- const endpoint = this.refsProjectPath;
this.loading = true;
if (reset) {
@@ -73,7 +88,7 @@ export default {
}
return axios
- .get(endpoint)
+ .get(this.refsProjectPath)
.then(({ data }) => {
this.branches = data.Branches || [];
this.tags = data.Tags || [];
@@ -90,7 +105,7 @@ export default {
});
},
getDefaultBranch() {
- return this.paramsBranch || emptyDropdownText;
+ return this.paramsBranch || EMPTY_DROPDOWN_TEXT;
},
onClick(revision) {
this.selectedRevision = revision;
@@ -119,24 +134,24 @@ export default {
@keyup.enter="onSearchEnter"
/>
</template>
- <gl-dropdown-section-header v-if="hasFilteredBranches">
+ <gl-dropdown-section-header v-if="hasBranches">
{{ s__('CompareRevisions|Branches') }}
</gl-dropdown-section-header>
<gl-dropdown-item
- v-for="(branch, index) in filteredBranches"
- :key="`branch${index}`"
+ v-for="branch in branches"
+ :key="branch"
is-check-item
:is-checked="selectedRevision === branch"
@click="onClick(branch)"
>
{{ branch }}
</gl-dropdown-item>
- <gl-dropdown-section-header v-if="hasFilteredTags">
+ <gl-dropdown-section-header v-if="hasTags">
{{ s__('CompareRevisions|Tags') }}
</gl-dropdown-section-header>
<gl-dropdown-item
- v-for="(tag, index) in filteredTags"
- :key="`tag${index}`"
+ v-for="tag in tags"
+ :key="tag"
is-check-item
:is-checked="selectedRevision === tag"
@click="onClick(tag)"
diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
index 13d80b5ae0b..f57a8942a77 100644
--- a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
+++ b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
@@ -55,6 +55,11 @@ export default {
return this.filteredTags.length;
},
},
+ watch: {
+ paramsBranch(newBranch) {
+ this.setSelectedRevision(newBranch);
+ },
+ },
mounted() {
this.fetchBranchesAndTags();
},
@@ -83,10 +88,14 @@ export default {
return this.paramsBranch || s__('CompareRevisions|Select branch/tag');
},
onClick(revision) {
- this.selectedRevision = revision;
+ this.setSelectedRevision(revision);
},
onSearchEnter() {
- this.selectedRevision = this.searchTerm;
+ this.setSelectedRevision(this.searchTerm);
+ },
+ setSelectedRevision(revision) {
+ this.selectedRevision = revision || s__('CompareRevisions|Select branch/tag');
+ this.$emit('selectRevision', { direction: this.paramsName, revision });
},
},
};
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue
index ef61fba88fe..1060b37067e 100644
--- a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue
@@ -1,8 +1,9 @@
<script>
/* eslint-disable vue/no-v-html */
import { GlBreadcrumb, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { experiment } from '~/experimentation/utils';
import { __, s__ } from '~/locale';
-
+import { NEW_REPO_EXPERIMENT } from '../constants';
import blankProjectIllustration from '../illustrations/blank-project.svg';
import ciCdProjectIllustration from '../illustrations/ci-cd-project.svg';
import createFromTemplateIllustration from '../illustrations/create-from-template.svg';
@@ -13,8 +14,10 @@ import WelcomePage from './welcome.vue';
const BLANK_PANEL = 'blank_project';
const CI_CD_PANEL = 'cicd_for_external_repo';
const LAST_ACTIVE_TAB_KEY = 'new_project_last_active_tab';
+
const PANELS = [
{
+ key: 'blank',
name: BLANK_PANEL,
selector: '#blank-project-pane',
title: s__('ProjectsNew|Create blank project'),
@@ -24,6 +27,7 @@ const PANELS = [
illustration: blankProjectIllustration,
},
{
+ key: 'template',
name: 'create_from_template',
selector: '#create-from-template-pane',
title: s__('ProjectsNew|Create from template'),
@@ -33,6 +37,7 @@ const PANELS = [
illustration: createFromTemplateIllustration,
},
{
+ key: 'import',
name: 'import_project',
selector: '#import-project-pane',
title: s__('ProjectsNew|Import project'),
@@ -42,6 +47,7 @@ const PANELS = [
illustration: importProjectIllustration,
},
{
+ key: 'ci',
name: CI_CD_PANEL,
selector: '#ci-cd-project-pane',
title: s__('ProjectsNew|Run CI/CD for external repository'),
@@ -85,16 +91,34 @@ export default {
},
computed: {
+ decoratedPanels() {
+ const PANEL_TITLES = experiment(NEW_REPO_EXPERIMENT, {
+ use: () => ({
+ blank: s__('ProjectsNew|Create blank project'),
+ import: s__('ProjectsNew|Import project'),
+ }),
+ try: () => ({
+ blank: s__('ProjectsNew|Create blank project/repository'),
+ import: s__('ProjectsNew|Import project/repository'),
+ }),
+ });
+
+ return PANELS.map(({ key, title, ...el }) => ({
+ ...el,
+ title: PANEL_TITLES[key] !== undefined ? PANEL_TITLES[key] : title,
+ }));
+ },
+
availablePanels() {
if (this.isCiCdAvailable) {
- return PANELS;
+ return this.decoratedPanels;
}
- return PANELS.filter((p) => p.name !== CI_CD_PANEL);
+ return this.decoratedPanels.filter((p) => p.name !== CI_CD_PANEL);
},
activePanel() {
- return PANELS.find((p) => p.name === this.activeTab);
+ return this.decoratedPanels.find((p) => p.name === this.activeTab);
},
breadcrumbs() {
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue
index ed82a635b1f..d342ce4c9c2 100644
--- a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue
@@ -1,9 +1,10 @@
<script>
/* eslint-disable vue/no-v-html */
import Tracking from '~/tracking';
+import { NEW_REPO_EXPERIMENT } from '../constants';
import NewProjectPushTipPopover from './new_project_push_tip_popover.vue';
-const trackingMixin = Tracking.mixin(gon.tracking_data);
+const trackingMixin = Tracking.mixin({ ...gon.tracking_data, experiment: NEW_REPO_EXPERIMENT });
export default {
components: {
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/constants.js b/app/assets/javascripts/projects/experiment_new_project_creation/constants.js
new file mode 100644
index 00000000000..402ca887cf1
--- /dev/null
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/constants.js
@@ -0,0 +1 @@
+export const NEW_REPO_EXPERIMENT = 'new_repo';
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
index 4a8e1424fa8..8d005373508 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
@@ -3,8 +3,6 @@ import { GlTabs, GlTab } from '@gitlab/ui';
import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility';
import PipelineCharts from './pipeline_charts.vue';
-const charts = ['pipelines', 'deployments'];
-
export default {
components: {
GlTabs,
@@ -12,9 +10,11 @@ export default {
PipelineCharts,
DeploymentFrequencyCharts: () =>
import('ee_component/projects/pipelines/charts/components/deployment_frequency_charts.vue'),
+ LeadTimeCharts: () =>
+ import('ee_component/projects/pipelines/charts/components/lead_time_charts.vue'),
},
inject: {
- shouldRenderDeploymentFrequencyCharts: {
+ shouldRenderDoraCharts: {
type: Boolean,
default: false,
},
@@ -24,20 +24,31 @@ export default {
selectedTab: 0,
};
},
+ computed: {
+ charts() {
+ const chartsToShow = ['pipelines'];
+
+ if (this.shouldRenderDoraCharts) {
+ chartsToShow.push('deployments', 'lead-time');
+ }
+
+ return chartsToShow;
+ },
+ },
created() {
this.selectTab();
window.addEventListener('popstate', this.selectTab);
},
methods: {
selectTab() {
- const [chart] = getParameterValues('chart') || charts;
- const tab = charts.indexOf(chart);
+ const [chart] = getParameterValues('chart') || this.charts;
+ const tab = this.charts.indexOf(chart);
this.selectedTab = tab >= 0 ? tab : 0;
},
onTabChange(index) {
if (index !== this.selectedTab) {
this.selectedTab = index;
- const path = mergeUrlParams({ chart: charts[index] }, window.location.pathname);
+ const path = mergeUrlParams({ chart: this.charts[index] }, window.location.pathname);
updateHistory({ url: path, title: window.title });
}
},
@@ -46,13 +57,18 @@ export default {
</script>
<template>
<div>
- <gl-tabs v-if="shouldRenderDeploymentFrequencyCharts" :value="selectedTab" @input="onTabChange">
+ <gl-tabs v-if="charts.length > 1" :value="selectedTab" @input="onTabChange">
<gl-tab :title="__('Pipelines')">
<pipeline-charts />
</gl-tab>
- <gl-tab :title="__('Deployments')">
- <deployment-frequency-charts />
- </gl-tab>
+ <template v-if="shouldRenderDoraCharts">
+ <gl-tab :title="__('Deployments')">
+ <deployment-frequency-charts />
+ </gl-tab>
+ <gl-tab :title="__('Lead Time')">
+ <lead-time-charts />
+ </gl-tab>
+ </template>
</gl-tabs>
<pipeline-charts v-else />
</div>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue b/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue
index 3590e2c4632..ad3e6713e45 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue
@@ -30,12 +30,16 @@ export default {
<resizable-chart-container>
<gl-area-chart
slot-scope="{ width }"
+ v-bind="$attrs"
:width="width"
:height="$options.chartContainerHeight"
:data="chartData"
:include-legend-avg-max="false"
:option="areaChartOptions"
- />
+ >
+ <slot slot="tooltip-title" name="tooltip-title"></slot>
+ <slot slot="tooltip-content" name="tooltip-content"></slot>
+ </gl-area-chart>
</resizable-chart-container>
</div>
</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue
index 43b36da8b2c..f4fd57e4cdc 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue
@@ -41,10 +41,14 @@ export default {
<gl-segmented-control v-model="selectedChart" :options="chartRanges" class="gl-mb-4" />
<ci-cd-analytics-area-chart
v-if="chart"
+ v-bind="$attrs"
:chart-data="chart.data"
:area-chart-options="chartOptions"
>
{{ dateRange }}
+
+ <slot slot="tooltip-title" name="tooltip-title"></slot>
+ <slot slot="tooltip-content" name="tooltip-content"></slot>
</ci-cd-analytics-area-chart>
</div>
</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js
index 7e746423b6a..5f5ee44c204 100644
--- a/app/assets/javascripts/projects/pipelines/charts/index.js
+++ b/app/assets/javascripts/projects/pipelines/charts/index.js
@@ -13,9 +13,7 @@ const apolloProvider = new VueApollo({
const mountPipelineChartsApp = (el) => {
const { projectPath } = el.dataset;
- const shouldRenderDeploymentFrequencyCharts = parseBoolean(
- el.dataset.shouldRenderDeploymentFrequencyCharts,
- );
+ const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts);
return new Vue({
el,
@@ -26,7 +24,7 @@ const mountPipelineChartsApp = (el) => {
apolloProvider,
provide: {
projectPath,
- shouldRenderDeploymentFrequencyCharts,
+ shouldRenderDoraCharts,
},
render: (createElement) => createElement(ProjectPipelinesCharts, {}),
});
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue
index 625d491db6a..589b88d7bbe 100644
--- a/app/assets/javascripts/registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/registry/explorer/pages/list.vue
@@ -11,6 +11,8 @@ import {
import { get } from 'lodash';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
import createFlash from '~/flash';
+import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import { extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import Tracking from '~/tracking';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import DeleteImage from '../components/delete_image.vue';
@@ -81,6 +83,9 @@ export default {
searchConfig: SORT_FIELDS,
apollo: {
baseImages: {
+ skip() {
+ return !this.fetchBaseQuery;
+ },
query: getContainerRepositoriesQuery,
variables() {
return this.queryVariables;
@@ -124,15 +129,19 @@ export default {
sorting: { orderBy: 'UPDATED', sort: 'desc' },
name: null,
mutationLoading: false,
+ fetchBaseQuery: false,
fetchAdditionalDetails: false,
};
},
computed: {
images() {
- return this.baseImages.map((image, index) => ({
- ...image,
- ...get(this.additionalDetails, index, {}),
- }));
+ if (this.baseImages) {
+ return this.baseImages.map((image, index) => ({
+ ...image,
+ ...get(this.additionalDetails, index, {}),
+ }));
+ }
+ return [];
},
graphqlResource() {
return this.config.isGroupPage ? 'group' : 'project';
@@ -171,8 +180,15 @@ export default {
},
},
mounted() {
+ const { sorting, filters } = extractFilterAndSorting(this.$route.query);
+
+ this.filter = [...filters];
+ this.name = filters[0]?.value.data;
+ this.sorting = { ...this.sorting, ...sorting };
+
// If the two graphql calls - which are not batched - resolve togheter we will have a race
// condition when apollo sets the cache, with this we give the 'base' call an headstart
+ this.fetchBaseQuery = true;
setTimeout(() => {
this.fetchAdditionalDetails = true;
}, 200);
@@ -241,9 +257,12 @@ export default {
};
},
doFilter() {
- const search = this.filter.find((i) => i.type === 'filtered-search-term');
+ const search = this.filter.find((i) => i.type === FILTERED_SEARCH_TERM);
this.name = search?.value?.data;
},
+ updateUrlQueryString(query) {
+ this.$router.push({ query });
+ },
},
};
</script>
@@ -303,6 +322,7 @@ export default {
@sorting:changed="updateSorting"
@filter:changed="filter = $event"
@filter:submit="doFilter"
+ @query:changed="updateUrlQueryString"
/>
<div v-if="isLoading" class="gl-mt-5">
diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue
index eb731c382e1..1360e09a75d 100644
--- a/app/assets/javascripts/registry/settings/components/settings_form.vue
+++ b/app/assets/javascripts/registry/settings/components/settings_form.vue
@@ -110,12 +110,12 @@ export default {
mutationVariables() {
return {
projectPath: this.projectPath,
- enabled: this.value.enabled,
- cadence: this.value.cadence,
- olderThan: this.value.olderThan,
- keepN: this.value.keepN,
- nameRegex: this.value.nameRegex,
- nameRegexKeep: this.value.nameRegexKeep,
+ enabled: this.prefilledForm.enabled,
+ cadence: this.prefilledForm.cadence,
+ olderThan: this.prefilledForm.olderThan,
+ keepN: this.prefilledForm.keepN,
+ nameRegex: this.prefilledForm.nameRegex,
+ nameRegexKeep: this.prefilledForm.nameRegexKeep,
};
},
},
@@ -291,8 +291,8 @@ export default {
type="submit"
:disabled="isSubmitButtonDisabled"
:loading="showLoadingIcon"
- variant="success"
category="primary"
+ variant="confirm"
class="js-no-auto-disable gl-mr-4"
>
{{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index a8c7b7c857a..aecd0d6371e 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -22,7 +22,7 @@ export default {
TagField,
},
computed: {
- ...mapState('detail', [
+ ...mapState('editNew', [
'isFetchingRelease',
'isUpdatingRelease',
'fetchError',
@@ -36,13 +36,13 @@ export default {
'groupId',
'groupMilestonesAvailable',
]),
- ...mapGetters('detail', ['isValid', 'isExistingRelease']),
+ ...mapGetters('editNew', ['isValid', 'isExistingRelease']),
showForm() {
return Boolean(!this.isFetchingRelease && !this.fetchError && this.release);
},
releaseTitle: {
get() {
- return this.$store.state.detail.release.name;
+ return this.$store.state.editNew.release.name;
},
set(title) {
this.updateReleaseTitle(title);
@@ -50,7 +50,7 @@ export default {
},
releaseNotes: {
get() {
- return this.$store.state.detail.release.description;
+ return this.$store.state.editNew.release.description;
},
set(notes) {
this.updateReleaseNotes(notes);
@@ -58,7 +58,7 @@ export default {
},
releaseMilestones: {
get() {
- return this.$store.state.detail.release.milestones;
+ return this.$store.state.editNew.release.milestones;
},
set(milestones) {
this.updateReleaseMilestones(milestones);
@@ -93,7 +93,7 @@ export default {
this.$el.querySelector('input:enabled, button:enabled').focus();
},
methods: {
- ...mapActions('detail', [
+ ...mapActions('editNew', [
'initializeRelease',
'saveRelease',
'updateReleaseTitle',
@@ -114,7 +114,7 @@ export default {
<gl-sprintf
:message="
__(
- 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.',
+ 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0.0%{codeEnd}, %{codeStart}v2.1.0-pre%{codeEnd}.',
)
"
>
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index 32183e454c8..262b5614d65 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -20,7 +20,7 @@ export default {
ReleasesSort,
},
computed: {
- ...mapState('list', [
+ ...mapState('index', [
'documentationPath',
'illustrationPath',
'newReleasePath',
@@ -46,7 +46,7 @@ export default {
window.addEventListener('popstate', this.fetchReleases);
},
methods: {
- ...mapActions('list', {
+ ...mapActions('index', {
fetchReleasesStoreAction: 'fetchReleases',
}),
fetchReleases() {
diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue
index 9ef38503c10..c38e93d420b 100644
--- a/app/assets/javascripts/releases/components/app_show.vue
+++ b/app/assets/javascripts/releases/components/app_show.vue
@@ -1,5 +1,8 @@
<script>
-import { mapState, mapActions } from 'vuex';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import oneReleaseQuery from '../queries/one_release.query.graphql';
+import { convertGraphQLRelease } from '../util';
import ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
@@ -9,21 +12,58 @@ export default {
ReleaseBlock,
ReleaseSkeletonLoader,
},
- computed: {
- ...mapState('detail', ['isFetchingRelease', 'fetchError', 'release']),
+ inject: {
+ fullPath: {
+ default: '',
+ },
+ tagName: {
+ default: '',
+ },
},
- created() {
- this.fetchRelease();
+ apollo: {
+ release: {
+ query: oneReleaseQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ tagName: this.tagName,
+ };
+ },
+ update(data) {
+ if (data.project?.release) {
+ return convertGraphQLRelease(data.project.release);
+ }
+
+ return null;
+ },
+ result(result) {
+ // Handle the case where the query succeeded but didn't return any data
+ if (!result.error && !this.release) {
+ this.showFlash(
+ new Error(`No release found in project "${this.fullPath}" with tag "${this.tagName}"`),
+ );
+ }
+ },
+ error(error) {
+ this.showFlash(error);
+ },
+ },
},
methods: {
- ...mapActions('detail', ['fetchRelease']),
+ showFlash(error) {
+ createFlash({
+ message: s__('Release|Something went wrong while getting the release details.'),
+ captureError: true,
+ error,
+ });
+ },
},
};
</script>
<template>
<div class="gl-mt-3">
- <release-skeleton-loader v-if="isFetchingRelease" />
+ <release-skeleton-loader v-if="$apollo.queries.release.loading" />
- <release-block v-else-if="!fetchError" :release="release" />
+ <release-block v-else-if="release" :release="release" />
</div>
</template>
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index cfcb9f6978d..b9601428850 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -26,14 +26,14 @@ export default {
},
directives: { GlTooltip: GlTooltipDirective },
computed: {
- ...mapState('detail', ['release', 'releaseAssetsDocsPath']),
- ...mapGetters('detail', ['validationErrors']),
+ ...mapState('editNew', ['release', 'releaseAssetsDocsPath']),
+ ...mapGetters('editNew', ['validationErrors']),
},
created() {
this.ensureAtLeastOneLink();
},
methods: {
- ...mapActions('detail', [
+ ...mapActions('editNew', [
'addEmptyAssetLink',
'updateAssetLinkUrl',
'updateAssetLinkName',
diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue
index 356fc0f3bf3..89bc314db89 100644
--- a/app/assets/javascripts/releases/components/release_block_header.vue
+++ b/app/assets/javascripts/releases/components/release_block_header.vue
@@ -1,9 +1,13 @@
<script>
import { GlTooltipDirective, GlLink, GlBadge, GlButton, GlIcon } from '@gitlab/ui';
import { setUrlParams } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
import { BACK_URL_PARAM } from '~/releases/constants';
export default {
+ i18n: {
+ editButton: __('Edit this release'),
+ },
name: 'ReleaseBlockHeader',
components: {
GlLink,
@@ -69,7 +73,8 @@ export default {
variant="default"
icon="pencil"
class="gl-mr-3 js-edit-button ml-2 pb-2"
- :title="__('Edit this release')"
+ :title="$options.i18n.editButton"
+ :aria-label="$options.i18n.editButton"
:href="editLink"
/>
</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 cf4a6e07af7..de10b210ecd 100644
--- a/app/assets/javascripts/releases/components/release_block_milestone_info.vue
+++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue
@@ -179,7 +179,7 @@ export default {
/>
<issuable-stats
v-if="showMergeRequestStats"
- :label="__('Merge Requests')"
+ :label="__('Merge requests')"
:total="mergeRequestCounts.total"
:merged="mergeRequestCounts.merged"
:closed="mergeRequestCounts.closed"
diff --git a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue
index 7d024c47fb9..13cbf95b9af 100644
--- a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue
+++ b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue
@@ -7,13 +7,13 @@ export default {
name: 'ReleasesPaginationGraphql',
components: { GlKeysetPagination },
computed: {
- ...mapState('list', ['graphQlPageInfo']),
+ ...mapState('index', ['graphQlPageInfo']),
showPagination() {
return this.graphQlPageInfo.hasPreviousPage || this.graphQlPageInfo.hasNextPage;
},
},
methods: {
- ...mapActions('list', ['fetchReleases']),
+ ...mapActions('index', ['fetchReleases']),
onPrev(before) {
historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
this.fetchReleases({ before });
diff --git a/app/assets/javascripts/releases/components/releases_pagination_rest.vue b/app/assets/javascripts/releases/components/releases_pagination_rest.vue
index 24abb0f4498..5e97a5a0450 100644
--- a/app/assets/javascripts/releases/components/releases_pagination_rest.vue
+++ b/app/assets/javascripts/releases/components/releases_pagination_rest.vue
@@ -7,10 +7,10 @@ export default {
name: 'ReleasesPaginationRest',
components: { TablePagination },
computed: {
- ...mapState('list', ['restPageInfo']),
+ ...mapState('index', ['restPageInfo']),
},
methods: {
- ...mapActions('list', ['fetchReleases']),
+ ...mapActions('index', ['fetchReleases']),
onChangePage(page) {
historyPushState(buildUrlWithCurrentLocation(`?page=${page}`));
this.fetchReleases({ page });
diff --git a/app/assets/javascripts/releases/components/releases_sort.vue b/app/assets/javascripts/releases/components/releases_sort.vue
index c8e6e0e4996..4988904a2cd 100644
--- a/app/assets/javascripts/releases/components/releases_sort.vue
+++ b/app/assets/javascripts/releases/components/releases_sort.vue
@@ -10,7 +10,7 @@ export default {
GlSortingItem,
},
computed: {
- ...mapState('list', {
+ ...mapState('index', {
orderBy: (state) => state.sorting.orderBy,
sort: (state) => state.sorting.sort,
}),
@@ -26,7 +26,7 @@ export default {
},
},
methods: {
- ...mapActions('list', ['setSorting']),
+ ...mapActions('index', ['setSorting']),
onDirectionChange() {
const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER;
this.setSorting({ sort });
diff --git a/app/assets/javascripts/releases/components/tag_field.vue b/app/assets/javascripts/releases/components/tag_field.vue
index ed8d6e62926..f4c0fd5e9ce 100644
--- a/app/assets/javascripts/releases/components/tag_field.vue
+++ b/app/assets/javascripts/releases/components/tag_field.vue
@@ -9,7 +9,7 @@ export default {
TagFieldNew,
},
computed: {
- ...mapGetters('detail', ['isExistingRelease']),
+ ...mapGetters('editNew', ['isExistingRelease']),
},
};
</script>
diff --git a/app/assets/javascripts/releases/components/tag_field_existing.vue b/app/assets/javascripts/releases/components/tag_field_existing.vue
index 3345bbecf6e..11945fbaf3d 100644
--- a/app/assets/javascripts/releases/components/tag_field_existing.vue
+++ b/app/assets/javascripts/releases/components/tag_field_existing.vue
@@ -8,7 +8,7 @@ export default {
name: 'TagFieldExisting',
components: { GlFormGroup, GlFormInput, FormFieldContainer },
computed: {
- ...mapState('detail', ['release']),
+ ...mapState('editNew', ['release']),
inputId() {
return uniqueId('tag-name-input-');
},
diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue
index 21360a5c6cb..9df646ca798 100644
--- a/app/assets/javascripts/releases/components/tag_field_new.vue
+++ b/app/assets/javascripts/releases/components/tag_field_new.vue
@@ -27,8 +27,8 @@ export default {
};
},
computed: {
- ...mapState('detail', ['projectId', 'release', 'createFrom']),
- ...mapGetters('detail', ['validationErrors']),
+ ...mapState('editNew', ['projectId', 'release', 'createFrom']),
+ ...mapGetters('editNew', ['validationErrors']),
tagName: {
get() {
return this.release.tagName;
@@ -62,7 +62,7 @@ export default {
},
},
methods: {
- ...mapActions('detail', ['updateReleaseTagName', 'updateCreateFrom']),
+ ...mapActions('editNew', ['updateReleaseTagName', 'updateCreateFrom']),
markInputAsDirty() {
this.isInputDirty = true;
},
diff --git a/app/assets/javascripts/releases/mount_edit.js b/app/assets/javascripts/releases/mount_edit.js
index 1232d55847b..fad0451ceef 100644
--- a/app/assets/javascripts/releases/mount_edit.js
+++ b/app/assets/javascripts/releases/mount_edit.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import Vuex from 'vuex';
import ReleaseEditNewApp from './components/app_edit_new.vue';
import createStore from './stores';
-import createDetailModule from './stores/modules/detail';
+import createEditNewModule from './stores/modules/edit_new';
Vue.use(Vuex);
@@ -11,7 +11,7 @@ export default () => {
const store = createStore({
modules: {
- detail: createDetailModule(el.dataset),
+ editNew: createEditNewModule(el.dataset),
},
});
diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js
index a9538cbc9e5..0b453467c13 100644
--- a/app/assets/javascripts/releases/mount_index.js
+++ b/app/assets/javascripts/releases/mount_index.js
@@ -1,8 +1,8 @@
import Vue from 'vue';
import Vuex from 'vuex';
-import ReleaseListApp from './components/app_index.vue';
+import ReleaseIndexApp from './components/app_index.vue';
import createStore from './stores';
-import createListModule from './stores/modules/list';
+import createIndexModule from './stores/modules/index';
Vue.use(Vuex);
@@ -13,7 +13,7 @@ export default () => {
el,
store: createStore({
modules: {
- list: createListModule(el.dataset),
+ index: createIndexModule(el.dataset),
},
featureFlags: {
graphqlReleaseData: Boolean(gon.features?.graphqlReleaseData),
@@ -21,6 +21,6 @@ export default () => {
graphqlMilestoneStats: Boolean(gon.features?.graphqlMilestoneStats),
},
}),
- render: (h) => h(ReleaseListApp),
+ render: (h) => h(ReleaseIndexApp),
});
};
diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js
index d85f4cf77d5..b358a27f06d 100644
--- a/app/assets/javascripts/releases/mount_new.js
+++ b/app/assets/javascripts/releases/mount_new.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import Vuex from 'vuex';
import ReleaseEditNewApp from './components/app_edit_new.vue';
import createStore from './stores';
-import createDetailModule from './stores/modules/detail';
+import createEditNewModule from './stores/modules/edit_new';
Vue.use(Vuex);
@@ -11,7 +11,7 @@ export default () => {
const store = createStore({
modules: {
- detail: createDetailModule(el.dataset),
+ editNew: createEditNewModule(el.dataset),
},
});
diff --git a/app/assets/javascripts/releases/mount_show.js b/app/assets/javascripts/releases/mount_show.js
index f3ed7d6c5ff..7272880197a 100644
--- a/app/assets/javascripts/releases/mount_show.js
+++ b/app/assets/javascripts/releases/mount_show.js
@@ -1,26 +1,28 @@
import Vue from 'vue';
-import Vuex from 'vuex';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import ReleaseShowApp from './components/app_show.vue';
-import createStore from './stores';
-import createDetailModule from './stores/modules/detail';
-Vue.use(Vuex);
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
export default () => {
const el = document.getElementById('js-show-release-page');
- const store = createStore({
- modules: {
- detail: createDetailModule(el.dataset),
- },
- featureFlags: {
- graphqlIndividualReleasePage: Boolean(gon.features?.graphqlIndividualReleasePage),
- },
- });
+ if (!el) return false;
+
+ const { projectPath, tagName } = el.dataset;
return new Vue({
el,
- store,
+ apolloProvider,
+ provide: {
+ fullPath: projectPath,
+ tagName,
+ },
render: (h) => h(ReleaseShowApp),
});
};
diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
index 5fa002706c6..8dc2083dd2b 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -43,7 +43,7 @@ export const fetchRelease = ({ commit, state, rootState }) => {
})
.catch((error) => {
commit(types.RECEIVE_RELEASE_ERROR, error);
- createFlash(s__('Release|Something went wrong while getting the release details'));
+ createFlash(s__('Release|Something went wrong while getting the release details.'));
});
}
@@ -54,7 +54,7 @@ export const fetchRelease = ({ commit, state, rootState }) => {
})
.catch((error) => {
commit(types.RECEIVE_RELEASE_ERROR, error);
- createFlash(s__('Release|Something went wrong while getting the release details'));
+ createFlash(s__('Release|Something went wrong while getting the release details.'));
});
};
diff --git a/app/assets/javascripts/releases/stores/modules/detail/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
index 831037c8861..831037c8861 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
diff --git a/app/assets/javascripts/releases/stores/modules/detail/index.js b/app/assets/javascripts/releases/stores/modules/edit_new/index.js
index e1b7e69accc..e1b7e69accc 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/index.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/index.js
diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
index 1b2f5f33f02..1b2f5f33f02 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
index cf282f9ab2c..cf282f9ab2c 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
index 315d07ac664..315d07ac664 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/state.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
diff --git a/app/assets/javascripts/releases/stores/modules/list/actions.js b/app/assets/javascripts/releases/stores/modules/index/actions.js
index f1add54626a..f1add54626a 100644
--- a/app/assets/javascripts/releases/stores/modules/list/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/index/actions.js
diff --git a/app/assets/javascripts/releases/stores/modules/list/index.js b/app/assets/javascripts/releases/stores/modules/index/index.js
index d5ca191153a..d5ca191153a 100644
--- a/app/assets/javascripts/releases/stores/modules/list/index.js
+++ b/app/assets/javascripts/releases/stores/modules/index/index.js
diff --git a/app/assets/javascripts/releases/stores/modules/list/mutation_types.js b/app/assets/javascripts/releases/stores/modules/index/mutation_types.js
index 669168efb88..669168efb88 100644
--- a/app/assets/javascripts/releases/stores/modules/list/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/index/mutation_types.js
diff --git a/app/assets/javascripts/releases/stores/modules/list/mutations.js b/app/assets/javascripts/releases/stores/modules/index/mutations.js
index e1aaa2e2a19..e1aaa2e2a19 100644
--- a/app/assets/javascripts/releases/stores/modules/list/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/index/mutations.js
diff --git a/app/assets/javascripts/releases/stores/modules/list/state.js b/app/assets/javascripts/releases/stores/modules/index/state.js
index 164a496d450..164a496d450 100644
--- a/app/assets/javascripts/releases/stores/modules/list/state.js
+++ b/app/assets/javascripts/releases/stores/modules/index/state.js
diff --git a/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue b/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue
index c272e3b1dc4..99cdeae545e 100644
--- a/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue
+++ b/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue
@@ -46,6 +46,7 @@ export default {
:loading-text="groupedSummaryText"
:error-text="groupedSummaryText"
:has-issues="shouldRenderIssuesList"
+ track-action="users_expanding_testing_accessibility_report"
class="mr-widget-section grouped-security-reports mr-report"
>
<template #body>
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 654508f0736..d293165ef2f 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
@@ -62,7 +62,7 @@ export default {
helpPath: this.codequalityHelpPath,
});
- this.fetchReports(this.glFeatures.codequalityBackendComparison);
+ this.fetchReports();
},
methods: {
...mapActions(['fetchReports', 'setPaths']),
@@ -87,6 +87,7 @@ export default {
:component="$options.componentNames.CodequalityIssueBody"
:popover-options="codequalityPopover"
:show-report-section-status-icon="false"
+ track-action="users_expanding_testing_code_quality_report"
class="js-codequality-widget mr-widget-border-top mr-report"
>
<template v-if="hasError" #sub-heading>{{ statusReason }}</template>
diff --git a/app/assets/javascripts/reports/components/grouped_issues_list.vue b/app/assets/javascripts/reports/components/grouped_issues_list.vue
index 585127f901e..ca369022938 100644
--- a/app/assets/javascripts/reports/components/grouped_issues_list.vue
+++ b/app/assets/javascripts/reports/components/grouped_issues_list.vue
@@ -66,8 +66,8 @@ export default {
},
listClasses() {
return {
- 'gl-pl-7': this.nestedLevel === 1,
- 'gl-pl-9': this.nestedLevel === 2,
+ 'gl-pl-9': this.nestedLevel === 1,
+ 'gl-pl-11-5': this.nestedLevel === 2,
};
},
},
diff --git a/app/assets/javascripts/reports/components/issues_list.vue b/app/assets/javascripts/reports/components/issues_list.vue
index ea3f0d78d8c..9df0a1953b6 100644
--- a/app/assets/javascripts/reports/components/issues_list.vue
+++ b/app/assets/javascripts/reports/components/issues_list.vue
@@ -88,8 +88,8 @@ export default {
},
listClasses() {
return {
- 'gl-pl-7': this.nestedLevel === 1,
- 'gl-pl-8': this.nestedLevel === 2,
+ 'gl-pl-9': this.nestedLevel === 1,
+ 'gl-pl-11-5': this.nestedLevel === 2,
};
},
},
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index ff58cd20ca1..12b5cb9f207 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -1,17 +1,22 @@
<script>
+import { GlButton } from '@gitlab/ui';
+import api from '~/api';
import { __ } from '~/locale';
import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
import Popover from '~/vue_shared/components/help_popover.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { status, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '../constants';
import IssuesList from './issues_list.vue';
export default {
name: 'ReportSection',
components: {
+ GlButton,
IssuesList,
- StatusIcon,
Popover,
+ StatusIcon,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
alwaysOpen: {
type: Boolean,
@@ -96,6 +101,11 @@ export default {
required: false,
default: false,
},
+ trackAction: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
@@ -162,6 +172,10 @@ export default {
},
methods: {
toggleCollapsed() {
+ if (this.trackAction && this.glFeatures.usersExpandingWidgetsUsageData) {
+ api.trackRedisHllUserEvent(this.trackAction);
+ }
+
if (this.shouldEmitToggleEvent) {
this.$emit('toggleEvent');
}
@@ -186,16 +200,15 @@ export default {
<slot name="action-buttons" :is-collapsible="isCollapsible"></slot>
- <button
+ <gl-button
v-if="isCollapsible"
- type="button"
+ class="js-collapse-btn"
data-testid="report-section-expand-button"
- class="js-collapse-btn btn float-right btn-sm align-self-center"
data-qa-selector="expand_report_button"
@click="toggleCollapsed"
>
{{ collapseText }}
- </button>
+ </gl-button>
</div>
</div>
diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue
index 8eb43bcf1ba..6b7d81c4878 100644
--- a/app/assets/javascripts/reports/components/summary_row.vue
+++ b/app/assets/javascripts/reports/components/summary_row.vue
@@ -51,7 +51,7 @@ export default {
if (!this.nestedSummary) {
return ['gl-px-5'];
}
- return ['gl-pl-7', 'gl-pr-5', { 'gl-bg-gray-10': this.statusIcon === ICON_WARNING }];
+ return ['gl-pl-9', 'gl-pr-5', { 'gl-bg-gray-10': this.statusIcon === ICON_WARNING }];
},
statusIconSize() {
if (!this.nestedSummary) {
diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js
index 9250bfd7678..acd90ebf1b1 100644
--- a/app/assets/javascripts/reports/constants.js
+++ b/app/assets/javascripts/reports/constants.js
@@ -1,5 +1,5 @@
export const fieldTypes = {
- codeBock: 'codeBlock',
+ codeBlock: 'codeBlock',
link: 'link',
seconds: 'seconds',
text: 'text',
diff --git a/app/assets/javascripts/reports/grouped_test_report/components/modal.vue b/app/assets/javascripts/reports/grouped_test_report/components/modal.vue
index b0310fd003e..af93e5bc639 100644
--- a/app/assets/javascripts/reports/grouped_test_report/components/modal.vue
+++ b/app/assets/javascripts/reports/grouped_test_report/components/modal.vue
@@ -25,6 +25,14 @@ export default {
required: true,
},
},
+ computed: {
+ filteredModalData() {
+ // Filter out the properties that don't have a value
+ return Object.fromEntries(
+ Object.entries(this.modalData).filter((data) => Boolean(data[1].value)),
+ );
+ },
+ },
fieldTypes,
};
</script>
@@ -36,23 +44,18 @@ export default {
:hide-footer="true"
@hide="$emit('hide')"
>
- <div
- v-for="(field, key, index) in modalData"
- v-if="field.value"
- :key="index"
- class="row gl-mt-3 gl-mb-3"
- >
+ <div v-for="(field, key, index) in filteredModalData" :key="index" class="row gl-mt-3 gl-mb-3">
<strong class="col-sm-3 text-right"> {{ field.text }}: </strong>
<div class="col-sm-9 text-secondary">
- <code-block v-if="field.type === $options.fieldTypes.codeBock" :code="field.value" />
+ <code-block v-if="field.type === $options.fieldTypes.codeBlock" :code="field.value" />
<gl-link
v-else-if="field.type === $options.fieldTypes.link"
- :href="field.value"
+ :href="field.value.path"
target="_blank"
>
- {{ field.value }}
+ {{ field.value.text }}
</gl-link>
<gl-sprintf
diff --git a/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue b/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue
index 522245a442d..8913046d62f 100644
--- a/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue
+++ b/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue
@@ -24,7 +24,7 @@ export default {
n__(
'Reports|Failed %{count} time in %{base_branch} in the last 14 days',
'Reports|Failed %{count} times in %{base_branch} in the last 14 days',
- this.issue.recent_failures.count,
+ this.issue.recent_failures?.count,
),
this.issue.recent_failures,
);
@@ -44,20 +44,20 @@ export default {
<template>
<div class="gl-display-flex gl-mt-2 gl-mb-2">
<issue-status-icon :status="status" :status-icon-size="24" class="gl-mr-3" />
- <gl-badge
- v-if="showRecentFailures"
- variant="warning"
- class="gl-mr-2"
- data-testid="test-issue-body-recent-failures"
- >
- {{ recentFailureMessage }}
- </gl-badge>
<gl-button
button-text-classes="gl-white-space-normal! gl-word-break-all gl-text-left"
variant="link"
data-testid="test-issue-body-description"
@click="openModal({ issue })"
>
+ <gl-badge
+ v-if="showRecentFailures"
+ variant="warning"
+ class="gl-mr-2"
+ data-testid="test-issue-body-recent-failures"
+ >
+ {{ recentFailureMessage }}
+ </gl-badge>
{{ issue.name }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue b/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue
index b863e55ae94..82806793401 100644
--- a/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue
+++ b/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue
@@ -1,9 +1,9 @@
<script>
import { GlButton, GlIcon } from '@gitlab/ui';
-import { once } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
+import api from '~/api';
import { sprintf, s__ } from '~/locale';
-import Tracking from '~/tracking';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import GroupedIssuesList from '../components/grouped_issues_list.vue';
import { componentNames } from '../components/issue_body';
import ReportSection from '../components/report_section.vue';
@@ -28,7 +28,7 @@ export default {
GlButton,
GlIcon,
},
- mixins: [Tracking.mixin()],
+ mixins: [glFeatureFlagsMixin()],
props: {
endpoint: {
type: String,
@@ -39,6 +39,10 @@ export default {
required: false,
default: '',
},
+ headBlobPath: {
+ type: String,
+ required: true,
+ },
},
componentNames,
computed: {
@@ -66,19 +70,22 @@ export default {
showViewFullReport() {
return this.pipelinePath.length;
},
- handleToggleEvent() {
- return once(() => {
- this.track(this.$options.expandEvent);
- });
- },
},
created() {
- this.setEndpoint(this.endpoint);
+ this.setPaths({
+ endpoint: this.endpoint,
+ headBlobPath: this.headBlobPath,
+ });
this.fetchReports();
},
methods: {
- ...mapActions(['setEndpoint', 'fetchReports', 'closeModal']),
+ ...mapActions(['setPaths', 'fetchReports', 'closeModal']),
+ handleToggleEvent() {
+ if (this.glFeatures.usageDataITestingSummaryWidgetTotal) {
+ api.trackRedisHllUserEvent(this.$options.expandEvent);
+ }
+ },
reportText(report) {
const { name, summary } = report || {};
@@ -123,7 +130,7 @@ export default {
return report.resolved_failures.concat(report.resolved_errors);
},
},
- expandEvent: 'expand_test_report_widget',
+ expandEvent: 'i_testing_summary_widget_total',
};
</script>
<template>
@@ -135,7 +142,7 @@ export default {
:has-issues="reports.length > 0"
:should-emit-toggle-event="true"
class="mr-widget-section grouped-security-reports mr-report"
- @toggleEvent="handleToggleEvent"
+ @toggleEvent.once="handleToggleEvent"
>
<template v-if="showViewFullReport" #action-buttons>
<gl-button
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/actions.js b/app/assets/javascripts/reports/grouped_test_report/store/actions.js
index ebc8c735b03..e3db57ad846 100644
--- a/app/assets/javascripts/reports/grouped_test_report/store/actions.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/actions.js
@@ -4,7 +4,7 @@ import httpStatusCodes from '../../../lib/utils/http_status';
import Poll from '../../../lib/utils/poll';
import * as types from './mutation_types';
-export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
+export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths);
export const requestReports = ({ commit }) => commit(types.REQUEST_REPORTS);
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js b/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js
index 337085f9bf0..ff839c564b6 100644
--- a/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js
@@ -1,4 +1,4 @@
-export const SET_ENDPOINT = 'SET_ENDPOINT';
+export const SET_PATHS = 'SET_PATHS';
export const REQUEST_REPORTS = 'REQUEST_REPORTS';
export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS';
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/mutations.js b/app/assets/javascripts/reports/grouped_test_report/store/mutations.js
index 3bb31d71d8f..2b88776815b 100644
--- a/app/assets/javascripts/reports/grouped_test_report/store/mutations.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/mutations.js
@@ -1,9 +1,10 @@
import * as types from './mutation_types';
-import { countRecentlyFailedTests } from './utils';
+import { countRecentlyFailedTests, formatFilePath } from './utils';
export default {
- [types.SET_ENDPOINT](state, endpoint) {
+ [types.SET_PATHS](state, { endpoint, headBlobPath }) {
state.endpoint = endpoint;
+ state.headBlobPath = headBlobPath;
},
[types.REQUEST_REPORTS](state) {
state.isLoading = true;
@@ -42,17 +43,25 @@ export default {
state.status = null;
},
[types.SET_ISSUE_MODAL_DATA](state, payload) {
- state.modal.title = payload.issue.name;
+ const { issue } = payload;
+ state.modal.title = issue.name;
- Object.keys(payload.issue).forEach((key) => {
+ Object.keys(issue).forEach((key) => {
if (Object.prototype.hasOwnProperty.call(state.modal.data, key)) {
state.modal.data[key] = {
...state.modal.data[key],
- value: payload.issue[key],
+ value: issue[key],
};
}
});
+ if (issue.file) {
+ state.modal.data.filename.value = {
+ text: issue.file,
+ path: `${state.headBlobPath}/${formatFilePath(issue.file)}`,
+ };
+ }
+
state.modal.open = true;
},
[types.RESET_ISSUE_MODAL_DATA](state) {
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/state.js b/app/assets/javascripts/reports/grouped_test_report/store/state.js
index dd55c7abab4..46909bde337 100644
--- a/app/assets/javascripts/reports/grouped_test_report/store/state.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/state.js
@@ -41,16 +41,16 @@ export default () => ({
open: false,
data: {
- class: {
- value: null,
- text: s__('Reports|Class'),
- type: fieldTypes.link,
- },
classname: {
value: null,
text: s__('Reports|Classname'),
type: fieldTypes.text,
},
+ filename: {
+ value: null,
+ text: s__('Reports|Filename'),
+ type: fieldTypes.link,
+ },
execution_time: {
value: null,
text: s__('Reports|Execution time'),
@@ -59,12 +59,12 @@ export default () => ({
failure: {
value: null,
text: s__('Reports|Failure'),
- type: fieldTypes.codeBock,
+ type: fieldTypes.codeBlock,
},
system_output: {
value: null,
text: s__('Reports|System output'),
- type: fieldTypes.codeBock,
+ type: fieldTypes.codeBlock,
},
},
},
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/utils.js b/app/assets/javascripts/reports/grouped_test_report/store/utils.js
index 189b87bfa8d..df5dd73b66c 100644
--- a/app/assets/javascripts/reports/grouped_test_report/store/utils.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/utils.js
@@ -100,3 +100,12 @@ export const statusIcon = (status) => {
return ICON_NOTFOUND;
};
+
+/**
+ * Removes `./` from the beginning of a file path so it can be appended onto a blob path
+ * @param {String} file
+ * @returns {String} - formatted value
+ */
+export const formatFilePath = (file) => {
+ return file.replace(/^\.?\/*/, '');
+};
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
new file mode 100644
index 00000000000..58b42fb7859
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -0,0 +1,100 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import BlobContent from '~/blob/components/blob_content.vue';
+import BlobHeader from '~/blob/components/blob_header.vue';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import blobInfoQuery from '../queries/blob_info.query.graphql';
+import projectPathQuery from '../queries/project_path.query.graphql';
+
+export default {
+ components: {
+ BlobHeader,
+ BlobContent,
+ GlLoadingIcon,
+ },
+ apollo: {
+ projectPath: {
+ query: projectPathQuery,
+ },
+ blobInfo: {
+ query: blobInfoQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ filePath: this.path,
+ };
+ },
+ error() {
+ createFlash({ message: __('An error occurred while loading the file. Please try again.') });
+ },
+ },
+ },
+ provide() {
+ return {
+ blobHash: uniqueId(),
+ };
+ },
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ projectPath: '',
+ blobInfo: {
+ name: '',
+ size: '',
+ rawBlob: '',
+ type: '',
+ fileType: '',
+ tooLarge: false,
+ path: '',
+ editBlobPath: '',
+ ideEditPath: '',
+ storedExternally: false,
+ rawPath: '',
+ externalStorageUrl: '',
+ replacePath: '',
+ deletePath: '',
+ canLock: false,
+ isLocked: false,
+ lockLink: '',
+ canModifyBlob: true,
+ forkPath: '',
+ simpleViewer: '',
+ richViewer: '',
+ },
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.blobInfo.loading;
+ },
+ viewer() {
+ const { fileType, tooLarge, type } = this.blobInfo;
+
+ return { fileType, tooLarge, type };
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-loading-icon v-if="isLoading" />
+ <div v-if="blobInfo && !isLoading">
+ <blob-header :blob="blobInfo" />
+ <blob-content
+ :blob="blobInfo"
+ :content="blobInfo.rawBlob"
+ :is-raw-content="true"
+ :active-viewer="viewer"
+ :loading="false"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index 28d7dec85f4..0b8408643ac 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -5,6 +5,7 @@ import {
GlDropdownSectionHeader,
GlDropdownItem,
GlIcon,
+ GlModalDirective,
} from '@gitlab/ui';
import permissionsQuery from 'shared_queries/repository/permissions.query.graphql';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
@@ -12,12 +13,15 @@ import { __ } from '../../locale';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
import projectShortPathQuery from '../queries/project_short_path.query.graphql';
+import UploadBlobModal from './upload_blob_modal.vue';
const ROW_TYPES = {
header: 'header',
divider: 'divider',
};
+const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob';
+
export default {
components: {
GlDropdown,
@@ -25,6 +29,7 @@ export default {
GlDropdownSectionHeader,
GlDropdownItem,
GlIcon,
+ UploadBlobModal,
},
apollo: {
projectShortPath: {
@@ -46,6 +51,9 @@ export default {
},
},
},
+ directives: {
+ GlModal: GlModalDirective,
+ },
mixins: [getRefMixin],
props: {
currentPath: {
@@ -63,6 +71,21 @@ export default {
required: false,
default: false,
},
+ canPushCode: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ selectedBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ originalBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
newBranchPath: {
type: String,
required: false,
@@ -93,7 +116,13 @@ export default {
required: false,
default: null,
},
+ uploadPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
+ uploadBlobModalId: UPLOAD_BLOB_MODAL_ID,
data() {
return {
projectShortPath: '',
@@ -126,7 +155,10 @@ export default {
);
},
canCreateMrFromFork() {
- return this.userPermissions.forkProject && this.userPermissions.createMergeRequestIn;
+ return this.userPermissions?.forkProject && this.userPermissions?.createMergeRequestIn;
+ },
+ showUploadModal() {
+ return this.canEditTree && !this.$apollo.queries.userPermissions.loading;
},
dropdownItems() {
const items = [];
@@ -149,10 +181,9 @@ export default {
{
attrs: {
href: '#modal-upload-blob',
- 'data-target': '#modal-upload-blob',
- 'data-toggle': 'modal',
},
text: __('Upload file'),
+ modalId: UPLOAD_BLOB_MODAL_ID,
},
{
attrs: {
@@ -253,12 +284,26 @@ export default {
<gl-icon name="chevron-down" :size="16" class="float-left" />
</template>
<template v-for="(item, i) in dropdownItems">
- <component :is="getComponent(item.type)" :key="i" v-bind="item.attrs">
+ <component
+ :is="getComponent(item.type)"
+ :key="i"
+ v-bind="item.attrs"
+ v-gl-modal="item.modalId || null"
+ >
{{ item.text }}
</component>
</template>
</gl-dropdown>
</li>
</ol>
+ <upload-blob-modal
+ v-if="showUploadModal"
+ :modal-id="$options.uploadBlobModalId"
+ :commit-message="__('Upload New File')"
+ :target-branch="selectedBranch"
+ :original-branch="originalBranch"
+ :can-push-code="canPushCode"
+ :path="uploadPath"
+ />
</nav>
</template>
diff --git a/app/assets/javascripts/repository/components/directory_download_links.vue b/app/assets/javascripts/repository/components/directory_download_links.vue
index 8c029fc9973..c222a83300d 100644
--- a/app/assets/javascripts/repository/components/directory_download_links.vue
+++ b/app/assets/javascripts/repository/components/directory_download_links.vue
@@ -1,9 +1,9 @@
<script>
-import { GlLink } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
export default {
components: {
- GlLink,
+ GlButton,
},
props: {
currentPath: {
@@ -32,15 +32,15 @@ export default {
<h5 class="m-0 dropdown-bold-header">{{ __('Download this directory') }}</h5>
<div class="dropdown-menu-content">
<div class="btn-group ml-0 w-100">
- <gl-link
+ <gl-button
v-for="(link, index) in normalizedLinks"
:key="index"
:href="link.path"
- :class="{ 'btn-primary': index === 0 }"
- class="btn btn-xs"
+ :variant="index === 0 ? 'confirm' : 'default'"
+ size="small"
>
{{ link.text }}
- </gl-link>
+ </gl-button>
</div>
</div>
</section>
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 70918dd55e4..8ea5fce92fa 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -12,6 +12,7 @@ import { escapeRegExp } from 'lodash';
import { escapeFileUrl } from '~/lib/utils/url_utility';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getRefMixin from '../../mixins/get_ref';
import commitQuery from '../../queries/commit.query.graphql';
@@ -41,7 +42,7 @@ export default {
},
},
},
- mixins: [getRefMixin],
+ mixins: [getRefMixin, glFeatureFlagMixin()],
props: {
id: {
type: String,
@@ -103,10 +104,21 @@ export default {
};
},
computed: {
+ refactorBlobViewerEnabled() {
+ return this.glFeatures.refactorBlobViewer;
+ },
routerLinkTo() {
- return this.isFolder
- ? { path: `/-/tree/${this.escapedRef}/${escapeFileUrl(this.path)}` }
- : null;
+ const blobRouteConfig = { path: `/-/blob/${this.escapedRef}/${escapeFileUrl(this.path)}` };
+ const treeRouteConfig = { path: `/-/tree/${this.escapedRef}/${escapeFileUrl(this.path)}` };
+
+ if (this.refactorBlobViewerEnabled && this.isBlob) {
+ return blobRouteConfig;
+ }
+
+ return this.isFolder ? treeRouteConfig : null;
+ },
+ isBlob() {
+ return this.type === 'blob';
},
isFolder() {
return this.type === 'tree';
@@ -115,7 +127,7 @@ export default {
return this.type === 'commit';
},
linkComponent() {
- return this.isFolder ? 'router-link' : 'a';
+ return this.isFolder || (this.refactorBlobViewerEnabled && this.isBlob) ? 'router-link' : 'a';
},
fullPath() {
return this.path.replace(new RegExp(`^${escapeRegExp(this.currentPath)}/`), '');
diff --git a/app/assets/javascripts/repository/components/tree_action_link.vue b/app/assets/javascripts/repository/components/tree_action_link.vue
deleted file mode 100644
index c5ab150adaf..00000000000
--- a/app/assets/javascripts/repository/components/tree_action_link.vue
+++ /dev/null
@@ -1,28 +0,0 @@
-<script>
-import { GlLink } from '@gitlab/ui';
-
-export default {
- components: {
- GlLink,
- },
- props: {
- path: {
- type: String,
- required: true,
- },
- text: {
- type: String,
- required: true,
- },
- cssClass: {
- type: String,
- required: false,
- default: null,
- },
- },
-};
-</script>
-
-<template>
- <gl-link :href="path" :class="cssClass" class="btn gl-button">{{ text }}</gl-link>
-</template>
diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue
index ec7ba469ca0..d2ff01e7fc1 100644
--- a/app/assets/javascripts/repository/components/upload_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue
@@ -168,6 +168,7 @@ export default {
});
},
},
+ validFileMimetypes: [],
};
</script>
<template>
@@ -179,7 +180,12 @@ export default {
:action-cancel="cancelOptions"
@primary.prevent="uploadFile"
>
- <upload-dropzone class="gl-h-200! gl-mb-4" single-file-selection @change="setFile">
+ <upload-dropzone
+ class="gl-h-200! gl-mb-4"
+ single-file-selection
+ :valid-file-mimetypes="$options.validFileMimetypes"
+ @change="setFile"
+ >
<div
v-if="file"
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"
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index e6969b7c8b2..3a9a2adb417 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -1,14 +1,18 @@
import { GlButton } from '@gitlab/ui';
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { escapeFileUrl } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
-import { parseBoolean } from '../lib/utils/common_utils';
-import { escapeFileUrl } from '../lib/utils/url_utility';
-import { __ } from '../locale';
import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
import LastCommit from './components/last_commit.vue';
import apolloProvider from './graphql';
+import commitsQuery from './queries/commits.query.graphql';
+import projectPathQuery from './queries/project_path.query.graphql';
+import projectShortPathQuery from './queries/project_short_path.query.graphql';
+import refsQuery from './queries/ref.query.graphql';
import createRouter from './router';
import { updateFormAction } from './utils/dom';
import { setTitle } from './utils/title';
@@ -19,13 +23,32 @@ export default function setupVueRepositoryList() {
const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset;
const router = createRouter(projectPath, escapedRef);
- apolloProvider.clients.defaultClient.cache.writeData({
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: commitsQuery,
+ data: {
+ commits: [],
+ },
+ });
+
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: projectPathQuery,
data: {
projectPath,
+ },
+ });
+
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: projectShortPathQuery,
+ data: {
projectShortPath,
+ },
+ });
+
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: refsQuery,
+ data: {
ref,
escapedRef,
- commits: [],
},
});
@@ -55,6 +78,8 @@ export default function setupVueRepositoryList() {
const {
canCollaborate,
canEditTree,
+ canPushCode,
+ selectedBranch,
newBranchPath,
newTagPath,
newBlobPath,
@@ -65,8 +90,7 @@ export default function setupVueRepositoryList() {
newDirPath,
} = breadcrumbEl.dataset;
- router.afterEach(({ params: { path = '/' } }) => {
- updateFormAction('.js-upload-blob-form', uploadPath, path);
+ router.afterEach(({ params: { path } }) => {
updateFormAction('.js-create-dir-form', newDirPath, path);
});
@@ -81,12 +105,16 @@ export default function setupVueRepositoryList() {
currentPath: this.$route.params.path,
canCollaborate: parseBoolean(canCollaborate),
canEditTree: parseBoolean(canEditTree),
+ canPushCode: parseBoolean(canPushCode),
+ originalBranch: ref,
+ selectedBranch,
newBranchPath,
newTagPath,
newBlobPath,
forkNewBlobPath,
forkNewDirectoryPath,
forkUploadBlobPath,
+ uploadPath,
},
});
},
diff --git a/app/assets/javascripts/repository/pages/blob.vue b/app/assets/javascripts/repository/pages/blob.vue
new file mode 100644
index 00000000000..27af398be09
--- /dev/null
+++ b/app/assets/javascripts/repository/pages/blob.vue
@@ -0,0 +1,22 @@
+<script>
+// This file is in progress and behind a feature flag, please see the following issue for more:
+// https://gitlab.com/gitlab-org/gitlab/-/issues/323200
+
+import BlobContentViewer from '../components/blob_content_viewer.vue';
+
+export default {
+ components: {
+ BlobContentViewer,
+ },
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <blob-content-viewer :path="path" />
+</template>
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql
new file mode 100644
index 00000000000..e0bbf12f3eb
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql
@@ -0,0 +1,30 @@
+query getBlobInfo($projectPath: ID!, $filePath: String!) {
+ project(fullPath: $projectPath) {
+ id
+ repository {
+ blobs(path: $filePath) {
+ name
+ size
+ rawBlob
+ type
+ fileType
+ tooLarge
+ path
+ editBlobPath
+ ideEditPath
+ storedExternally
+ rawPath
+ externalStorageUrl
+ replacePath
+ deletePath
+ canLock
+ isLocked
+ lockLink
+ canModifyBlob
+ forkPath
+ simpleViewer
+ richViewer
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js
index ad6e32d7055..c7f7451fb55 100644
--- a/app/assets/javascripts/repository/router.js
+++ b/app/assets/javascripts/repository/router.js
@@ -2,6 +2,7 @@ import { escapeRegExp } from 'lodash';
import Vue from 'vue';
import VueRouter from 'vue-router';
import { joinPaths } from '../lib/utils/url_utility';
+import BlobPage from './pages/blob.vue';
import IndexPage from './pages/index.vue';
import TreePage from './pages/tree.vue';
@@ -15,6 +16,13 @@ export default function createRouter(base, baseRef) {
}),
};
+ const blobPathRoute = {
+ component: BlobPage,
+ props: (route) => ({
+ path: route.params.path,
+ }),
+ };
+
return new VueRouter({
mode: 'history',
base: joinPaths(gon.relative_url_root || '', base),
@@ -32,6 +40,18 @@ export default function createRouter(base, baseRef) {
...treePathRoute,
},
{
+ name: 'blobPathDecoded',
+ // Sometimes the ref needs decoding depending on how the backend sends it to us
+ path: `(/-)?/blob/${decodeURI(baseRef)}/:path*`,
+ ...blobPathRoute,
+ },
+ {
+ name: 'blobPath',
+ // Support without decoding as well just in case the ref doesn't need to be decoded
+ path: `(/-)?/blob/${escapeRegExp(baseRef)}/:path*`,
+ ...blobPathRoute,
+ },
+ {
path: '/',
name: 'projectRoot',
component: IndexPage,
diff --git a/app/assets/javascripts/runner/runner_details/constants.js b/app/assets/javascripts/runner/runner_details/constants.js
new file mode 100644
index 00000000000..bb57e85fa8a
--- /dev/null
+++ b/app/assets/javascripts/runner/runner_details/constants.js
@@ -0,0 +1,3 @@
+import { s__ } from '~/locale';
+
+export const I18N_TITLE = s__('Runners|Runner #%{runner_id}');
diff --git a/app/assets/javascripts/runner/runner_details/index.js b/app/assets/javascripts/runner/runner_details/index.js
new file mode 100644
index 00000000000..cbf70640ef7
--- /dev/null
+++ b/app/assets/javascripts/runner/runner_details/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import RunnerDetailsApp from './runner_details_app.vue';
+
+export const initRunnerDetail = (selector = '#js-runner-detail') => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { runnerId } = el.dataset;
+
+ return new Vue({
+ el,
+ render(h) {
+ return h(RunnerDetailsApp, {
+ props: {
+ runnerId,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/runner/runner_details/runner_details_app.vue b/app/assets/javascripts/runner/runner_details/runner_details_app.vue
new file mode 100644
index 00000000000..1b1485bfe72
--- /dev/null
+++ b/app/assets/javascripts/runner/runner_details/runner_details_app.vue
@@ -0,0 +1,20 @@
+<script>
+import { I18N_TITLE } from './constants';
+
+export default {
+ i18n: {
+ I18N_TITLE,
+ },
+ props: {
+ runnerId: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <h2 class="page-title">
+ {{ sprintf($options.i18n.I18N_TITLE, { runner_id: runnerId }) }}
+ </h2>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue
index 4640259314b..99cf16c8350 100644
--- a/app/assets/javascripts/search/sidebar/components/app.vue
+++ b/app/assets/javascripts/search/sidebar/components/app.vue
@@ -32,7 +32,9 @@ export default {
<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-button category="primary" variant="confirm" size="small" type="submit">
+ {{ __('Apply') }}
+ </gl-button>
<gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{
__('Reset filters')
}}</gl-link>
diff --git a/app/assets/javascripts/search/sort/components/app.vue b/app/assets/javascripts/search/sort/components/app.vue
index e4eba655e39..2bf144705c4 100644
--- a/app/assets/javascripts/search/sort/components/app.vue
+++ b/app/assets/javascripts/search/sort/components/app.vue
@@ -96,6 +96,7 @@ export default {
v-gl-tooltip
:disabled="!selectedSortOption.sortable"
:title="sortDirectionData.tooltip"
+ :aria-label="sortDirectionData.tooltip"
:icon="sortDirectionData.icon"
@click="handleSortDirectionChange"
/>
diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue
index 987735ed811..2439ab55923 100644
--- a/app/assets/javascripts/search/topbar/components/app.vue
+++ b/app/assets/javascripts/search/topbar/components/app.vue
@@ -65,9 +65,9 @@ export default {
<label class="gl-display-block">{{ __('Project') }}</label>
<project-filter :initial-data="projectInitialData" />
</div>
- <gl-button class="btn-search gl-lg-ml-2" variant="success" type="submit">{{
- __('Search')
- }}</gl-button>
+ <gl-button class="btn-search gl-lg-ml-2" category="primary" variant="confirm" type="submit"
+ >{{ __('Search') }}
+ </gl-button>
</section>
</gl-form>
</template>
diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
index 5fb7217db74..d16850cd889 100644
--- a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
+++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
@@ -9,10 +9,13 @@ import {
GlSkeletonLoader,
GlTooltipDirective,
} from '@gitlab/ui';
-
+import { __ } from '~/locale';
import { ANY_OPTION } from '../constants';
export default {
+ i18n: {
+ clearLabel: __('Clear'),
+ },
name: 'SearchableDropdown',
components: {
GlDropdown,
@@ -96,7 +99,8 @@ export default {
v-gl-tooltip
name="clear"
category="tertiary"
- :title="__('Clear')"
+ :title="$options.i18n.clearLabel"
+ :aria-label="$options.i18n.clearLabel"
class="gl-p-0! gl-mr-2"
@keydown.enter.stop="resetDropdown"
@click.stop="resetDropdown"
diff --git a/app/assets/javascripts/search_settings/constants.js b/app/assets/javascripts/search_settings/constants.js
index 499e42854ed..9452d149122 100644
--- a/app/assets/javascripts/search_settings/constants.js
+++ b/app/assets/javascripts/search_settings/constants.js
@@ -5,7 +5,7 @@ export const EXCLUDED_NODES = ['OPTION'];
export const HIDE_CLASS = 'gl-display-none';
// used to highlight the text that matches the * search term
-export const HIGHLIGHT_CLASS = 'gl-bg-orange-50';
+export const HIGHLIGHT_CLASS = 'gl-bg-orange-100';
// How many seconds to wait until the user * stops typing
export const TYPING_DELAY = 400;
diff --git a/app/assets/javascripts/security_configuration/components/manage_sast.vue b/app/assets/javascripts/security_configuration/components/manage_sast.vue
index a2528edd914..8a8827b41cd 100644
--- a/app/assets/javascripts/security_configuration/components/manage_sast.vue
+++ b/app/assets/javascripts/security_configuration/components/manage_sast.vue
@@ -54,6 +54,6 @@ export default {
<template>
<gl-button :loading="isLoading" variant="success" category="secondary" @click="mutate">{{
- s__('SecurityConfiguration|Configure via Merge Request')
+ s__('SecurityConfiguration|Configure via merge request')
}}</gl-button>
</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 bed264341a5..bff90254c04 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
@@ -1,14 +1,23 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlToast, GlModal, GlTooltipDirective, GlIcon, GlFormCheckbox } from '@gitlab/ui';
+import {
+ GlToast,
+ GlModal,
+ GlTooltipDirective,
+ GlIcon,
+ GlFormCheckbox,
+ GlDropdown,
+ GlDropdownItem,
+} from '@gitlab/ui';
import $ from 'jquery';
import Vue from 'vue';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import * as Emoji from '~/emoji';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
-import { __, s__ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import { updateUserStatus } from '~/rest_api';
+import { timeRanges } from '~/vue_shared/constants';
import EmojiMenuInModal from './emoji_menu_in_modal';
import { isUserBusy } from './utils';
@@ -20,11 +29,21 @@ export const AVAILABILITY_STATUS = {
Vue.use(GlToast);
+const statusTimeRanges = [
+ {
+ label: __('Never'),
+ name: 'never',
+ },
+ ...timeRanges,
+];
+
export default {
components: {
GlIcon,
GlModal,
GlFormCheckbox,
+ GlDropdown,
+ GlDropdownItem,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -53,6 +72,11 @@ export default {
required: false,
default: false,
},
+ currentClearStatusAfter: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -65,6 +89,10 @@ export default {
modalId: 'set-user-status-modal',
noEmoji: true,
availability: isUserBusy(this.currentAvailability),
+ clearStatusAfter: statusTimeRanges[0].label,
+ clearStatusAfterMessage: sprintf(s__('SetStatusModal|Your status resets on %{date}.'), {
+ date: this.currentClearStatusAfter,
+ }),
};
},
computed: {
@@ -161,12 +189,16 @@ export default {
this.setStatus();
},
setStatus() {
- const { emoji, message, availability } = this;
+ const { emoji, message, availability, clearStatusAfter } = this;
updateUserStatus({
emoji,
message,
availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET,
+ clearStatusAfter:
+ clearStatusAfter === statusTimeRanges[0].label
+ ? null
+ : clearStatusAfter.replace(' ', '_'),
})
.then(this.onUpdateSuccess)
.catch(this.onUpdateFail);
@@ -183,7 +215,11 @@ export default {
this.closeModal();
},
+ setClearStatusAfter(after) {
+ this.clearStatusAfter = after;
+ },
},
+ statusTimeRanges,
};
</script>
@@ -268,10 +304,31 @@ export default {
</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') }}
+ {{ s__('SetStatusModal|A busy indicator is shown next to your name and avatar.') }}
</span>
</div>
</div>
+ <div class="form-group">
+ <div class="gl-display-flex gl-align-items-baseline">
+ <span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span>
+ <gl-dropdown :text="clearStatusAfter" data-testid="clear-status-at-dropdown">
+ <gl-dropdown-item
+ v-for="after in $options.statusTimeRanges"
+ :key="after.name"
+ :data-testid="after.name"
+ @click="setClearStatusAfter(after.label)"
+ >{{ after.label }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
+ <div
+ v-if="currentClearStatusAfter.length"
+ class="gl-mt-3 gl-text-gray-400 gl-font-sm"
+ data-testid="clear-status-at-message"
+ >
+ {{ clearStatusAfterMessage }}
+ </div>
+ </div>
</div>
</div>
</gl-modal>
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 d0a65b48522..98fc0b0a783 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
@@ -103,10 +103,10 @@ export default {
v-gl-tooltip="tooltipOption"
:href="assigneeUrl"
:title="tooltipTitle"
- class="d-inline-block"
+ class="gl-display-inline-block"
>
<!-- use d-flex so that slot can be appropriately styled -->
- <span class="d-flex">
+ <span class="gl-display-flex">
<assignee-avatar :user="user" :img-size="32" :issuable-type="issuableType" />
<slot></slot>
</span>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
index ca86d6c6c3e..f98798582c1 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
@@ -1,7 +1,7 @@
<script>
import actionCable from '~/actioncable_consumer';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql';
+import { assigneesQueries } from '~/sidebar/constants';
export default {
subscription: null,
@@ -9,7 +9,8 @@ export default {
props: {
mediator: {
type: Object,
- required: true,
+ required: false,
+ default: null,
},
issuableIid: {
type: String,
@@ -19,10 +20,16 @@ export default {
type: String,
required: true,
},
+ issuableType: {
+ type: String,
+ required: true,
+ },
},
apollo: {
- project: {
- query,
+ workspace: {
+ query() {
+ return assigneesQueries[this.issuableType].query;
+ },
variables() {
return {
iid: this.issuableIid,
@@ -30,7 +37,9 @@ export default {
};
},
result(data) {
- this.handleFetchResult(data);
+ if (this.mediator) {
+ this.handleFetchResult(data);
+ }
},
},
},
@@ -43,7 +52,7 @@ export default {
methods: {
received(data) {
if (data.event === 'updated') {
- this.$apollo.queries.project.refetch();
+ this.$apollo.queries.workspace.refetch();
}
},
initActionCablePolling() {
@@ -57,7 +66,7 @@ export default {
);
},
handleFetchResult({ data }) {
- const { nodes } = data.project.issue.assignees;
+ const { nodes } = data.workspace.issuable.assignees;
const assignees = nodes.map((n) => ({
...n,
@@ -69,7 +78,7 @@ export default {
},
},
render() {
- return this.$slots.default;
+ return null;
},
};
</script>
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index b53b7039018..e93aced12f3 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -18,6 +18,11 @@ export default {
required: false,
default: 'issue',
},
+ signedIn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
assigneesText() {
@@ -34,20 +39,28 @@ export default {
<div class="gl-display-flex gl-flex-direction-column issuable-assignees">
<div
v-if="emptyUsers"
- class="gl-display-flex gl-align-items-center gl-text-gray-500"
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-2 hide-collapsed"
data-testid="none"
>
- <span> {{ __('None') }} -</span>
- <gl-button
- data-testid="assign-yourself"
- category="tertiary"
- variant="link"
- class="gl-ml-2"
- @click="$emit('assign-self')"
- >
- <span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span>
- </gl-button>
+ <span> {{ __('None') }}</span>
+ <template v-if="signedIn">
+ <span class="gl-ml-2">-</span>
+ <gl-button
+ data-testid="assign-yourself"
+ category="tertiary"
+ variant="link"
+ class="gl-ml-2"
+ @click="$emit('assign-self')"
+ >
+ <span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span>
+ </gl-button>
+ </template>
</div>
- <uncollapsed-assignee-list v-else :users="users" :issuable-type="issuableType" />
+ <uncollapsed-assignee-list
+ v-else
+ :users="users"
+ :issuable-type="issuableType"
+ class="gl-mt-2 hide-collapsed"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index 6595debf9a5..e15ea595190 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -123,6 +123,7 @@ export default {
v-if="shouldEnableRealtime"
:issuable-iid="issuableIid"
:project-path="projectPath"
+ :issuable-type="issuableType"
:mediator="mediator"
/>
<assignee-title
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
index cc2201ad359..78cac989850 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -1,26 +1,28 @@
<script>
-import {
- GlDropdownItem,
- GlDropdownDivider,
- GlAvatarLabeled,
- GlAvatarLink,
- GlSearchBoxByType,
- GlLoadingIcon,
-} from '@gitlab/ui';
+import { GlDropdownItem, GlDropdownDivider, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import Vue from 'vue';
import createFlash from '~/flash';
import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
import { IssuableType } from '~/issue_show/constants';
import { __, n__ } from '~/locale';
+import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { assigneesQueries, ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import SidebarInviteMembers from './sidebar_invite_members.vue';
+import SidebarParticipant from './sidebar_participant.vue';
export const assigneesWidget = Vue.observable({
updateAssignees: null,
});
+
+const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', {
+ bubbles: true,
+});
+
export default {
i18n: {
unassigned: __('Unassigned'),
@@ -28,17 +30,26 @@ export default {
assignees: __('Assignees'),
assignTo: __('Assign to'),
},
- assigneesQueries,
components: {
SidebarEditableItem,
IssuableAssignees,
MultiSelectDropdown,
GlDropdownItem,
GlDropdownDivider,
- GlAvatarLabeled,
- GlAvatarLink,
GlSearchBoxByType,
GlLoadingIcon,
+ SidebarInviteMembers,
+ SidebarParticipant,
+ SidebarAssigneesRealtime,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ inject: {
+ directlyInviteMembers: {
+ default: false,
+ },
+ indirectlyInviteMembers: {
+ default: false,
+ },
},
props: {
iid: {
@@ -76,12 +87,13 @@ export default {
selected: [],
isSettingAssignees: false,
isSearching: false,
+ isDirty: false,
};
},
apollo: {
issuable: {
query() {
- return this.$options.assigneesQueries[this.issuableType].query;
+ return assigneesQueries[this.issuableType].query;
},
variables() {
return this.queryVariables;
@@ -109,15 +121,20 @@ export default {
},
update(data) {
const searchResults = data.workspace?.users?.nodes.map(({ user }) => user) || [];
- const mergedSearchResults = this.participants.reduce((acc, current) => {
- if (
- !acc.some((user) => current.username === user.username) &&
- (current.name.includes(this.search) || current.username.includes(this.search))
- ) {
+ const filteredParticipants = this.participants.filter(
+ (user) =>
+ user.name.toLowerCase().includes(this.search.toLowerCase()) ||
+ user.username.toLowerCase().includes(this.search.toLowerCase()),
+ );
+ const mergedSearchResults = searchResults.reduce((acc, current) => {
+ // Some users are duplicated in the query result:
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/327822
+ if (!acc.some((user) => current.username === user.username)) {
acc.push(current);
}
return acc;
- }, searchResults);
+ }, filteredParticipants);
+
return mergedSearchResults;
},
debounce: ASSIGNEES_DEBOUNCE_DELAY,
@@ -134,6 +151,10 @@ export default {
},
},
computed: {
+ shouldEnableRealtime() {
+ // Note: Realtime is only available on issues right now, future support for MR wil be built later.
+ return this.glFeatures.realTimeIssueSidebar && this.issuableType === IssuableType.Issue;
+ },
queryVariables() {
return {
iid: this.iid,
@@ -155,6 +176,9 @@ export default {
},
assigneeText() {
const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected;
+ if (!items) {
+ return __('Assignee');
+ }
return n__('Assignee', '%d Assignees', items.length);
},
selectedFiltered() {
@@ -197,8 +221,15 @@ export default {
noUsersFound() {
return !this.isSearchEmpty && this.searchUsers.length === 0;
},
+ signedIn() {
+ return this.currentUser.username !== undefined;
+ },
showCurrentUser() {
- return !this.isCurrentUserInParticipants && (this.isSearchEmpty || this.isSearching);
+ return (
+ this.signedIn &&
+ !this.isCurrentUserInParticipants &&
+ (this.isSearchEmpty || this.isSearching)
+ );
},
},
watch: {
@@ -221,7 +252,7 @@ export default {
this.isSettingAssignees = true;
return this.$apollo
.mutate({
- mutation: this.$options.assigneesQueries[this.issuableType].mutation,
+ mutation: assigneesQueries[this.issuableType].mutation,
variables: {
...this.queryVariables,
assigneeUsernames,
@@ -239,20 +270,22 @@ export default {
});
},
selectAssignee(name) {
- if (name === undefined) {
- this.clearSelected();
- return;
- }
+ this.isDirty = true;
if (!this.multipleAssignees) {
- this.selected = [name];
+ this.selected = name ? [name] : [];
this.collapseWidget();
- } else {
- this.selected = this.selected.concat(name);
+ return;
+ }
+ if (name === undefined) {
+ this.clearSelected();
+ return;
}
+ this.selected = this.selected.concat(name);
},
unselect(name) {
this.selected = this.selected.filter((user) => user.username !== name);
+ this.isDirty = true;
if (!this.multipleAssignees) {
this.collapseWidget();
@@ -265,7 +298,9 @@ export default {
this.selected = [];
},
saveAssignees() {
+ this.isDirty = false;
this.updateAssignees(this.selectedUserNames);
+ this.$el.dispatchEvent(hideDropdownEvent);
},
isChecked(id) {
return this.selectedUserNames.includes(id);
@@ -291,6 +326,9 @@ export default {
collapseWidget() {
this.$refs.toggle.collapse();
},
+ expandWidget() {
+ this.$refs.toggle.expand();
+ },
showDivider(list) {
return list.length > 0 && this.isSearchEmpty;
},
@@ -299,121 +337,113 @@ export default {
</script>
<template>
- <div
- v-if="isAssigneesLoading"
- class="gl-display-flex gl-align-items-center assignee"
- data-testid="loading-assignees"
- >
- {{ __('Assignee') }}
- <gl-loading-icon size="sm" class="gl-ml-2" />
- </div>
- <sidebar-editable-item
- v-else
- ref="toggle"
- :loading="isSettingAssignees"
- :title="assigneeText"
- @open="focusSearch"
- @close="saveAssignees"
- >
- <template #collapsed>
- <issuable-assignees
- :users="assignees"
- :issuable-type="issuableType"
- class="gl-mt-2"
- @assign-self="assignSelf"
- />
- </template>
+ <div data-testid="assignees-widget">
+ <sidebar-assignees-realtime
+ v-if="shouldEnableRealtime"
+ :project-path="fullPath"
+ :issuable-iid="iid"
+ :issuable-type="issuableType"
+ />
+ <sidebar-editable-item
+ ref="toggle"
+ :loading="isSettingAssignees"
+ :initial-loading="isAssigneesLoading"
+ :title="assigneeText"
+ :is-dirty="isDirty"
+ @open="focusSearch"
+ @close="saveAssignees"
+ >
+ <template #collapsed>
+ <slot name="collapsed" :users="assignees" :on-click="expandWidget"></slot>
+ <issuable-assignees
+ :users="assignees"
+ :issuable-type="issuableType"
+ :signed-in="signedIn"
+ @assign-self="assignSelf"
+ @expand-widget="expandWidget"
+ />
+ </template>
- <template #default>
- <multi-select-dropdown
- class="gl-w-full dropdown-menu-user"
- :text="$options.i18n.assignees"
- :header-text="$options.i18n.assignTo"
- @toggle="collapseWidget"
- >
- <template #search>
- <gl-search-box-by-type ref="search" v-model.trim="search" />
- </template>
- <template #items>
- <gl-loading-icon
- v-if="$apollo.queries.searchUsers.loading || $apollo.queries.issuable.loading"
- data-testid="loading-participants"
- size="lg"
- />
- <template v-else>
- <template v-if="isSearchEmpty || isSearching">
+ <template #default>
+ <multi-select-dropdown
+ class="gl-w-full dropdown-menu-user"
+ :text="$options.i18n.assignees"
+ :header-text="$options.i18n.assignTo"
+ @toggle="collapseWidget"
+ >
+ <template #search>
+ <gl-search-box-by-type
+ ref="search"
+ v-model.trim="search"
+ class="js-dropdown-input-field"
+ />
+ </template>
+ <template #items>
+ <gl-loading-icon
+ v-if="$apollo.queries.searchUsers.loading || $apollo.queries.issuable.loading"
+ data-testid="loading-participants"
+ size="lg"
+ />
+ <template v-else>
+ <template v-if="isSearchEmpty || isSearching">
+ <gl-dropdown-item
+ :is-checked="selectedIsEmpty"
+ :is-check-centered="true"
+ data-testid="unassign"
+ @click="selectAssignee()"
+ >
+ <span
+ :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'"
+ class="gl-font-weight-bold"
+ >{{ $options.i18n.unassigned }}</span
+ ></gl-dropdown-item
+ >
+ </template>
+ <gl-dropdown-divider v-if="showDivider(selectedFiltered)" />
<gl-dropdown-item
- :is-checked="selectedIsEmpty"
+ v-for="item in selectedFiltered"
+ :key="item.id"
+ :is-checked="isChecked(item.username)"
:is-check-centered="true"
- data-testid="unassign"
- @click="selectAssignee()"
+ data-testid="selected-participant"
+ @click.stop="unselect(item.username)"
>
- <span
- :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'"
- class="gl-font-weight-bold"
- >{{ $options.i18n.unassigned }}</span
- ></gl-dropdown-item
+ <sidebar-participant :user="item" />
+ </gl-dropdown-item>
+ <template v-if="showCurrentUser">
+ <gl-dropdown-divider />
+ <gl-dropdown-item
+ data-testid="current-user"
+ @click.stop="selectAssignee(currentUser)"
+ >
+ <sidebar-participant :user="currentUser" class="gl-pl-6!" />
+ </gl-dropdown-item>
+ </template>
+ <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
+ <gl-dropdown-item
+ v-for="unselectedUser in unselectedFiltered"
+ :key="unselectedUser.id"
+ data-testid="unselected-participant"
+ @click="selectAssignee(unselectedUser)"
>
- </template>
- <gl-dropdown-divider v-if="showDivider(selectedFiltered)" />
- <gl-dropdown-item
- v-for="item in selectedFiltered"
- :key="item.id"
- :is-checked="isChecked(item.username)"
- :is-check-centered="true"
- data-testid="selected-participant"
- @click.stop="unselect(item.username)"
- >
- <gl-avatar-link>
- <gl-avatar-labeled
- :size="32"
- :label="item.name"
- :sub-label="item.username"
- :src="item.avatarUrl || item.avatar || item.avatar_url"
- class="gl-align-items-center"
- />
- </gl-avatar-link>
- </gl-dropdown-item>
- <template v-if="showCurrentUser">
- <gl-dropdown-divider />
+ <sidebar-participant :user="unselectedUser" class="gl-pl-6!" />
+ </gl-dropdown-item>
<gl-dropdown-item
- data-testid="current-user"
- @click.stop="selectAssignee(currentUser)"
+ v-if="noUsersFound && !isSearching"
+ data-testid="empty-results"
+ class="gl-pl-6!"
>
- <gl-avatar-link>
- <gl-avatar-labeled
- :size="32"
- :label="currentUser.name"
- :sub-label="currentUser.username"
- :src="currentUser.avatarUrl"
- class="gl-align-items-center gl-pl-6!"
- />
- </gl-avatar-link>
+ {{ __('No matching results') }}
</gl-dropdown-item>
</template>
- <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
- <gl-dropdown-item
- v-for="unselectedUser in unselectedFiltered"
- :key="unselectedUser.id"
- data-testid="unselected-participant"
- @click="selectAssignee(unselectedUser)"
- >
- <gl-avatar-link class="gl-pl-6!">
- <gl-avatar-labeled
- :size="32"
- :label="unselectedUser.name"
- :sub-label="unselectedUser.username"
- :src="unselectedUser.avatarUrl || unselectedUser.avatar"
- class="gl-align-items-center"
- />
- </gl-avatar-link>
- </gl-dropdown-item>
- <gl-dropdown-item v-if="noUsersFound && !isSearching" data-testid="empty-results">
- {{ __('No matching results') }}
+ </template>
+ <template #footer>
+ <gl-dropdown-item>
+ <sidebar-invite-members v-if="directlyInviteMembers || indirectlyInviteMembers" />
</gl-dropdown-item>
</template>
- </template>
- </multi-select-dropdown>
- </template>
- </sidebar-editable-item>
+ </multi-select-dropdown>
+ </template>
+ </sidebar-editable-item>
+ </div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
new file mode 100644
index 00000000000..9952c6db582
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
@@ -0,0 +1,51 @@
+<script>
+import InviteMemberModal from '~/invite_member/components/invite_member_modal.vue';
+import InviteMemberTrigger from '~/invite_member/components/invite_member_trigger.vue';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import { __ } from '~/locale';
+
+export default {
+ displayText: __('Invite members'),
+ dataTrackLabel: 'edit_assignee',
+ components: {
+ InviteMemberTrigger,
+ InviteMemberModal,
+ InviteMembersTrigger,
+ },
+ inject: {
+ projectMembersPath: {
+ default: '',
+ },
+ directlyInviteMembers: {
+ default: false,
+ },
+ },
+ computed: {
+ trackEvent() {
+ return this.directlyInviteMembers ? 'click_invite_members' : 'click_invite_members_version_b';
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <invite-members-trigger
+ v-if="directlyInviteMembers"
+ trigger-element="anchor"
+ :display-text="$options.displayText"
+ :event="trackEvent"
+ :label="$options.dataTrackLabel"
+ classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!"
+ />
+ <template v-else>
+ <invite-member-trigger
+ :display-text="$options.displayText"
+ :event="trackEvent"
+ :label="$options.dataTrackLabel"
+ class="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!"
+ />
+ <invite-member-modal :members-path="projectMembersPath" />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
new file mode 100644
index 00000000000..e2a38a100b9
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+
+export default {
+ components: {
+ GlAvatarLabeled,
+ GlAvatarLink,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ userLabel() {
+ if (!this.user.status) {
+ return this.user.name;
+ }
+ return sprintf(s__('UserAvailability|%{author} (Busy)'), {
+ author: this.user.name,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-avatar-link>
+ <gl-avatar-labeled
+ :size="32"
+ :label="userLabel"
+ :sub-label="user.username"
+ :src="user.avatarUrl || user.avatar || user.avatar_url"
+ class="gl-align-items-center"
+ />
+ </gl-avatar-link>
+</template>
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 d0da4a9c75a..b7080bb05b8 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -1,4 +1,5 @@
<script>
+import { IssuableType } from '~/issue_show/constants';
import { __, sprintf } from '~/locale';
import AssigneeAvatarLink from './assignee_avatar_link.vue';
import UserNameWithStatus from './user_name_with_status.vue';
@@ -58,7 +59,10 @@ export default {
this.showLess = !this.showLess;
},
userAvailability(u) {
- return u?.availability || '';
+ if (this.issuableType === IssuableType.MergeRequest) {
+ return u?.availability || '';
+ }
+ return u?.status?.availability || '';
},
},
};
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
index a21ac73f131..1fb4bd26533 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
@@ -18,8 +18,15 @@ export default {
GlSprintf,
GlButton,
},
- inject: ['fullPath', 'iid'],
props: {
+ iid: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
confidential: {
required: true,
type: Boolean,
@@ -121,7 +128,7 @@ export default {
</gl-button>
<gl-button
category="secondary"
- variant="warning"
+ variant="confirm"
:disabled="loading"
:loading="loading"
data-testid="confidential-toggle"
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
index ec5f07f9785..372368707af 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
@@ -27,8 +27,20 @@ export default {
SidebarConfidentialityContent,
SidebarConfidentialityForm,
},
- inject: ['fullPath', 'iid'],
+ inject: {
+ isClassicSidebar: {
+ default: false,
+ },
+ },
props: {
+ iid: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
issuableType: {
required: true,
type: String,
@@ -126,6 +138,7 @@ export default {
v-if="!isLoading"
:confidential="confidential"
:issuable-type="issuableType"
+ :class="{ 'gl-mt-3': !isClassicSidebar }"
@expandSidebar="expandSidebar"
/>
</div>
@@ -133,6 +146,8 @@ export default {
<template #default>
<sidebar-confidentiality-content :confidential="confidential" :issuable-type="issuableType" />
<sidebar-confidentiality-form
+ :iid="iid"
+ :full-path="fullPath"
:confidential="confidential"
:issuable-type="issuableType"
@closeForm="closeForm"
diff --git a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
index 8c8241cf6a4..0d8cb8cb2b6 100644
--- a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
+++ b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
@@ -1,43 +1,24 @@
<script>
-import { s__, __, sprintf } from '~/locale';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import CopyableField from '../../vue_shared/components/sidebar/copyable_field.vue';
export default {
- i18n: {
- copyEmail: __('Copy email address'),
- },
components: {
- ClipboardButton,
+ CopyableField,
},
props: {
- copyText: {
+ issueEmailAddress: {
type: String,
required: true,
},
},
- computed: {
- emailText() {
- return sprintf(s__('RightSidebar|Issue email: %{copyText}'), { copyText: this.copyText });
- },
- },
};
</script>
<template>
- <div
+ <copyable-field
data-qa-selector="copy-forward-email"
- class="copy-email-address gl-display-flex gl-align-items-center gl-justify-content-space-between"
- >
- <span
- class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap hide-collapsed gl-w-85p"
- >{{ emailText }}</span
- >
- <clipboard-button
- class="copy-email-button gl-bg-none!"
- category="tertiary"
- :title="$options.i18n.copyEmail"
- :text="copyText"
- tooltip-placement="left"
- />
- </div>
+ :name="s__('RightSidebar|Issue email')"
+ :clipboard-tooltip-text="s__('RightSidebar|Copy email address')"
+ :value="issueEmailAddress"
+ />
</template>
diff --git a/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue b/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue
new file mode 100644
index 00000000000..141c2b3aae9
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue
@@ -0,0 +1,203 @@
+<script>
+import { GlButton, GlIcon, GlDatepicker, GlTooltipDirective } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { IssuableType } from '~/issue_show/constants';
+import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility';
+import { __, sprintf } from '~/locale';
+import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import { dueDateQueries } from '~/sidebar/constants';
+
+const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', {
+ bubbles: true,
+});
+
+export default {
+ tracking: {
+ event: 'click_edit_button',
+ label: 'right_sidebar',
+ property: 'dueDate',
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlButton,
+ GlIcon,
+ GlDatepicker,
+ SidebarEditableItem,
+ },
+ inject: ['fullPath', 'iid', 'canUpdate'],
+ props: {
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ dueDate: null,
+ loading: false,
+ };
+ },
+ apollo: {
+ dueDate: {
+ query() {
+ return dueDateQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: String(this.iid),
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.dueDate || null;
+ },
+ result({ data }) {
+ this.$emit('dueDateUpdated', data.workspace?.issuable?.dueDate);
+ },
+ error() {
+ createFlash({
+ message: sprintf(__('Something went wrong while setting %{issuableType} due date.'), {
+ issuableType: this.issuableType,
+ }),
+ });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.dueDate.loading || this.loading;
+ },
+ hasDueDate() {
+ return this.dueDate !== null;
+ },
+ parsedDueDate() {
+ if (!this.hasDueDate) {
+ return null;
+ }
+
+ return parsePikadayDate(this.dueDate);
+ },
+ formattedDueDate() {
+ if (!this.hasDueDate) {
+ return this.$options.i18n.noDueDate;
+ }
+
+ return dateInWords(this.parsedDueDate, true);
+ },
+ workspacePath() {
+ return this.issuableType === IssuableType.Issue
+ ? {
+ projectPath: this.fullPath,
+ }
+ : {
+ groupPath: this.fullPath,
+ };
+ },
+ },
+ methods: {
+ closeForm() {
+ this.$refs.editable.collapse();
+ this.$el.dispatchEvent(hideDropdownEvent);
+ this.$emit('closeForm');
+ },
+ openDatePicker() {
+ this.$refs.datePicker.calendar.show();
+ },
+ setDueDate(date) {
+ this.loading = true;
+ this.$refs.editable.collapse();
+ this.$apollo
+ .mutate({
+ mutation: dueDateQueries[this.issuableType].mutation,
+ variables: {
+ input: {
+ ...this.workspacePath,
+ iid: this.iid,
+ dueDate: date ? formatDate(date, 'yyyy-mm-dd') : null,
+ },
+ },
+ })
+ .then(
+ ({
+ data: {
+ issuableSetDueDate: { errors },
+ },
+ }) => {
+ if (errors.length) {
+ createFlash({
+ message: errors[0],
+ });
+ } else {
+ this.$emit('closeForm');
+ }
+ },
+ )
+ .catch(() => {
+ createFlash({
+ message: sprintf(__('Something went wrong while setting %{issuableType} due date.'), {
+ issuableType: this.issuableType,
+ }),
+ });
+ })
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ },
+ i18n: {
+ dueDate: __('Due date'),
+ noDueDate: __('None'),
+ removeDueDate: __('remove due date'),
+ },
+};
+</script>
+
+<template>
+ <sidebar-editable-item
+ ref="editable"
+ :title="$options.i18n.dueDate"
+ :tracking="$options.tracking"
+ :loading="isLoading"
+ class="block"
+ data-testid="due-date"
+ @open="openDatePicker"
+ >
+ <template #collapsed>
+ <div v-gl-tooltip :title="$options.i18n.dueDate" class="sidebar-collapsed-icon">
+ <gl-icon :size="16" name="calendar" />
+ <span class="collapse-truncated-title">{{ formattedDueDate }}</span>
+ </div>
+ <div class="gl-display-flex gl-align-items-center hide-collapsed">
+ <span
+ :class="hasDueDate ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'"
+ data-testid="sidebar-duedate-value"
+ >
+ {{ formattedDueDate }}
+ </span>
+ <div v-if="hasDueDate && canUpdate" class="gl-display-flex">
+ <span class="gl-px-2">-</span>
+ <gl-button
+ variant="link"
+ class="gl-text-gray-500!"
+ data-testid="reset-button"
+ :disabled="isLoading"
+ @click="setDueDate(null)"
+ >
+ {{ $options.i18n.removeDueDate }}
+ </gl-button>
+ </div>
+ </div>
+ </template>
+ <template #default>
+ <gl-datepicker
+ ref="datePicker"
+ :value="parsedDueDate"
+ show-clear-button
+ @input="setDueDate"
+ @clear="setDueDate(null)"
+ />
+ </template>
+ </sidebar-editable-item>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue b/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue
index 567c921b74e..d07c6e0cbd2 100644
--- a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue
+++ b/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue
@@ -1,17 +1,11 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import { referenceQueries } from '~/sidebar/constants';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue';
export default {
- i18n: {
- copyReference: __('Copy reference'),
- text: __('Reference'),
- },
components: {
- ClipboardButton,
- GlLoadingIcon,
+ CopyableField,
},
inject: ['fullPath', 'iid'],
props: {
@@ -56,29 +50,10 @@ export default {
</script>
<template>
- <div class="sub-block">
- <clipboard-button
- v-if="!isLoading"
- :title="$options.i18n.copyReference"
- :text="reference"
- category="tertiary"
- css-class="sidebar-collapsed-icon dont-change-state"
- tooltip-placement="left"
- />
- <div class="gl-display-flex gl-align-items-center gl-justify-between gl-mb-2 hide-collapsed">
- <span class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap">
- {{ $options.i18n.text }}: {{ reference }}
- <gl-loading-icon v-if="isLoading" inline :label="$options.i18n.text" />
- </span>
- <clipboard-button
- v-if="!isLoading"
- :title="$options.i18n.copyReference"
- :text="reference"
- size="small"
- category="tertiary"
- css-class="gl-mr-1"
- tooltip-placement="left"
- />
- </div>
- </div>
+ <copyable-field
+ class="sub-block"
+ :is-loading="isLoading"
+ :name="__('Reference')"
+ :value="reference"
+ />
</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
index dd1d54d67f2..c6fef86c6ff 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -1,12 +1,15 @@
<script>
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
+import { __, sprintf, s__ } from '~/locale';
import ReviewerAvatarLink from './reviewer_avatar_link.vue';
const LOADING_STATE = 'loading';
const SUCCESS_STATE = 'success';
export default {
+ i18n: {
+ reRequestReview: __('Re-request review'),
+ },
components: {
GlButton,
GlIcon,
@@ -109,7 +112,8 @@ export default {
<gl-button
v-else-if="user.can_update_merge_request && user.reviewed"
v-gl-tooltip.left
- :title="__('Re-request review')"
+ :title="$options.i18n.reRequestReview"
+ :aria-label="$options.i18n.reRequestReview"
:loading="loadingStates[user.id] === $options.LOADING_STATE"
class="float-right gl-text-gray-500!"
size="small"
diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
index 4ab4606ac1c..caf1c92c28a 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
export default {
components: { GlButton, GlLoadingIcon },
@@ -20,6 +21,16 @@ export default {
required: false,
default: false,
},
+ initialLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isDirty: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
tracking: {
type: Object,
required: false,
@@ -35,6 +46,11 @@ export default {
edit: false,
};
},
+ computed: {
+ editButtonText() {
+ return this.isDirty ? __('Apply') : __('Edit');
+ },
+ },
destroyed() {
window.removeEventListener('click', this.collapseWhenOffClick);
window.removeEventListener('keyup', this.collapseOnEscape);
@@ -86,15 +102,15 @@ export default {
<template>
<div>
<div class="gl-display-flex gl-align-items-center" @click.self="collapse">
- <span class="hide-collapsed" data-testid="title">{{ title }}</span>
- <gl-loading-icon v-if="loading" inline class="gl-ml-2 hide-collapsed" />
+ <span class="hide-collapsed" data-testid="title" @click="collapse">{{ title }}</span>
+ <gl-loading-icon v-if="loading || initialLoading" inline class="gl-ml-2 hide-collapsed" />
<gl-loading-icon
v-if="loading && isClassicSidebar"
inline
class="gl-mx-auto gl-my-0 hide-expanded"
/>
<gl-button
- v-if="canUpdate"
+ v-if="canUpdate && !initialLoading"
variant="link"
class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto hide-collapsed"
data-testid="edit-button"
@@ -105,14 +121,16 @@ export default {
@keyup.esc="toggle"
@click="toggle"
>
- {{ __('Edit') }}
+ {{ editButtonText }}
</gl-button>
</div>
- <div v-show="!edit" data-testid="collapsed-content">
- <slot name="collapsed">{{ __('None') }}</slot>
- </div>
- <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }">
- <slot :edit="edit"></slot>
- </div>
+ <template v-if="!initialLoading">
+ <div v-show="!edit" data-testid="collapsed-content">
+ <slot name="collapsed">{{ __('None') }}</slot>
+ </div>
+ <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }">
+ <slot :edit="edit"></slot>
+ </div>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index a0e636488f4..80e07d556bf 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -1,10 +1,12 @@
import { IssuableType } from '~/issue_show/constants';
import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql';
import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
+import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql';
import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql';
import updateEpicMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql';
import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql';
+import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql';
import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
@@ -42,3 +44,10 @@ export const referenceQueries = {
query: mergeRequestReferenceQuery,
},
};
+
+export const dueDateQueries = {
+ [IssuableType.Issue]: {
+ query: issueDueDateQuery,
+ mutation: updateIssueDueDateMutation,
+ },
+};
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 312c0c89f29..1304e84814b 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -10,7 +10,10 @@ import {
parseBoolean,
} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
+import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue';
+import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
+import SidebarDueDateWidget from '~/sidebar/components/due_date/sidebar_due_date_widget.vue';
import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
import { apolloProvider } from '~/sidebar/graphql';
import Translate from '../vue_shared/translate';
@@ -32,15 +35,6 @@ function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-op
return JSON.parse(sidebarOptEl.innerHTML);
}
-/**
- * Extracts the list of assignees with availability information from a hidden input
- * field and converts to a key:value pair for use in the sidebar assignees component.
- * The assignee username is used as the key and their busy status is the value
- *
- * e.g { root: 'busy', admin: '' }
- *
- * @returns {Object}
- */
function getSidebarAssigneeAvailabilityData() {
const sidebarAssigneeEl = document.querySelectorAll('.js-sidebar-assignee-data input');
return Array.from(sidebarAssigneeEl)
@@ -54,7 +48,7 @@ function getSidebarAssigneeAvailabilityData() {
);
}
-function mountAssigneesComponent(mediator) {
+function mountAssigneesComponentDeprecated(mediator) {
const el = document.getElementById('js-vue-sidebar-assignees');
if (!el) return;
@@ -86,6 +80,51 @@ function mountAssigneesComponent(mediator) {
});
}
+function mountAssigneesComponent() {
+ const el = document.getElementById('js-vue-sidebar-assignees');
+
+ if (!el) return;
+
+ const { iid, fullPath, editable, projectMembersPath } = getSidebarOptions();
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ components: {
+ SidebarAssigneesWidget,
+ },
+ provide: {
+ canUpdate: editable,
+ projectMembersPath,
+ directlyInviteMembers: el.hasAttribute('data-directly-invite-members'),
+ indirectlyInviteMembers: el.hasAttribute('data-indirectly-invite-members'),
+ },
+ render: (createElement) =>
+ createElement('sidebar-assignees-widget', {
+ props: {
+ iid: String(iid),
+ fullPath,
+ issuableType:
+ isInIssuePage() || isInIncidentPage() || isInDesignPage()
+ ? IssuableType.Issue
+ : IssuableType.MergeRequest,
+ multipleAssignees: !el.dataset.maxAssignees,
+ },
+ scopedSlots: {
+ collapsed: ({ users, onClick }) =>
+ createElement(CollapsedAssigneeList, {
+ props: {
+ users,
+ },
+ nativeOn: {
+ click: onClick,
+ },
+ }),
+ },
+ }),
+ });
+}
+
function mountReviewersComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-reviewers');
@@ -151,14 +190,14 @@ function mountConfidentialComponent() {
SidebarConfidentialityWidget,
},
provide: {
- iid: String(iid),
- fullPath,
canUpdate: initialData.is_editable,
},
render: (createElement) =>
createElement('sidebar-confidentiality-widget', {
props: {
+ iid: String(iid),
+ fullPath,
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue
@@ -168,6 +207,36 @@ function mountConfidentialComponent() {
});
}
+function mountDueDateComponent() {
+ const el = document.getElementById('js-due-date-entry-point');
+ if (!el) {
+ return;
+ }
+
+ const { fullPath, iid, editable } = getSidebarOptions();
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ components: {
+ SidebarDueDateWidget,
+ },
+ provide: {
+ iid: String(iid),
+ fullPath,
+ canUpdate: editable,
+ },
+
+ render: (createElement) =>
+ createElement('sidebar-due-date-widget', {
+ props: {
+ issuableType: IssuableType.Issue,
+ },
+ }),
+ });
+}
+
function mountReferenceComponent() {
const el = document.getElementById('js-reference-entry-point');
if (!el) {
@@ -337,14 +406,22 @@ function mountCopyEmailComponent() {
new Vue({
el,
render: (createElement) =>
- createElement(CopyEmailToClipboard, { props: { copyText: createNoteEmail } }),
+ createElement(CopyEmailToClipboard, { props: { issueEmailAddress: createNoteEmail } }),
});
}
+const isAssigneesWidgetShown =
+ (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget;
+
export function mountSidebar(mediator) {
- mountAssigneesComponent(mediator);
+ if (isAssigneesWidgetShown) {
+ mountAssigneesComponent();
+ } else {
+ mountAssigneesComponentDeprecated(mediator);
+ }
mountReviewersComponent(mediator);
mountConfidentialComponent(mediator);
+ mountDueDateComponent(mediator);
mountReferenceComponent(mediator);
mountLockComponent();
mountParticipantsComponent(mediator);
diff --git a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql
new file mode 100644
index 00000000000..6d3f782bd0a
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql
@@ -0,0 +1,10 @@
+query issueDueDate($fullPath: ID!, $iid: String) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
+ issuable: issue(iid: $iid) {
+ __typename
+ id
+ dueDate
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql
new file mode 100644
index 00000000000..f2b806102f4
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql
@@ -0,0 +1,8 @@
+mutation epicSetSubscription($input: EpicSetSubscriptionInput!) {
+ updateIssuableSubscription: epicSetSubscription(input: $input) {
+ epic {
+ subscribed
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql
new file mode 100644
index 00000000000..317b48c142d
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql
@@ -0,0 +1,8 @@
+mutation updateEpic($input: UpdateEpicInput!) {
+ updateIssuableTitle: updateEpic(input: $input) {
+ epic {
+ title
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql
new file mode 100644
index 00000000000..cf7eccd61c7
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql
@@ -0,0 +1,9 @@
+mutation updateIssueDueDate($input: UpdateIssueInput!) {
+ issuableSetDueDate: updateIssue(input: $input) {
+ issuable: issue {
+ id
+ dueDate
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index bee9d7b8c2a..c53d0575752 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -33,7 +33,6 @@ export default {
SnippetBlobActionsEdit,
TitleField,
FormFooterActions,
- CaptchaModal: () => import('~/captcha/captcha_modal.vue'),
GlButton,
GlLoadingIcon,
},
@@ -68,10 +67,6 @@ export default {
description: '',
visibilityLevel: this.selectedLevel,
},
- captchaResponse: '',
- needsCaptchaResponse: false,
- captchaSiteKey: '',
- spamLogId: '',
};
},
computed: {
@@ -103,8 +98,6 @@ export default {
description: this.snippet.description,
visibilityLevel: this.snippet.visibilityLevel,
blobActions: this.actions,
- ...(this.spamLogId && { spamLogId: this.spamLogId }),
- ...(this.captchaResponse && { captchaResponse: this.captchaResponse }),
};
},
saveButtonLabel() {
@@ -171,20 +164,14 @@ export default {
},
handleFormSubmit() {
this.isUpdating = true;
+
this.$apollo
.mutate(this.newSnippet ? this.createMutation() : this.updateMutation())
.then(({ data }) => {
const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet;
- if (baseObj.needsCaptchaResponse) {
- // If we need a captcha response, start process for receiving captcha response.
- // We will resubmit after the response is obtained.
- this.requestCaptchaResponse(baseObj.captchaSiteKey, baseObj.spamLogId);
- return;
- }
-
const errors = baseObj?.errors;
- if (errors.length) {
+ if (errors?.length) {
this.flashAPIFailure(errors[0]);
} else {
redirectTo(baseObj.snippet.webUrl);
@@ -200,38 +187,6 @@ export default {
updateActions(actions) {
this.actions = actions;
},
- /**
- * Start process for getting captcha response from user
- *
- * @param captchaSiteKey Stored in data and used to display the captcha.
- * @param spamLogId Stored in data and included when the form is re-submitted.
- */
- requestCaptchaResponse(captchaSiteKey, spamLogId) {
- this.captchaSiteKey = captchaSiteKey;
- this.spamLogId = spamLogId;
- this.needsCaptchaResponse = true;
- },
- /**
- * Handle the captcha response from the user
- *
- * @param captchaResponse The captchaResponse value emitted from the modal.
- */
- receivedCaptchaResponse(captchaResponse) {
- this.needsCaptchaResponse = false;
- this.captchaResponse = captchaResponse;
-
- if (this.captchaResponse) {
- // If the user solved the captcha, resubmit the form.
- // NOTE: we do not need to clear out the captchaResponse and spamLogId
- // data values after submit, because this component always does a full page reload.
- // Otherwise, we would need to.
- this.handleFormSubmit();
- } else {
- // If the user didn't solve the captcha (e.g. they just closed the modal),
- // finish the update and allow them to continue editing or manually resubmit the form.
- this.isUpdating = false;
- }
- },
},
};
</script>
@@ -249,11 +204,6 @@ export default {
class="loading-animation prepend-top-20 gl-mb-6"
/>
<template v-else>
- <captcha-modal
- :captcha-site-key="captchaSiteKey"
- :needs-captcha-response="needsCaptchaResponse"
- @receivedCaptchaResponse="receivedCaptchaResponse"
- />
<title-field
id="snippet-title"
v-model="snippet.title"
diff --git a/app/assets/javascripts/snippets/components/embed_dropdown.vue b/app/assets/javascripts/snippets/components/embed_dropdown.vue
index f6c9c569b5f..ad1b08a5a07 100644
--- a/app/assets/javascripts/snippets/components/embed_dropdown.vue
+++ b/app/assets/javascripts/snippets/components/embed_dropdown.vue
@@ -65,6 +65,7 @@ export default {
<gl-button
v-gl-tooltip.hover
:title="$options.MSG_COPY"
+ :aria-label="$options.MSG_COPY"
:data-clipboard-text="value"
icon="copy-to-clipboard"
data-qa-selector="copy_button"
diff --git a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
index 64d5d7c30fa..f688868d1b9 100644
--- a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
+++ b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
@@ -4,7 +4,5 @@ mutation CreateSnippet($input: CreateSnippetInput!) {
snippet {
webUrl
}
- needsCaptchaResponse
- captchaSiteKey
}
}
diff --git a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
index 0a72f71b7c9..548725f7357 100644
--- a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
+++ b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
@@ -4,8 +4,5 @@ mutation UpdateSnippet($input: UpdateSnippetInput!) {
snippet {
webUrl
}
- needsCaptchaResponse
- captchaSiteKey
- spamLogId
}
}
diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js
index 23f800517c9..2ae2baddbcc 100644
--- a/app/assets/javascripts/static_site_editor/graphql/index.js
+++ b/app/assets/javascripts/static_site_editor/graphql/index.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import appDataQuery from './queries/app_data.query.graphql';
import fileResolver from './resolvers/file';
import hasSubmittedChangesResolver from './resolvers/has_submitted_changes';
import submitContentChangesResolver from './resolvers/submit_content_changes';
@@ -28,7 +29,8 @@ const createApolloProvider = (appData) => {
// eslint-disable-next-line @gitlab/require-i18n-strings
const mounts = appData.mounts.map((mount) => ({ __typename: 'Mount', ...mount }));
- defaultClient.cache.writeData({
+ defaultClient.cache.writeQuery({
+ query: appDataQuery,
data: {
appData: {
__typename: 'AppData',
diff --git a/app/assets/javascripts/tags/components/sort_dropdown.vue b/app/assets/javascripts/tags/components/sort_dropdown.vue
new file mode 100644
index 00000000000..036ce2cca78
--- /dev/null
+++ b/app/assets/javascripts/tags/components/sort_dropdown.vue
@@ -0,0 +1,77 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui';
+import { mergeUrlParams, visitUrl, getParameterValues } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ searchPlaceholder: s__('TagsPage|Filter by tag name'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByClick,
+ },
+ inject: ['sortOptions', 'filterTagsPath'],
+ data() {
+ return {
+ selectedKey: 'updated_desc',
+ searchTerm: '',
+ };
+ },
+ computed: {
+ selectedSortMethod() {
+ return this.sortOptions[this.selectedKey];
+ },
+ },
+ created() {
+ const sortValue = getParameterValues('sort');
+ const searchValue = getParameterValues('search');
+
+ if (sortValue.length > 0) {
+ [this.selectedKey] = sortValue;
+ }
+
+ if (searchValue.length > 0) {
+ [this.searchTerm] = searchValue;
+ }
+ },
+ methods: {
+ isSortMethodSelected(sortKey) {
+ return sortKey === this.selectedKey;
+ },
+ visitUrlFromOption(sortKey) {
+ this.selectedKey = sortKey;
+ const urlParams = {};
+
+ urlParams.search = this.searchTerm.length > 0 ? this.searchTerm : null;
+ urlParams.sort = sortKey;
+
+ const newUrl = mergeUrlParams(urlParams, this.filterTagsPath);
+ visitUrl(newUrl);
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-pr-3">
+ <gl-search-box-by-click
+ v-model="searchTerm"
+ :placeholder="$options.i18n.searchPlaceholder"
+ class="gl-pr-3"
+ data-testid="tag-search"
+ @submit="visitUrlFromOption(selectedKey)"
+ />
+ <gl-dropdown :text="selectedSortMethod" right data-testid="tags-dropdown">
+ <gl-dropdown-item
+ v-for="(value, key) in sortOptions"
+ :key="key"
+ :is-checked="isSortMethodSelected(key)"
+ is-check-item
+ @click="visitUrlFromOption(key)"
+ >
+ {{ value }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/tags/index.js b/app/assets/javascripts/tags/index.js
new file mode 100644
index 00000000000..68510f3fe3a
--- /dev/null
+++ b/app/assets/javascripts/tags/index.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import SortDropdown from './components/sort_dropdown.vue';
+
+const mountDropdownApp = (el) => {
+ const { sortOptions, filterTagsPath } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'SortTagsDropdownApp',
+ components: {
+ SortDropdown,
+ },
+ provide: {
+ sortOptions: JSON.parse(sortOptions),
+ filterTagsPath,
+ },
+ render: (createElement) => createElement(SortDropdown),
+ });
+};
+
+export default () => {
+ const el = document.getElementById('js-tags-sort-dropdown');
+ return el ? mountDropdownApp(el) : null;
+};
diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js
index f60c0759c72..49a43b120e0 100644
--- a/app/assets/javascripts/tooltips/index.js
+++ b/app/assets/javascripts/tooltips/index.js
@@ -44,10 +44,7 @@ const addTooltips = (elements, config) => {
const handleTooltipEvent = (rootTarget, e, selector, config = {}) => {
for (let { target } = e; target && target !== rootTarget; target = target.parentNode) {
if (isTooltip(target, selector)) {
- addTooltips([target], {
- show: true,
- ...config,
- });
+ addTooltips([target], config);
break;
}
}
diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js
index 01de034417e..cdfecceb78a 100644
--- a/app/assets/javascripts/tracking.js
+++ b/app/assets/javascripts/tracking.js
@@ -27,22 +27,33 @@ const DEFAULT_SNOWPLOW_OPTIONS = {
pageUnloadTimer: 10,
};
+const addExperimentContext = (opts) => {
+ const { experiment, ...options } = opts;
+ if (experiment) {
+ const data = getExperimentData(experiment);
+ if (data) {
+ const context = { schema: TRACKING_CONTEXT_SCHEMA, data };
+ return { ...options, context };
+ }
+ }
+ return options;
+};
+
const createEventPayload = (el, { suffix = '' } = {}) => {
- const action = el.dataset.trackEvent + (suffix || '');
+ const action = (el.dataset.trackAction || el.dataset.trackEvent) + (suffix || '');
let value = el.dataset.trackValue || el.value || undefined;
if (el.type === 'checkbox' && !el.checked) value = false;
- let context = el.dataset.trackContext;
- if (el.dataset.trackExperiment) {
- const data = getExperimentData(el.dataset.trackExperiment);
- if (data) context = { schema: TRACKING_CONTEXT_SCHEMA, data };
- }
+ const context = addExperimentContext({
+ experiment: el.dataset.trackExperiment,
+ context: el.dataset.trackContext,
+ });
const data = {
label: el.dataset.trackLabel,
property: el.dataset.trackProperty,
value,
- context,
+ ...context,
};
return {
@@ -52,7 +63,7 @@ const createEventPayload = (el, { suffix = '' } = {}) => {
};
const eventHandler = (e, func, opts = {}) => {
- const el = e.target.closest('[data-track-event]');
+ const el = e.target.closest('[data-track-event], [data-track-action]');
if (!el) return;
@@ -130,7 +141,9 @@ export default class Tracking {
static trackLoadEvents(category = document.body.dataset.page, parent = document) {
if (!this.enabled()) return [];
- const loadEvents = parent.querySelectorAll('[data-track-event="render"]');
+ const loadEvents = parent.querySelectorAll(
+ '[data-track-action="render"], [data-track-event="render"]',
+ );
loadEvents.forEach((element) => {
const { action, data } = createEventPayload(element);
@@ -148,7 +161,8 @@ export default class Tracking {
return localCategory || opts.category;
},
trackingOptions() {
- return { ...opts, ...this.tracking };
+ const options = addExperimentContext(opts);
+ return { ...options, ...this.tracking };
},
},
methods: {
diff --git a/app/assets/javascripts/user_lists/components/user_list.vue b/app/assets/javascripts/user_lists/components/user_list.vue
index e33a4b3ffb4..4cf3f3010b9 100644
--- a/app/assets/javascripts/user_lists/components/user_list.vue
+++ b/app/assets/javascripts/user_lists/components/user_list.vue
@@ -126,6 +126,7 @@ export default {
category="secondary"
variant="danger"
icon="remove"
+ :aria-label="__('Remove user')"
data-testid="delete-user-id"
@click="removeUserId(id)"
/>
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index e1a4a74b982..7c17ce85cc6 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -11,7 +11,6 @@ import {
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { isUserBusy } from '~/set_status_modal/utils';
import { fixTitle, dispose } from '~/tooltips';
-import ModalStore from '../boards/stores/modal_store';
import axios from '../lib/utils/axios_utils';
import { parseBoolean, spriteIcon } from '../lib/utils/common_utils';
import { loadCSSFile } from '../lib/utils/css_utils';
@@ -258,7 +257,11 @@ function UsersSelect(currentUser, els, options = {}) {
deprecatedJQueryDropdown.options.processData(term, users, callback);
});
},
- processData(term, data, callback) {
+ processData(term, dataArg, callback) {
+ // Sometimes the `dataArg` can contain special dropdown items like
+ // dividers which we don't want to consider here.
+ const data = dataArg.filter((x) => !x.type);
+
let users = data;
// Only show assigned user list when there is no search term
@@ -504,9 +507,7 @@ function UsersSelect(currentUser, els, options = {}) {
}
return;
}
- if ($el.closest('.add-issues-modal').length) {
- ModalStore.store.filter[$dropdown.data('fieldName')] = user.id;
- } else if (handleClick) {
+ if (handleClick) {
e.preventDefault();
handleClick(user, isMarking);
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
index cc3efae565a..b25c0cc0d96 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
@@ -68,6 +68,7 @@ export default {
category="primary"
size="small"
:title="buttonTitle"
+ :aria-label="buttonTitle"
:loading="isLoading"
:disabled="isActionInProgress"
:class="`inline gl-ml-2 ${containerClasses}`"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 3419abd4738..1248a891ed9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -14,6 +14,7 @@ import { s__, n__ } from '~/locale';
import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import { MT_MERGE_STRATEGY } from '../constants';
@@ -28,6 +29,7 @@ export default {
GlTooltip,
PipelineArtifacts,
PipelineMiniGraph,
+ TimeAgoTooltip,
TooltipOnTruncate,
LinkedPipelinesMiniList: () =>
import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
@@ -114,6 +116,9 @@ export default {
showSourceBranch() {
return Boolean(this.pipeline.ref.branch);
},
+ finishedAt() {
+ return this.pipeline?.details?.finished_at;
+ },
coverageDeltaClass() {
const delta = this.pipelineCoverageDelta;
if (delta && parseFloat(delta) > 0) {
@@ -127,10 +132,20 @@ export default {
pipelineCoverageJobNumberText() {
return n__('from %d job', 'from %d jobs', this.buildsWithCoverage.length);
},
+ pipelineCoverageTooltipDeltaDescription() {
+ const delta = parseFloat(this.pipelineCoverageDelta) || 0;
+ if (delta > 0) {
+ return s__('Pipeline|This change will increase the overall test coverage if merged.');
+ }
+ if (delta < 0) {
+ return s__('Pipeline|This change will decrease the overall test coverage if merged.');
+ }
+ return s__('Pipeline|This change will not change the overall test coverage if merged.');
+ },
pipelineCoverageTooltipDescription() {
return n__(
- 'Coverage value for this pipeline was calculated by the coverage value of %d job.',
- 'Coverage value for this pipeline was calculated by averaging the resulting coverage values of %d jobs.',
+ 'Test coverage value for this pipeline was calculated by the coverage value of %d job.',
+ 'Test coverage value for this pipeline was calculated by averaging the resulting coverage values of %d jobs.',
this.buildsWithCoverage.length,
);
},
@@ -216,15 +231,24 @@ export default {
class="label-branch label-truncate gl-font-weight-normal"
/>
</template>
+ <template v-if="finishedAt">
+ <time-ago-tooltip
+ :time="finishedAt"
+ tooltip-placement="bottom"
+ data-testid="finished-at"
+ />
+ </template>
</div>
<div v-if="pipeline.coverage" class="coverage" data-testid="pipeline-coverage">
- {{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}%
+ {{ s__('Pipeline|Test coverage') }} {{ pipeline.coverage }}%
<span
v-if="pipelineCoverageDelta"
+ ref="pipelineCoverageDelta"
:class="coverageDeltaClass"
data-testid="pipeline-coverage-delta"
- >({{ pipelineCoverageDelta }}%)</span
>
+ ({{ pipelineCoverageDelta }}%)
+ </span>
{{ pipelineCoverageJobNumberText }}
<span ref="pipelineCoverageQuestion">
<gl-icon name="question" :size="12" />
@@ -242,6 +266,12 @@ export default {
{{ build.name }} ({{ build.coverage }}%)
</div>
</gl-tooltip>
+ <gl-tooltip
+ :target="() => $refs.pipelineCoverageDelta"
+ data-testid="pipeline-coverage-delta-tooltip"
+ >
+ {{ pipelineCoverageTooltipDeltaDescription }}
+ </gl-tooltip>
</div>
</div>
</div>
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 84a21a25552..6d68c15cf2d 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
@@ -71,11 +71,11 @@ export default {
return (this.glFeatures.mergeRequestWidgetGraphql ? this.state : this.mr).targetBranch;
},
shouldRemoveSourceBranch() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.state.shouldRemoveSourceBranch || this.state.forceRemoveSourceBranch;
- }
+ if (!this.glFeatures.mergeRequestWidgetGraphql) return this.mr.shouldRemoveSourceBranch;
+
+ if (!this.state.shouldRemoveSourceBranch) return false;
- return this.mr.shouldRemoveSourceBranch;
+ return this.state.shouldRemoveSourceBranch || this.state.forceRemoveSourceBranch;
},
autoMergeStrategy() {
return (this.glFeatures.mergeRequestWidgetGraphql ? this.state : this.mr).autoMergeStrategy;
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index 23f415c3116..ee90d734ecb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -1,6 +1,5 @@
<script>
-import { GlButton, GlModalDirective, GlSkeletonLoader, GlPopover, GlLink } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { GlButton, GlModalDirective, GlSkeletonLoader } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import userPermissionsQuery from '../../queries/permissions.query.graphql';
@@ -13,8 +12,6 @@ export default {
GlSkeletonLoader,
StatusIcon,
GlButton,
- GlPopover,
- GlLink,
},
directives: {
GlModalDirective,
@@ -93,24 +90,12 @@ export default {
return this.mr.sourceBranchProtected;
},
- popoverTitle() {
- return s__(
- 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.',
- );
- },
showResolveButton() {
- return this.mr.conflictResolutionPath && this.canPushToSourceBranch;
- },
- showPopover() {
- return this.showResolveButton && this.sourceBranchProtected;
+ return (
+ this.mr.conflictResolutionPath && this.canPushToSourceBranch && !this.sourceBranchProtected
+ );
},
},
- i18n: {
- title: s__(
- 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.',
- ),
- linkText: s__('mrWidget|Learn more about resolving conflicts'),
- },
};
</script>
<template>
@@ -141,33 +126,13 @@ export default {
}}
</span>
</span>
- <span v-if="showResolveButton" ref="popover">
- <gl-button
- :href="mr.conflictResolutionPath"
- :disabled="sourceBranchProtected"
- data-testid="resolve-conflicts-button"
- >
- {{ s__('mrWidget|Resolve conflicts') }}
- </gl-button>
- <gl-popover
- v-if="showPopover"
- :target="() => $refs.popover"
- placement="top"
- triggers="hover focus"
- >
- <template #title>
- <div class="gl-font-weight-normal gl-font-base">
- {{ $options.i18n.title }}
- </div>
- </template>
-
- <div class="gl-text-center">
- <gl-link :href="mr.conflictsDocsPath" target="_blank" rel="noopener noreferrer">
- {{ $options.i18n.linkText }}
- </gl-link>
- </div>
- </gl-popover>
- </span>
+ <gl-button
+ v-if="showResolveButton"
+ :href="mr.conflictResolutionPath"
+ data-testid="resolve-conflicts-button"
+ >
+ {{ s__('mrWidget|Resolve conflicts') }}
+ </gl-button>
<gl-button
v-if="canMerge"
v-gl-modal-directive="'modal-merge-info'"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
index 043d14e32a2..9da3bea9362 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -130,6 +130,7 @@ export default {
size="small"
category="secondary"
variant="warning"
+ data-qa-selector="revert_button"
@click="openRevertModal"
>
{{ revertLabel }}
@@ -151,6 +152,7 @@ export default {
v-gl-tooltip.hover
:title="cherryPickTitle"
size="small"
+ data-qa-selector="cherry_pick_button"
@click="openCherryPickModal"
>
{{ cherryPickLabel }}
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 62c5cd90035..751f8082e1a 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
@@ -15,10 +15,12 @@ import { isEmpty } from 'lodash';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
+import createFlash from '~/flash';
+import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import simplePoll from '~/lib/utils/simple_poll';
import { __ } from '~/locale';
+import SmartInterval from '~/smart_interval';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { deprecatedCreateFlash as Flash } from '../../../flash';
import MergeRequest from '../../../merge_request';
import { AUTO_MERGE_STRATEGIES, DANGER, INFO, WARNING } from '../../constants';
import eventHub from '../../event_hub';
@@ -52,20 +54,27 @@ export default {
},
manual: true,
result({ data }) {
+ if (Object.keys(this.state).length === 0) {
+ this.removeSourceBranch =
+ data.project.mergeRequest.shouldRemoveSourceBranch ||
+ data.project.mergeRequest.forceRemoveSourceBranch ||
+ false;
+ this.commitMessage = data.project.mergeRequest.defaultMergeCommitMessage;
+ this.squashBeforeMerge = data.project.mergeRequest.squashOnMerge;
+ this.isSquashReadOnly = data.project.squashReadOnly;
+ this.squashCommitMessage = data.project.mergeRequest.defaultSquashCommitMessage;
+ }
+
this.state = {
...data.project.mergeRequest,
mergeRequestsFfOnlyEnabled: data.project.mergeRequestsFfOnlyEnabled,
onlyAllowMergeIfPipelineSucceeds: data.project.onlyAllowMergeIfPipelineSucceeds,
};
- this.removeSourceBranch =
- data.project.mergeRequest.shouldRemoveSourceBranch ||
- data.project.mergeRequest.forceRemoveSourceBranch ||
- false;
- this.commitMessage = data.project.mergeRequest.defaultMergeCommitMessage;
- this.squashBeforeMerge = data.project.mergeRequest.squashOnMerge;
- this.isSquashReadOnly = data.project.squashReadOnly;
- this.squashCommitMessage = data.project.mergeRequest.defaultSquashCommitMessage;
this.loading = false;
+
+ if (this.state.mergeTrainsCount !== null && this.state.mergeTrainsCount !== undefined) {
+ this.initPolling();
+ }
},
},
},
@@ -124,7 +133,7 @@ export default {
},
pipeline() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.state.pipelines?.nodes?.[0];
+ return this.state.headPipeline;
}
return this.mr.pipeline;
@@ -291,8 +300,23 @@ export default {
if (this.glFeatures.mergeRequestWidgetGraphql) {
eventHub.$off('ApprovalUpdated', this.updateGraphqlState);
}
+
+ if (this.pollingInterval) {
+ this.pollingInterval.destroy();
+ }
},
methods: {
+ initPolling() {
+ const startingPollInterval = secondsToMilliseconds(5);
+
+ this.pollingInterval = new SmartInterval({
+ callback: () => this.$apollo.queries.state.refetch(),
+ startingInterval: startingPollInterval,
+ maxInterval: startingPollInterval + secondsToMilliseconds(4 * 60),
+ hiddenInterval: secondsToMilliseconds(6 * 60),
+ incrementByFactorOf: 2,
+ });
+ },
updateGraphqlState() {
return this.$apollo.queries.state.refetch();
},
@@ -351,7 +375,9 @@ export default {
})
.catch(() => {
this.isMakingRequest = false;
- new Flash(__('Something went wrong. Please try again.')); // eslint-disable-line
+ createFlash({
+ message: __('Something went wrong. Please try again.'),
+ });
});
},
handleMergeImmediatelyButtonClick() {
@@ -402,7 +428,9 @@ export default {
}
})
.catch(() => {
- new Flash(__('Something went wrong while merging this merge request. Please try again.')); // eslint-disable-line
+ createFlash({
+ message: __('Something went wrong while merging this merge request. Please try again.'),
+ });
stopPolling();
});
},
@@ -432,7 +460,9 @@ export default {
}
})
.catch(() => {
- new Flash(__('Something went wrong while deleting the source branch. Please try again.')); // eslint-disable-line
+ createFlash({
+ message: __('Something went wrong while deleting the source branch. Please try again.'),
+ });
});
},
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
index 6388b817e46..41b5983ae0c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
@@ -44,7 +44,8 @@ export default {
:checked="value"
:disabled="isDisabled"
name="squash"
- class="qa-squash-checkbox js-squash-checkbox gl-mr-2 gl-display-flex gl-align-items-center"
+ class="js-squash-checkbox gl-mr-2 gl-display-flex gl-align-items-center"
+ data-qa-selector="squash_checkbox"
:title="tooltipTitle"
@change="(checked) => $emit('input', checked)"
>
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 89b095fbfc1..264ea36137f 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
@@ -480,6 +480,7 @@ export default {
v-if="mr.testResultsPath"
class="js-reports-container"
:endpoint="mr.testResultsPath"
+ :head-blob-path="mr.headBlobPath"
:pipeline-path="mr.pipeline.path"
/>
@@ -513,7 +514,7 @@ export default {
>
{{
s__(
- 'mrWidget|Fork merge requests do not create merge request pipelines which validate a post merge result',
+ 'mrWidget|If the last pipeline ran in the fork project, it may be inaccurate. Before merge, we advise running a pipeline in this project.',
)
}}
</mr-widget-alert-message>
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
index 13ea07884b1..871aa880b36 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
@@ -11,11 +11,10 @@ query getState($projectPath: ID!, $iid: String!) {
mergeError
mergeStatus
mergeableDiscussionsState
- pipelines(first: 1) {
- nodes {
- status
- warnings
- }
+ headPipeline {
+ id
+ status
+ warnings
}
shouldBeRebased
sourceBranchExists
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
index 8ee45b05431..367b9ad1cdf 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
@@ -30,13 +30,11 @@ fragment ReadyToMerge on Project {
message
}
}
- pipelines(first: 1) {
- nodes {
- id
- status
- path
- active
- }
+ headPipeline {
+ id
+ status
+ path
+ active
}
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 7ccbd771379..f57b638dd81 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -60,6 +60,7 @@ export default class MergeRequestStore {
this.rebaseInProgress = data.rebase_in_progress;
this.mergeRequestDiffsPath = data.diffs_path;
this.approvalsWidgetType = data.approvals_widget_type;
+ this.mergeRequestWidgetPath = data.merge_request_widget_path;
if (data.issues_links) {
const links = data.issues_links;
@@ -163,7 +164,7 @@ export default class MergeRequestStore {
setGraphqlData(project) {
const { mergeRequest } = project;
- const pipeline = mergeRequest.pipelines?.nodes?.[0];
+ const pipeline = mergeRequest.headPipeline;
this.projectArchived = project.archived;
this.onlyAllowMergeIfPipelineSucceeds = project.onlyAllowMergeIfPipelineSucceeds;
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
index f7b49a85b83..3905ce2596c 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
@@ -21,7 +21,7 @@ import Tracking from '~/tracking';
import initUserPopovers from '~/user_popovers';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { SEVERITY_LEVELS } from '../constants';
+import { PAGE_CONFIG, SEVERITY_LEVELS } from '../constants';
import createIssueMutation from '../graphql/mutations/alert_issue_create.mutation.graphql';
import toggleSidebarStatusMutation from '../graphql/mutations/alert_sidebar_status.mutation.graphql';
import alertQuery from '../graphql/queries/alert_details.query.graphql';
@@ -92,6 +92,9 @@ export default {
projectIssuesPath: {
default: '',
},
+ statuses: {
+ default: PAGE_CONFIG.OPERATIONS.STATUSES,
+ },
trackAlertsDetailsViewsOptions: {
default: null,
},
@@ -367,7 +370,7 @@ export default {
>
{{ alert.runbook }}
</alert-summary-row>
- <alert-details-table :alert="alert" :loading="loading" />
+ <alert-details-table :alert="alert" :loading="loading" :statuses="statuses" />
</gl-tab>
<gl-tab
v-if="!isThreatMonitoringPage"
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue
index a01bd462196..554c7a573fe 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue
@@ -19,10 +19,6 @@ export default {
projectId: {
default: '',
},
- // TODO remove this limitation in https://gitlab.com/gitlab-org/gitlab/-/issues/296717
- isThreatMonitoringPage: {
- default: false,
- },
},
props: {
alert: {
@@ -66,7 +62,6 @@ export default {
@alert-error="$emit('alert-error', $event)"
/>
<sidebar-status
- v-if="!isThreatMonitoringPage"
:project-path="projectPath"
:alert="alert"
@toggle-sidebar="$emit('toggle-sidebar')"
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue
index 8d5eb24ed1d..672761af1cf 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue
@@ -3,6 +3,7 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import updateAlertStatusMutation from '~/graphql_shared/mutations/alert_status_update.mutation.graphql';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
+import { PAGE_CONFIG } from '../constants';
export default {
i18n: {
@@ -11,11 +12,6 @@ export default {
),
UPDATE_ALERT_STATUS_INSTRUCTION: s__('AlertManagement|Please try again.'),
},
- statuses: {
- TRIGGERED: s__('AlertManagement|Triggered'),
- ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
- RESOLVED: s__('AlertManagement|Resolved'),
- },
components: {
GlDropdown,
GlDropdownItem,
@@ -42,6 +38,11 @@ export default {
type: Boolean,
required: true,
},
+ statuses: {
+ type: Object,
+ required: false,
+ default: () => PAGE_CONFIG.OPERATIONS.STATUSES,
+ },
},
computed: {
dropdownClass() {
@@ -57,13 +58,13 @@ export default {
mutation: updateAlertStatusMutation,
variables: {
iid: this.alert.iid,
- status: status.toUpperCase(),
+ status,
projectPath: this.projectPath,
},
})
.then((resp) => {
if (this.trackAlertStatusUpdateOptions) {
- this.trackStatusUpdate(status);
+ this.trackStatusUpdate(this.statuses[status]);
}
const errors = resp.data?.updateAlertStatus?.errors || [];
@@ -99,7 +100,7 @@ export default {
<gl-dropdown
ref="dropdown"
right
- :text="$options.statuses[alert.status]"
+ :text="statuses[alert.status]"
class="w-100"
toggle-class="dropdown-menu-toggle"
@keydown.esc.native="$emit('hide-dropdown')"
@@ -110,12 +111,12 @@ export default {
</p>
<div class="dropdown-content dropdown-body">
<gl-dropdown-item
- v-for="(label, field) in $options.statuses"
+ v-for="(label, field) in statuses"
:key="field"
data-testid="statusDropdownItem"
- :active="label.toUpperCase() === alert.status"
+ :active="field === alert.status"
:active-class="'is-active'"
- @click="updateAlertStatus(label)"
+ @click="updateAlertStatus(field)"
>
{{ label }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
index 0a2bad5510b..3822b9153a4 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
@@ -1,14 +1,9 @@
<script>
import { GlIcon, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { PAGE_CONFIG } from '../../constants';
import AlertStatus from '../alert_status.vue';
export default {
- statuses: {
- TRIGGERED: s__('AlertManagement|Triggered'),
- ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
- RESOLVED: s__('AlertManagement|Resolved'),
- },
components: {
GlIcon,
GlLoadingIcon,
@@ -16,6 +11,11 @@ export default {
GlSprintf,
AlertStatus,
},
+ inject: {
+ statuses: {
+ default: PAGE_CONFIG.OPERATIONS.STATUSES,
+ },
+ },
props: {
projectPath: {
type: String,
@@ -94,6 +94,7 @@ export default {
:project-path="projectPath"
:is-dropdown-showing="isDropdownShowing"
:is-sidebar="true"
+ :statuses="statuses"
@alert-error="$emit('alert-error', $event)"
@hide-dropdown="hideDropdown"
@handle-updating="handleUpdating"
@@ -103,14 +104,11 @@ export default {
<p
v-else-if="!isDropdownShowing"
class="value gl-m-0"
- :class="{ 'no-value': !$options.statuses[alert.status] }"
+ :class="{ 'no-value': !statuses[alert.status] }"
>
- <span
- v-if="$options.statuses[alert.status]"
- class="gl-text-gray-500"
- data-testid="status"
- >{{ $options.statuses[alert.status] }}</span
- >
+ <span v-if="statuses[alert.status]" class="gl-text-gray-500" data-testid="status">
+ {{ statuses[alert.status] }}
+ </span>
<span v-else>
{{ s__('AlertManagement|None') }}
</span>
diff --git a/app/assets/javascripts/vue_shared/alert_details/constants.js b/app/assets/javascripts/vue_shared/alert_details/constants.js
index 2ab5160534c..6cc70739eaa 100644
--- a/app/assets/javascripts/vue_shared/alert_details/constants.js
+++ b/app/assets/javascripts/vue_shared/alert_details/constants.js
@@ -13,6 +13,11 @@ export const SEVERITY_LEVELS = {
export const PAGE_CONFIG = {
OPERATIONS: {
TITLE: 'OPERATIONS',
+ STATUSES: {
+ TRIGGERED: s__('AlertManagement|Triggered'),
+ ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
+ RESOLVED: s__('AlertManagement|Resolved'),
+ },
// Tracks snowplow event when user views alert details
TRACK_ALERTS_DETAILS_VIEWS_OPTIONS: {
category: 'Alert Management',
@@ -27,5 +32,11 @@ export const PAGE_CONFIG = {
},
THREAT_MONITORING: {
TITLE: 'THREAT_MONITORING',
+ STATUSES: {
+ TRIGGERED: s__('ThreatMonitoring|Unreviewed'),
+ ACKNOWLEDGED: s__('ThreatMonitoring|In review'),
+ RESOLVED: s__('ThreatMonitoring|Resolved'),
+ IGNORED: s__('ThreatMonitoring|Dismissed'),
+ },
},
};
diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js
index 50f2e63702b..fda405c0fa5 100644
--- a/app/assets/javascripts/vue_shared/alert_details/index.js
+++ b/app/assets/javascripts/vue_shared/alert_details/index.js
@@ -42,7 +42,8 @@ export default (selector) => {
}),
});
- apolloProvider.clients.defaultClient.cache.writeData({
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: sidebarStatusQuery,
data: {
sidebarStatus: false,
},
@@ -54,6 +55,7 @@ export default (selector) => {
page,
projectIssuesPath,
projectId,
+ statuses: PAGE_CONFIG[page].STATUSES,
};
if (page === PAGE_CONFIG.OPERATIONS.TITLE) {
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 3d49a1cb1c5..a74e9d97143 100644
--- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue
+++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
@@ -7,6 +7,7 @@ import {
splitCamelCase,
} from '~/lib/utils/text_utility';
import { s__ } from '~/locale';
+import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants';
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!';
@@ -42,6 +43,11 @@ export default {
type: Boolean,
required: true,
},
+ statuses: {
+ type: Object,
+ required: false,
+ default: () => PAGE_CONFIG.OPERATIONS.STATUSES,
+ },
},
fields: [
{
@@ -71,6 +77,8 @@ export default {
let value;
if (fieldName === 'environment') {
value = fieldValue?.name;
+ } else if (fieldName === 'status') {
+ value = this.statuses[fieldValue] || fieldValue;
} else {
value = fieldValue;
}
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index 82b3545117f..08d3e163257 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -44,6 +44,16 @@ export default {
required: false,
default: () => [],
},
+ selectedClass: {
+ type: String,
+ required: false,
+ default: 'selected',
+ },
+ },
+ data() {
+ return {
+ isMenuOpen: false,
+ };
},
computed: {
groupedDefaultAwards() {
@@ -68,7 +78,7 @@ export default {
methods: {
getAwardClassBindings(awardList) {
return {
- selected: this.hasReactionByCurrentUser(awardList),
+ [this.selectedClass]: this.hasReactionByCurrentUser(awardList),
disabled: this.currentUserId === NO_USER_ID,
};
},
@@ -147,6 +157,11 @@ export default {
const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName;
this.$emit('award', parsedName);
+
+ if (document.activeElement) document.activeElement.blur();
+ },
+ setIsMenuOpen(menuOpen) {
+ this.isMenuOpen = menuOpen;
},
},
};
@@ -172,8 +187,10 @@ export default {
<div v-if="canAwardEmoji" class="award-menu-holder">
<emoji-picker
v-if="glFeatures.improvedEmojiPicker"
- toggle-class="add-reaction-button gl-relative!"
+ :toggle-class="['add-reaction-button gl-relative!', { 'is-active': isMenuOpen }]"
@click="handleAward"
+ @shown="setIsMenuOpen(true)"
+ @hidden="setIsMenuOpen(false)"
>
<template #button-content>
<span class="reaction-control-icon reaction-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 db61d0f6b05..9c2ed5abf04 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
@@ -11,6 +11,16 @@ export default {
type: String,
required: true,
},
+ isRawContent: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ fileName: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
mounted() {
eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT);
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 5bb31f55e6c..f477610ff1d 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
@@ -1,14 +1,17 @@
<script>
/* eslint-disable vue/no-v-html */
import { GlIcon } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { HIGHLIGHT_CLASS_NAME } from './constants';
import ViewerMixin from './mixins';
export default {
components: {
GlIcon,
+ EditorLite: () =>
+ import(/* webpackChunkName: 'EditorLite' */ '~/vue_shared/components/editor_lite.vue'),
},
- mixins: [ViewerMixin],
+ mixins: [ViewerMixin, glFeatureFlagsMixin()],
inject: ['blobHash'],
data() {
return {
@@ -19,6 +22,9 @@ export default {
lineNumbers() {
return this.content.split('\n').length;
},
+ refactorBlobViewerEnabled() {
+ return this.glFeatures.refactorBlobViewer;
+ },
},
mounted() {
const { hash } = window.location;
@@ -45,27 +51,31 @@ export default {
};
</script>
<template>
- <div
- class="file-content code js-syntax-highlight"
- data-qa-selector="file_content"
- :class="$options.userColorScheme"
- >
- <div class="line-numbers">
- <a
- v-for="line in lineNumbers"
- :id="`L${line}`"
- :key="line"
- class="diff-line-num js-line-number"
- :href="`#LC${line}`"
- :data-line-number="line"
- @click="scrollToLine(`#LC${line}`)"
- >
- <gl-icon :size="12" name="link" />
- {{ line }}
- </a>
- </div>
- <div class="blob-content">
- <pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre>
+ <div>
+ <editor-lite
+ v-if="isRawContent && refactorBlobViewerEnabled"
+ :value="content"
+ :file-name="fileName"
+ :editor-options="{ readOnly: true }"
+ />
+ <div v-else class="file-content code js-syntax-highlight" :class="$options.userColorScheme">
+ <div class="line-numbers">
+ <a
+ v-for="line in lineNumbers"
+ :id="`L${line}`"
+ :key="line"
+ class="diff-line-num js-line-number"
+ :href="`#LC${line}`"
+ :data-line-number="line"
+ @click="scrollToLine(`#LC${line}`)"
+ >
+ <gl-icon :size="12" name="link" />
+ {{ line }}
+ </a>
+ </div>
+ <div class="blob-content">
+ <pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre>
+ </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
index cd5f63afc79..f14e1992901 100644
--- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
@@ -56,6 +56,7 @@ export default {
<gl-button
v-gl-tooltip.hover
:title="$options.copyURLTooltip"
+ :aria-label="$options.copyURLTooltip"
:data-clipboard-text="sshLink"
data-qa-selector="copy_ssh_url_button"
icon="copy-to-clipboard"
@@ -75,6 +76,7 @@ export default {
<gl-button
v-gl-tooltip.hover
:title="$options.copyURLTooltip"
+ :aria-label="$options.copyURLTooltip"
:data-clipboard-text="httpLink"
data-qa-selector="copy_http_url_button"
icon="copy-to-clipboard"
diff --git a/app/assets/javascripts/vue_shared/components/delete_label_modal.vue b/app/assets/javascripts/vue_shared/components/delete_label_modal.vue
new file mode 100644
index 00000000000..1ff0938d086
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/delete_label_modal.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlModal, GlSprintf, GlButton } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ GlButton,
+ },
+ props: {
+ selector: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ labelName: '',
+ subjectName: '',
+ destroyPath: '',
+ modalId: uniqueId('modal-delete-label-'),
+ };
+ },
+ mounted() {
+ document.querySelectorAll(this.selector).forEach((button) => {
+ button.addEventListener('click', (e) => {
+ e.preventDefault();
+
+ const { labelName, subjectName, destroyPath } = button.dataset;
+ this.labelName = labelName;
+ this.subjectName = subjectName;
+ this.destroyPath = destroyPath;
+ this.openModal();
+ });
+ });
+ },
+ methods: {
+ openModal() {
+ this.$refs.modal.show();
+ },
+ closeModal() {
+ this.$refs.modal.hide();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal ref="modal" :modal-id="modalId">
+ <template #modal-title>
+ <gl-sprintf :message="__('Delete label: %{labelName}')">
+ <template #labelName>
+ {{ labelName }}
+ </template>
+ </gl-sprintf>
+ </template>
+ <gl-sprintf
+ :message="
+ __(
+ `%{strongStart}${labelName}%{strongEnd} will be permanently deleted from ${subjectName}. This cannot be undone.`,
+ )
+ "
+ >
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ <template #modal-footer>
+ <gl-button category="secondary" @click="closeModal">{{ __('Cancel') }}</gl-button>
+ <gl-button
+ category="primary"
+ variant="danger"
+ :href="destroyPath"
+ data-method="delete"
+ data-testid="delete-button"
+ >{{ __('Delete label') }}</gl-button
+ >
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
deleted file mode 100644
index 3f55f43edbb..00000000000
--- a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
+++ /dev/null
@@ -1,146 +0,0 @@
-<script>
-/* eslint-disable vue/require-default-prop */
-import { __ } from '~/locale';
-
-export default {
- name: 'DeprecatedModal', // use GlModal instead
-
- props: {
- id: {
- type: String,
- required: false,
- },
- title: {
- type: String,
- required: false,
- },
- text: {
- type: String,
- required: false,
- },
- hideFooter: {
- type: Boolean,
- required: false,
- default: false,
- },
- kind: {
- type: String,
- required: false,
- default: 'primary',
- },
- modalDialogClass: {
- type: String,
- required: false,
- default: '',
- },
- closeKind: {
- type: String,
- required: false,
- default: 'default',
- },
- closeButtonLabel: {
- type: String,
- required: false,
- default: __('Cancel'),
- },
- primaryButtonLabel: {
- type: String,
- required: false,
- default: '',
- },
- secondaryButtonLabel: {
- type: String,
- required: false,
- default: '',
- },
- submitDisabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-
- computed: {
- btnKindClass() {
- return {
- [`btn-${this.kind}`]: true,
- };
- },
- btnCancelKindClass() {
- return {
- [`btn-${this.closeKind}`]: true,
- };
- },
- },
-
- methods: {
- emitCancel(event) {
- this.$emit('cancel', event);
- },
- emitSubmit(event) {
- this.$emit('submit', event);
- },
- },
-};
-</script>
-
-<template>
- <div class="modal-open">
- <div :id="id" :class="id ? '' : 'd-block'" class="modal" role="dialog" tabindex="-1">
- <div :class="modalDialogClass" class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <slot name="header">
- <h4 class="modal-title float-left">{{ title }}</h4>
- <button
- type="button"
- class="close float-right"
- data-dismiss="modal"
- :aria-label="__('Close')"
- @click="emitCancel($event)"
- >
- <span aria-hidden="true">&times;</span>
- </button>
- </slot>
- </div>
- <div class="modal-body">
- <slot :text="text" name="body">
- <p>{{ text }}</p>
- </slot>
- </div>
- <div v-if="!hideFooter" class="modal-footer">
- <button
- :class="btnCancelKindClass"
- type="button"
- class="btn"
- data-dismiss="modal"
- @click="emitCancel($event)"
- >
- {{ closeButtonLabel }}
- </button>
-
- <slot v-if="secondaryButtonLabel" name="secondary-button">
- <button v-if="secondaryButtonLabel" type="button" class="btn" data-dismiss="modal">
- {{ secondaryButtonLabel }}
- </button>
- </slot>
-
- <button
- v-if="primaryButtonLabel"
- :disabled="submitDisabled"
- :class="btnKindClass"
- type="button"
- class="btn js-primary-button"
- data-dismiss="modal"
- data-qa-selector="save_changes_button"
- @click="emitSubmit($event)"
- >
- {{ primaryButtonLabel }}
- </button>
- </div>
- </div>
- </div>
- </div>
- <div v-if="!id" class="modal-backdrop fade show"></div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index 4ec54b33bce..fbadb202d51 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -3,6 +3,7 @@ import { GlIcon } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import Mousetrap from 'mousetrap';
import VirtualList from 'vue-virtual-scroll-list';
+import { keysFor, MR_GO_TO_FILE } from '~/behaviors/shortcuts/keybindings';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import Item from './item.vue';
@@ -128,7 +129,7 @@ export default {
this.focusedIndex = 0;
}
- Mousetrap.bind(['t', 'mod+p'], (e) => {
+ Mousetrap.bind(keysFor(MR_GO_TO_FILE), (e) => {
if (e.preventDefault) {
e.preventDefault();
}
diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
index f7cfb59be01..e622b505570 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
+++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
@@ -128,6 +128,7 @@ const fileExtensionIcons = {
c: 'c',
m: 'c',
h: 'h',
+ 'c++': 'cpp',
cc: 'cpp',
cpp: 'cpp',
mm: 'cpp',
@@ -402,14 +403,15 @@ const fileNameIcons = {
'gradle.properties': 'gradle',
gradlew: 'gradle',
'gradle-wrapper.properties': 'gradle',
- license: 'certificate',
- 'license.md': 'certificate',
- 'license.md.rendered': 'certificate',
- 'license.txt': 'certificate',
- licence: 'certificate',
- 'licence.md': 'certificate',
- 'licence.md.rendered': 'certificate',
- 'licence.txt': 'certificate',
+ COPYING: 'certificate',
+ 'COPYING.LESSER': 'certificate',
+ LICENSE: 'certificate',
+ LICENCE: 'certificate',
+ 'LICENSE.md': 'certificate',
+ 'LICENCE.md': 'certificate',
+ 'LICENSE.txt': 'certificate',
+ 'LICENCE.txt': 'certificate',
+ '.gitlab-license': 'certificate',
dockerfile: 'docker',
'docker-compose.yml': 'docker',
'.mailmap': 'email',
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 97a8f681faf..107ced550c1 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
@@ -58,7 +58,7 @@ export default {
type: String,
required: false,
default: '',
- validator: (value) => value === '' || /(_desc)|(_asc)/g.test(value),
+ validator: (value) => value === '' || /(_desc)|(_asc)/gi.test(value),
},
showCheckbox: {
type: Boolean,
@@ -363,6 +363,7 @@ export default {
<gl-button
v-gl-tooltip
:title="sortDirectionTooltip"
+ :aria-label="sortDirectionTooltip"
:icon="sortDirectionIcon"
class="flex-shrink-1"
@click="handleSortDirectionClick"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
index d53c829a48e..aeb698a3adb 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
@@ -45,6 +45,9 @@ export default {
activeAuthor() {
return this.authors.find((author) => author.username.toLowerCase() === this.currentValue);
},
+ activeAuthorAvatar() {
+ return this.avatarUrl(this.activeAuthor);
+ },
},
watch: {
active: {
@@ -74,6 +77,9 @@ export default {
this.loading = false;
});
},
+ avatarUrl(author) {
+ return author.avatarUrl || author.avatar_url;
+ },
searchAuthors: debounce(function debouncedSearch({ data }) {
this.fetchAuthorBySearchTerm(data);
}, DEBOUNCE_DELAY),
@@ -92,7 +98,7 @@ export default {
<gl-avatar
v-if="activeAuthor"
:size="16"
- :src="activeAuthor.avatar_url"
+ :src="activeAuthorAvatar"
shape="circle"
class="gl-mr-2"
/>
@@ -115,7 +121,7 @@ export default {
:value="author.username"
>
<div class="d-flex">
- <gl-avatar :size="32" :src="author.avatar_url" />
+ <gl-avatar :size="32" :src="avatarUrl(author)" />
<div>
<div>{{ author.name }}</div>
<div>@{{ author.username }}</div>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
new file mode 100644
index 00000000000..98190d716c9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
@@ -0,0 +1,105 @@
+<script>
+import {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import { __ } from '~/locale';
+
+import { DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY, DEBOUNCE_DELAY } from '../constants';
+import { stripQuotes } from '../filtered_search_utils';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ emojis: this.config.initialEmojis || [],
+ defaultEmojis: this.config.defaultEmojis || [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY],
+ loading: true,
+ };
+ },
+ computed: {
+ currentValue() {
+ return this.value.data.toLowerCase();
+ },
+ activeEmoji() {
+ return this.emojis.find(
+ (emoji) => emoji.name.toLowerCase() === stripQuotes(this.currentValue),
+ );
+ },
+ },
+ methods: {
+ fetchEmojiBySearchTerm(searchTerm) {
+ this.loading = true;
+ this.config
+ .fetchEmojis(searchTerm)
+ .then((res) => {
+ this.emojis = Array.isArray(res) ? res : res.data;
+ })
+ .catch(() => createFlash(__('There was a problem fetching emojis.')))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ searchEmojis: debounce(function debouncedSearch({ data }) {
+ this.fetchEmojiBySearchTerm(data);
+ }, DEBOUNCE_DELAY),
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchEmojis"
+ >
+ <template #view="{ inputValue }">
+ <gl-emoji v-if="activeEmoji" :data-name="activeEmoji.name" />
+ <span v-else>{{ inputValue }}</span>
+ </template>
+ <template #suggestions>
+ <gl-filtered-search-suggestion
+ v-for="emoji in defaultEmojis"
+ :key="emoji.value"
+ :value="emoji.value"
+ >
+ {{ emoji.value }}
+ </gl-filtered-search-suggestion>
+ <gl-dropdown-divider v-if="defaultEmojis.length" />
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="emoji in emojis"
+ :key="emoji.name"
+ :value="emoji.name"
+ >
+ <div class="gl-display-flex">
+ <gl-emoji :data-name="emoji.name" />
+ <span class="gl-ml-3">{{ emoji.name }}</span>
+ </div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
new file mode 100644
index 00000000000..101c7150c55
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
@@ -0,0 +1,133 @@
+<script>
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import { debounce } from 'lodash';
+
+import createFlash from '~/flash';
+import { isNumeric } from '~/lib/utils/number_utils';
+import { __ } from '~/locale';
+import { DEBOUNCE_DELAY } from '../constants';
+import { stripQuotes } from '../filtered_search_utils';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ epics: this.config.initialEpics || [],
+ loading: true,
+ };
+ },
+ computed: {
+ currentValue() {
+ /*
+ * When the URL contains the epic_iid, we'd get: '123'
+ */
+ if (isNumeric(this.value.data)) {
+ return parseInt(this.value.data, 10);
+ }
+
+ /*
+ * When the token is added in current session it'd be: 'Foo::&123'
+ */
+ const id = this.value.data.split('::&')[1];
+
+ if (id) {
+ return parseInt(id, 10);
+ }
+
+ return this.value.data;
+ },
+ activeEpic() {
+ const currentValueIsString = typeof this.currentValue === 'string';
+ return this.epics.find(
+ (epic) => epic[currentValueIsString ? 'title' : 'iid'] === this.currentValue,
+ );
+ },
+ },
+ watch: {
+ active: {
+ immediate: true,
+ handler(newValue) {
+ if (!newValue && !this.epics.length) {
+ this.searchEpics({ data: this.currentValue });
+ }
+ },
+ },
+ },
+ methods: {
+ fetchEpicsBySearchTerm(searchTerm = '') {
+ this.loading = true;
+ this.config
+ .fetchEpics(searchTerm)
+ .then(({ data }) => {
+ this.epics = data;
+ })
+ .catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ fetchSingleEpic(iid) {
+ this.loading = true;
+ this.config
+ .fetchSingleEpic(iid)
+ .then(({ data }) => {
+ this.epics = [data];
+ })
+ .catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ searchEpics: debounce(function debouncedSearch({ data }) {
+ if (isNumeric(data)) {
+ return this.fetchSingleEpic(data);
+ }
+ return this.fetchEpicsBySearchTerm(data);
+ }, DEBOUNCE_DELAY),
+
+ getEpicValue(epic) {
+ return `${epic.title}::&${epic.iid}`;
+ },
+ },
+ stripQuotes,
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchEpics"
+ >
+ <template #view="{ inputValue }">
+ <span>{{ activeEpic ? getEpicValue(activeEpic) : $options.stripQuotes(inputValue) }}</span>
+ </template>
+ <template #suggestions>
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="epic in epics"
+ :key="epic.id"
+ :value="getEpicValue(epic)"
+ >
+ <div>{{ epic.title }}</div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
index 9c2a644b7a9..76b005772ec 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
@@ -46,7 +46,7 @@ export default {
},
activeLabel() {
return this.labels.find(
- (label) => label.title.toLowerCase() === stripQuotes(this.currentValue),
+ (label) => this.getLabelName(label).toLowerCase() === stripQuotes(this.currentValue),
);
},
containerStyle() {
@@ -69,6 +69,21 @@ export default {
},
},
methods: {
+ /**
+ * There's an inconsistency between private and public API
+ * for labels where label name is included in a different
+ * property;
+ *
+ * Private API => `label.title`
+ * Public API => `label.name`
+ *
+ * This method allows compatibility as there may be instances
+ * where `config.fetchLabels` provided externally may still be
+ * using either of the two APIs.
+ */
+ getLabelName(label) {
+ return label.name || label.title;
+ },
fetchLabelBySearchTerm(searchTerm) {
this.loading = true;
this.config
@@ -85,7 +100,7 @@ export default {
});
},
searchLabels: debounce(function debouncedSearch({ data }) {
- this.fetchLabelBySearchTerm(data);
+ if (!this.loading) this.fetchLabelBySearchTerm(data);
}, DEBOUNCE_DELAY),
},
};
@@ -100,7 +115,7 @@ export default {
>
<template #view-token="{ inputValue, cssClasses, listeners }">
<gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners"
- >~{{ activeLabel ? activeLabel.title : inputValue }}</gl-token
+ >~{{ activeLabel ? getLabelName(activeLabel) : inputValue }}</gl-token
>
</template>
<template #suggestions>
@@ -114,13 +129,17 @@ export default {
<gl-dropdown-divider v-if="defaultLabels.length" />
<gl-loading-icon v-if="loading" />
<template v-else>
- <gl-filtered-search-suggestion v-for="label in labels" :key="label.id" :value="label.title">
- <div class="gl-display-flex">
+ <gl-filtered-search-suggestion
+ v-for="label in labels"
+ :key="label.id"
+ :value="getLabelName(label)"
+ >
+ <div class="gl-display-flex gl-align-items-center">
<span
:style="{ backgroundColor: label.color }"
class="gl-display-inline-block mr-2 p-2"
></span>
- <div>{{ label.title }}</div>
+ <div>{{ getLabelName(label) }}</div>
</div>
</gl-filtered-search-suggestion>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/gl_toggle_vuex.vue b/app/assets/javascripts/vue_shared/components/gl_toggle_vuex.vue
deleted file mode 100644
index b649dac029a..00000000000
--- a/app/assets/javascripts/vue_shared/components/gl_toggle_vuex.vue
+++ /dev/null
@@ -1,49 +0,0 @@
-<script>
-import { GlToggle } from '@gitlab/ui';
-import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-
-export default {
- name: 'GlToggleVuex',
- components: {
- GlToggle,
- },
- props: {
- stateProperty: {
- type: String,
- required: true,
- },
- storeModule: {
- type: String,
- required: false,
- default: null,
- },
- setAction: {
- type: String,
- required: false,
- default() {
- return `set${capitalizeFirstCharacter(this.stateProperty)}`;
- },
- },
- },
- computed: {
- value: {
- get() {
- const { state } = this.$store;
- const { stateProperty, storeModule } = this;
- return storeModule ? state[storeModule][stateProperty] : state[stateProperty];
- },
- set(value) {
- const { stateProperty, storeModule, setAction } = this;
- const action = storeModule ? `${storeModule}/${setAction}` : setAction;
- this.$store.dispatch(action, { key: stateProperty, value });
- },
- },
- },
-};
-</script>
-
-<template>
- <gl-toggle v-model="value">
- <slot v-bind="{ value }"></slot>
- </gl-toggle>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index b4cac13168a..f169921d8a6 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -121,13 +121,7 @@ export default {
:title="user.email"
class="js-user-link commit-committer-link"
>
- <user-avatar-image
- :img-src="avatarUrl"
- :img-alt="userAvatarAltText"
- :tooltip-text="user.name"
- :img-size="24"
- />
-
+ <user-avatar-image :img-src="avatarUrl" :img-alt="userAvatarAltText" :size="24" />
{{ user.name }}
</gl-link>
<gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]">
diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue
index 051c65bae70..f36b9107a6e 100644
--- a/app/assets/javascripts/vue_shared/components/help_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/help_popover.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlPopover } from '@gitlab/ui';
+import { GlButton, GlPopover, GlSafeHtmlDirective } from '@gitlab/ui';
/**
* Render a button with a question mark icon
@@ -11,6 +11,9 @@ export default {
GlButton,
GlPopover,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
props: {
options: {
type: Object,
@@ -22,15 +25,13 @@ export default {
</script>
<template>
<span>
- <gl-button ref="popoverTrigger" variant="link" icon="question" tabindex="0" />
- <gl-popover triggers="hover focus" :target="() => $refs.popoverTrigger.$el" v-bind="options">
- <template #title>
- <!-- eslint-disable-next-line vue/no-v-html -->
- <span v-html="options.title"></span>
+ <gl-button ref="popoverTrigger" variant="link" icon="question" :aria-label="__('Help')" />
+ <gl-popover :target="() => $refs.popoverTrigger.$el" v-bind="options">
+ <template v-if="options.title" #title>
+ <span v-safe-html="options.title"></span>
</template>
<template #default>
- <!-- eslint-disable-next-line vue/no-v-html -->
- <div v-html="options.content"></div>
+ <div v-safe-html="options.content"></div>
</template>
</gl-popover>
</span>
diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/props_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/props_utils.js
new file mode 100644
index 00000000000..b115b1fb34b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/lib/utils/props_utils.js
@@ -0,0 +1,35 @@
+/**
+ * Return the union of the given components' props options. Required props take
+ * precendence over non-required props of the same name.
+ *
+ * This makes two assumptions:
+ * - All given components define their props in verbose object format.
+ * - The components all agree on the `type` of a common prop.
+ *
+ * @param {object[]} components The components to derive the union from.
+ * @returns {object} The union of the props of the given components.
+ */
+export const propsUnion = (components) =>
+ components.reduce((acc, component) => {
+ Object.entries(component.props ?? {}).forEach(([propName, propOptions]) => {
+ if (process.env.NODE_ENV !== 'production') {
+ if (typeof propOptions !== 'object' || !('type' in propOptions)) {
+ throw new Error(
+ `Cannot create props union: expected verbose prop options for prop "${propName}"`,
+ );
+ }
+
+ if (propName in acc && acc[propName]?.type !== propOptions?.type) {
+ throw new Error(
+ `Cannot create props union: incompatible prop types for prop "${propName}"`,
+ );
+ }
+ }
+
+ if (!(propName in acc) || propOptions.required) {
+ acc[propName] = propOptions;
+ }
+ });
+
+ return acc;
+ }, {});
diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
index 10887aee689..90ac20fe748 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
@@ -34,6 +34,7 @@ export default {
boundary="window"
right
menu-class="gl-w-full!"
+ data-qa-selector="apply_suggestion_button"
@shown="$refs.commitMessage.$el.focus()"
>
<gl-dropdown-form class="gl-px-4! gl-m-0!">
@@ -44,12 +45,14 @@ export default {
v-model="message"
:placeholder="defaultCommitMessage"
submit-on-enter
+ data-qa-selector="commit_message_textbox"
@submit="onApply"
/>
<gl-button
class="gl-w-auto! gl-mt-3 gl-text-center! gl-hover-text-white! gl-transition-medium! float-right"
category="primary"
variant="success"
+ data-qa-selector="commit_with_custom_message_button"
@click="onApply"
>
{{ __('Apply') }}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 25d01dc550f..80b7a9b7d05 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -62,6 +62,11 @@ export default {
required: false,
default: true,
},
+ uploadsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
enableAutocomplete: {
type: Boolean,
required: false,
@@ -72,6 +77,11 @@ export default {
required: false,
default: null,
},
+ lines: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
note: {
type: Object,
required: false,
@@ -110,6 +120,20 @@ export default {
return this.referencedUsers.length >= referencedUsersThreshold;
},
lineContent() {
+ if (this.lines.length) {
+ return this.lines
+ .map((line) => {
+ const { rich_text: richText, text } = line;
+
+ if (text) {
+ return text;
+ }
+
+ return unescape(stripHtml(richText).replace(/\n/g, ''));
+ })
+ .join('\\n');
+ }
+
if (this.line) {
const { rich_text: richText, text } = this.line;
@@ -144,6 +168,9 @@ export default {
false,
);
},
+ suggestionsStartIndex() {
+ return Math.max(this.lines.length - 1, 0);
+ },
},
watch: {
isSubmitting(isSubmitting) {
@@ -229,12 +256,14 @@ export default {
ref="gl-form"
:class="{ 'gl-mt-3 gl-mb-3': addSpacingClasses }"
class="js-vue-markdown-field md-area position-relative gfm-form"
+ :data-uploads-path="uploadsPath"
>
<markdown-header
:preview-markdown="previewMarkdown"
:line-content="lineContent"
:can-suggest="canSuggest"
:show-suggest-popover="showSuggestPopover"
+ :suggestion-start-index="suggestionsStartIndex"
@preview-markdown="showPreviewTab"
@write-markdown="showWriteTab"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 5bc1786d692..01cf0beea3a 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,6 +1,7 @@
<script>
import { GlPopover, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import $ from 'jquery';
+import { keysFor, BOLD_TEXT, ITALIC_TEXT, LINK_TEXT } from '~/behaviors/shortcuts/keybindings';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm';
@@ -36,6 +37,11 @@ export default {
required: false,
default: false,
},
+ suggestionStartIndex: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
@@ -53,7 +59,9 @@ export default {
].join('\n');
},
mdSuggestion() {
- return ['```suggestion:-0+0', `{text}`, '```'].join('\n');
+ return [['```', `suggestion:-${this.suggestionStartIndex}+0`].join(''), `{text}`, '```'].join(
+ '\n',
+ );
},
isMac() {
// Accessing properties using ?. to allow tests to use
@@ -116,6 +124,11 @@ export default {
.catch(() => {});
},
},
+ shortcuts: {
+ bold: keysFor(BOLD_TEXT),
+ italic: keysFor(ITALIC_TEXT),
+ link: keysFor(LINK_TEXT),
+ },
};
</script>
@@ -143,7 +156,7 @@ export default {
:button-title="
sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey })
"
- shortcuts="mod+b"
+ :shortcuts="$options.shortcuts.bold"
icon="bold"
/>
<toolbar-button
@@ -151,7 +164,7 @@ export default {
:button-title="
sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey })
"
- shortcuts="mod+i"
+ :shortcuts="$options.shortcuts.italic"
icon="italic"
/>
<toolbar-button
@@ -208,7 +221,7 @@ export default {
:button-title="
sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey })
"
- shortcuts="mod+k"
+ :shortcuts="$options.shortcuts.link"
icon="link"
/>
</div>
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 7c28e74e256..83b8a6ae562 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
@@ -1,13 +1,11 @@
<script>
import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ApplySuggestion from './apply_suggestion.vue';
export default {
components: { GlIcon, GlButton, GlLoadingIcon, ApplySuggestion },
directives: { 'gl-tooltip': GlTooltipDirective },
- mixins: [glFeatureFlagsMixin()],
props: {
batchSuggestionsCount: {
type: Number,
@@ -59,9 +57,6 @@ export default {
};
},
computed: {
- canBeBatched() {
- return Boolean(this.glFeatures.batchSuggestions);
- },
isApplying() {
return this.isApplyingSingle || this.isApplyingBatch;
},
@@ -118,7 +113,7 @@ export default {
<gl-loading-icon class="d-flex-center mr-2" />
<span>{{ applyingSuggestionsMessage }}</span>
</div>
- <div v-else-if="canApply && canBeBatched && isBatched" class="d-flex align-items-center">
+ <div v-else-if="canApply && isBatched" class="d-flex align-items-center">
<gl-button
class="btn-inverted js-remove-from-batch-btn btn-grouped"
:disabled="isApplying"
@@ -142,7 +137,7 @@ export default {
</div>
<div v-else class="d-flex align-items-center">
<gl-button
- v-if="suggestionsCount > 1 && canBeBatched && !isDisableButton"
+ v-if="suggestionsCount > 1 && !isDisableButton"
class="btn-inverted js-add-to-batch-btn btn-grouped"
data-qa-selector="add_suggestion_batch_button"
:disabled="isDisableButton"
@@ -152,6 +147,7 @@ export default {
</gl-button>
<apply-suggestion
v-if="isLoggedIn"
+ v-gl-tooltip.viewport="tooltipMessage"
:disabled="isDisableButton"
:default-commit-message="defaultCommitMessage"
class="gl-ml-3"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 387b100a04f..7393a8791b7 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,13 +1,18 @@
<script>
import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui';
+import { isExperimentVariant } from '~/experimentation/utils';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
export default {
+ inviteMembersInComment: INVITE_MEMBERS_IN_COMMENT,
components: {
GlButton,
GlLink,
GlLoadingIcon,
GlSprintf,
GlIcon,
+ InviteMembersTrigger,
},
props: {
markdownDocsPath: {
@@ -29,6 +34,9 @@ export default {
hasQuickActionsDocsPath() {
return this.quickActionsDocsPath !== '';
},
+ inviteCommentEnabled() {
+ return isExperimentVariant(INVITE_MEMBERS_IN_COMMENT, 'invite_member_link');
+ },
},
};
</script>
@@ -37,9 +45,9 @@ export default {
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
<template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
- <gl-link :href="markdownDocsPath" target="_blank">{{
- __('Markdown is supported')
- }}</gl-link>
+ <gl-link :href="markdownDocsPath" target="_blank">
+ {{ __('Markdown is supported') }}
+ </gl-link>
</template>
<template v-if="hasQuickActionsDocsPath && markdownDocsPath">
<gl-sprintf
@@ -59,6 +67,16 @@ export default {
</template>
</div>
<span v-if="canAttachFile" class="uploading-container">
+ <invite-members-trigger
+ v-if="inviteCommentEnabled"
+ classes="gl-mr-3 gl-vertical-align-text-bottom"
+ :display-text="s__('InviteMember|Invite Member')"
+ icon="assignee"
+ variant="link"
+ :track-experiment="$options.inviteMembersInComment"
+ :trigger-source="$options.inviteMembersInComment"
+ data-track-event="comment_invite_click"
+ />
<span class="uploading-progress-container hide">
<gl-icon name="media" />
<span class="attaching-file-message"></span>
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 7b36d57dfbf..38afd56bae6 100644
--- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
+++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
@@ -101,6 +101,7 @@ export default {
:data-clipboard-target="target"
:data-clipboard-text="text"
:title="title"
+ :aria-label="title"
:category="category"
icon="copy-to-clipboard"
/>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 50972a8c32c..149909d263e 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -28,6 +28,7 @@ import {
import $ from 'jquery';
import { mapGetters, mapActions, mapState } from 'vuex';
import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history';
+import { __ } from '~/locale';
import initMRPopovers from '~/mr_popover/';
import noteHeader from '~/notes/components/note_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -37,6 +38,9 @@ import TimelineEntryItem from './timeline_entry_item.vue';
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
export default {
+ i18n: {
+ deleteButtonLabel: __('Remove description history'),
+ },
name: 'SystemNote',
components: {
GlIcon,
@@ -139,7 +143,8 @@ export default {
<gl-button
v-if="displayDeleteButton"
v-gl-tooltip
- :title="__('Remove description history')"
+ :title="$options.i18n.deleteButtonLabel"
+ :aria-label="$options.i18n.deleteButtonLabel"
variant="default"
category="tertiary"
icon="remove"
diff --git a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue b/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue
new file mode 100644
index 00000000000..ff2847624c5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
+
+export default {
+ components: {
+ GlSprintf,
+ GlLink,
+ },
+ props: {
+ schedules: {
+ type: Array,
+ required: true,
+ },
+ userName: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ title() {
+ return this.isCurrentUser
+ ? s__('OnCallSchedules|You are currently a part of:')
+ : sprintf(s__('OnCallSchedules|User %{name} is currently part of:'), {
+ name: this.userName,
+ });
+ },
+ footer() {
+ return this.isCurrentUser
+ ? s__(
+ 'OnCallSchedules|Removing yourself may put your on-call team at risk of missing a notification.',
+ )
+ : s__(
+ 'OnCallSchedules|Removing this user may put their on-call team at risk of missing a notification.',
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <p data-testid="title">{{ title }}</p>
+
+ <ul data-testid="schedules-list">
+ <li v-for="(schedule, index) in schedules" :key="`${schedule.name}-${index}`">
+ <gl-sprintf
+ :message="s__('OnCallSchedules|On-call schedule %{schedule} in Project %{project}')"
+ >
+ <template #schedule>
+ <gl-link :href="schedule.scheduleUrl" target="_blank">{{ schedule.name }}</gl-link>
+ </template>
+ <template #project>
+ <gl-link :href="schedule.projectUrl" target="_blank">{{
+ schedule.projectName
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ </ul>
+
+ <p data-testid="footer">{{ footer }}</p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
deleted file mode 100644
index e193883b6e9..00000000000
--- a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import createEventHub from '~/helpers/event_hub_factory';
-
-// see recaptcha_tags in app/views/shared/_recaptcha_form.html.haml
-export const callbackName = 'recaptchaDialogCallback';
-
-export const eventHub = createEventHub();
-
-const throwDuplicateCallbackError = () => {
- throw new Error(`${callbackName} is already defined!`);
-};
-
-if (window[callbackName]) {
- throwDuplicateCallbackError();
-}
-
-const callback = () => eventHub.$emit('submit');
-
-Object.defineProperty(window, callbackName, {
- get: () => callback,
- set: throwDuplicateCallbackError,
-});
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
deleted file mode 100644
index fc1f3675a3d..00000000000
--- a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
+++ /dev/null
@@ -1,90 +0,0 @@
-<script>
-/* eslint-disable vue/no-v-html */
-import DeprecatedModal from './deprecated_modal.vue';
-import { eventHub } from './recaptcha_eventhub';
-
-export default {
- name: 'RecaptchaModal',
-
- components: {
- DeprecatedModal,
- },
-
- props: {
- html: {
- type: String,
- required: false,
- default: '',
- },
- },
-
- data() {
- return {
- script: {},
- scriptSrc: 'https://www.recaptcha.net/recaptcha/api.js',
- };
- },
-
- watch: {
- html() {
- this.appendRecaptchaScript();
- },
- },
-
- mounted() {
- eventHub.$on('submit', this.submit);
-
- if (this.html) {
- this.appendRecaptchaScript();
- }
- },
-
- beforeDestroy() {
- eventHub.$off('submit', this.submit);
- },
-
- methods: {
- appendRecaptchaScript() {
- this.removeRecaptchaScript();
-
- const script = document.createElement('script');
- script.src = this.scriptSrc;
- script.classList.add('js-recaptcha-script');
- script.async = true;
- script.defer = true;
-
- this.script = script;
-
- document.body.appendChild(script);
- },
-
- removeRecaptchaScript() {
- if (this.script instanceof Element) this.script.remove();
- },
-
- close() {
- this.removeRecaptchaScript();
- this.$emit('close');
- },
-
- submit() {
- this.$el.querySelector('form').submit();
- },
- },
-};
-</script>
-
-<template>
- <deprecated-modal
- :hide-footer="true"
- :title="__('Please solve the reCAPTCHA')"
- kind="warning"
- class="recaptcha-modal js-recaptcha-modal"
- @cancel="close"
- >
- <div slot="body">
- <p>{{ __('We want to be sure it is you, please confirm you are not a robot.') }}</p>
- <div ref="recaptcha" v-html="html"></div>
- </div>
- </deprecated-modal>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
index 62453a25f62..0825c3a76ea 100644
--- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
@@ -1,5 +1,6 @@
<script>
import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui';
+import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
const ASCENDING_ORDER = 'asc';
const DESCENDING_ORDER = 'desc';
@@ -45,18 +46,60 @@ export default {
isSortAscending() {
return this.sorting.sort === ASCENDING_ORDER;
},
+ baselineQueryStringFilters() {
+ return this.tokens.reduce((acc, curr) => {
+ acc[curr.type] = '';
+ return acc;
+ }, {});
+ },
},
methods: {
+ generateQueryData({ sorting = {}, filter = [] } = {}) {
+ // Ensure that we clean up the query when we remove a token from the search
+ const result = { ...this.baselineQueryStringFilters, ...sorting, search: [] };
+
+ filter.forEach((f) => {
+ if (f.type === FILTERED_SEARCH_TERM) {
+ result.search.push(f.value.data);
+ } else {
+ result[f.type] = f.value.data;
+ }
+ });
+ return result;
+ },
onDirectionChange() {
const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
+ const newQueryString = this.generateQueryData({
+ sorting: { ...this.sorting, sort },
+ filter: this.filter,
+ });
this.$emit('sorting:changed', { sort });
+ this.$emit('query:changed', newQueryString);
},
onSortItemClick(item) {
+ const newQueryString = this.generateQueryData({
+ sorting: { ...this.sorting, orderBy: item },
+ filter: this.filter,
+ });
this.$emit('sorting:changed', { orderBy: item });
+ this.$emit('query:changed', newQueryString);
+ },
+ submitSearch() {
+ const newQueryString = this.generateQueryData({
+ sorting: this.sorting,
+ filter: this.filter,
+ });
+ this.$emit('filter:submit');
+ this.$emit('query:changed', newQueryString);
},
clearSearch() {
+ const newQueryString = this.generateQueryData({
+ sorting: this.sorting,
+ });
+
this.$emit('filter:changed', []);
this.$emit('filter:submit');
+ this.$emit('query:changed', newQueryString);
},
},
};
@@ -69,7 +112,7 @@ export default {
class="gl-mr-4 gl-flex-fill-1"
:placeholder="__('Filter results')"
:available-tokens="tokens"
- @submit="$emit('filter:submit')"
+ @submit="submitSearch"
@clear="clearSearch"
/>
<gl-sorting
diff --git a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
index 88d1b15aee3..dff3a6a8c3f 100644
--- a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
@@ -1,8 +1,10 @@
<script>
import { GlFormCheckbox, GlModal } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { parseBoolean } from '~/lib/utils/common_utils';
import csrf from '~/lib/utils/csrf';
-import { __ } from '~/locale';
+import { s__, __ } from '~/locale';
+import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
export default {
actionCancel: {
@@ -12,6 +14,7 @@ export default {
components: {
GlFormCheckbox,
GlModal,
+ OncallSchedulesList,
},
data() {
return {
@@ -22,8 +25,20 @@ export default {
isAccessRequest() {
return parseBoolean(this.modalData.isAccessRequest);
},
+ isInvite() {
+ return parseBoolean(this.modalData.isInvite);
+ },
+ isGroupMember() {
+ return this.modalData.memberType === 'GroupMember';
+ },
actionText() {
- return this.isAccessRequest ? __('Deny access request') : __('Remove member');
+ if (this.isAccessRequest) {
+ return __('Deny access request');
+ } else if (this.isInvite) {
+ return s__('Member|Revoke invite');
+ }
+
+ return __('Remove member');
},
actionPrimary() {
return {
@@ -33,6 +48,21 @@ export default {
},
};
},
+ showUnassignIssuablesCheckbox() {
+ return !this.isAccessRequest && !this.isInvite;
+ },
+ isPartOfOncallSchedules() {
+ return !this.isAccessRequest && this.oncallSchedules.schedules?.length;
+ },
+ oncallSchedules() {
+ let schedules = {};
+ try {
+ schedules = JSON.parse(this.modalData.oncallSchedules);
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+ return schedules;
+ },
},
mounted() {
document.addEventListener('click', this.handleClick);
@@ -68,9 +98,18 @@ export default {
<form ref="form" :action="modalData.memberPath" method="post">
<p data-testid="modal-message">{{ modalData.message }}</p>
+ <oncall-schedules-list
+ v-if="isPartOfOncallSchedules"
+ :schedules="oncallSchedules.schedules"
+ :user-name="oncallSchedules.name"
+ />
+
<input ref="method" type="hidden" name="_method" value="delete" />
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
- <gl-form-checkbox v-if="!isAccessRequest" name="unassign_issuables">
+ <gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships">
+ {{ __('Also remove direct user membership from subgroups and projects') }}
+ </gl-form-checkbox>
+ <gl-form-checkbox v-if="showUnassignIssuablesCheckbox" name="unassign_issuables">
{{ __('Also unassign this user from related issues and merge requests') }}
</gl-form-checkbox>
</form>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
index 4271f6053ed..85a67c087bb 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
@@ -21,7 +21,11 @@ export default {
};
</script>
<template>
- <button v-gl-tooltip="{ title: tooltip }" class="p-0 gl-display-flex toolbar-button">
+ <button
+ v-gl-tooltip="{ title: tooltip }"
+ :aria-label="tooltip"
+ class="p-0 gl-display-flex toolbar-button"
+ >
<gl-icon class="gl-mx-auto gl-align-self-center" :name="icon" />
</button>
</template>
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
index ff0626167a9..76f152e5453 100644
--- 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
@@ -1,4 +1,4 @@
-query getRunnerPlatforms($projectPath: ID!, $groupPath: ID!) {
+query getRunnerPlatforms {
runnerPlatforms {
nodes {
name
@@ -11,10 +11,4 @@ query getRunnerPlatforms($projectPath: ID!, $groupPath: ID!) {
}
}
}
- 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
index 643c1991807..c0248a35e3f 100644
--- 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
@@ -1,15 +1,5 @@
-query runnerSetupInstructions(
- $platform: String!
- $architecture: String!
- $projectId: ID!
- $groupId: ID!
-) {
- runnerSetup(
- platform: $platform
- architecture: $architecture
- projectId: $projectId
- groupId: $groupId
- ) {
+query runnerSetupInstructions($platform: String!, $architecture: String!) {
+ runnerSetup(platform: $platform, architecture: $architecture) {
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
index 1d6db576942..d886a67fff7 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
@@ -1,155 +1,31 @@
<script>
-import {
- GlAlert,
- GlButton,
- GlModal,
- GlModalDirective,
- GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlIcon,
-} from '@gitlab/ui';
-import { __, s__ } from '~/locale';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
-import {
- PLATFORMS_WITHOUT_ARCHITECTURES,
- INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES,
-} from './constants';
-import getRunnerPlatforms from './graphql/queries/get_runner_platforms.query.graphql';
-import getRunnerSetupInstructions from './graphql/queries/get_runner_setup.query.graphql';
+import { GlButton, GlModalDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import RunnerInstructionsModal from './runner_instructions_modal.vue';
export default {
components: {
- GlAlert,
GlButton,
- GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlModal,
- GlIcon,
- ModalCopyButton,
+ RunnerInstructionsModal,
},
directives: {
GlModalDirective,
},
- inject: {
- projectPath: {
- default: '',
- },
- groupPath: {
- default: '',
- },
- },
- apollo: {
- runnerPlatforms: {
- query: getRunnerPlatforms,
- variables() {
- return {
- projectPath: this.projectPath,
- groupPath: this.groupPath,
- };
- },
- error() {
- this.showAlert = true;
- },
- result({ data }) {
- this.project = data?.project;
- this.group = data?.group;
-
- this.selectPlatform(this.platforms[0].name);
- },
- },
+ modalId: 'runner-instructions-modal',
+ i18n: {
+ buttonText: s__('Runners|Show Runner installation instructions'),
},
data() {
return {
- showAlert: false,
- selectedPlatformArchitectures: [],
- selectedPlatform: {
- name: '',
- },
- selectedArchitecture: {},
- runnerPlatforms: {},
- instructions: {},
- project: {},
- group: {},
+ opened: false,
};
},
- computed: {
- isPlatformSelected() {
- return Object.keys(this.selectedPlatform).length > 0;
- },
- instructionsEmpty() {
- return Object.keys(this.instructions).length === 0;
- },
- groupId() {
- return this.group?.id ?? '';
- },
- projectId() {
- return this.project?.id ?? '';
- },
- platforms() {
- return this.runnerPlatforms?.nodes;
- },
- hasArchitecureList() {
- return !PLATFORMS_WITHOUT_ARCHITECTURES.includes(this.selectedPlatform?.name);
- },
- instructionsWithoutArchitecture() {
- return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform.name]?.instructions;
- },
- runnerInstallationLink() {
- return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform.name]?.link;
- },
- },
methods: {
- selectPlatform(name) {
- this.selectedPlatform = this.platforms.find((platform) => platform.name === name);
- if (this.hasArchitecureList) {
- 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;
+ onClick() {
+ // lazily mount modal to prevent premature instructions requests
+ this.opened = true;
},
},
- modalId: 'installation-instructions-modal',
- i18n: {
- installARunner: s__('Runners|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__('Runners|An error has occurred fetching instructions'),
- instructions: s__('Runners|Show Runner installation instructions'),
- copyInstructions: s__('Runners|Copy instructions'),
- },
- closeButton: {
- text: __('Close'),
- attributes: [{ variant: 'default' }],
- },
};
</script>
<template>
@@ -158,104 +34,10 @@ export default {
v-gl-modal-directive="$options.modalId"
class="gl-mt-4"
data-testid="show-modal-button"
+ @click="onClick"
>
- {{ $options.i18n.instructions }}
+ {{ $options.i18n.buttonText }}
</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="hasArchitecureList">
- <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="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line"
- data-testid="binary-instructions"
- >
-
- {{ instructions.installInstructions }}
- </pre
- >
- <modal-copy-button
- :title="$options.i18n.copyInstructions"
- :text="instructions.installInstructions"
- :modal-id="$options.modalId"
- css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
- category="tertiary"
- />
- </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="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line"
- data-testid="runner-instructions"
- >
- {{ instructions.registerInstructions }}
- </pre
- >
- <modal-copy-button
- :title="$options.i18n.copyInstructions"
- :text="instructions.registerInstructions"
- :modal-id="$options.modalId"
- css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
- category="tertiary"
- />
- </div>
- </template>
- </template>
- <template v-else>
- <div>
- <p>{{ instructionsWithoutArchitecture }}</p>
- <gl-button :href="runnerInstallationLink">
- <gl-icon name="external-link" />
- {{ s__('Runners|View installation instructions') }}
- </gl-button>
- </div>
- </template>
- </gl-modal>
+ <runner-instructions-modal v-if="opened" :modal-id="$options.modalId" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
new file mode 100644
index 00000000000..795b4f58ac5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
@@ -0,0 +1,249 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlModal,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlLoadingIcon,
+ GlSkeletonLoader,
+} from '@gitlab/ui';
+import { isEmpty } from 'lodash';
+import { __, s__ } from '~/locale';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import {
+ PLATFORMS_WITHOUT_ARCHITECTURES,
+ INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES,
+} from './constants';
+import getRunnerPlatformsQuery from './graphql/queries/get_runner_platforms.query.graphql';
+import getRunnerSetupInstructionsQuery from './graphql/queries/get_runner_setup.query.graphql';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ GlIcon,
+ GlLoadingIcon,
+ GlSkeletonLoader,
+ ModalCopyButton,
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ platforms: {
+ query: getRunnerPlatformsQuery,
+ update(data) {
+ return data?.runnerPlatforms?.nodes.map(({ name, humanReadableName, architectures }) => {
+ return {
+ name,
+ humanReadableName,
+ architectures: architectures?.nodes || [],
+ };
+ });
+ },
+ result() {
+ // Select first platform by default
+ if (this.platforms?.[0]) {
+ this.selectPlatform(this.platforms[0]);
+ }
+ },
+ error() {
+ this.toggleAlert(true);
+ },
+ },
+ instructions: {
+ query: getRunnerSetupInstructionsQuery,
+ skip() {
+ return !this.selectedPlatform;
+ },
+ variables() {
+ return {
+ platform: this.selectedPlatformName,
+ architecture: this.selectedArchitectureName || '',
+ };
+ },
+ update(data) {
+ return data?.runnerSetup;
+ },
+ error() {
+ this.toggleAlert(true);
+ },
+ },
+ },
+ data() {
+ return {
+ platforms: [],
+ selectedPlatform: null,
+ selectedArchitecture: null,
+ showAlert: false,
+ instructions: {},
+ };
+ },
+ computed: {
+ platformsEmpty() {
+ return isEmpty(this.platforms);
+ },
+ instructionsEmpty() {
+ return isEmpty(this.instructions);
+ },
+ selectedPlatformName() {
+ return this.selectedPlatform?.name;
+ },
+ selectedArchitectureName() {
+ return this.selectedArchitecture?.name;
+ },
+ hasArchitecureList() {
+ return !PLATFORMS_WITHOUT_ARCHITECTURES.includes(this.selectedPlatformName);
+ },
+ instructionsWithoutArchitecture() {
+ return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatformName]?.instructions;
+ },
+ runnerInstallationLink() {
+ return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatformName]?.link;
+ },
+ },
+ methods: {
+ selectPlatform(platform) {
+ this.selectedPlatform = platform;
+
+ if (!platform.architectures?.some(({ name }) => name === this.selectedArchitectureName)) {
+ // Select first architecture when current value is not available
+ this.selectArchitecture(platform.architectures[0]);
+ }
+ },
+ selectArchitecture(architecture) {
+ this.selectedArchitecture = architecture;
+ },
+ toggleAlert(state) {
+ this.showAlert = state;
+ },
+ },
+ i18n: {
+ installARunner: s__('Runners|Install a runner'),
+ architecture: s__('Runners|Architecture'),
+ downloadInstallBinary: s__('Runners|Download and install binary'),
+ downloadLatestBinary: s__('Runners|Download latest binary'),
+ registerRunnerCommand: s__('Runners|Command to register runner'),
+ fetchError: s__('Runners|An error has occurred fetching instructions'),
+ copyInstructions: s__('Runners|Copy instructions'),
+ },
+ closeButton: {
+ text: __('Close'),
+ attributes: [{ variant: 'default' }],
+ },
+};
+</script>
+<template>
+ <gl-modal
+ :modal-id="modalId"
+ :title="$options.i18n.installARunner"
+ :action-secondary="$options.closeButton"
+ >
+ <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)">
+ {{ $options.i18n.fetchError }}
+ </gl-alert>
+
+ <gl-skeleton-loader v-if="platformsEmpty && $apollo.loading" />
+
+ <template v-if="!platformsEmpty">
+ <h5>
+ {{ __('Environment') }}
+ </h5>
+ <gl-button-group class="gl-mb-3">
+ <gl-button
+ v-for="platform in platforms"
+ :key="platform.name"
+ :selected="selectedPlatform && selectedPlatform.name === platform.name"
+ data-testid="platform-button"
+ @click="selectPlatform(platform)"
+ >
+ {{ platform.humanReadableName }}
+ </gl-button>
+ </gl-button-group>
+ </template>
+ <template v-if="hasArchitecureList">
+ <template v-if="selectedPlatform">
+ <h5>
+ {{ $options.i18n.architecture }}
+ <gl-loading-icon v-if="$apollo.loading" inline />
+ </h5>
+
+ <gl-dropdown class="gl-mb-3" :text="selectedArchitectureName">
+ <gl-dropdown-item
+ v-for="architecture in selectedPlatform.architectures"
+ :key="architecture.name"
+ :is-check-item="true"
+ :is-checked="selectedArchitectureName === 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-3">
+ <h5>{{ $options.i18n.downloadInstallBinary }}</h5>
+ <gl-button
+ class="gl-ml-auto"
+ :href="selectedArchitecture.downloadLocation"
+ download
+ icon="download"
+ data-testid="binary-download-button"
+ >
+ {{ $options.i18n.downloadLatestBinary }}
+ </gl-button>
+ </div>
+ </template>
+ <template v-if="!instructionsEmpty">
+ <div class="gl-display-flex">
+ <pre
+ class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line"
+ data-testid="binary-instructions"
+ >{{ instructions.installInstructions }}</pre
+ >
+ <modal-copy-button
+ :title="$options.i18n.copyInstructions"
+ :text="instructions.installInstructions"
+ :modal-id="$options.modalId"
+ css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
+ category="tertiary"
+ />
+ </div>
+
+ <h5 class="gl-mb-3">{{ $options.i18n.registerRunnerCommand }}</h5>
+ <div class="gl-display-flex">
+ <pre
+ class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line"
+ data-testid="register-command"
+ >{{ instructions.registerInstructions }}</pre
+ >
+ <modal-copy-button
+ :title="$options.i18n.copyInstructions"
+ :text="instructions.registerInstructions"
+ :modal-id="$options.modalId"
+ css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
+ category="tertiary"
+ />
+ </div>
+ </template>
+ </template>
+ <template v-else>
+ <div>
+ <p>{{ instructionsWithoutArchitecture }}</p>
+ <gl-button :href="runnerInstallationLink">
+ <gl-icon name="external-link" />
+ {{ s__('Runners|View installation instructions') }}
+ </gl-button>
+ </div>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue
new file mode 100644
index 00000000000..bbc7e6e7a6e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue
@@ -0,0 +1,88 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { s__, __, sprintf } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+/**
+ * Renders an inline field, whose value can be copied to the clipboard,
+ * for use in the GitLab sidebar (issues, MRs, etc.).
+ */
+export default {
+ name: 'CopyableField',
+ components: {
+ GlLoadingIcon,
+ ClipboardButton,
+ },
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ clipboardTooltipText: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ },
+ computed: {
+ clipboardProps() {
+ return {
+ category: 'tertiary',
+ tooltipBoundary: 'viewport',
+ tooltipPlacement: 'left',
+ text: this.value,
+ title:
+ this.clipboardTooltipText ||
+ sprintf(this.$options.i18n.clipboardTooltip, { name: this.name }),
+ };
+ },
+ loadingIconLabel() {
+ return sprintf(this.$options.i18n.loadingIconLabel, { name: this.name });
+ },
+ templateText() {
+ return sprintf(this.$options.i18n.templateText, {
+ name: this.name,
+ value: this.value,
+ });
+ },
+ },
+ i18n: {
+ loadingIconLabel: __('Loading %{name}'),
+ clipboardTooltip: __('Copy %{name}'),
+ templateText: s__('Sidebar|%{name}: %{value}'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <clipboard-button
+ v-if="!isLoading"
+ css-class="sidebar-collapsed-icon dont-change-state gl-rounded-0! gl-hover-bg-transparent"
+ v-bind="clipboardProps"
+ />
+
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-space-between hide-collapsed"
+ >
+ <span
+ class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap"
+ :title="value"
+ >
+ {{ templateText }}
+ </span>
+
+ <gl-loading-icon v-if="isLoading" inline :label="loadingIconLabel" />
+ <clipboard-button v-else size="small" v-bind="clipboardProps" />
+ </div>
+ </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
index 1d3bd312b09..320e2048f1c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
@@ -164,6 +164,7 @@ export default {
variant="link"
icon="close"
class="gl-mr-2 gl-w-auto! gl-p-2!"
+ :aria-label="__('Close')"
@click.prevent="handleDropdownCloseClick"
/>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
index 426ae430ce7..f547433f322 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
@@ -172,9 +172,11 @@ export default {
after: this.handleVuexActionDispatch,
});
+ document.addEventListener('mousedown', this.handleDocumentMousedown);
document.addEventListener('click', this.handleDocumentClick);
},
beforeDestroy() {
+ document.removeEventListener('mousedown', this.handleDocumentMousedown);
document.removeEventListener('click', this.handleDocumentClick);
},
methods: {
@@ -197,11 +199,36 @@ export default {
}
},
/**
+ * This method stores a mousedown event's target.
+ * Required by the click listener because the click
+ * event itself has no reference to this element.
+ */
+ handleDocumentMousedown({ target }) {
+ this.mousedownTarget = target;
+ },
+ /**
* This method listens for document-wide click event
* and toggle dropdown if user clicks anywhere outside
* the dropdown while dropdown is visible.
*/
handleDocumentClick({ target }) {
+ // We also perform the toggle exception check for the
+ // last mousedown event's target to avoid hiding the
+ // box when the mousedown happened inside the box and
+ // only the mouseup did not.
+ if (
+ this.showDropdownContents &&
+ !this.preventDropdownToggleOnClick(target) &&
+ !this.preventDropdownToggleOnClick(this.mousedownTarget)
+ ) {
+ this.toggleDropdownContents();
+ }
+ },
+ /**
+ * This method checks whether a given click target
+ * should prevent the dropdown from being toggled.
+ */
+ preventDropdownToggleOnClick(target) {
// This approach of element detection is needed
// as the dropdown wrapper is not using `GlDropdown` as
// it will also require us to use `BDropdownForm`
@@ -216,19 +243,20 @@ export default {
target?.parentElement?.classList.contains(className),
);
- const hadExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
+ const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
(className) => $(target).parents(className).length,
);
- if (
- this.showDropdownContents &&
- !hadExceptionParent &&
- !hasExceptionClass &&
- !this.$refs.dropdownButtonCollapsed?.$el.contains(target) &&
- !this.$refs.dropdownContents?.$el.contains(target)
- ) {
- this.toggleDropdownContents();
- }
+ const isInDropdownButtonCollapsed = this.$refs.dropdownButtonCollapsed?.$el.contains(target);
+
+ const isInDropdownContents = this.$refs.dropdownContents?.$el.contains(target);
+
+ return (
+ hasExceptionClass ||
+ hasExceptionParent ||
+ isInDropdownButtonCollapsed ||
+ isInDropdownContents
+ );
},
handleDropdownClose(labels) {
// Only emit label updates if there are any labels to update
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
index ef5f052527b..17904f20341 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
@@ -30,5 +30,8 @@ export default {
<gl-dropdown-form>
<slot name="items"></slot>
</gl-dropdown-form>
+ <template #footer>
+ <slot name="footer"></slot>
+ </template>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
index 459ea27e9cd..3885127fa8e 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
@@ -1,4 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query issueParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
@@ -9,11 +10,13 @@ query issueParticipants($fullPath: ID!, $iid: String!) {
participants {
nodes {
...User
+ ...UserAvailability
}
}
assignees {
nodes {
...User
+ ...UserAvailability
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
index 43bd9f17e9a..63482873b69 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
@@ -1,4 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query getMrParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
@@ -7,11 +8,13 @@ query getMrParticipants($fullPath: ID!, $iid: String!) {
participants {
nodes {
...User
+ ...UserAvailability
}
}
assignees {
nodes {
...User
+ ...UserAvailability
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
index 8ee8de2cb5c..3f40c0368d7 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
@@ -1,4 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
issuableSetAssignees: issueSetAssignees(
@@ -9,11 +10,13 @@ mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullP
assignees {
nodes {
...User
+ ...UserAvailability
}
}
participants {
nodes {
...User
+ ...UserAvailability
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
index a0f15a07692..77140ea36d8 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
@@ -1,4 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
mergeRequestSetAssignees(
@@ -9,11 +10,13 @@ mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!,
assignees {
nodes {
...User
+ ...UserAvailability
}
}
participants {
nodes {
...User
+ ...UserAvailability
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/url_sync.vue b/app/assets/javascripts/vue_shared/components/url_sync.vue
index 2844d9e9e94..925c6008836 100644
--- a/app/assets/javascripts/vue_shared/components/url_sync.vue
+++ b/app/assets/javascripts/vue_shared/components/url_sync.vue
@@ -2,11 +2,18 @@
import { historyPushState } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
+/**
+ * Renderless component to update the query string,
+ * the update is done by updating the query property or
+ * by using updateQuery method in the scoped slot.
+ * note: do not use both prop and updateQuery method.
+ */
export default {
props: {
query: {
type: Object,
- required: true,
+ required: false,
+ default: null,
},
},
watch: {
@@ -14,12 +21,19 @@ export default {
immediate: true,
deep: true,
handler(newQuery) {
- historyPushState(mergeUrlParams(newQuery, window.location.href, { spreadArrays: true }));
+ if (newQuery) {
+ this.updateQuery(newQuery);
+ }
},
},
},
+ methods: {
+ updateQuery(newQuery) {
+ historyPushState(mergeUrlParams(newQuery, window.location.href, { spreadArrays: true }));
+ },
+ },
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.({ updateQuery: this.updateQuery });
},
};
</script>
diff --git a/app/assets/javascripts/admin/users/components/user_date.vue b/app/assets/javascripts/vue_shared/components/user_date.vue
index 38dddbf72c2..38dddbf72c2 100644
--- a/app/assets/javascripts/admin/users/components/user_date.vue
+++ b/app/assets/javascripts/vue_shared/components/user_date.vue
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 dbd8efec948..11f484b2cdf 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -1,11 +1,6 @@
<script>
/* eslint-disable vue/no-v-html */
-import {
- GlPopover,
- GlLink,
- GlDeprecatedSkeletonLoading as GlSkeletonLoading,
- GlIcon,
-} from '@gitlab/ui';
+import { GlPopover, GlLink, GlSkeletonLoader, GlIcon } from '@gitlab/ui';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
import { glEmojiTag } from '../../../emoji';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
@@ -19,7 +14,7 @@ export default {
GlIcon,
GlLink,
GlPopover,
- GlSkeletonLoading,
+ GlSkeletonLoader,
UserAvatarImage,
UserNameWithStatus,
},
@@ -60,20 +55,18 @@ export default {
<template>
<!-- 200ms delay so not every mouseover triggers Popover -->
- <gl-popover :target="target" :delay="200" boundary="viewport" triggers="hover" placement="top">
+ <gl-popover :target="target" :delay="200" boundary="viewport" placement="top">
<div class="gl-p-3 gl-line-height-normal gl-display-flex" data-testid="user-popover">
<div class="gl-p-2 flex-shrink-1">
<user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="gl-mr-3!" />
</div>
- <div class="gl-p-2 gl-w-full">
+ <div class="gl-p-2 gl-w-full gl-min-w-0">
<template v-if="userIsLoading">
- <!-- `gl-skeleton-loading` does not support equal length lines -->
- <!-- This can be migrated to `gl-skeleton-loader` when https://gitlab.com/gitlab-org/gitlab-ui/-/issues/872 is completed -->
- <gl-skeleton-loading
- v-for="n in $options.maxSkeletonLines"
- :key="n"
- :lines="1"
- class="animation-container-small gl-mb-2"
+ <gl-skeleton-loader
+ :lines="$options.maxSkeletonLines"
+ preserve-aspect-ratio="none"
+ equal-width-lines
+ :height="52"
/>
</template>
<template v-else>
diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js
index 5262a15136b..9a5ad195de9 100644
--- a/app/assets/javascripts/vue_shared/constants.js
+++ b/app/assets/javascripts/vue_shared/constants.js
@@ -8,6 +8,8 @@ const INTERVALS = {
export const FILE_SYMLINK_MODE = '120000';
+export const SHORT_DATE_FORMAT = 'd mmm, yyyy';
+
export const timeRanges = [
{
label: __('30 minutes'),
diff --git a/app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js b/app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js
deleted file mode 100644
index ff1f565e79a..00000000000
--- a/app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import recaptchaModal from '../components/recaptcha_modal.vue';
-
-export default {
- data() {
- return {
- showRecaptcha: false,
- recaptchaHTML: '',
- };
- },
-
- components: {
- recaptchaModal,
- },
-
- methods: {
- openRecaptcha() {
- this.showRecaptcha = true;
- },
-
- closeRecaptcha() {
- this.showRecaptcha = false;
- },
-
- checkForSpam(data) {
- if (!data.recaptcha_html) return data;
-
- this.recaptchaHTML = data.recaptcha_html;
-
- const spamError = new Error(data.error_message);
- spamError.name = 'SpamError';
- spamError.message = 'SpamError';
-
- throw spamError;
- },
- },
-};
diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql
index 310d8d88904..4ce13827da2 100644
--- a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql
+++ b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql
@@ -6,6 +6,7 @@ query securityReportDownloadPaths(
project(fullPath: $projectPath) {
mergeRequest(iid: $iid) {
headPipeline {
+ id
jobs(securityReportTypes: $reportTypes) {
nodes {
name
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 b27dd33835f..1151cffa76f 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
@@ -184,6 +184,7 @@ export default {
:has-issues="false"
class="mr-widget-border-top mr-report"
data-testid="security-mr-widget"
+ track-action="users_expanding_secure_security_report"
>
<template v-for="slot in $options.summarySlots" #[slot]>
<span :key="slot">
@@ -212,6 +213,7 @@ export default {
:has-issues="false"
class="mr-widget-border-top mr-report"
data-testid="security-mr-widget"
+ track-action="users_expanding_secure_security_report"
>
<template #error>
{{ $options.i18n.scansHaveRun }}
diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue
index f4ac4f81eac..4a387edbe3f 100644
--- a/app/assets/javascripts/whats_new/components/app.vue
+++ b/app/assets/javascripts/whats_new/components/app.vue
@@ -1,13 +1,5 @@
<script>
-import {
- GlDrawer,
- GlInfiniteScroll,
- GlResizeObserverDirective,
- GlTabs,
- GlTab,
- GlBadge,
- GlLoadingIcon,
-} from '@gitlab/ui';
+import { GlDrawer, GlInfiniteScroll, GlResizeObserverDirective } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import Tracking from '~/tracking';
import { getDrawerBodyHeight } from '../utils/get_drawer_body_height';
@@ -20,37 +12,24 @@ export default {
components: {
GlDrawer,
GlInfiniteScroll,
- GlTabs,
- GlTab,
SkeletonLoader,
Feature,
- GlBadge,
- GlLoadingIcon,
},
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
mixins: [trackingMixin],
props: {
- storageKey: {
+ versionDigest: {
type: String,
required: true,
},
- versions: {
- type: Array,
- required: true,
- },
- gitlabDotCom: {
- type: Boolean,
- required: false,
- default: false,
- },
},
computed: {
...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight', 'fetching']),
},
mounted() {
- this.openDrawer(this.storageKey);
+ this.openDrawer(this.versionDigest);
this.fetchItems();
const body = document.querySelector('body');
@@ -70,16 +49,6 @@ export default {
const height = getDrawerBodyHeight(this.$refs.drawer.$el);
this.setDrawerBodyHeight(height);
},
- featuresForVersion(version) {
- return this.features.filter((feature) => {
- return feature.release === parseFloat(version);
- });
- },
- fetchVersion(version) {
- if (this.featuresForVersion(version).length === 0) {
- this.fetchItems({ version });
- }
- },
},
};
</script>
@@ -99,7 +68,6 @@ export default {
</template>
<template v-if="features.length">
<gl-infinite-scroll
- v-if="gitlabDotCom"
:fetched-items="features.length"
:max-list-height="drawerBodyHeight"
class="gl-p-0"
@@ -109,26 +77,6 @@ export default {
<feature v-for="feature in features" :key="feature.title" :feature="feature" />
</template>
</gl-infinite-scroll>
- <gl-tabs v-else :style="{ height: `${drawerBodyHeight}px` }" class="gl-p-0">
- <gl-tab
- v-for="(version, index) in versions"
- :key="version"
- @click="fetchVersion(version)"
- >
- <template #title>
- <span>{{ version }}</span>
- <gl-badge v-if="index === 0">{{ __('Your Version') }}</gl-badge>
- </template>
- <gl-loading-icon v-if="fetching" size="lg" class="text-center" />
- <template v-else>
- <feature
- v-for="feature in featuresForVersion(version)"
- :key="feature.title"
- :feature="feature"
- />
- </template>
- </gl-tab>
- </gl-tabs>
</template>
<div v-else class="gl-mt-5">
<skeleton-loader />
diff --git a/app/assets/javascripts/whats_new/index.js b/app/assets/javascripts/whats_new/index.js
index 6da141cb19a..3ac3a3a3611 100644
--- a/app/assets/javascripts/whats_new/index.js
+++ b/app/assets/javascripts/whats_new/index.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import { mapState } from 'vuex';
import App from './components/app.vue';
import store from './store';
-import { getStorageKey, setNotification } from './utils/notification';
+import { getVersionDigest, setNotification } from './utils/notification';
let whatsNewApp;
@@ -27,9 +27,7 @@ export default (el) => {
render(createElement) {
return createElement('app', {
props: {
- storageKey: getStorageKey(el),
- versions: JSON.parse(el.getAttribute('data-versions')),
- gitlabDotCom: el.getAttribute('data-gitlab-dot-com'),
+ versionDigest: getVersionDigest(el),
},
});
},
diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js
index 4b3cfa55977..1dc92ea2606 100644
--- a/app/assets/javascripts/whats_new/store/actions.js
+++ b/app/assets/javascripts/whats_new/store/actions.js
@@ -1,19 +1,20 @@
import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { STORAGE_KEY } from '../utils/notification';
import * as types from './mutation_types';
export default {
closeDrawer({ commit }) {
commit(types.CLOSE_DRAWER);
},
- openDrawer({ commit }, storageKey) {
+ openDrawer({ commit }, versionDigest) {
commit(types.OPEN_DRAWER);
- if (storageKey) {
- localStorage.setItem(storageKey, JSON.stringify(false));
+ if (versionDigest) {
+ localStorage.setItem(STORAGE_KEY, versionDigest);
}
},
- fetchItems({ commit, state }, { page, version } = { page: null, version: null }) {
+ fetchItems({ commit, state }, { page } = { page: null }) {
if (state.fetching) {
return false;
}
@@ -24,7 +25,6 @@ export default {
.get('/-/whats_new', {
params: {
page,
- version,
},
})
.then(({ data, headers }) => {
diff --git a/app/assets/javascripts/whats_new/utils/notification.js b/app/assets/javascripts/whats_new/utils/notification.js
index 52ca8058d1c..3d4326c4b3a 100644
--- a/app/assets/javascripts/whats_new/utils/notification.js
+++ b/app/assets/javascripts/whats_new/utils/notification.js
@@ -1,11 +1,18 @@
-export const getStorageKey = (appEl) => appEl.getAttribute('data-storage-key');
+export const STORAGE_KEY = 'display-whats-new-notification';
+
+export const getVersionDigest = (appEl) => appEl.getAttribute('data-version-digest');
export const setNotification = (appEl) => {
- const storageKey = getStorageKey(appEl);
+ const versionDigest = getVersionDigest(appEl);
const notificationEl = document.querySelector('.header-help');
let notificationCountEl = notificationEl.querySelector('.js-whats-new-notification-count');
- if (JSON.parse(localStorage.getItem(storageKey)) === false) {
+ const legacyStorageKey = 'display-whats-new-notification-13.10';
+ const localStoragePairs = [
+ [legacyStorageKey, false],
+ [STORAGE_KEY, versionDigest],
+ ];
+ if (localStoragePairs.some((pair) => localStorage.getItem(pair[0]) === pair[1].toString())) {
notificationEl.classList.remove('with-notifications');
if (notificationCountEl) {
notificationCountEl.parentElement.removeChild(notificationCountEl);
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index 4a15e0eb458..fa5ab590232 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -26,7 +26,6 @@
@import './pages/projects';
@import './pages/prometheus';
@import './pages/registry';
-@import './pages/runners';
@import './pages/search';
@import './pages/service_desk';
@import './pages/settings';
diff --git a/app/assets/stylesheets/application_dark.scss b/app/assets/stylesheets/application_dark.scss
index e55141e15df..90aab7ce342 100644
--- a/app/assets/stylesheets/application_dark.scss
+++ b/app/assets/stylesheets/application_dark.scss
@@ -1,3 +1,60 @@
@import './themes/dark';
@import './application';
+
+@import './themes/theme_helper';
+
+body.gl-dark {
+ @include gitlab-theme(
+ $gray-900,
+ $gray-400,
+ $gray-500,
+ $gray-800,
+ $gray-900,
+ $white
+ );
+
+ .logo-text svg {
+ fill: var(--gl-text-color);
+ }
+
+ .navbar-gitlab {
+ background-color: var(--gray-50);
+ box-shadow: 0 1px 0 0 var(--gray-100);
+
+ .navbar-sub-nav,
+ .navbar-nav {
+ li {
+ > a:hover,
+ > a:focus,
+ > button:hover,
+ > button:focus {
+ color: var(--gl-text-color);
+ background-color: var(--gray-200);
+ }
+ }
+
+ li.active,
+ li.dropdown.show {
+ > a,
+ > button {
+ color: var(--gl-text-color);
+ background-color: var(--gray-200);
+ }
+ }
+ }
+
+ .search {
+ form {
+ background-color: var(--gray-100);
+ box-shadow: inset 0 0 0 1px var(--border-color);
+
+ &:active,
+ &:hover {
+ background-color: var(--gray-100);
+ box-shadow: inset 0 0 0 1px var(--blue-200);
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/components/feature_highlight.scss b/app/assets/stylesheets/components/feature_highlight.scss
new file mode 100644
index 00000000000..08706951967
--- /dev/null
+++ b/app/assets/stylesheets/components/feature_highlight.scss
@@ -0,0 +1,9 @@
+.gl-badge.feature-highlight-badge {
+ background-color: $purple-light;
+ color: $purple;
+
+ &,
+ &.sm {
+ padding: 0.25rem;
+ }
+}
diff --git a/app/assets/stylesheets/fontawesome_custom.scss b/app/assets/stylesheets/fontawesome_custom.scss
deleted file mode 100644
index b9bb3edaaab..00000000000
--- a/app/assets/stylesheets/fontawesome_custom.scss
+++ /dev/null
@@ -1,43 +0,0 @@
-// Custom Font Awesome styles that render emojis in asciidoc
-.md {
- .fa {
- display: inline-block;
- font-style: normal;
- font-size: 14px;
- text-rendering: auto;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- }
-
- .fa-2x {
- font-size: 2em;
- }
-
- .fa-exclamation-triangle::before {
- content: '⚠';
- }
-
- .fa-exclamation-circle::before {
- content: '❗';
- }
-
- .fa-lightbulb-o::before {
- content: '💡';
- }
-
- .fa-thumb-tack::before {
- content: '📌';
- }
-
- .fa-fire::before {
- content: '🔥';
- }
-
- .fa-square-o::before {
- content: '\2610';
- }
-
- .fa-check-square-o::before {
- content: '\2611';
- }
-}
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index 662f7f52d61..412a1e8d6c9 100644
--- a/app/assets/stylesheets/framework/awards.scss
+++ b/app/assets/stylesheets/framework/awards.scss
@@ -122,12 +122,8 @@
}
}
-.award-control {
+.gl-button.btn.award-control {
margin: 4px 8px 4px 0;
- outline: 0;
- position: relative;
- display: block;
- float: left;
&.disabled {
cursor: default;
@@ -145,15 +141,6 @@
&:hover,
&:active,
&.is-active {
- background-color: $blue-50;
- border-color: $blue-200;
- box-shadow: none;
- outline: 0;
-
- .award-control-icon svg {
- fill: $blue-500;
- }
-
.award-control-icon-neutral {
opacity: 0;
}
@@ -164,6 +151,14 @@
}
}
+ &.active,
+ &.is-active,
+ &:active {
+ background-color: $blue-50;
+ border-color: $blue-200;
+ box-shadow: inset 0 0 2px $blue-200;
+ }
+
&.is-active {
.award-control-icon-positive {
opacity: 0;
@@ -192,10 +187,6 @@
&:focus {
outline: 0;
}
-
- .award-control-icon {
- margin: 0;
- }
}
&.is-loading {
@@ -213,9 +204,7 @@
gl-emoji,
.award-control-icon {
vertical-align: middle;
- margin-right: 0.15em;
- font-size: 1.5em;
- line-height: 1;
+ line-height: 0.5em;
}
.award-control-icon-loading {
@@ -224,11 +213,8 @@
.award-control-icon {
color: $border-gray-normal;
- margin-top: 1px;
- padding: 0 2px;
svg {
- margin-bottom: 1px;
height: $default-icon-size;
width: $default-icon-size;
border-radius: 50%;
@@ -239,10 +225,8 @@
.award-control-icon-positive,
.award-control-icon-super-positive {
@include transition(opacity, transform);
- position: absolute;
- left: 10px;
- bottom: 6px;
opacity: 0;
+ position: absolute;
path {
fill: $award-emoji-positive-add-lines;
@@ -261,7 +245,6 @@
// migrated to Vue.
.gl-button .award-emoji-block gl-emoji {
- top: -1px;
margin-top: -1px;
margin-bottom: -1px;
}
@@ -363,3 +346,7 @@
}
}
}
+
+.awards .is-active {
+ box-shadow: inset 0 0 0 1px $blue-200;
+}
diff --git a/app/assets/stylesheets/framework/ci_variable_list.scss b/app/assets/stylesheets/framework/ci_variable_list.scss
index 95025459cc9..ef4355ad157 100644
--- a/app/assets/stylesheets/framework/ci_variable_list.scss
+++ b/app/assets/stylesheets/framework/ci_variable_list.scss
@@ -75,26 +75,3 @@
padding-top: 5px;
padding-bottom: 5px;
}
-
-.ci-variable-row-remove-button {
- @include transition(color);
- flex-shrink: 0;
- display: flex;
- justify-content: center;
- align-items: center;
- width: $ci-variable-remove-button-width;
- height: $input-height;
- padding: 0;
- background: transparent;
- color: $gl-text-color-secondary;
-
- &:hover,
- &:focus {
- outline: none;
- color: $gl-text-color;
- }
-
- &[disabled] {
- color: $gl-text-color-disabled;
- }
-}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 5d182373fb1..652ffd79ab3 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -121,10 +121,6 @@ hr {
@include str-truncated(30%);
}
- &-60 {
- @include str-truncated(60%);
- }
-
&-100 {
@include str-truncated(100%);
}
@@ -292,11 +288,6 @@ img.emoji {
}
}
-.search_box {
- @extend .card.card-body;
- text-align: center;
-}
-
.dropzone .dz-preview .dz-progress {
border-color: $border-color !important;
@@ -426,7 +417,6 @@ img.emoji {
.mw-460 { max-width: 460px; }
.mw-6em { max-width: 6em; }
.mw-70p { max-width: 70%; }
-.mw-90p { max-width: 90%; }
// By default flex items don't shrink below their minimum content size.
// To change this, these clases set a min-width or min-height
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index bd15022eadf..bc7a31c112f 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -532,10 +532,6 @@ table.code {
&.parallel {
display: table-cell;
width: 46%;
-
- span {
- word-break: break-all;
- }
}
&.old {
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index bdde6c7b313..ff42cd836da 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -133,13 +133,15 @@
}
}
-.dropdown-menu-toggle {
+// This is double classed to solve a specificity issue with the gitlab ui buttons
+.dropdown-menu-toggle.dropdown-menu-toggle {
@extend .dropdown-toggle;
+ justify-content: flex-start;
+ overflow: hidden;
padding-right: 25px;
position: relative;
- width: 160px;
text-overflow: ellipsis;
- overflow: hidden;
+ width: 160px;
.fa {
position: absolute;
@@ -376,11 +378,13 @@
}
> a,
- > button {
+ > button,
+ > .gl-button {
display: flex;
+ justify-content: flex-start;
margin: 0;
- text-overflow: inherit;
text-align: left;
+ text-overflow: inherit;
&.btn .fa:not(:last-child) {
margin-left: 5px;
diff --git a/app/assets/stylesheets/framework/editor-lite.scss b/app/assets/stylesheets/framework/editor-lite.scss
index eda3e33a6aa..78995c6e4f5 100644
--- a/app/assets/stylesheets/framework/editor-lite.scss
+++ b/app/assets/stylesheets/framework/editor-lite.scss
@@ -24,3 +24,53 @@
[id^='editor-lite-'] {
height: 500px;
}
+
+.monaco-editor.gl-editor-lite {
+ .margin-view-overlays {
+ .line-numbers {
+ @include gl-display-flex;
+ @include gl-justify-content-end;
+ @include gl-relative;
+
+ &::before {
+ @include gl-visibility-hidden;
+ @include gl-align-self-center;
+ @include gl-bg-gray-400;
+ @include gl-mr-2;
+ @include gl-w-4;
+ @include gl-h-4;
+ mask-image: asset_url('icons-stacked.svg#link');
+ mask-repeat: no-repeat;
+ mask-size: cover;
+ mask-position: center;
+ content: '';
+ }
+
+ &:hover {
+ @include gl-text-decoration-underline;
+ cursor: pointer !important;
+ }
+
+ &:hover::before {
+ @include gl-visibility-visible;
+ }
+
+ &:focus::before {
+ @include gl-visibility-visible;
+ outline: auto;
+ }
+
+ .link-anchor {
+ @include gl-display-block;
+ @include gl-absolute;
+ @include gl-w-full;
+ @include gl-h-full;
+ }
+ }
+ }
+}
+
+.active-line-text {
+ @include gl-bg-orange-600;
+ @include gl-opacity-3;
+}
diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss
index c5c660c1014..1ddde3d2ed6 100644
--- a/app/assets/stylesheets/framework/emojis.scss
+++ b/app/assets/stylesheets/framework/emojis.scss
@@ -1,10 +1,10 @@
gl-emoji {
font-style: normal;
display: inline-flex;
- vertical-align: middle;
+ vertical-align: baseline;
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
- font-size: 1.4em;
- line-height: 1em;
+ font-size: 1.2em;
+ line-height: 1;
}
.user-status-emoji {
@@ -26,6 +26,13 @@ gl-emoji {
height: 30px;
// Create a width that fits 9 emojis per row
width: 100 / 9 * 1%;
+ transition: transform 0.15s cubic-bezier(0.3, 0, 0.2, 2) !important;
+ will-change: transform;
+
+ &:hover,
+ &:focus {
+ transform: scale(1.3);
+ }
}
.emoji-picker .gl-new-dropdown .dropdown-menu {
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index a4af45a467c..f76101d92b1 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -204,10 +204,6 @@
margin-bottom: 10px;
}
- &:hover {
- @extend .form-control:hover;
- }
-
&.focus,
&.focus:hover {
border-color: $blue-300;
@@ -294,14 +290,14 @@
flex-direction: column;
}
-.filtered-search-history-dropdown-toggle-button {
- flex: 1;
- width: auto;
+.filtered-search-history-dropdown-toggle-button.gl-button {
border-radius: $border-radius-default 0 0 $border-radius-default;
- border: 0;
border-right: 1px solid $border-color;
+ box-shadow: none;
color: $gl-text-color-secondary;
+ flex: 1;
transition: color 0.1s linear;
+ width: auto;
&:hover,
&:focus {
@@ -342,12 +338,6 @@
}
}
-.filter-dropdown-container {
- .dropdown-toggle {
- line-height: 22px;
- }
-}
-
@include media-breakpoint-down(sm) {
.issues-details-filters,
.epics-details-filters {
@@ -402,7 +392,6 @@
> svg {
position: absolute;
- top: 11px;
right: 6px;
}
}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index fdb56a128c7..432be7d0b3f 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -442,22 +442,22 @@
font-weight: $gl-font-weight-normal;
margin-left: -6px;
font-size: 11px;
- color: $white;
+ color: var(--gray-950, $white);
padding: 0 5px;
line-height: 12px;
border-radius: 7px;
box-shadow: 0 1px 0 rgba($gl-header-color, 0.2);
&.green-badge {
- background-color: $green-400;
+ background-color: var(--green-400, $green-400);
}
&.merge-requests-count {
- background-color: $orange-400;
+ background-color: var(--orange-400, $orange-400);
}
&.todos-count {
- background-color: $blue-400;
+ background-color: var(--blue-400, $blue-400);
}
}
@@ -511,7 +511,7 @@
.header-user {
&.show .dropdown-menu {
margin-top: 4px;
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
left: auto;
max-height: $dropdown-max-height-lg;
@@ -580,7 +580,7 @@
.no-emoji-placeholder,
.clear-user-status {
svg {
- fill: $gl-text-color-secondary;
+ fill: var(--gray-500, $gray-500);
}
}
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index ec433434573..48a18e0d145 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -94,14 +94,6 @@ body.modal-open {
padding-right: 0 !important;
}
-.modal-no-backdrop {
- @extend .modal-dialog;
-
- .modal-content {
- box-shadow: none;
- }
-}
-
.modal {
background-color: $black-transparent;
diff --git a/app/assets/stylesheets/framework/page_header.scss b/app/assets/stylesheets/framework/page_header.scss
index 660e3dcac8d..c8b4e306a2e 100644
--- a/app/assets/stylesheets/framework/page_header.scss
+++ b/app/assets/stylesheets/framework/page_header.scss
@@ -12,28 +12,11 @@
}
}
- .header-action-buttons {
- i {
- color: $gl-text-color-secondary;
- font-size: 13px;
- margin-right: 3px;
- }
-
- @include media-breakpoint-down(xs) {
- .btn {
- width: 100%;
- margin-top: 10px;
- }
-
- .dropdown {
- width: 100%;
- }
- }
- }
-
.avatar {
- @extend .avatar-inline;
- margin-left: 0;
+ float: none;
+ display: inline-block;
+ margin-left: 2px;
+ flex-shrink: 0;
@include media-breakpoint-up(sm) {
margin-left: 4px;
diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss
index 07c3eb19fd4..f57d906e73c 100644
--- a/app/assets/stylesheets/framework/responsive_tables.scss
+++ b/app/assets/stylesheets/framework/responsive_tables.scss
@@ -3,6 +3,7 @@
max-width: #{$max + '%'};
}
+.gl-responsive-table-row,
.gl-responsive-table-row-layout {
width: 100%;
@@ -17,7 +18,6 @@
}
.gl-responsive-table-row {
- @extend .gl-responsive-table-row-layout;
margin-top: 10px;
border: 1px solid $border-color;
color: $gray-500;
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 2ad9a9d2dff..27b7cac2df5 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -108,7 +108,7 @@
> .dropdown,
> input,
> form {
- margin-right: $gl-padding-top;
+ margin-right: $gl-padding-8;
&:last-child {
margin-right: 0;
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 241aaad015e..cb8a0c40f7f 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -58,19 +58,6 @@
height: $gl-padding;
}
}
-
- .copy-email-button { // TODO: replace with utility
- @include gl-w-full;
- @include gl-h-full;
- }
-
- .copy-email-address {
- height: 60px;
-
- &:hover {
- background: $gray-100;
- }
- }
}
.right-sidebar-expanded {
diff --git a/app/assets/stylesheets/framework/spinner.scss b/app/assets/stylesheets/framework/spinner.scss
index 2aa0ab6c1eb..c8eadce5c51 100644
--- a/app/assets/stylesheets/framework/spinner.scss
+++ b/app/assets/stylesheets/framework/spinner.scss
@@ -20,7 +20,7 @@
}
}
-@mixin spinner($size: 16px, $border-width: 2px, $color: $orange-400) {
+@mixin spinner($size: 16px, $border-width: 2px, $color: $gray-700) {
border-radius: 50%;
position: relative;
margin: 0 auto;
@@ -46,7 +46,7 @@
}
&.spinner-dark {
- @include spinner-color($gray-500);
+ @include spinner-color($gray-700);
}
&.spinner-light {
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 5624a6ea8a3..648ae29e212 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -1,6 +1,3 @@
-// Custom Fontawesome icons
-@import 'fontawesome_custom';
-
/**
* Apply Markup (Markdown/AsciiDoc) typography
*
@@ -435,7 +432,9 @@
}
}
- a.with-attachment-icon {
+ a.with-attachment-icon,
+ a[href*='/uploads/'],
+ a[href*='storage.googleapis.com/google-code-attachments/'] {
&::before {
margin-right: 4px;
@@ -449,8 +448,6 @@
a[href*='/uploads/'],
a[href*='storage.googleapis.com/google-code-attachments/'] {
- @extend .with-attachment-icon;
-
&.no-attachment-icon {
&::before {
display: none;
@@ -507,32 +504,56 @@
text-decoration: line-through;
}
- .admonitionblock td.icon {
- width: 1%;
+ // Custom Font Awesome styles that render emojis in asciidoc
+ .fa {
+ display: inline-block;
+ font-style: normal;
+ font-size: 14px;
+ text-rendering: auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
- [class^='fa icon-'] {
- @extend .fa-2x;
- }
+ .fa-2x,
+ .admonitionblock td.icon [class^='fa icon-'] {
+ font-size: 2em;
+ }
- .icon-note {
- @extend .fa-thumb-tack;
- }
+ .fa-exclamation-triangle::before,
+ .admonitionblock td.icon .icon-warning::before {
+ content: '⚠';
+ }
- .icon-tip {
- @extend .fa-lightbulb-o;
- }
+ .fa-exclamation-circle::before,
+ .admonitionblock td.icon .icon-important::before {
+ content: '❗';
+ }
- .icon-warning {
- @extend .fa-exclamation-triangle;
- }
+ .fa-lightbulb-o::before,
+ .admonitionblock td.icon .icon-tip::before {
+ content: '💡';
+ }
- .icon-caution {
- @extend .fa-fire;
- }
+ .fa-thumb-tack::before,
+ .admonitionblock td.icon .icon-note::before {
+ content: '📌';
+ }
- .icon-important {
- @extend .fa-exclamation-circle;
- }
+ .fa-fire::before,
+ .admonitionblock td.icon .icon-caution::before {
+ content: '🔥';
+ }
+
+ .fa-square-o::before {
+ content: '\2610';
+ }
+
+ .fa-check-square-o::before {
+ content: '\2611';
+ }
+
+ .admonitionblock td.icon {
+ width: 1%;
}
.metrics-embed {
@@ -640,12 +661,13 @@ code {
.commit-sha,
.ref-name,
.pipeline-number {
- @extend .monospace;
+ font-family: $monospace-font;
font-size: 95%;
}
-.git-revision-dropdown .dropdown-content ul li a {
- @extend .ref-name;
+.git-revision-dropdown .dropdown-content li:not(.dropdown-menu-empty-item) a {
+ font-family: $monospace-font;
+ font-size: 95%;
word-break: break-all;
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 1aa4177c902..18aa0d3013d 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -82,7 +82,7 @@ $darken-border-dashed-factor: 25% !default;
$white: #fff !default;
$white-normal: #f0f0f0 !default;
$white-dark: #eaeaea !default;
-$white-transparent: rgba(255, 255, 255, 0.8) !default;
+$white-transparent: rgba($white, 0.8) !default;
$gray-lightest: #fdfdfd !default;
$gray-light: #fafafa !default;
@@ -352,8 +352,8 @@ $border-gray-normal-dashed: darken($gray-normal, $darken-border-dashed-factor);
*/
$border-color: $gray-100;
$shadow-color: $t-gray-a-08;
-$well-expand-item: #e8f2f7;
-$well-inner-border: #eef0f2;
+$well-expand-item: #e8f2f7 !default;
+$well-inner-border: #eef0f2 !default;
$well-light-border: #f1f1f1;
$well-light-text-color: #5b6169;
@@ -590,7 +590,7 @@ $gl-btn-xs-line-height: 13px;
/*
* Badges
*/
-$badge-bg: rgba(0, 0, 0, 0.07);
+$badge-bg: rgba($black, 0.07);
/*
* Pagination
@@ -842,10 +842,10 @@ $linked-project-column-margin: 60px;
/*
Performance Bar
*/
-$perf-bar-production: #222;
-$perf-bar-staging: #291430;
-$perf-bar-development: #4c1210;
-$perf-bar-bucket-bg: #111;
+$perf-bar-production: $gray-950;
+$perf-bar-staging: $indigo-950;
+$perf-bar-development: $red-950;
+$perf-bar-bucket-bg: $black;
$perf-bar-bucket-box-shadow-from: rgba($white, 0.2);
$perf-bar-bucket-box-shadow-to: rgba($black, 0.25);
$perf-bar-canary-text: $orange-400;
@@ -924,11 +924,12 @@ Issue Analytics
$issues-analytics-popover-boarder-color: rgba(0, 0, 0, 0.15);
/*
-Merge Requests
+Merge requests
*/
$mr-tabs-height: 48px;
$mr-version-controls-height: 56px;
$mr-widget-margin-left: 40px;
+$mr-review-bar-height: calc(2rem + 13px);
/*
Compare Branches
diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss
index 6c050f33b07..8270db9966e 100644
--- a/app/assets/stylesheets/highlight/common.scss
+++ b/app/assets/stylesheets/highlight/common.scss
@@ -1,5 +1,6 @@
@import '../framework/variables';
@import './conflict_colors';
+@import 'page_bundles/mixins_and_variables_and_functions';
@mixin diff-background($background, $idiff, $border) {
background: $background;
@@ -93,3 +94,30 @@
}
}
}
+
+@mixin line-number-link($color) {
+ &::before {
+ @include gl-visibility-hidden;
+ @include gl-display-inline-block;
+ @include gl-align-self-center;
+ @include gl-mt-2;
+ @include gl-mr-2;
+ @include gl-w-4;
+ @include gl-h-4;
+ @include gl-float-left;
+ background-color: $color;
+ mask-image: asset_url('icons-stacked.svg#link');
+ mask-repeat: no-repeat;
+ mask-size: cover;
+ mask-position: center;
+ content: '';
+ }
+
+ &:hover::before {
+ @include gl-visibility-visible;
+ }
+
+ &:focus::before {
+ @include gl-visibility-visible;
+ }
+}
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 0dc01213606..d6523265a43 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -90,6 +90,10 @@ $dark-il: #de935f;
.code.dark {
// Line numbers
+ .file-line-num {
+ @include line-number-link($dark-line-num-color);
+ }
+
.line-numbers,
.diff-line-num {
background-color: $dark-main-bg;
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index 95c3e8e9103..027f2fa63d3 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -91,6 +91,10 @@ $monokai-gh: #75715e;
.code.monokai {
// Line numbers
+ .file-line-num {
+ @include line-number-link($monokai-line-num-color);
+ }
+
.line-numbers,
.diff-line-num {
background-color: $monokai-bg;
diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss
index 4fc6e5dba39..5002726bbc5 100644
--- a/app/assets/stylesheets/highlight/themes/none.scss
+++ b/app/assets/stylesheets/highlight/themes/none.scss
@@ -11,6 +11,10 @@
.code.none {
// Line numbers
+ .file-line-num {
+ @include line-number-link($black-transparent);
+ }
+
.line-numbers,
.diff-line-num {
background-color: $gray-light;
diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index f95f5393323..cd0cb65e4e2 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -94,6 +94,10 @@ $solarized-dark-il: #2aa198;
.code.solarized-dark {
// Line numbers
+ .file-line-num {
+ @include line-number-link($solarized-dark-line-color);
+ }
+
.line-numbers,
.diff-line-num {
background-color: $solarized-dark-line-bg;
diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index dc4bc2f32c2..77e88053424 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -101,6 +101,10 @@ $solarized-light-il: #2aa198;
.code.solarized-light {
// Line numbers
+ .file-line-num {
+ @include line-number-link($solarized-light-line-color);
+ }
+
.line-numbers,
.diff-line-num {
background-color: $solarized-light-line-bg;
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index 128fe0cc046..18b2f0a5d58 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -78,6 +78,10 @@ $white-gc-bg: #eaf2f5;
}
// Line numbers
+.file-line-num {
+ @include line-number-link($black-transparent);
+}
+
.line-numbers,
.diff-line-num {
background-color: $gray-light;
diff --git a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
index 0d40159f6de..272f94176d0 100644
--- a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
+++ b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
@@ -12,9 +12,9 @@
.select2-container,
.select2-container.select2-drop-above {
.select2-choice {
- background: $white;
- color: $gl-text-color;
- border-color: $border-color;
+ background: var(--white, $white);
+ color: var(--gl-text-color, $gl-text-color);
+ border-color: var(--border-color, $border-color);
height: 34px;
padding: $gl-vert-padding $gl-input-padding;
font-size: $gl-font-size;
@@ -27,6 +27,10 @@
/* stylelint-disable-next-line function-url-quotes */
background: url(asset_path('chevron-down.png')) no-repeat 2px 8px;
+ .gl-dark & {
+ filter: invert(0.9);
+ }
+
b {
display: none;
}
@@ -37,8 +41,8 @@
}
&:hover {
- border-color: $gray-darkest;
- color: $gl-text-color;
+ border-color: var(--gray-200, $gray-200);
+ color: var(--gl-text-color, $gl-text-color);
}
}
@@ -49,8 +53,8 @@
// .select2-focusser element instead.
&.select2-container-active:not(.select2-dropdown-open) {
.select2-choice {
- color: $input-focus-color;
- background-color: $input-focus-bg;
+ color: var(--gray-700, $gray-700);
+ background-color: var(--white, $white);
border-color: $input-focus-border-color;
outline: 0;
}
@@ -85,19 +89,19 @@
.select2-choices,
.select2-choice {
- border-color: $red-500;
+ border-color: var(--red-500, $red-500);
}
}
}
.select2-drop,
.select2-drop.select2-drop-above {
- background: $white;
+ background: var(--white, $white);
box-shadow: 0 2px 4px $dropdown-shadow-color;
border-radius: $gl-border-radius-base;
- border: 1px solid $border-color;
+ border: 1px solid var(--border-color, $border-color);
min-width: 175px;
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
z-index: 999;
.modal-open & {
@@ -114,7 +118,7 @@
}
.select2-drop.select2-drop-above.select2-drop-active {
- border-top: 1px solid $border-color;
+ border-top: 1px solid var(--border-color, $border-color);
margin-top: -6px;
}
@@ -128,7 +132,7 @@
.select2-dropdown-open,
.select2-dropdown-open.select2-drop-above {
.select2-choice {
- border-color: $gray-darkest;
+ border-color: var(--border-color, $border-color);
outline: 0;
}
}
@@ -136,7 +140,7 @@
.select2-container-multi {
.select2-choices {
border-radius: $border-radius-default;
- border-color: $border-color;
+ border-color: var(--border-color, $border-color);
background: none;
.select2-search-field input {
@@ -149,10 +153,10 @@
.select2-search-choice {
margin: 5px 0 0 8px;
box-shadow: none;
- border-color: $border-color;
- color: $gl-text-color;
+ border-color: var(--border-color, $border-color);
+ color: var(--gl-text-color, $gl-text-color);
line-height: 15px;
- background-color: $gray-light;
+ background-color: var(--gray-50, $gray-50);
background-image: none;
padding: 3px 18px 3px 5px;
@@ -163,7 +167,7 @@
}
&.select2-search-choice-focus {
- border-color: $gl-text-color;
+ border-color: var(--gl-text-color, $gl-text-color);
}
}
}
@@ -188,22 +192,22 @@
input {
padding: $grid-size;
background: transparent image-url('select2.png');
- color: $gl-text-color;
+ color: var(--gl-text-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: 1px solid var(--border-color, $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;
+ border-color: var(--blue-300, $blue-300);
}
&.select2-active {
- background-color: $white;
+ background-color: var(--white, $white);
background-image: image-url('select2-spinner.gif') !important;
background-origin: content-box;
background-repeat: no-repeat;
@@ -236,10 +240,10 @@
.select2-highlighted {
background: transparent;
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
.select2-result-label {
- background: $gray-darker;
+ background: var(--gray-50, $gray-50);
}
}
@@ -249,14 +253,14 @@
li.select2-result-with-children > .select2-result-label {
font-weight: $gl-font-weight-bold;
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
}
}
.select2-highlighted {
.group-result {
.group-path {
- color: $gray-700;
+ color: var(--gray-700, $gray-700);
}
}
}
diff --git a/app/assets/stylesheets/page_bundles/alert_management_settings.scss b/app/assets/stylesheets/page_bundles/alert_management_settings.scss
index fb7c1602cba..ed2707ffbcd 100644
--- a/app/assets/stylesheets/page_bundles/alert_management_settings.scss
+++ b/app/assets/stylesheets/page_bundles/alert_management_settings.scss
@@ -6,7 +6,6 @@ $stroke-size: 1px;
@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;
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index fdf9e157e1e..a00a71b07e7 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -40,8 +40,8 @@
[data-page$='epic_boards:index'],
[data-page$='epic_boards:show'] {
- .filter-form {
- display: none;
+ .filtered-search-wrapper {
+ display: none !important;
}
}
@@ -365,7 +365,7 @@
margin: 5px;
}
-.right-sidebar.issue-boards-sidebar {
+.right-sidebar.boards-sidebar {
.gutter-toggle {
bottom: 15px;
width: 22px;
@@ -401,99 +401,6 @@
}
}
-.add-issues-modal {
- background-color: rgba($black, 0.3);
- z-index: 9999;
-}
-
-.add-issues-container {
- width: 90vw;
- height: 85vh;
- max-width: 1100px;
- min-height: 500px;
- padding: 25px 15px 0;
- background-color: var(--white, $white);
- box-shadow: 0 2px 12px rgba(var(--black, $black), 0.5);
-
- .empty-state {
- &.add-issues-empty-state-filter {
- flex-direction: column;
- justify-content: center;
- }
-
- .svg-content {
- margin-top: -40px;
- }
- }
-}
-
-.add-issues-header {
- margin: -25px -15px -5px;
- border-bottom: 1px solid $border-color;
- border-top-right-radius: $border-radius-default;
- border-top-left-radius: $border-radius-default;
-
- > h2 {
- font-size: 18px;
- }
-}
-
-.add-issues-list-column {
- width: 100%;
-
- @include media-breakpoint-up(sm) {
- width: 50%;
- }
-
- @include media-breakpoint-up(md) {
- width: (100% / 3);
- }
-}
-
-.add-issues-list {
- padding-top: 3px;
- margin-left: -$gl-vert-padding;
- margin-right: -$gl-vert-padding;
- overflow-y: scroll;
-
- .board-card-parent {
- padding: 0 5px 5px;
- }
-
- .board-card {
- border: 1px solid var(--gray-900, $gray-900);
- box-shadow: 0 1px 2px rgba(var(--black, $black), 0.4);
- cursor: pointer;
- }
-}
-
-.add-issues-footer {
- margin: auto -15px 0;
- padding-left: 15px;
- padding-right: 15px;
- border-bottom-right-radius: $border-radius-default;
- border-bottom-left-radius: $border-radius-default;
-}
-
-.add-issues-footer-to-list {
- padding-left: $gl-vert-padding;
- padding-right: $gl-vert-padding;
- line-height: $input-height;
-}
-
-.issue-card-selected {
- position: absolute;
- right: -3px;
- top: -3px;
- width: 17px;
- background-color: var(--blue-500, $blue-500);
- color: $white;
- border: 1px solid var(--blue-600, $blue-600);
- font-size: 9px;
- line-height: 15px;
- border-radius: 50%;
-}
-
.board-card-info {
color: var(--gray-500, $gray-500);
white-space: nowrap;
@@ -555,12 +462,28 @@
overflow-x: scroll;
}
- .issue-boards-sidebar {
+ .boards-sidebar {
height: 100%;
top: 0;
}
}
+.boards-sidebar {
+ .sidebar-collapsed-icon {
+ display: none;
+ }
+}
+
.board-header-collapsed-info-icon:hover {
color: var(--gray-900, $gray-900);
}
+
+.board-card-skeleton {
+ height: 110px;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+
+ .board-card-skeleton-inner {
+ width: 340px;
+ height: 100px;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss
index 34f88f9405a..b91850f1775 100644
--- a/app/assets/stylesheets/page_bundles/build.scss
+++ b/app/assets/stylesheets/page_bundles/build.scss
@@ -8,9 +8,9 @@
.archived-job {
top: $header-height;
border-radius: 2px 2px 0 0;
- color: $orange-600;
- background-color: $orange-50;
- border: 1px solid $border-gray-normal;
+ color: var(--orange-600, $orange-600);
+ background-color: var(--orange-50, $orange-50);
+ border: 1px solid var(--border-color, $border-color);
padding: 3px 12px;
margin: auto;
align-items: center;
@@ -86,33 +86,17 @@
padding: 10px 0 9px;
}
- .header-action-buttons {
- display: flex;
-
- @include media-breakpoint-down(xs) {
- .sidebar-toggle-btn {
- margin-top: 0;
- margin-left: 10px;
- max-height: 34px;
- }
- }
- }
-
.header-content {
a {
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
&:hover {
- color: $blue-600;
+ color: var(--blue-600, $blue-600);
text-decoration: none;
}
}
}
- code {
- color: $code-color;
- }
-
.avatar {
float: none;
margin-right: 2px;
@@ -160,12 +144,12 @@
.trigger-build-variable {
font-weight: $gl-font-weight-normal;
- color: $code-color;
+ color: var(--gray-950, $gray-950);
}
.trigger-build-value {
padding: 2px 4px;
- color: $black;
+ color: var(--black, $black);
}
.trigger-variables-table-cell {
@@ -185,7 +169,7 @@
cursor: pointer;
&:hover {
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
}
}
@@ -223,7 +207,7 @@
}
&.retried {
- background-color: $gray-lightest;
+ background-color: var(--gray-10, $gray-10);
}
&:hover {
@@ -231,10 +215,6 @@
}
}
}
-
- .link-commit {
- color: var(--blue-600, $blue-600);
- }
}
.build-sidebar {
diff --git a/app/assets/stylesheets/page_bundles/ci_status.scss b/app/assets/stylesheets/page_bundles/ci_status.scss
index 232d363b7f1..6b976106cc9 100644
--- a/app/assets/stylesheets/page_bundles/ci_status.scss
+++ b/app/assets/stylesheets/page_bundles/ci_status.scss
@@ -80,17 +80,3 @@
}
}
}
-
-.d-block.d-sm-none-inline {
- .ci-status-link {
- position: relative;
- top: 2px;
- left: 5px;
- }
-}
-
-.ci-status-link {
- svg {
- overflow: visible;
- }
-}
diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss
index eb2dd6e578e..db4be3f18e8 100644
--- a/app/assets/stylesheets/page_bundles/jira_connect.scss
+++ b/app/assets/stylesheets/page_bundles/jira_connect.scss
@@ -4,37 +4,22 @@
@import 'bootstrap-vue/src/index';
@import '@gitlab/ui/src/scss/utilities';
-@import '@gitlab/ui/src/components/base/alert/alert';
// We should only import styles that we actually use.
@import '@gitlab/ui/src/components/base/alert/alert';
@import '@gitlab/ui/src/components/base/avatar/avatar';
-@import '@gitlab/ui/src/components/base/badge/badge';
@import '@gitlab/ui/src/components/base/button/button';
@import '@gitlab/ui/src/components/base/icon/icon';
@import '@gitlab/ui/src/components/base/link/link';
@import '@gitlab/ui/src/components/base/loading_icon/loading_icon';
@import '@gitlab/ui/src/components/base/modal/modal';
@import '@gitlab/ui/src/components/base/pagination/pagination';
-@import '@gitlab/ui/src/components/base/tabs/tabs/tabs';
+@import '@gitlab/ui/src/components/base/table/table';
@import '@gitlab/ui/src/components/base/tooltip/tooltip';
+@import '@gitlab/ui/src/components/base/search_box_by_type/search_box_by_type';
-$atlaskit-border-color: #dfe1e6;
$header-height: 40px;
-.subscription-form {
- .field-group-input {
- display: flex;
- padding-top: $gl-padding-4;
-
- .ak-button {
- align-items: center;
- height: auto;
- margin-left: $btn-margin-5;
- }
- }
-}
-
.jira-connect-header {
min-height: $header-height;
position: fixed;
@@ -56,45 +41,7 @@ $header-height: 40px;
}
.jira-connect-app-body {
- max-width: 600px;
+ max-width: 768px;
margin-left: auto;
margin-right: auto;
}
-
-// for external_link buttons
-svg {
- fill: currentColor;
-
- &.s16 {
- height: 16px;
- width: 16px;
- }
-}
-
-.ak-field-group label {
- text-align: left;
-}
-
-.ak-button__appearance-primary {
- &:hover {
- color: $white;
- text-decoration: none;
- }
-
- svg {
- align-self: center;
- margin-left: 4px;
- }
-}
-
-.subscriptions {
- tbody {
- tr {
- border-bottom: 1px solid $atlaskit-border-color;
- }
-
- td {
- padding: $gl-padding-8;
- }
- }
-}
diff --git a/app/assets/stylesheets/page_bundles/learn_gitlab.scss b/app/assets/stylesheets/page_bundles/learn_gitlab.scss
index 189aefb330b..10a4a210d41 100644
--- a/app/assets/stylesheets/page_bundles/learn_gitlab.scss
+++ b/app/assets/stylesheets/page_bundles/learn_gitlab.scss
@@ -1,3 +1,11 @@
.learn-gitlab-info-card-content {
height: 200px;
}
+
+.learn-gitlab-section-card {
+ height: 400px;
+}
+
+.learn-gitlab-section-card-header {
+ height: 165px;
+}
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 3263a5067ea..9fdc30359f8 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -9,6 +9,18 @@
min-width: 0;
}
+.with-system-header {
+ --system-header-height: #{$system-header-height};
+}
+
+.with-performance-bar {
+ --performance-bar-height: #{$performance-bar-height};
+}
+
+.review-bar-visible {
+ --review-bar-height: #{$mr-review-bar-height};
+}
+
.diff-tree-list {
// This 11px value should match the additional value found in
// /assets/stylesheets/framework/diffs.scss
@@ -23,24 +35,13 @@
position: -webkit-sticky;
position: sticky;
- top: $top-pos;
- max-height: calc(100vh - #{$top-pos});
+ // Unitless zero values are not allowed in calculations https://stackoverflow.com/a/55391061
+ // stylelint-disable-next-line length-zero-no-unit
+ top: calc(#{$top-pos} + var(--system-header-height, 0px) + var(--performance-bar-height, 0px));
+ // stylelint-disable-next-line length-zero-no-unit
+ max-height: calc(100vh - #{$top-pos} - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px));
z-index: 202;
- .with-system-header & {
- top: $top-pos + $system-header-height;
- }
-
- .with-system-header.with-performance-bar & {
- top: $top-pos + $system-header-height + $performance-bar-height;
- }
-
- .with-performance-bar & {
- $performance-bar-top-pos: $performance-bar-height + $top-pos;
- top: $performance-bar-top-pos;
- max-height: calc(100vh - #{$performance-bar-top-pos});
- }
-
.drag-handle {
bottom: 16px;
transform: translateX(10px);
diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss
index d9ab52774bd..2f3cf889549 100644
--- a/app/assets/stylesheets/page_bundles/pipeline.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline.scss
@@ -139,12 +139,28 @@
width: 186px;
}
+.gl-pipeline-job-width\! {
+ width: 186px !important;
+}
+
.gl-linked-pipeline-padding {
padding-right: 120px;
}
.gl-build-content {
- @include build-content();
+ display: inline-block;
+ padding: 8px 10px 9px;
+ width: 100%;
+ border: 1px solid var(--border-color, $border-color);
+ border-radius: 30px;
+ background-color: var(--white, $white);
+
+ &:hover,
+ &:focus {
+ background-color: var(--gray-50, $gray-50);
+ border: 1px solid $dropdown-toggle-active-border-color;
+ color: var(--gl-text-color, $gl-text-color);
+ }
}
.gl-ci-action-icon-container {
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 7111d3d4107..a114a1dc82d 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -1,4 +1,5 @@
-%commit-description-base {
+.commit-description,
+.commit-row-description {
padding: $gl-padding-8 0 $gl-padding-8 $gl-padding-8;
margin-top: $gl-padding-8;
border: 0;
@@ -10,10 +11,6 @@
color: $gl-text-color-secondary;
}
-.commit-description {
- @extend %commit-description-base;
-}
-
.commit-box {
border-top: 1px solid $border-color;
padding: $gl-padding 0;
@@ -249,7 +246,6 @@
}
.commit-row-description {
- @extend %commit-description-base;
display: none;
flex: 1;
}
diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss
index ef737e11799..14cff5b038a 100644
--- a/app/assets/stylesheets/pages/editor.scss
+++ b/app/assets/stylesheets/pages/editor.scss
@@ -36,7 +36,7 @@
}
.file-title {
- @extend .monospace;
+ @include gl-font-monospace;
line-height: 35px;
padding-top: 7px;
padding-bottom: 7px;
diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss
index 5738cbb4b31..c8da025131d 100644
--- a/app/assets/stylesheets/pages/events.scss
+++ b/app/assets/stylesheets/pages/events.scss
@@ -50,7 +50,7 @@
.event-user-info {
margin-bottom: $gl-padding-4;
- .author_name {
+ .author-name {
a {
color: $gl-text-color;
font-weight: $gl-font-weight-bold;
@@ -118,26 +118,11 @@
}
}
- .event_icon {
- position: relative;
- float: right;
- border: 1px solid $gray-darker;
- padding: 5px;
- border-radius: 5px;
- background: $gray-light;
- margin-left: 10px;
- top: -6px;
-
- img {
- width: 20px;
- }
- }
-
&:last-child {
border: 0;
}
- .event_commits {
+ .event-commits {
li {
&.commit {
background: transparent;
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index ee1385d36df..2ec2da9241b 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -160,17 +160,6 @@
vertical-align: top;
}
-
- .notification-dropdown {
- .dropdown-menu {
- @extend .dropdown-menu-right;
- }
-
- .icon {
- fill: $gl-text-color-secondary;
- }
- }
-
.new-project-subgroup {
.dropdown-primary {
min-width: 115px;
@@ -378,10 +367,6 @@ table.pipeline-project-metrics tr td {
.folder-caret {
width: $gl-font-size-large;
-
- svg {
- margin-bottom: 2px;
- }
}
.item-type-icon {
diff --git a/app/assets/stylesheets/pages/help.scss b/app/assets/stylesheets/pages/help.scss
index 540060d60de..c05216ac6e6 100644
--- a/app/assets/stylesheets/pages/help.scss
+++ b/app/assets/stylesheets/pages/help.scss
@@ -26,13 +26,6 @@
text-align: right;
white-space: nowrap;
}
-
- .key {
- @extend .badge.badge-pill;
- background-color: $label-inverse-bg;
- font: 11px Consolas, 'Liberation Mono', Menlo, Courier, monospace;
- padding: 3px 5px;
- }
}
.documentation {
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index a6ab5459a84..b9f5a427a24 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -225,8 +225,7 @@
}
}
- .cross-project-reference,
- .sidebar-mr-source-branch {
+ .cross-project-reference {
color: inherit;
span {
@@ -238,10 +237,6 @@
text-overflow: ellipsis;
}
- cite {
- font-style: normal;
- }
-
button {
float: right;
padding: 1px 5px;
@@ -292,7 +287,7 @@
padding-top: 10px;
}
- &:not(.issue-boards-sidebar):not([data-signed-in]):not([data-always-show-toggle]) {
+ &:not(.boards-sidebar):not([data-signed-in]):not([data-always-show-toggle]) {
.issuable-sidebar-header {
display: none;
}
@@ -638,7 +633,7 @@
}
.btn-link:hover {
- @extend a:hover;
+ color: $blue-800;
text-decoration: none;
}
@@ -797,6 +792,40 @@
}
}
+.add-issuable-form-input-token-list {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ list-style: none;
+ margin-bottom: 0;
+ padding-left: 0;
+}
+
+.add-issuable-form-token-list-item {
+ max-width: 100%;
+ margin-bottom: $gl-vert-padding;
+ margin-right: 5px;
+}
+
+.add-issuable-form-input-list-item {
+ flex: 1;
+ min-width: 200px;
+ margin-bottom: $gl-vert-padding;
+}
+
+.add-issuable-form-input {
+ width: 100%;
+ border: 0;
+
+ &:focus {
+ outline: none;
+ }
+}
+
+.add-issuable-form-actions {
+ margin-top: $gl-padding;
+}
+
.time-tracker {
.sidebar-collapsed-icon {
> .stopwatch-svg {
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 2a8a86615f6..97c8182bab8 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -173,11 +173,10 @@ ul.related-merge-requests > li {
margin-top: 4px;
// override dropdown item styles
- .btn.btn-success {
+ .btn.btn-confirm {
@include btn-default;
- @include btn-green;
+ @include btn-blue;
- border-style: solid;
border-width: 1px;
line-height: $line-height-base;
width: auto;
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index b7d05fc411a..c7b4dd660d0 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -120,7 +120,6 @@
}
.labels-container {
- background-color: $gray-100;
border-radius: $border-radius-default;
padding: $gl-padding $gl-padding-8;
}
@@ -160,7 +159,7 @@
color: $blue-600;
}
- &.remove-row:hover {
+ &.hover-red:hover {
color: $red-500;
}
}
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index 2d04354a99d..9d437531e6d 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -179,8 +179,9 @@
}
input[type='submit'] {
- @extend .btn-block;
margin-bottom: 0;
+ display: block;
+ width: 100%;
}
.devise-errors {
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 23e368a2e73..36d39c1a613 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -236,8 +236,8 @@ $tabs-holder-z-index: 250;
}
.label-branch {
- @extend .ref-name;
-
+ @include gl-font-monospace;
+ font-size: 95%;
color: $gl-text-color;
font-weight: normal;
overflow: hidden;
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index cb5050fc578..59768f4cda8 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -48,7 +48,6 @@
}
.note-image-attach {
- @extend .col-lg-4;
margin-left: 45px;
float: none;
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 190bdcb1efd..801dd44be8e 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -771,6 +771,26 @@ $system-note-svg-size: 16px;
}
}
+.unified-diff-components-diff-note-button {
+ &::before {
+ background-color: $blue-500;
+ mask-image: asset_url('icons-stacked.svg#comment');
+ mask-repeat: no-repeat;
+ mask-size: cover;
+ mask-position: center;
+ content: '';
+ width: 12px;
+ height: 12px;
+ }
+
+ &:hover,
+ &.inverted {
+ &::before {
+ background-color: $white;
+ }
+ }
+}
+
.disabled-comment {
background-color: $gray-light;
border-radius: $border-radius-base;
diff --git a/app/assets/stylesheets/pages/notifications.scss b/app/assets/stylesheets/pages/notifications.scss
index 33ab42b5511..298de33888d 100644
--- a/app/assets/stylesheets/pages/notifications.scss
+++ b/app/assets/stylesheets/pages/notifications.scss
@@ -1,8 +1,4 @@
.notification-list-item {
- .dropdown-menu {
- @extend .dropdown-menu-right;
- }
-
@include media-breakpoint-down(sm) {
.notification-dropdown {
width: 100%;
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index e51cc0b4479..16f96ebadc9 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -115,11 +115,6 @@
font-size: $gl-font-size;
line-height: $gl-font-size-large;
}
-
- .home-panel-topic-list,
- .home-panel-metadata {
- font-size: $gl-font-size-small;
- }
}
}
@@ -141,10 +136,6 @@
}
}
- .notification-dropdown .dropdown-menu {
- @extend .dropdown-menu-right;
- }
-
.download-button {
@include media-breakpoint-down(md) {
margin-left: 0;
@@ -843,7 +834,7 @@ pre.light-well {
}
.form-control {
- @extend .monospace;
+ @include gl-font-monospace;
background-color: $white;
border-color: $border-color;
font-size: 14px;
diff --git a/app/assets/stylesheets/pages/runners.scss b/app/assets/stylesheets/pages/runners.scss
deleted file mode 100644
index 856e49bd144..00000000000
--- a/app/assets/stylesheets/pages/runners.scss
+++ /dev/null
@@ -1,56 +0,0 @@
-.runner-state {
- padding: 6px 12px;
- margin-right: 10px;
- color: $white;
-
- &.runner-state-shared {
- background: $green-400;
- }
-
- &.runner-state-specific {
- background: $blue-400;
- }
-}
-
-.runner-status {
- &.runner-status-online {
- background-color: $green-600;
- }
-
- &.runner-status-offline {
- background-color: $gray-darkest;
- }
-
- &.runner-status-paused {
- background-color: $red-500;
- }
-}
-
-.runner {
- .btn {
- padding: 1px 6px;
- }
-
- h4 {
- font-weight: $gl-font-weight-normal;
- }
-}
-
-.admin-runner-btn-group-cell {
- min-width: 150px;
-
- .btn-sm {
- padding: 4px 9px;
- }
-
- .btn-default {
- color: $gl-text-color-secondary;
- }
-}
-
-@include media-breakpoint-down(md) {
- .runners-content {
- width: 100%;
- overflow: auto;
- }
-}
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index f31b6d96f03..aa9ebfe2968 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -278,10 +278,6 @@
.card-header {
display: flex;
align-items: center;
-
- > .btn-success {
- margin-left: auto;
- }
}
.custom-metric {
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index fa008a05e11..a371aa37e07 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -22,7 +22,7 @@
.control {
float: left;
- margin-left: 10px;
+ margin-left: 8px;
}
}
diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss
index c6c9f3b7365..bcc3c35e00e 100644
--- a/app/assets/stylesheets/performance_bar.scss
+++ b/app/assets/stylesheets/performance_bar.scss
@@ -14,7 +14,7 @@
color: $gray-300;
select {
- color: $gray-300;
+ color: $white;
width: 200px;
}
diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss
index 7b66b61ff36..1d0333d1e2f 100644
--- a/app/assets/stylesheets/print.scss
+++ b/app/assets/stylesheets/print.scss
@@ -60,3 +60,11 @@ pre {
a[href]::after {
content: none !important;
}
+
+.with-performance-bar .layout-page {
+ margin-top: 0;
+}
+
+.content-wrapper {
+ margin-top: 0;
+}
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index d8f74a2913e..11b4bde74a6 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -214,6 +214,9 @@ $yiq-text-light: $gray-950;
$line-added-dark: $green-200;
$line-removed-dark: $red-200;
+$well-expand-item: $gray-200;
+$well-inner-border: $gray-200;
+
// Misc component overrides that should live elsewhere
.gl-label {
filter: brightness(0.9) contrast(1.1);
diff --git a/app/assets/stylesheets/themes/theme_light.scss b/app/assets/stylesheets/themes/theme_light.scss
index 228bff94f5d..b41377475c5 100644
--- a/app/assets/stylesheets/themes/theme_light.scss
+++ b/app/assets/stylesheets/themes/theme_light.scss
@@ -81,50 +81,4 @@ body {
color: $gray-900;
}
}
-
- &.gl-dark {
- .logo-text svg {
- fill: var(--gl-text-color);
- }
-
- .navbar-gitlab {
- background-color: var(--gray-50);
- box-shadow: 0 1px 0 0 var(--gray-100);
-
- .navbar-sub-nav,
- .navbar-nav {
- li {
- > a:hover,
- > a:focus,
- > button:hover,
- > button:focus {
- color: var(--gl-text-color);
- background-color: var(--gray-200);
- }
- }
-
- li.active,
- li.dropdown.show {
- > a,
- > button {
- color: var(--gl-text-color);
- background-color: var(--gray-200);
- }
- }
- }
-
- .search {
- form {
- background-color: var(--gray-100);
- box-shadow: inset 0 0 0 1px var(--border-color);
-
- &:active,
- &:hover {
- background-color: var(--gray-100);
- box-shadow: inset 0 0 0 1px var(--blue-200);
- }
- }
- }
- }
- }
}
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 7c6a444ce7a..646a6dffd10 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -11,11 +11,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
# https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/30233
before_action :set_application_setting, except: :integrations
- before_action :whitelist_query_limiting, only: [:usage_data]
-
- before_action only: [:ci_cd] do
- push_frontend_feature_flag(:ci_instance_variables_ui, default_enabled: true)
- end
+ before_action :disable_query_limiting, only: [:usage_data]
feature_category :not_owned, [
:general, :reporting, :metrics_and_profiling, :network,
@@ -194,8 +190,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
@plans = Plan.all
end
- def whitelist_query_limiting
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/63107')
+ def disable_query_limiting
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/29418')
end
def application_setting_params
diff --git a/app/controllers/admin/clusters/integrations_controller.rb b/app/controllers/admin/clusters/integrations_controller.rb
new file mode 100644
index 00000000000..d163ae7368d
--- /dev/null
+++ b/app/controllers/admin/clusters/integrations_controller.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Admin::Clusters::IntegrationsController < Clusters::IntegrationsController
+ include EnforcesAdminAuthentication
+
+ private
+
+ def clusterable
+ @clusterable ||= InstanceClusterablePresenter.fabricate(Clusters::Instance.new, current_user: current_user)
+ end
+end
diff --git a/app/controllers/admin/dev_ops_report_controller.rb b/app/controllers/admin/dev_ops_report_controller.rb
index 4ebc643be33..4178e51fb13 100644
--- a/app/controllers/admin/dev_ops_report_controller.rb
+++ b/app/controllers/admin/dev_ops_report_controller.rb
@@ -1,11 +1,11 @@
# frozen_string_literal: true
class Admin::DevOpsReportController < Admin::ApplicationController
- include Analytics::UniqueVisitsHelper
+ include RedisTracking
helper_method :show_adoption?
- track_unique_visits :show, target_id: 'i_analytics_dev_ops_score'
+ track_redis_hll_event :show, name: 'i_analytics_dev_ops_score', if: -> { should_track_devops_score? }
feature_category :devops_reports
@@ -18,6 +18,10 @@ class Admin::DevOpsReportController < Admin::ApplicationController
def show_adoption?
false
end
+
+ def should_track_devops_score?
+ true
+ end
end
Admin::DevOpsReportController.prepend_if_ee('EE::Admin::DevOpsReportController')
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index 0a1c85eef3f..e14cfc236cf 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -30,9 +30,11 @@ class Admin::GroupsController < Admin::ApplicationController
def new
@group = Group.new
+ @group.build_admin_note
end
def edit
+ @group.build_admin_note unless @group.admin_note
end
def create
@@ -49,6 +51,8 @@ class Admin::GroupsController < Admin::ApplicationController
end
def update
+ @group.build_admin_note unless @group.admin_note
+
if @group.update(group_params)
redirect_to [:admin, @group], notice: _('Group was successfully updated.')
else
@@ -58,7 +62,7 @@ class Admin::GroupsController < Admin::ApplicationController
def members_update
member_params = params.permit(:user_ids, :access_level, :expires_at)
- result = Members::CreateService.new(current_user, member_params.merge(limit: -1)).execute(@group)
+ result = Members::CreateService.new(current_user, member_params.merge(limit: -1, source: @group)).execute
if result[:status] == :success
redirect_to [:admin, @group], notice: _('Users were successfully added.')
@@ -105,7 +109,10 @@ class Admin::GroupsController < Admin::ApplicationController
:require_two_factor_authentication,
:two_factor_grace_period,
:project_creation_level,
- :subgroup_creation_level
+ :subgroup_creation_level,
+ admin_note_attributes: [
+ :note
+ ]
]
end
end
diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb
index 379e74bb249..9f951e838c8 100644
--- a/app/controllers/admin/services_controller.rb
+++ b/app/controllers/admin/services_controller.rb
@@ -4,12 +4,12 @@ class Admin::ServicesController < Admin::ApplicationController
include ServiceParams
before_action :service, only: [:edit, :update]
- before_action :whitelist_query_limiting, only: [:index]
+ before_action :disable_query_limiting, only: [:index]
feature_category :integrations
def index
- @services = Service.find_or_create_templates.sort_by(&:title)
+ @activated_services = Service.for_template.active.sort_by(&:title)
@existing_instance_types = Service.for_instance.pluck(:type) # rubocop: disable CodeReuse/ActiveRecord
end
@@ -39,7 +39,7 @@ class Admin::ServicesController < Admin::ApplicationController
end
# rubocop: enable CodeReuse/ActiveRecord
- def whitelist_query_limiting
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/-/issues/220357')
+ def disable_query_limiting
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/220357')
end
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index d0761083c8b..8a090c8ef10 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -13,7 +13,7 @@ class Admin::UsersController < Admin::ApplicationController
def index
@users = User.filter_items(params[:filter]).order_name_asc
@users = @users.search_with_secondary_emails(params[:search_query]) if params[:search_query].present?
- @users = @users.includes(:authorized_projects) # rubocop: disable CodeReuse/ActiveRecord
+ @users = users_with_included_associations(@users)
@users = @users.sort_by_attribute(@sort = params[:sort])
@users = @users.page(params[:page])
@@ -228,6 +228,10 @@ class Admin::UsersController < Admin::ApplicationController
protected
+ def users_with_included_associations(users)
+ users.includes(:authorized_projects) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
def admin_making_changes_for_another_user?
user != current_user
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 379da90827a..607f3435394 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -16,7 +16,6 @@ class ApplicationController < ActionController::Base
include SessionlessAuthentication
include SessionsHelper
include ConfirmEmailWarning
- include Gitlab::Tracking::ControllerConcern
include Gitlab::Experimentation::ControllerConcern
include InitializesCurrentUserMode
include Impersonation
@@ -463,7 +462,7 @@ class ApplicationController < ActionController::Base
feature_category: feature_category) do
yield
ensure
- @current_context = Labkit::Context.current.to_h
+ @current_context = Gitlab::ApplicationContext.current
end
end
@@ -483,7 +482,7 @@ class ApplicationController < ActionController::Base
end
def set_current_admin(&block)
- return yield unless Feature.enabled?(:user_mode_in_session)
+ return yield unless Gitlab::CurrentSettings.admin_mode
return yield unless current_user
Gitlab::Auth::CurrentUserMode.with_current_admin(current_user, &block)
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index f5a9b9b61db..347bf1f4fa8 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -13,7 +13,7 @@ module Boards
requires_cross_project_access if: -> { board&.group_board? }
- before_action :whitelist_query_limiting, only: [:bulk_move]
+ before_action :disable_query_limiting, only: [:bulk_move]
before_action :authorize_read_issue, only: [:index]
before_action :authorize_create_issue, only: [:create]
before_action :authorize_update_issue, only: [:update]
@@ -147,8 +147,8 @@ module Boards
serializer.represent(resource, opts)
end
- def whitelist_query_limiting
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/issues/35174')
+ def disable_query_limiting
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/issues/35174')
end
def validate_id_list
diff --git a/app/controllers/chaos_controller.rb b/app/controllers/chaos_controller.rb
index 0ec6a2cb38a..1cfcd2905f2 100644
--- a/app/controllers/chaos_controller.rb
+++ b/app/controllers/chaos_controller.rb
@@ -20,7 +20,11 @@ class ChaosController < ActionController::Base
end
def kill
- do_chaos :kill, Chaos::KillWorker
+ do_chaos :kill, Chaos::KillWorker, 'KILL'
+ end
+
+ def quit
+ do_chaos :kill, Chaos::KillWorker, 'QUIT'
end
def gc
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 9800d94964d..c64301f72ba 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -60,6 +60,9 @@ class Clusters::ClustersController < Clusters::BaseController
end
def show
+ if params[:tab] == 'integrations'
+ @prometheus_integration = Clusters::IntegrationPresenter.new(@cluster.find_or_build_integration_prometheus)
+ end
end
def update
@@ -305,7 +308,8 @@ class Clusters::ClustersController < Clusters::BaseController
def proxy_variable_substitution_service
@empty_service ||= Class.new(BaseService) do
def initialize(proxyable, params)
- @proxyable, @params = proxyable, params
+ @proxyable = proxyable
+ @params = params
end
def execute
diff --git a/app/controllers/clusters/integrations_controller.rb b/app/controllers/clusters/integrations_controller.rb
new file mode 100644
index 00000000000..a8c7eb10136
--- /dev/null
+++ b/app/controllers/clusters/integrations_controller.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Clusters
+ class IntegrationsController < ::Clusters::BaseController
+ before_action :cluster
+ before_action :authorize_admin_cluster!, only: [:create_or_update]
+
+ def create_or_update
+ service_response = Clusters::Integrations::CreateService
+ .new(container: clusterable, cluster: cluster, current_user: current_user, params: cluster_integration_params)
+ .execute
+
+ if service_response.success?
+ redirect_to cluster.show_path(params: { tab: 'integrations' }), notice: service_response.message
+ else
+ redirect_to cluster.show_path(params: { tab: 'integrations' }), alert: service_response.message
+ end
+ end
+
+ private
+
+ def clusterable
+ raise NotImplementedError
+ end
+
+ def cluster_integration_params
+ params.require(:integration).permit(:application_type, :enabled)
+ end
+
+ def cluster
+ @cluster ||= clusterable.clusters.find(params[:cluster_id]).present(current_user: current_user)
+ end
+ end
+end
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index 5c74d79951f..87555a28eb8 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -70,7 +70,7 @@ module AuthenticatesWithTwoFactor
elsif !user.confirmed?
I18n.t('devise.failure.unconfirmed')
else
- _('Invalid Login or password')
+ _('Invalid login or password')
end
end
diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb
index e8e681ce649..7bfcda67aa2 100644
--- a/app/controllers/concerns/creates_commit.rb
+++ b/app/controllers/concerns/creates_commit.rb
@@ -5,19 +5,23 @@ module CreatesCommit
include Gitlab::Utils::StrongMemoize
# rubocop:disable Gitlab/ModuleWithInstanceVariables
- def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil)
- if user_access(@project).can_push_to_branch?(branch_name_or_ref)
- @project_to_commit_into = @project
+ def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil, target_project: nil)
+ target_project ||= @project
+
+ if user_access(target_project).can_push_to_branch?(branch_name_or_ref)
+ @project_to_commit_into = target_project
@branch_name ||= @ref
else
- @project_to_commit_into = current_user.fork_of(@project)
+ @project_to_commit_into = current_user.fork_of(target_project)
@branch_name ||= @project_to_commit_into.repository.next_branch('patch')
end
@start_branch ||= @ref || @branch_name
+ start_project = Feature.enabled?(:pick_into_project, @project, default_enabled: :yaml) ? @project_to_commit_into : @project
+
commit_params = @commit_params.merge(
- start_project: @project,
+ start_project: start_project,
start_branch: @start_branch,
branch_name: @branch_name
)
@@ -27,7 +31,7 @@ module CreatesCommit
if result[:status] == :success
update_flash_notice(success_notice)
- success_path = final_success_path(success_path)
+ success_path = final_success_path(success_path, target_project)
respond_to do |format|
format.html { redirect_to success_path }
@@ -79,9 +83,9 @@ module CreatesCommit
end
end
- def final_success_path(success_path)
+ def final_success_path(success_path, target_project)
if create_merge_request?
- merge_request_exists? ? existing_merge_request_path : new_merge_request_path
+ merge_request_exists? ? existing_merge_request_path : new_merge_request_path(target_project)
else
success_path = success_path.call if success_path.respond_to?(:call)
@@ -90,12 +94,12 @@ module CreatesCommit
end
# rubocop:disable Gitlab/ModuleWithInstanceVariables
- def new_merge_request_path
+ def new_merge_request_path(target_project)
project_new_merge_request_path(
@project_to_commit_into,
merge_request: {
source_project_id: @project_to_commit_into.id,
- target_project_id: @project.id,
+ target_project_id: target_project.id,
source_branch: @branch_name,
target_branch: @start_branch
}
diff --git a/app/controllers/concerns/enforces_admin_authentication.rb b/app/controllers/concerns/enforces_admin_authentication.rb
index 527759de0bb..94c0e98c91a 100644
--- a/app/controllers/concerns/enforces_admin_authentication.rb
+++ b/app/controllers/concerns/enforces_admin_authentication.rb
@@ -15,7 +15,7 @@ module EnforcesAdminAuthentication
def authenticate_admin!
return render_404 unless current_user.admin?
- return unless Feature.enabled?(:user_mode_in_session)
+ return unless Gitlab::CurrentSettings.admin_mode
unless current_user_mode.admin_mode?
current_user_mode.request_admin_mode!
diff --git a/app/controllers/concerns/labels_as_hash.rb b/app/controllers/concerns/labels_as_hash.rb
index 1171aa9cf44..e428520f709 100644
--- a/app/controllers/concerns/labels_as_hash.rb
+++ b/app/controllers/concerns/labels_as_hash.rb
@@ -11,7 +11,7 @@ module LabelsAsHash
label_hashes = available_labels.as_json(only: [:title, :color])
- if target&.respond_to?(:labels)
+ if target.respond_to?(:labels)
already_set_labels = available_labels & target.labels
if already_set_labels.present?
titles = already_set_labels.map(&:title)
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index 9e3625d1b36..7bbee8ba79e 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -6,7 +6,7 @@ module MembershipActions
def create
create_params = params.permit(:user_ids, :access_level, :expires_at)
- result = Members::CreateService.new(current_user, create_params).execute(membershipable)
+ result = Members::CreateService.new(current_user, create_params.merge({ source: membershipable })).execute
if result[:status] == :success
redirect_to members_page_url, notice: _('Users were successfully added.')
@@ -43,10 +43,11 @@ module MembershipActions
def destroy
member = membershipable.members_and_requesters.find(params[:id])
+ skip_subresources = !ActiveRecord::Type::Boolean.new.cast(params.delete(:remove_sub_memberships))
# !! is used in case unassign_issuables contains empty string which would result in nil
unassign_issuables = !!ActiveRecord::Type::Boolean.new.cast(params.delete(:unassign_issuables))
- Members::DestroyService.new(current_user).execute(member, unassign_issuables: unassign_issuables)
+ Members::DestroyService.new(current_user).execute(member, skip_subresources: skip_subresources, unassign_issuables: unassign_issuables)
respond_to do |format|
format.html do
@@ -54,7 +55,11 @@ module MembershipActions
begin
case membershipable
when Namespace
- _("User was successfully removed from group and any subresources.")
+ if skip_subresources
+ _("User was successfully removed from group.")
+ else
+ _("User was successfully removed from group and any subgroups and projects.")
+ end
else
_("User was successfully removed from project.")
end
diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb
index 6470c75dfbd..0a859bd3af9 100644
--- a/app/controllers/concerns/milestone_actions.rb
+++ b/app/controllers/concerns/milestone_actions.rb
@@ -20,7 +20,7 @@ module MilestoneActions
format.html { redirect_to milestone_redirect_path }
format.json do
render json: tabs_json("shared/milestones/_merge_requests_tab", {
- merge_requests: @milestone.sorted_merge_requests(current_user), # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ merge_requests: @milestone.sorted_merge_requests(current_user).preload_milestoneish_associations, # rubocop:disable Gitlab/ModuleWithInstanceVariables
show_project_name: Gitlab::Utils.to_boolean(params[:show_project_name])
})
end
diff --git a/app/controllers/concerns/redis_tracking.rb b/app/controllers/concerns/redis_tracking.rb
index a7e75f802a8..3155208f47c 100644
--- a/app/controllers/concerns/redis_tracking.rb
+++ b/app/controllers/concerns/redis_tracking.rb
@@ -10,26 +10,31 @@
# track_redis_hll_event :index, :show, name: 'i_analytics_dev_ops_score'
#
# You can also pass custom conditions using `if:`, using the same format as with Rails callbacks.
+# You can also pass an optional block that calculates and returns a custom id to track.
module RedisTracking
extend ActiveSupport::Concern
class_methods do
- def track_redis_hll_event(*controller_actions, name:, if: nil)
+ def track_redis_hll_event(*controller_actions, name:, if: nil, &block)
custom_conditions = Array.wrap(binding.local_variable_get('if'))
conditions = [:trackable_request?, *custom_conditions]
after_action only: controller_actions, if: conditions do
- track_unique_redis_hll_event(name)
+ track_unique_redis_hll_event(name, &block)
end
end
end
private
- def track_unique_redis_hll_event(event_name)
- return unless visitor_id
+ def track_unique_redis_hll_event(event_name, &block)
+ custom_id = block_given? ? yield(self) : nil
- Gitlab::UsageDataCounters::HLLRedisCounter.track_event(event_name, values: visitor_id)
+ unique_id = custom_id || visitor_id
+
+ return unless unique_id
+
+ Gitlab::UsageDataCounters::HLLRedisCounter.track_event(event_name, values: unique_id)
end
def trackable_request?
diff --git a/app/controllers/concerns/renders_commits.rb b/app/controllers/concerns/renders_commits.rb
index 826fae834fa..4ea07c814ef 100644
--- a/app/controllers/concerns/renders_commits.rb
+++ b/app/controllers/concerns/renders_commits.rb
@@ -17,12 +17,13 @@ module RendersCommits
def set_commits_for_rendering(commits, commits_count: nil)
@total_commit_count = commits_count || commits.size
limited, @hidden_commit_count = limited_commits(commits, @total_commit_count)
- commits.each(&:lazy_author) # preload authors
prepare_commits_for_rendering(limited)
end
# rubocop: enable Gitlab/ModuleWithInstanceVariables
def prepare_commits_for_rendering(commits)
+ commits.each(&:lazy_author) # preload commits' authors
+
Banzai::CommitRenderer.render(commits, @project, current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables
commits
diff --git a/app/controllers/concerns/runner_setup_scripts.rb b/app/controllers/concerns/runner_setup_scripts.rb
index c0e657a32d1..ccae15b824f 100644
--- a/app/controllers/concerns/runner_setup_scripts.rb
+++ b/app/controllers/concerns/runner_setup_scripts.rb
@@ -5,8 +5,8 @@ module RunnerSetupScripts
private
- def private_runner_setup_scripts(**kwargs)
- instructions = Gitlab::Ci::RunnerInstructions.new(current_user: current_user, os: script_params[:os], arch: script_params[:arch], **kwargs)
+ def private_runner_setup_scripts
+ instructions = Gitlab::Ci::RunnerInstructions.new(os: script_params[:os], arch: script_params[:arch])
output = {
install: instructions.install_script,
register: instructions.register_command
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index 3cab198c1f9..7c57d321c80 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -44,6 +44,8 @@ module ServiceParams
# make those event names plural as special case.
:issues_events,
:issues_url,
+ :jenkins_url,
+ :jira_issue_transition_automatic,
:jira_issue_transition_id,
:manual_configuration,
:merge_requests_events,
@@ -55,6 +57,7 @@ module ServiceParams
:password,
:priority,
:project_key,
+ :project_name,
:project_url,
:recipients,
:restrict_to_branch,
diff --git a/app/controllers/concerns/sessionless_authentication.rb b/app/controllers/concerns/sessionless_authentication.rb
index a9ef33bf3b9..882fef7a342 100644
--- a/app/controllers/concerns/sessionless_authentication.rb
+++ b/app/controllers/concerns/sessionless_authentication.rb
@@ -27,7 +27,7 @@ module SessionlessAuthentication
end
def sessionless_bypass_admin_mode!(&block)
- return yield unless Feature.enabled?(:user_mode_in_session)
+ return yield unless Gitlab::CurrentSettings.admin_mode
Gitlab::Auth::CurrentUserMode.bypass_session!(current_user.id, &block)
end
diff --git a/app/controllers/customers_dot/proxy_controller.rb b/app/controllers/customers_dot/proxy_controller.rb
new file mode 100644
index 00000000000..5abf8a487c6
--- /dev/null
+++ b/app/controllers/customers_dot/proxy_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module CustomersDot
+ class ProxyController < ApplicationController
+ skip_before_action :authenticate_user!
+ skip_before_action :verify_authenticity_token
+
+ feature_category :purchase
+
+ BASE_URL = Gitlab::SubscriptionPortal::SUBSCRIPTIONS_URL
+
+ def graphql
+ response = Gitlab::HTTP.post("#{BASE_URL}/graphql",
+ body: request.raw_post,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ render json: response.body, status: response.code
+ end
+ end
+end
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index 53064041ab8..a13ec1daddb 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -4,7 +4,8 @@ class GraphqlController < ApplicationController
# Unauthenticated users have access to the API for public data
skip_before_action :authenticate_user!
- WHITELIST_HEADER = 'HTTP_X_GITLAB_QUERY_WHITELIST_ISSUE'
+ # Header can be passed by tests to disable SQL query limits.
+ DISABLE_SQL_QUERY_LIMIT_HEADER = 'HTTP_X_GITLAB_DISABLE_SQL_QUERY_LIMIT'
# If a user is using their session to access GraphQL, we need to have session
# storage, since the admin-mode check is session wide.
@@ -23,7 +24,7 @@ class GraphqlController < ApplicationController
before_action(only: [:execute]) { authenticate_sessionless_user!(:api) }
before_action :set_user_last_activity
before_action :track_vs_code_usage
- before_action :whitelist_query!
+ before_action :disable_query_limiting
# Since we deactivate authentication from the main ApplicationController and
# defer it to :authorize_access_api!, we need to override the bypass session
@@ -34,7 +35,6 @@ class GraphqlController < ApplicationController
def execute
result = multiplex? ? execute_multiplex : execute_query
-
render json: result
end
@@ -62,12 +62,14 @@ class GraphqlController < ApplicationController
private
- # Tests may mark some queries as exempt from query limits
- def whitelist_query!
- whitelist_issue = request.headers[WHITELIST_HEADER]
- return unless whitelist_issue
+ # Tests may mark some GraphQL queries as exempt from SQL query limits
+ def disable_query_limiting
+ return unless Gitlab::QueryLimiting.enabled_for_env?
+
+ disable_issue = request.headers[DISABLE_SQL_QUERY_LIMIT_HEADER]
+ return unless disable_issue
- Gitlab::QueryLimiting.whitelist(whitelist_issue)
+ Gitlab::QueryLimiting.disable!(disable_issue)
end
def set_user_last_activity
@@ -144,8 +146,7 @@ class GraphqlController < ApplicationController
end
def logs
- RequestStore.store[:graphql_logs].to_h
- .except(:duration_s, :query_string)
- .merge(operation_name: params[:operationName])
+ RequestStore.store[:graphql_logs].to_a
+ .map { |log| log.except(:duration_s, :query_string) }
end
end
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index 3354c0a5c9f..be38fe25842 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -9,7 +9,7 @@ class Groups::BoardsController < Groups::ApplicationController
before_action :assign_endpoint_vars
before_action do
push_frontend_feature_flag(:graphql_board_lists, group, default_enabled: false)
- push_frontend_feature_flag(:boards_filtered_search, group)
+ push_frontend_feature_flag(:swimlanes_buffered_rendering, group, default_enabled: :yaml)
end
feature_category :boards
diff --git a/app/controllers/groups/clusters/integrations_controller.rb b/app/controllers/groups/clusters/integrations_controller.rb
new file mode 100644
index 00000000000..e8c8a14c164
--- /dev/null
+++ b/app/controllers/groups/clusters/integrations_controller.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class Groups::Clusters::IntegrationsController < Clusters::IntegrationsController
+ include ControllerWithCrossProjectAccessCheck
+
+ prepend_before_action :group
+ requires_cross_project_access
+
+ private
+
+ def clusterable
+ @clusterable ||= ClusterablePresenter.fabricate(group, current_user: current_user)
+ end
+
+ def group
+ @group ||= find_routable!(Group, params[:group_id] || params[:id])
+ end
+end
diff --git a/app/controllers/groups/email_campaigns_controller.rb b/app/controllers/groups/email_campaigns_controller.rb
index cbb0176ea7b..4ce7d86be3c 100644
--- a/app/controllers/groups/email_campaigns_controller.rb
+++ b/app/controllers/groups/email_campaigns_controller.rb
@@ -2,7 +2,6 @@
class Groups::EmailCampaignsController < Groups::ApplicationController
include InProductMarketingHelper
- include Gitlab::Tracking::ControllerConcern
EMAIL_CAMPAIGNS_SCHEMA_URL = 'iglu:com.gitlab/email_campaigns/jsonschema/1-0-0'
@@ -18,14 +17,19 @@ class Groups::EmailCampaignsController < Groups::ApplicationController
private
def track_click
- data = {
- namespace_id: group.id,
- track: @track,
- series: @series,
- subject_line: subject_line(@track, @series)
- }
-
- track_self_describing_event(EMAIL_CAMPAIGNS_SCHEMA_URL, data: data)
+ if Gitlab.com?
+ data = {
+ namespace_id: group.id,
+ track: @track.to_s,
+ series: @series,
+ subject_line: subject_line(@track, @series)
+ }
+ context = SnowplowTracker::SelfDescribingJson.new(EMAIL_CAMPAIGNS_SCHEMA_URL, data)
+
+ ::Gitlab::Tracking.event(self.class.name, 'click', context: [context])
+ else
+ ::Users::InProductMarketingEmail.save_cta_click(current_user, @track, @series)
+ end
end
def redirect_link
diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb
index c5dd3e1df35..86dde454cbc 100644
--- a/app/controllers/groups/labels_controller.rb
+++ b/app/controllers/groups/labels_controller.rb
@@ -16,7 +16,8 @@ class Groups::LabelsController < Groups::ApplicationController
format.html do
# at group level we do not want to list project labels,
# we only want `only_group_labels = false` when pulling labels for label filter dropdowns, fetched through json
- @labels = available_labels(params.merge(only_group_labels: true)).page(params[:page])
+ @labels = available_labels(params.merge(only_group_labels: true)).page(params[:page]) # rubocop: disable CodeReuse/ActiveRecord
+ Preloaders::LabelsPreloader.new(@labels, current_user).preload_all
end
format.json do
render json: LabelSerializer.new.represent_appearance(available_labels)
diff --git a/app/controllers/groups/settings/applications_controller.rb b/app/controllers/groups/settings/applications_controller.rb
new file mode 100644
index 00000000000..cefb5425867
--- /dev/null
+++ b/app/controllers/groups/settings/applications_controller.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module Groups
+ module Settings
+ class ApplicationsController < Groups::ApplicationController
+ include OauthApplications
+
+ prepend_before_action :authorize_admin_group!
+ before_action :set_application, only: [:show, :edit, :update, :destroy]
+ before_action :load_scopes, only: [:index, :create, :edit, :update]
+
+ feature_category :authentication_and_authorization
+
+ def index
+ set_index_vars
+ end
+
+ def show
+ end
+
+ def edit
+ end
+
+ def create
+ @application = Applications::CreateService.new(current_user, application_params).execute(request)
+
+ if @application.persisted?
+ flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create])
+
+ redirect_to group_settings_application_url(@group, @application)
+ else
+ set_index_vars
+ render :index
+ end
+ end
+
+ def update
+ if @application.update(application_params)
+ redirect_to group_settings_application_path(@group, @application), notice: _('Application was successfully updated.')
+ else
+ render :edit
+ end
+ end
+
+ def destroy
+ @application.destroy
+ redirect_to group_settings_applications_url(@group), status: :found, notice: _('Application was successfully destroyed.')
+ end
+
+ private
+
+ def set_index_vars
+ # TODO: Remove limit(100) and implement pagination
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/324187
+ @applications = @group.oauth_applications.limit(100)
+
+ # Don't overwrite a value possibly set by `create`
+ @application ||= Doorkeeper::Application.new
+ end
+
+ def set_application
+ @application = @group.oauth_applications.find(params[:id])
+ end
+
+ def application_params
+ params
+ .require(:doorkeeper_application)
+ .permit(:name, :redirect_uri, :scopes, :confidential)
+ .tap do |params|
+ params[:owner] = @group
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index 723edc4b7e9..f1a6bcbe825 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -5,10 +5,12 @@ module Groups
class CiCdController < Groups::ApplicationController
include RunnerSetupScripts
+ layout 'group_settings'
skip_cross_project_access_check :show
before_action :authorize_admin_group!
before_action :authorize_update_max_artifacts_size!, only: [:update]
before_action :define_variables, only: [:show]
+ before_action :push_licensed_features, only: [:show]
feature_category :continuous_integration
@@ -51,7 +53,7 @@ module Groups
end
def runner_setup_scripts
- private_runner_setup_scripts(group: group)
+ private_runner_setup_scripts
end
private
@@ -90,6 +92,12 @@ module Groups
def update_group_params
params.require(:group).permit(:max_artifacts_size)
end
+
+ # Overridden in EE
+ def push_licensed_features
+ end
end
end
end
+
+Groups::Settings::CiCdController.prepend_if_ee('EE::Groups::Settings::CiCdController')
diff --git a/app/controllers/groups/settings/integrations_controller.rb b/app/controllers/groups/settings/integrations_controller.rb
index 8903feaff04..c3c93fe238a 100644
--- a/app/controllers/groups/settings/integrations_controller.rb
+++ b/app/controllers/groups/settings/integrations_controller.rb
@@ -9,6 +9,8 @@ module Groups
feature_category :integrations
+ layout 'group_settings'
+
def index
@integrations = Service.find_or_initialize_all_non_project_specific(Service.for_group(group)).sort_by(&:title)
end
diff --git a/app/controllers/groups/settings/packages_and_registries_controller.rb b/app/controllers/groups/settings/packages_and_registries_controller.rb
index 0135c03026c..90fb6497e20 100644
--- a/app/controllers/groups/settings/packages_and_registries_controller.rb
+++ b/app/controllers/groups/settings/packages_and_registries_controller.rb
@@ -3,6 +3,7 @@
module Groups
module Settings
class PackagesAndRegistriesController < Groups::ApplicationController
+ layout 'group_settings'
before_action :authorize_admin_group!
before_action :verify_packages_enabled!
diff --git a/app/controllers/groups/settings/repository_controller.rb b/app/controllers/groups/settings/repository_controller.rb
index ccc1fa12458..7404075985b 100644
--- a/app/controllers/groups/settings/repository_controller.rb
+++ b/app/controllers/groups/settings/repository_controller.rb
@@ -3,6 +3,7 @@
module Groups
module Settings
class RepositoryController < Groups::ApplicationController
+ layout 'group_settings'
skip_cross_project_access_check :show
before_action :authorize_create_deploy_token!
before_action :define_deploy_token_variables
diff --git a/app/controllers/groups/shared_projects_controller.rb b/app/controllers/groups/shared_projects_controller.rb
index 90ec64d4768..7acdacc2d46 100644
--- a/app/controllers/groups/shared_projects_controller.rb
+++ b/app/controllers/groups/shared_projects_controller.rb
@@ -25,13 +25,13 @@ module Groups
def finder_params
@finder_params ||= begin
- # Make the `search` param consistent for the frontend,
- # which will be using `filter`.
- params[:search] ||= params[:filter] if params[:filter]
- # Don't show archived projects
- params[:non_archived] = true
- params.permit(:sort, :search, :non_archived)
- end
+ # Make the `search` param consistent for the frontend,
+ # which will be using `filter`.
+ params[:search] ||= params[:filter] if params[:filter]
+ # Don't show archived projects
+ params[:non_archived] = true
+ params.permit(:sort, :search, :non_archived)
+ end
end
end
end
diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb
index a2289b540ec..75bb6975c6e 100644
--- a/app/controllers/groups/variables_controller.rb
+++ b/app/controllers/groups/variables_controller.rb
@@ -56,3 +56,5 @@ module Groups
end
end
end
+
+Groups::VariablesController.prepend_if_ee('EE::Groups::VariablesController')
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 5de207857bb..63f138aa462 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -8,6 +8,7 @@ class GroupsController < Groups::ApplicationController
include RecordUserLastActivity
include SendFileUpload
include FiltersEvents
+ include Recaptcha::Verify
extend ::Gitlab::Utils::Override
respond_to :html
@@ -15,6 +16,7 @@ class GroupsController < Groups::ApplicationController
prepend_before_action(only: [:show, :issues]) { authenticate_sessionless_user!(:rss) }
prepend_before_action(only: [:issues_calendar]) { authenticate_sessionless_user!(:ics) }
prepend_before_action :ensure_export_enabled, only: [:export, :download_export]
+ prepend_before_action :check_captcha, only: :create, if: -> { captcha_enabled? }
before_action :authenticate_user!, only: [:new, :create]
before_action :group, except: [:index, :new, :create]
@@ -22,6 +24,7 @@ class GroupsController < Groups::ApplicationController
# Authorize
before_action :authorize_admin_group!, only: [:edit, :update, :destroy, :projects, :transfer, :export, :download_export]
before_action :authorize_create_group!, only: [:new]
+ before_action :load_recaptcha, only: [:new], if: -> { captcha_required? }
before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests]
before_action :event_filter, only: [:activity]
@@ -38,6 +41,8 @@ class GroupsController < Groups::ApplicationController
before_action :export_rate_limit, only: [:export, :download_export]
+ helper_method :captcha_required?
+
skip_cross_project_access_check :index, :new, :create, :edit, :update,
:destroy, :projects
# When loading show as an atom feed, we render events that could leak cross
@@ -263,7 +268,8 @@ class GroupsController < Groups::ApplicationController
:subgroup_creation_level,
:default_branch_protection,
:default_branch_name,
- :allow_mfa_for_subgroups
+ :allow_mfa_for_subgroups,
+ :resource_access_token_creation_allowed
]
end
@@ -319,6 +325,23 @@ class GroupsController < Groups::ApplicationController
private
+ def load_recaptcha
+ Gitlab::Recaptcha.load_configurations!
+ end
+
+ def check_captcha
+ return if group_params[:parent_id].present? # Only require for top-level groups
+
+ load_recaptcha
+
+ return if verify_recaptcha
+
+ flash[:alert] = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
+ flash.delete :recaptcha_error
+ @group = Group.new(group_params)
+ render action: 'new'
+ end
+
def successful_creation_hooks; end
def groups
@@ -336,6 +359,14 @@ class GroupsController < Groups::ApplicationController
def has_project_list?
%w(details show index).include?(action_name)
end
+
+ def captcha_enabled?
+ Gitlab::Recaptcha.enabled? && Feature.enabled?(:recaptcha_on_top_level_group_creation, type: :ops)
+ end
+
+ def captcha_required?
+ captcha_enabled? && !params[:parent_id]
+ end
end
GroupsController.prepend_if_ee('EE::GroupsController')
diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb
index ea67c76a8bc..4c7a91ee602 100644
--- a/app/controllers/ide_controller.rb
+++ b/app/controllers/ide_controller.rb
@@ -10,6 +10,7 @@ class IdeController < ApplicationController
before_action do
push_frontend_feature_flag(:build_service_proxy)
push_frontend_feature_flag(:schema_linting)
+ push_frontend_feature_flag(:reject_unsigned_commits_by_gitlab, default_enabled: :yaml)
define_index_vars
end
@@ -27,9 +28,20 @@ class IdeController < ApplicationController
@branch = params[:branch]
@path = params[:path]
@merge_request = params[:merge_request_id]
+ @fork_info = fork_info(project, @branch)
+ end
+
+ def fork_info(project, branch)
+ return if can?(current_user, :push_code, project)
+
+ existing_fork = current_user.fork_of(project)
- unless can?(current_user, :push_code, project)
- @forked_project = ForkProjectsFinder.new(project, current_user: current_user).execute.first
+ if existing_fork
+ path = helpers.ide_edit_path(existing_fork, branch, '')
+ { ide_path: path }
+ elsif can?(current_user, :fork_project, project)
+ path = helpers.ide_fork_and_edit_path(project, branch, '', with_notice: false)
+ { fork_path: path }
end
end
diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb
index 0e6b0af6baf..8de270e9d25 100644
--- a/app/controllers/import/gitlab_projects_controller.rb
+++ b/app/controllers/import/gitlab_projects_controller.rb
@@ -3,7 +3,7 @@
class Import::GitlabProjectsController < Import::BaseController
include WorkhorseAuthorization
- before_action :whitelist_query_limiting, only: [:create]
+ before_action :disable_query_limiting, only: [:create]
before_action :verify_gitlab_project_import_enabled
def new
@@ -42,8 +42,8 @@ class Import::GitlabProjectsController < Import::BaseController
)
end
- def whitelist_query_limiting
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42437')
+ def disable_query_limiting
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/20823')
end
def uploader_class
diff --git a/app/controllers/import/manifest_controller.rb b/app/controllers/import/manifest_controller.rb
index fdca6da95c5..8497e15c07c 100644
--- a/app/controllers/import/manifest_controller.rb
+++ b/app/controllers/import/manifest_controller.rb
@@ -3,7 +3,7 @@
class Import::ManifestController < Import::BaseController
extend ::Gitlab::Utils::Override
- before_action :whitelist_query_limiting, only: [:create]
+ before_action :disable_query_limiting, only: [:create]
before_action :verify_import_enabled
before_action :ensure_import_vars, only: [:create, :status]
@@ -115,7 +115,7 @@ class Import::ManifestController < Import::BaseController
render_404 unless manifest_import_enabled?
end
- def whitelist_query_limiting
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/48939')
+ def disable_query_limiting
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/23147')
end
end
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 08a23dc8927..0eb08d2d0ad 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -91,15 +91,29 @@ class InvitesController < ApplicationController
def authenticate_user!
return if current_user
- notice = ["To accept this invitation, sign in"]
- notice << "or create an account" if Gitlab::CurrentSettings.allow_signup?
- notice = notice.join(' ') + "."
+ store_location_for :user, request.fullpath
- redirect_params = member ? { invite_email: member.invite_email } : {}
+ if user_sign_up?
+ redirect_to new_user_registration_path(invite_email: member.invite_email), notice: _("To accept this invitation, create an account or sign in.")
+ else
+ redirect_to new_user_session_path(sign_in_redirect_params), notice: sign_in_notice
+ end
+ end
- store_location_for :user, request.fullpath
+ def sign_in_redirect_params
+ member ? { invite_email: member.invite_email } : {}
+ end
+
+ def user_sign_up?
+ Gitlab::CurrentSettings.allow_signup? && member && !User.find_by_any_email(member.invite_email)
+ end
- redirect_to new_user_session_path(redirect_params), notice: notice
+ def sign_in_notice
+ if Gitlab::CurrentSettings.allow_signup?
+ _("To accept this invitation, sign in or create an account.")
+ else
+ _("To accept this invitation, sign in.")
+ end
end
def invite_details
diff --git a/app/controllers/ldap/omniauth_callbacks_controller.rb b/app/controllers/ldap/omniauth_callbacks_controller.rb
index 4b6339c21cd..ebc35448964 100644
--- a/app/controllers/ldap/omniauth_callbacks_controller.rb
+++ b/app/controllers/ldap/omniauth_callbacks_controller.rb
@@ -16,7 +16,7 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController
def ldap
return unless Gitlab::Auth::Ldap::Config.sign_in_enabled?
- if Feature.enabled?(:user_mode_in_session)
+ if Gitlab::CurrentSettings.admin_mode
return admin_mode_flow(Gitlab::Auth::Ldap::User) if current_user_mode.admin_mode_requested?
end
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index c9791703413..af502c083d7 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -95,7 +95,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
def after_omniauth_failure_path_for(scope)
- if Feature.enabled?(:user_mode_in_session)
+ if Gitlab::CurrentSettings.admin_mode
return new_admin_session_path if current_user_mode.admin_mode_requested?
end
@@ -112,7 +112,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
log_audit_event(current_user, with: oauth['provider'])
- if Feature.enabled?(:user_mode_in_session)
+ if Gitlab::CurrentSettings.admin_mode
return admin_mode_flow(auth_module::User) if current_user_mode.admin_mode_requested?
end
diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb
index 0a73239709a..6ef0ed6d365 100644
--- a/app/controllers/profiles/notifications_controller.rb
+++ b/app/controllers/profiles/notifications_controller.rb
@@ -3,18 +3,13 @@
class Profiles::NotificationsController < Profiles::ApplicationController
feature_category :users
- # rubocop: disable CodeReuse/ActiveRecord
def show
@user = current_user
@user_groups = user_groups
@group_notifications = UserGroupNotificationSettingsFinder.new(current_user, user_groups).execute
-
- @project_notifications = current_user.notification_settings.for_projects.order(:id)
- .preload_source_route
- .select { |notification| current_user.can?(:read_project, notification.source) }
+ @project_notifications = project_notifications_with_preloaded_associations
@global_notification_setting = current_user.global_notification_setting
end
- # rubocop: enable CodeReuse/ActiveRecord
def update
result = Users::UpdateService.new(current_user, user_params.merge(user: current_user)).execute
@@ -37,4 +32,20 @@ class Profiles::NotificationsController < Profiles::ApplicationController
def user_groups
GroupsFinder.new(current_user, all_available: false).execute.order_name_asc.page(params[:page])
end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def project_notifications_with_preloaded_associations
+ project_notifications = current_user
+ .notification_settings
+ .for_projects
+ .order_by_id_asc
+ .preload_source_route
+
+ projects = project_notifications.map(&:source)
+ ActiveRecord::Associations::Preloader.new.preload(projects, { namespace: [:route, :owner], group: [] })
+ Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute
+
+ project_notifications.select { |notification| current_user.can?(:read_project, notification.source) }
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb
index d5de0d38152..2c7c49b4250 100644
--- a/app/controllers/projects/blame_controller.rb
+++ b/app/controllers/projects/blame_controller.rb
@@ -20,7 +20,7 @@ class Projects::BlameController < Projects::ApplicationController
environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit }
environment_params[:find_latest] = true
- @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
+ @environment = EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last
@blame = Gitlab::Blame.new(@blob, @commit)
@blame = Gitlab::View::Presenter::Factory.new(@blame, project: @project, path: @path).fabricate!
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 0080ae1a5be..a398fc56a35 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -214,7 +214,7 @@ class Projects::BlobController < Projects::ApplicationController
def show_html
environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit }
environment_params[:find_latest] = true
- @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
+ @environment = EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last
@last_commit = @repository.last_commit_for_path(@commit.id, @blob.path, literal_pathspec: true)
@code_navigation_path = Gitlab::CodeNavigationPath.new(@project, @blob.commit_id).full_json_path_for(@blob.path)
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 418f2ee1592..349649c7b35 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -8,7 +8,8 @@ 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(:add_issues_button)
+ push_frontend_feature_flag(:swimlanes_buffered_rendering, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:graphql_board_lists, project, default_enabled: :yaml)
end
feature_category :boards
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index 6f3c96fa654..f522dffdf3e 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -49,7 +49,7 @@ class Projects::BranchesController < Projects::ApplicationController
branches = BranchesFinder.new(repository, params.permit(names: [])).execute
Gitlab::GitalyClient.allow_n_plus_1_calls do
- render json: branches.map { |branch| [branch.name, service.call(branch)] }.to_h
+ render json: branches.to_h { |branch| [branch.name, service.call(branch)] }
end
end
end
diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb
index 4d491b33aa0..754e2ccf4f9 100644
--- a/app/controllers/projects/ci/pipeline_editor_controller.rb
+++ b/app/controllers/projects/ci/pipeline_editor_controller.rb
@@ -7,6 +7,7 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
push_frontend_feature_flag(:ci_config_merged_tab, @project, default_enabled: :yaml)
push_frontend_feature_flag(:pipeline_status_for_pipeline_editor, @project, default_enabled: :yaml)
push_frontend_feature_flag(:pipeline_editor_empty_state_action, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:pipeline_editor_branch_switcher, @project, default_enabled: :yaml)
end
feature_category :pipeline_authoring
diff --git a/app/controllers/projects/clusters/integrations_controller.rb b/app/controllers/projects/clusters/integrations_controller.rb
new file mode 100644
index 00000000000..94b8dd653e6
--- /dev/null
+++ b/app/controllers/projects/clusters/integrations_controller.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Projects::Clusters::IntegrationsController < ::Clusters::IntegrationsController
+ prepend_before_action :project
+
+ private
+
+ def clusterable
+ @clusterable ||= ClusterablePresenter.fabricate(project, current_user: current_user)
+ end
+
+ def project
+ @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]))
+ end
+end
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index 8154128da29..0c3ff07bc76 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -19,13 +19,8 @@ class Projects::CommitController < Projects::ApplicationController
before_action :define_commit_box_vars, only: [:show, :pipelines]
before_action :define_note_vars, only: [:show, :diff_for_path, :diff_files]
before_action :authorize_edit_tree!, only: [:revert, :cherry_pick]
-
- before_action only: [:show, :pipelines] do
- push_frontend_feature_flag(:ci_commit_pipeline_mini_graph_vue, @project, default_enabled: :yaml)
- end
-
before_action do
- push_frontend_feature_flag(:pick_into_project)
+ push_frontend_feature_flag(:pick_into_project, @project, default_enabled: :yaml)
end
BRANCH_SEARCH_LIMIT = 1000
@@ -59,8 +54,6 @@ class Projects::CommitController < Projects::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def pipelines
- set_pipeline_feature_flag
-
@pipelines = @commit.pipelines.order(id: :desc)
@pipelines = @pipelines.where(ref: params[:ref]).page(params[:page]).per(30) if params[:ref]
@@ -119,7 +112,7 @@ class Projects::CommitController < Projects::ApplicationController
@branch_name = create_new_branch? ? @commit.revert_branch_name : @start_branch
create_commit(Commits::RevertService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully reverted.",
- success_path: -> { successful_change_path }, failure_path: failed_change_path)
+ success_path: -> { successful_change_path(@project) }, failure_path: failed_change_path)
end
def cherry_pick
@@ -127,24 +120,25 @@ class Projects::CommitController < Projects::ApplicationController
return render_404 if @start_branch.blank?
+ target_project = find_cherry_pick_target_project
+ return render_404 unless target_project
+
@branch_name = create_new_branch? ? @commit.cherry_pick_branch_name : @start_branch
create_commit(Commits::CherryPickService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully cherry-picked into #{@branch_name}.",
- success_path: -> { successful_change_path }, failure_path: failed_change_path)
+ success_path: -> { successful_change_path(target_project) },
+ failure_path: failed_change_path,
+ target_project: target_project)
end
private
- def set_pipeline_feature_flag
- push_frontend_feature_flag(:new_pipelines_table, @project, default_enabled: :yaml)
- end
-
def create_new_branch?
params[:create_merge_request].present? || !can?(current_user, :push_code, @project)
end
- def successful_change_path
- referenced_merge_request_url || project_commits_url(@project, @branch_name)
+ def successful_change_path(target_project)
+ referenced_merge_request_url || project_commits_url(target_project, @branch_name)
end
def failed_change_path
@@ -173,7 +167,7 @@ class Projects::CommitController < Projects::ApplicationController
@diffs = commit.diffs(opts)
@notes_count = commit.notes.count
- @environment = EnvironmentsFinder.new(@project, current_user, commit: @commit, find_latest: true).execute.last
+ @environment = EnvironmentsByDeploymentsFinder.new(@project, current_user, commit: @commit, find_latest: true).execute.last
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -214,7 +208,6 @@ class Projects::CommitController < Projects::ApplicationController
def define_commit_box_vars
@last_pipeline = @commit.last_pipeline
- return unless ::Gitlab::Ci::Features.ci_commit_pipeline_mini_graph_vue_enabled?(@project)
return unless @commit.last_pipeline
@last_pipeline_stages = StageSerializer.new(project: @project, current_user: @current_user).represent(@last_pipeline.stages)
@@ -224,4 +217,14 @@ class Projects::CommitController < Projects::ApplicationController
@start_branch = params[:start_branch]
@commit_params = { commit: @commit }
end
+
+ def find_cherry_pick_target_project
+ return @project if params[:target_project_id].blank?
+ return @project unless Feature.enabled?(:pick_into_project, @project, default_enabled: :yaml)
+
+ MergeRequestTargetProjectFinder
+ .new(current_user: current_user, source_project: @project, project_feature: :repository)
+ .execute
+ .find_by_id(params[:target_project_id])
+ end
end
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index d267ab732f9..9ca917841e9 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -8,7 +8,6 @@ class Projects::CommitsController < Projects::ApplicationController
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
around_action :allow_gitaly_ref_name_caching
- before_action :whitelist_query_limiting, except: :commits_root
before_action :require_non_empty_project
before_action :assign_ref_vars, except: :commits_root
before_action :authorize_download_code!
@@ -64,7 +63,8 @@ class Projects::CommitsController < Projects::ApplicationController
def set_commits
render_404 unless @path.empty? || request.format == :atom || @repository.blob_at(@commit.id, @path) || @repository.tree(@commit.id, @path).entries.present?
- @limit, @offset = (params[:limit] || 40).to_i, (params[:offset] || 0).to_i
+ @limit = (params[:limit] || 40).to_i
+ @offset = (params[:offset] || 0).to_i
search = params[:search]
author = params[:author]
@@ -82,8 +82,4 @@ class Projects::CommitsController < Projects::ApplicationController
@commits = @commits.with_latest_pipeline(@ref)
@commits = set_commits_for_rendering(@commits)
end
-
- def whitelist_query_limiting
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42330')
- end
end
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index 81f80d37662..221bc16e256 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -132,7 +132,7 @@ class Projects::CompareController < Projects::ApplicationController
if compare
environment_params = source_project.repository.branch_exists?(head_ref) ? { ref: head_ref } : { commit: compare.commit }
environment_params[:find_latest] = true
- @environment = EnvironmentsFinder.new(source_project, current_user, environment_params).execute.last
+ @environment = EnvironmentsByDeploymentsFinder.new(source_project, current_user, environment_params).execute.last
end
end
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index 33f046f414f..9fc8e8c063b 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -7,7 +7,7 @@ class Projects::ForksController < Projects::ApplicationController
include Gitlab::Utils::StrongMemoize
# Authorize
- before_action :whitelist_query_limiting, only: [:create]
+ before_action :disable_query_limiting, only: [:create]
before_action :require_non_empty_project
before_action :authorize_download_code!
before_action :authenticate_user!, only: [:new, :create]
@@ -44,13 +44,17 @@ class Projects::ForksController < Projects::ApplicationController
def new
respond_to do |format|
format.html do
- @own_namespace = current_user.namespace if fork_service.valid_fork_targets.include?(current_user.namespace)
+ @own_namespace = current_user.namespace if can_fork_to?(current_user.namespace)
@project = project
end
format.json do
namespaces = load_namespaces_with_associations - [project.namespace]
+ namespaces = [current_user.namespace] + namespaces if
+ Feature.enabled?(:fork_project_form, project, default_enabled: :yaml) &&
+ can_fork_to?(current_user.namespace)
+
render json: {
namespaces: ForkNamespaceSerializer.new.represent(namespaces, project: project, current_user: current_user, memberships: memberships_hash)
}
@@ -78,6 +82,10 @@ class Projects::ForksController < Projects::ApplicationController
private
+ def can_fork_to?(namespace)
+ ForkTargetsFinder.new(@project, current_user).execute.id_in(current_user.namespace).any?
+ end
+
def load_forks
forks = ForkProjectsFinder.new(
project,
@@ -110,8 +118,8 @@ class Projects::ForksController < Projects::ApplicationController
access_denied! unless fork_namespace && fork_service.valid_fork_target?
end
- def whitelist_query_limiting
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42335')
+ def disable_query_limiting
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/20783')
end
def load_namespaces_with_associations
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index c454ae6eaf4..cae5cc411bc 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -18,7 +18,7 @@ class Projects::IssuesController < Projects::ApplicationController
prepend_before_action :authenticate_user!, only: [:new, :export_csv]
prepend_before_action :store_uri, only: [:new, :show, :designs]
- before_action :whitelist_query_limiting, only: [:create, :create_merge_request, :move, :bulk_update]
+ before_action :disable_query_limiting, only: [:create_merge_request, :move, :bulk_update]
before_action :check_issues_available!
before_action :issue, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) }
after_action :log_issue_show, unless: ->(c) { ISSUES_EXCEPT_ACTIONS.include?(c.action_name.to_sym) }
@@ -45,6 +45,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:vue_issuables_list, project)
push_frontend_feature_flag(:usage_data_design_action, project, default_enabled: true)
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:vue_issues_list, project)
end
before_action only: :show do
@@ -53,15 +54,22 @@ class Projects::IssuesController < Projects::ApplicationController
push_to_gon_attributes(:features, real_time_feature_flag, real_time_enabled)
push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml)
record_experiment_user(:invite_members_version_b)
+
+ experiment(:invite_members_in_comment, namespace: @project.root_ancestor) do |experiment_instance|
+ experiment_instance.exclude! unless helpers.can_import_members?
+
+ experiment_instance.use {}
+ experiment_instance.try(:invite_member_link) {}
+
+ experiment_instance.track(:view, property: @project.root_ancestor.id.to_s)
+ end
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
- before_action :run_null_hypothesis_experiment,
- only: [:index, :new, :create]
-
respond_to :html
alias_method :designs, :show
@@ -344,13 +352,13 @@ class Projects::IssuesController < Projects::ApplicationController
IssuesFinder
end
- def whitelist_query_limiting
+ def disable_query_limiting
# Also see the following issues:
#
- # 1. https://gitlab.com/gitlab-org/gitlab-foss/issues/42423
- # 2. https://gitlab.com/gitlab-org/gitlab-foss/issues/42424
- # 3. https://gitlab.com/gitlab-org/gitlab-foss/issues/42426
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42422')
+ # 1. https://gitlab.com/gitlab-org/gitlab/-/issues/20815
+ # 2. https://gitlab.com/gitlab-org/gitlab/-/issues/20816
+ # 3. https://gitlab.com/gitlab-org/gitlab/-/issues/21068
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/20814')
end
private
@@ -390,14 +398,6 @@ class Projects::IssuesController < Projects::ApplicationController
action_name == 'service_desk'
end
- def run_null_hypothesis_experiment
- experiment(:null_hypothesis, project: project) do |e|
- e.use { } # define the control
- e.try { } # define the candidate
- e.track(action_name) # track the action so we can build a funnel
- end
- end
-
# Overridden in EE
def create_vulnerability_issue_feedback(issue); end
end
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index f19a86209fc..92442fd4e28 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -15,6 +15,7 @@ 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 :push_jobs_table_vue, only: [:index]
layout 'project'
@@ -256,4 +257,8 @@ class Projects::JobsController < Projects::ApplicationController
::Gitlab::Workhorse.channel_websocket(service)
end
+
+ def push_jobs_table_vue
+ push_frontend_feature_flag(:jobs_table_vue, @project, default_enabled: :yaml)
+ end
end
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index 3992165d07c..6bf3885fb7a 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -17,11 +17,14 @@ class Projects::LabelsController < Projects::ApplicationController
feature_category :issue_tracking
def index
- @prioritized_labels = @available_labels.prioritized(@project)
- @labels = @available_labels.unprioritized(@project).page(params[:page])
-
respond_to do |format|
- format.html
+ format.html do
+ @prioritized_labels = @available_labels.prioritized(@project)
+ @labels = @available_labels.unprioritized(@project).page(params[:page])
+ # preload group, project, and subscription data
+ Preloaders::LabelsPreloader.new(@prioritized_labels, current_user, @project).preload_all
+ Preloaders::LabelsPreloader.new(@labels, current_user, @project).preload_all
+ end
format.json do
render json: LabelSerializer.new.represent_appearance(@available_labels)
end
diff --git a/app/controllers/projects/logs_controller.rb b/app/controllers/projects/logs_controller.rb
index b9aa9bfc947..f9b8091a419 100644
--- a/app/controllers/projects/logs_controller.rb
+++ b/app/controllers/projects/logs_controller.rb
@@ -58,7 +58,7 @@ module Projects
def environment
strong_memoize(:environment) do
if cluster_params.key?(:environment_name)
- EnvironmentsFinder.new(project, current_user, name: cluster_params[:environment_name]).find.first
+ EnvironmentsFinder.new(project, current_user, name: cluster_params[:environment_name]).execute.first
else
project.default_environment
end
diff --git a/app/controllers/projects/merge_requests/content_controller.rb b/app/controllers/projects/merge_requests/content_controller.rb
index 399745151b1..dfc060c9204 100644
--- a/app/controllers/projects/merge_requests/content_controller.rb
+++ b/app/controllers/projects/merge_requests/content_controller.rb
@@ -14,6 +14,8 @@ class Projects::MergeRequests::ContentController < Projects::MergeRequests::Appl
SLOW_POLLING_INTERVAL = 5.minutes.in_milliseconds
def widget
+ check_mergeability_async!
+
respond_to do |format|
format.json do
render json: serializer(MergeRequestPollWidgetEntity)
@@ -38,6 +40,13 @@ class Projects::MergeRequests::ContentController < Projects::MergeRequests::Appl
def serializer(entity)
serializer = MergeRequestSerializer.new(current_user: current_user, project: merge_request.project)
- serializer.represent(merge_request, {}, entity)
+ serializer.represent(merge_request, { async_mergeability_check: params[:async_mergeability_check] }, entity)
+ end
+
+ def check_mergeability_async!
+ return unless Feature.enabled?(:check_mergeability_async_in_widget, merge_request.project, default_enabled: :yaml)
+ return if params[:async_mergeability_check].blank?
+
+ merge_request.check_mergeability(async: true)
end
end
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index e79c19c3b67..dc77b5e09c8 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -6,7 +6,6 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
include RendersCommits
skip_before_action :merge_request
- before_action :whitelist_query_limiting, only: [:create]
before_action :authorize_create_merge_request_from!
before_action :apply_diff_view_cookie!, only: [:diffs, :diff_for_path]
before_action :build_merge_request, except: [:create]
@@ -122,24 +121,24 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
# rubocop: disable CodeReuse/ActiveRecord
def selected_target_project
- if @project.id.to_s == params[:target_project_id] || !@project.forked?
- @project
- elsif params[:target_project_id].present?
+ return @project unless @project.forked?
+
+ if params[:target_project_id].present?
+ return @project if @project.id.to_s == params[:target_project_id]
+
MergeRequestTargetProjectFinder.new(current_user: current_user, source_project: @project)
.find_by(id: params[:target_project_id])
else
- @project.forked_from_project
+ @project.default_merge_request_target
end
end
# rubocop: enable CodeReuse/ActiveRecord
- def whitelist_query_limiting
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42384')
- end
-
def incr_count_webide_merge_request
return if params[:nav_source] != 'webide'
Gitlab::UsageDataCounters::WebIdeCounter.increment_merge_requests_count
end
end
+
+Projects::MergeRequests::CreationsController.prepend_ee_mod
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 2c6d5f62b4e..4e409b5f28f 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -14,7 +14,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
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 :disable_query_limiting, only: [:assign_related_issues, :update]
before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort]
before_action :authorize_read_actual_head_pipeline!, only: [
:test_reports,
@@ -30,21 +30,32 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
before_action only: [:show] do
push_frontend_feature_flag(:file_identifier_hash)
- push_frontend_feature_flag(:batch_suggestions, @project, default_enabled: true)
push_frontend_feature_flag(:approvals_commented_by, @project, default_enabled: true)
push_frontend_feature_flag(:merge_request_widget_graphql, @project, default_enabled: :yaml)
push_frontend_feature_flag(:drag_comment_selection, @project, default_enabled: true)
push_frontend_feature_flag(:unified_diff_components, @project, default_enabled: true)
push_frontend_feature_flag(:default_merge_ref_for_diffs, @project, default_enabled: :yaml)
push_frontend_feature_flag(:core_security_mr_widget_counts, @project)
- push_frontend_feature_flag(:remove_resolve_note, @project, default_enabled: true)
push_frontend_feature_flag(:diffs_gradual_load, @project, default_enabled: true)
- push_frontend_feature_flag(:codequality_backend_comparison, @project, default_enabled: :yaml)
push_frontend_feature_flag(:local_file_reviews, default_enabled: :yaml)
push_frontend_feature_flag(:paginated_notes, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:new_pipelines_table, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:usage_data_i_testing_summary_widget_total, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
+
+ # Usage data feature flags
+ push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml)
record_experiment_user(:invite_members_version_b)
+
+ experiment(:invite_members_in_comment, namespace: @project.root_ancestor) do |experiment_instance|
+ experiment_instance.exclude! unless helpers.can_import_members?
+
+ experiment_instance.use {}
+ experiment_instance.try(:invite_member_link) {}
+
+ experiment_instance.track(:view, property: @project.root_ancestor.id.to_s)
+ end
end
before_action do
@@ -57,16 +68,19 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
feature_category :code_review, [
:assign_related_issues, :bulk_update, :cancel_auto_merge,
- :ci_environments_status, :commit_change_content, :commits,
- :context_commits, :destroy, :diff_for_path, :discussions,
- :edit, :exposed_artifacts, :index, :merge,
- :pipeline_status, :pipelines, :rebase, :remove_wip, :show,
- :toggle_award_emoji, :toggle_subscription, :update
+ :commit_change_content, :commits, :context_commits, :destroy,
+ :discussions, :edit, :index, :merge, :rebase, :remove_wip,
+ :show, :toggle_award_emoji, :toggle_subscription, :update
+ ]
+
+ feature_category :code_testing, [
+ :test_reports, :coverage_reports, :codequality_reports,
+ :codequality_mr_diff_reports
]
- feature_category :code_testing, [:test_reports, :coverage_reports, :codequality_mr_diff_reports]
feature_category :accessibility_testing, [:accessibility_reports]
feature_category :infrastructure_as_code, [:terraform_reports]
+ feature_category :continuous_integration, [:pipeline_status, :pipelines, :exposed_artifacts]
def index
@merge_requests = @issuables
@@ -83,7 +97,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def show
close_merge_request_if_no_source_project
- @merge_request.check_mergeability(async: true)
+
+ if Feature.disabled?(:check_mergeability_async_in_widget, @project, default_enabled: :yaml)
+ @merge_request.check_mergeability(async: true)
+ end
respond_to do |format|
format.html do
@@ -102,6 +119,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@show_whitespace_default = current_user.nil? || current_user.show_whitespace_in_diffs
@file_by_file_default = current_user&.view_diffs_file_by_file
@coverage_path = coverage_reports_project_merge_request_path(@project, @merge_request, format: :json) if @merge_request.has_coverage_reports?
+ @update_current_user_path = expose_path(api_v4_user_preferences_path)
@endpoint_metadata_url = endpoint_metadata_url(@project, @merge_request)
set_pipeline_variables
@@ -459,9 +477,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
access_denied! unless @merge_request.can_be_merged_by?(current_user)
end
- def whitelist_query_limiting
- # Also see https://gitlab.com/gitlab-org/gitlab-foss/issues/42441
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42438')
+ def disable_query_limiting
+ # Also see https://gitlab.com/gitlab-org/gitlab/-/issues/20827
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/20824')
end
def reports_response(report_comparison, pipeline = nil)
diff --git a/app/controllers/projects/network_controller.rb b/app/controllers/projects/network_controller.rb
index 89b679fc033..f3a7bc7913e 100644
--- a/app/controllers/projects/network_controller.rb
+++ b/app/controllers/projects/network_controller.rb
@@ -4,7 +4,6 @@ class Projects::NetworkController < Projects::ApplicationController
include ExtractsPath
include ApplicationHelper
- before_action :whitelist_query_limiting
before_action :require_non_empty_project
before_action :assign_ref_vars
before_action :authorize_download_code!
@@ -41,8 +40,4 @@ class Projects::NetworkController < Projects::ApplicationController
@commit = @repo.commit(@options[:extended_sha1])
end
-
- def whitelist_query_limiting
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42333')
- end
end
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index 77fd7688caf..e7e6aed8ec8 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -6,7 +6,7 @@ class Projects::NotesController < Projects::ApplicationController
include NotesHelper
include ToggleAwardEmoji
- before_action :whitelist_query_limiting, only: [:create, :update]
+ before_action :disable_query_limiting, only: [:create, :update]
before_action :authorize_read_note!
before_action :authorize_create_note!, only: [:create]
before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
@@ -87,7 +87,7 @@ class Projects::NotesController < Projects::ApplicationController
access_denied! unless can?(current_user, :create_note, noteable)
end
- def whitelist_query_limiting
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42383')
+ def disable_query_limiting
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/20800')
end
end
diff --git a/app/controllers/projects/packages/infrastructure_registry_controller.rb b/app/controllers/projects/packages/infrastructure_registry_controller.rb
new file mode 100644
index 00000000000..22ae1d65013
--- /dev/null
+++ b/app/controllers/projects/packages/infrastructure_registry_controller.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Projects
+ module Packages
+ class InfrastructureRegistryController < Projects::ApplicationController
+ feature_category :infrastructure_as_code
+ end
+ end
+end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 9b5f5871c41..ee1e10221ec 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -4,7 +4,7 @@ class Projects::PipelinesController < Projects::ApplicationController
include ::Gitlab::Utils::StrongMemoize
include Analytics::UniqueVisitsHelper
- before_action :whitelist_query_limiting, only: [:create, :retry]
+ before_action :disable_query_limiting, only: [:create, :retry]
before_action :pipeline, except: [:index, :new, :create, :charts, :config_variables]
before_action :set_pipeline_path, only: [:show]
before_action :authorize_read_pipeline!
@@ -14,10 +14,11 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action do
push_frontend_feature_flag(:new_pipeline_form, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:pipeline_graph_layers_view, project, type: :development, default_enabled: :yaml)
+ push_frontend_feature_flag(:pipeline_filter_jobs, project, default_enabled: :yaml)
push_frontend_feature_flag(:graphql_pipeline_details, project, type: :development, default_enabled: :yaml)
push_frontend_feature_flag(:graphql_pipeline_details_users, current_user, type: :development, default_enabled: :yaml)
push_frontend_feature_flag(:jira_for_vulnerabilities, project, type: :development, default_enabled: :yaml)
- push_frontend_feature_flag(:new_pipelines_table, project, default_enabled: :yaml)
end
before_action :ensure_pipeline, only: [:show]
@@ -44,7 +45,17 @@ class Projects::PipelinesController < Projects::ApplicationController
@pipelines_count = limited_pipelines_count(project)
respond_to do |format|
- format.html
+ format.html do
+ experiment(:pipeline_empty_state_templates, actor: current_user) do |e|
+ e.exclude! unless current_user
+ e.exclude! if @pipelines_count.to_i > 0
+ e.exclude! if helpers.has_gitlab_ci?(project)
+
+ e.use {}
+ e.try {}
+ e.track(:view, value: project.namespace_id)
+ end
+ end
format.json do
Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
@@ -92,10 +103,10 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def show
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/-/issues/26657')
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/26657')
respond_to do |format|
- format.html
+ format.html { render_show }
format.json do
Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
@@ -150,15 +161,6 @@ class Projects::PipelinesController < Projects::ApplicationController
.represent(@stage, details: true, retried: params[:retried])
end
- # TODO: This endpoint is used by mini-pipeline-graph
- # TODO: This endpoint should be migrated to `stage.json`
- def stage_ajax
- @stage = pipeline.legacy_stage(params[:stage])
- return not_found unless @stage
-
- render json: { html: view_to_html_string('projects/pipelines/_stage') }
- end
-
def retry
pipeline.retry_failed(current_user)
@@ -185,10 +187,7 @@ class Projects::PipelinesController < Projects::ApplicationController
def test_report
respond_to do |format|
- format.html do
- render 'show'
- end
-
+ format.html { render_show }
format.json do
render json: TestReportSerializer
.new(current_user: @current_user)
@@ -217,6 +216,8 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def render_show
+ @stages = @pipeline.stages.with_latest_and_retried_statuses
+
respond_to do |format|
format.html do
render 'show'
@@ -269,9 +270,9 @@ class Projects::PipelinesController < Projects::ApplicationController
&.present(current_user: current_user)
end
- def whitelist_query_limiting
- # Also see https://gitlab.com/gitlab-org/gitlab-foss/issues/42343
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42339')
+ def disable_query_limiting
+ # Also see https://gitlab.com/gitlab-org/gitlab/-/issues/20785
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/20784')
end
def authorize_update_pipeline!
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
index 28a86ecc9f0..8acebd89033 100644
--- a/app/controllers/projects/registry/repositories_controller.rb
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -6,22 +6,11 @@ module Projects
include PackagesHelper
before_action :authorize_update_container_image!, only: [:destroy]
- before_action :ensure_root_container_repository!, only: [:index]
def index
respond_to do |format|
- format.html
- format.json do
- @images = ContainerRepositoriesFinder.new(user: current_user, subject: project, params: params.slice(:name))
- .execute
-
- track_package_event(:list_repositories, :container)
-
- serializer = ContainerRepositoriesSerializer
- .new(project: project, current_user: current_user)
-
- render json: serializer.with_pagination(request, response).represent(@images)
- end
+ format.html { ensure_root_container_repository! }
+ format.json { render_404 }
end
end
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index 614bada09ed..26382856761 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -12,7 +12,6 @@ class Projects::ReleasesController < Projects::ApplicationController
push_frontend_feature_flag(:graphql_release_data, project, default_enabled: true)
push_frontend_feature_flag(:graphql_milestone_stats, project, default_enabled: true)
push_frontend_feature_flag(:graphql_releases_page, project, default_enabled: true)
- push_frontend_feature_flag(:graphql_individual_release_page, project, default_enabled: true)
end
before_action :authorize_update_release!, only: %i[edit update]
before_action :authorize_create_release!, only: :new
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index 554eb01defe..ccb8b393bfe 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -71,7 +71,7 @@ class Projects::ServicesController < Projects::ApplicationController
end
result[:data].presence || {}
- rescue Gitlab::HTTP::BlockedUrlError => e
+ rescue *Gitlab::HTTP::HTTP_ERRORS => 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 74350147825..e3bb8c616df 100644
--- a/app/controllers/projects/settings/access_tokens_controller.rb
+++ b/app/controllers/projects/settings/access_tokens_controller.rb
@@ -5,7 +5,10 @@ module Projects
class AccessTokensController < Projects::ApplicationController
include ProjectsHelper
- before_action :check_feature_availability
+ layout 'project_settings'
+ before_action -> { check_permission(:read_resource_access_tokens) }, only: [:index]
+ before_action -> { check_permission(:destroy_resource_access_tokens) }, only: [:revoke]
+ before_action -> { check_permission(:create_resource_access_tokens) }, only: [:create]
feature_category :authentication_and_authorization
@@ -42,8 +45,8 @@ module Projects
private
- def check_feature_availability
- render_404 unless project_access_token_available?(@project)
+ def check_permission(action)
+ render_404 unless can?(current_user, action, @project)
end
def create_params
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index 34b11c456b9..1a465406660 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -7,6 +7,7 @@ module Projects
NUMBER_OF_RUNNERS_PER_PAGE = 20
+ layout 'project_settings'
before_action :authorize_admin_pipeline!
before_action :define_variables
before_action do
@@ -61,7 +62,7 @@ module Projects
end
def runner_setup_scripts
- private_runner_setup_scripts(project: @project)
+ private_runner_setup_scripts
end
private
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index c407b15e29f..a05793a0283 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -3,6 +3,7 @@
module Projects
module Settings
class OperationsController < Projects::ApplicationController
+ layout 'project_settings'
before_action :authorize_admin_operations!
before_action :authorize_read_prometheus_alerts!, only: [:reset_alerting_token]
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index 821560e32ba..bb5ad8e9aea 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -3,6 +3,7 @@
module Projects
module Settings
class RepositoryController < Projects::ApplicationController
+ layout 'project_settings'
before_action :authorize_admin_project!
before_action :define_variables, only: [:create_deploy_token]
before_action do
diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb
index 94b0473e1f3..3bf9988ca22 100644
--- a/app/controllers/projects/tags_controller.rb
+++ b/app/controllers/projects/tags_controller.rb
@@ -9,6 +9,9 @@ class Projects::TagsController < Projects::ApplicationController
before_action :require_non_empty_project
before_action :authorize_download_code!
before_action :authorize_admin_tag!, only: [:new, :create, :destroy]
+ before_action do
+ push_frontend_feature_flag(:gldropdown_tags, default_enabled: :yaml)
+ end
feature_category :source_code_management, [:index, :show, :new, :destroy]
feature_category :release_evidence, [:create]
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index bc48ebd1c74..7c9d6daad02 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -14,7 +14,7 @@ class ProjectsController < Projects::ApplicationController
around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
- before_action :whitelist_query_limiting, only: [:show, :create]
+ before_action :disable_query_limiting, only: [:show, :create]
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]
@@ -35,6 +35,10 @@ class ProjectsController < Projects::ApplicationController
push_frontend_feature_flag(:allow_editing_commit_messages, @project)
end
+ before_action do
+ push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml)
+ end
+
layout :determine_layout
feature_category :projects, [
@@ -70,6 +74,7 @@ class ProjectsController < Projects::ApplicationController
@project = ::Projects::CreateService.new(current_user, project_params(attributes: project_params_create_attributes)).execute
if @project.saved?
+ experiment(:new_repo, user: current_user).track(:project_created)
experiment(:new_project_readme, actor: current_user).track(
:created,
property: active_new_project_tab,
@@ -399,6 +404,7 @@ class ProjectsController < Projects::ApplicationController
show_default_award_emojis
squash_option
allow_editing_commit_messages
+ mr_default_target_self
]
end
@@ -510,8 +516,8 @@ class ProjectsController < Projects::ApplicationController
redirect_to(request.original_url.sub(%r{\.git/?\Z}, ''))
end
- def whitelist_query_limiting
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/-/issues/20826')
+ def disable_query_limiting
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/20826')
end
def present_project
diff --git a/app/controllers/registrations/experience_levels_controller.rb b/app/controllers/registrations/experience_levels_controller.rb
index b6ed0366177..3a721823d89 100644
--- a/app/controllers/registrations/experience_levels_controller.rb
+++ b/app/controllers/registrations/experience_levels_controller.rb
@@ -6,7 +6,7 @@ module Registrations
before_action :ensure_namespace_path_param
- feature_category :navigation
+ feature_category :onboarding
def update
current_user.experience_level = params[:experience_level]
diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb
index a1a6a057171..62ec03206c4 100644
--- a/app/controllers/registrations/welcome_controller.rb
+++ b/app/controllers/registrations/welcome_controller.rb
@@ -35,7 +35,7 @@ module Registrations
end
def update_params
- params.require(:user).permit(:role, :other_role, :setup_for_company)
+ params.require(:user).permit(:role, :other_role, :setup_for_company, :email_opted_in)
end
def requires_confirmation?(user)
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 44c08863dd6..61218a95add 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -9,7 +9,7 @@ class RegistrationsController < Devise::RegistrationsController
layout 'devise'
prepend_before_action :check_captcha, only: :create
- before_action :whitelist_query_limiting, :ensure_destroy_prerequisites_met, only: [:destroy]
+ before_action :ensure_destroy_prerequisites_met, only: [:destroy]
before_action :load_recaptcha, only: :new
before_action :set_invite_params, only: :new
@@ -162,10 +162,6 @@ class RegistrationsController < Devise::RegistrationsController
@devise_mapping ||= Devise.mappings[:user]
end
- def whitelist_query_limiting
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42380')
- end
-
def load_recaptcha
Gitlab::Recaptcha.load_configurations!
end
diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb
index dab2f3bd67a..672a03ad11d 100644
--- a/app/controllers/root_controller.rb
+++ b/app/controllers/root_controller.rb
@@ -13,7 +13,6 @@ class RootController < Dashboard::ProjectsController
before_action :redirect_unlogged_user, if: -> { current_user.nil? }
before_action :redirect_logged_user, if: -> { current_user.present? }
- before_action :customize_homepage, only: :index, if: -> { current_user.present? }
# We only need to load the projects when the user is logged in but did not
# configure a dashboard. In which case we render projects. We can do that straight
# from the #index action.
@@ -69,10 +68,6 @@ class RootController < Dashboard::ProjectsController
root_urls.exclude?(home_page_url)
end
-
- def customize_homepage
- @customize_homepage = Feature.enabled?(:customize_homepage, default_enabled: :yaml)
- end
end
RootController.prepend_if_ee('EE::RootController')
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 45c1c35a655..3b218822395 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -47,7 +47,11 @@ class SearchController < ApplicationController
params.require([:search, :scope])
scope = search_service.scope
- count = search_service.search_results.formatted_count(scope)
+
+ count = 0
+ ApplicationRecord.with_fast_read_statement_timeout do
+ count = search_service.search_results.formatted_count(scope)
+ end
# Users switching tabs will keep fetching the same tab counts so it's a
# good idea to cache in their browser just for a short time. They can still
diff --git a/app/controllers/whats_new_controller.rb b/app/controllers/whats_new_controller.rb
index 12a52f30bd0..e24b0bbc7bb 100644
--- a/app/controllers/whats_new_controller.rb
+++ b/app/controllers/whats_new_controller.rb
@@ -5,7 +5,7 @@ class WhatsNewController < ApplicationController
skip_before_action :authenticate_user!
- before_action :check_valid_page_param, :set_pagination_headers, unless: -> { has_version_param? }
+ before_action :check_valid_page_param, :set_pagination_headers
feature_category :navigation
@@ -29,19 +29,11 @@ class WhatsNewController < ApplicationController
def highlights
strong_memoize(:highlights) do
- if has_version_param?
- ReleaseHighlight.for_version(version: params[:version])
- else
- ReleaseHighlight.paginated(page: current_page)
- end
+ ReleaseHighlight.paginated(page: current_page)
end
end
def set_pagination_headers
response.set_header('X-Next-Page', highlights.next_page)
end
-
- def has_version_param?
- params[:version].present?
- end
end
diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb
index ed0d146af8c..01105f6cec4 100644
--- a/app/experiments/application_experiment.rb
+++ b/app/experiments/application_experiment.rb
@@ -27,7 +27,7 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
# track the event, and mix in the experiment signature data
Gitlab::Tracking.event(name, action.to_s, **event_args.merge(
context: (event_args[:context] || []) << SnowplowTracker::SelfDescribingJson.new(
- 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-3-0', signature
+ 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0', signature
)
))
end
diff --git a/app/experiments/members/invite_email_experiment.rb b/app/experiments/members/invite_email_experiment.rb
index a0f4dee2866..6a7d2b110d3 100644
--- a/app/experiments/members/invite_email_experiment.rb
+++ b/app/experiments/members/invite_email_experiment.rb
@@ -8,7 +8,82 @@ module Members
INVITE_TYPE = 'initial_email'
def resolve_variant_name
- Strategy::RoundRobin.new(feature_flag_name, %i[avatar permission_info control]).execute
+ RoundRobin.new(feature_flag_name, %i[avatar permission_info control]).execute
+ end
+ end
+
+ class RoundRobin
+ CacheError = Class.new(StandardError)
+
+ COUNTER_EXPIRE_TIME = 86400 # one day
+
+ def initialize(key, variants)
+ @key = key
+ @variants = variants
+ end
+
+ def execute
+ increment_counter
+ resolve_variant_name
+ end
+
+ # When the counter would expire
+ #
+ # @api private Used internally by SRE and debugging purpose
+ # @return [Integer] Number in seconds until expiration or false if never
+ def counter_expires_in
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.ttl(key)
+ end
+ end
+
+ # Return the actual counter value
+ #
+ # @return [Integer] value
+ def counter_value
+ Gitlab::Redis::SharedState.with do |redis|
+ (redis.get(key) || 0).to_i
+ end
+ end
+
+ # Reset the counter
+ #
+ # @private Used internally by SRE and debugging purpose
+ # @return [Boolean] whether reset was a success
+ def reset!
+ redis_cmd do |redis|
+ redis.del(key)
+ end
+ end
+
+ private
+
+ attr_reader :key, :variants
+
+ # Increase the counter
+ #
+ # @return [Boolean] whether operation was a success
+ def increment_counter
+ redis_cmd do |redis|
+ redis.incr(key)
+ redis.expire(key, COUNTER_EXPIRE_TIME)
+ end
+ end
+
+ def resolve_variant_name
+ remainder = counter_value % variants.size
+
+ variants[remainder]
+ end
+
+ def redis_cmd
+ Gitlab::Redis::SharedState.with { |redis| yield(redis) }
+
+ true
+ rescue CacheError => e
+ Gitlab::AppLogger.warn("GitLab: An unexpected error occurred in writing to Redis: #{e}")
+
+ false
end
end
end
diff --git a/app/experiments/strategy/round_robin.rb b/app/experiments/strategy/round_robin.rb
deleted file mode 100644
index 7b80c0e984d..00000000000
--- a/app/experiments/strategy/round_robin.rb
+++ /dev/null
@@ -1,78 +0,0 @@
-# frozen_string_literal: true
-
-module Strategy
- class RoundRobin
- CacheError = Class.new(StandardError)
-
- COUNTER_EXPIRE_TIME = 86400 # one day
-
- def initialize(key, variants)
- @key = key
- @variants = variants
- end
-
- def execute
- increment_counter
- resolve_variant_name
- end
-
- # When the counter would expire
- #
- # @api private Used internally by SRE and debugging purpose
- # @return [Integer] Number in seconds until expiration or false if never
- def counter_expires_in
- Gitlab::Redis::SharedState.with do |redis|
- redis.ttl(key)
- end
- end
-
- # Return the actual counter value
- #
- # @return [Integer] value
- def counter_value
- Gitlab::Redis::SharedState.with do |redis|
- (redis.get(key) || 0).to_i
- end
- end
-
- # Reset the counter
- #
- # @private Used internally by SRE and debugging purpose
- # @return [Boolean] whether reset was a success
- def reset!
- redis_cmd do |redis|
- redis.del(key)
- end
- end
-
- private
-
- attr_reader :key, :variants
-
- # Increase the counter
- #
- # @return [Boolean] whether operation was a success
- def increment_counter
- redis_cmd do |redis|
- redis.incr(key)
- redis.expire(key, COUNTER_EXPIRE_TIME)
- end
- end
-
- def resolve_variant_name
- remainder = counter_value % variants.size
-
- variants[remainder]
- end
-
- def redis_cmd
- Gitlab::Redis::SharedState.with { |redis| yield(redis) }
-
- true
- rescue CacheError => e
- Gitlab::AppLogger.warn("GitLab: An unexpected error occurred in writing to Redis: #{e}")
-
- false
- end
- end
-end
diff --git a/app/finders/alert_management/http_integrations_finder.rb b/app/finders/alert_management/http_integrations_finder.rb
index 9f511be0ace..5d4c9b6fbe3 100644
--- a/app/finders/alert_management/http_integrations_finder.rb
+++ b/app/finders/alert_management/http_integrations_finder.rb
@@ -27,7 +27,7 @@ module AlertManagement
first_id = project.alert_management_http_integrations
.ordered_by_id
.select(:id)
- .at_most(1)
+ .limit(1)
@collection = collection.id_in(first_id)
end
diff --git a/app/finders/applications_finder.rb b/app/finders/applications_finder.rb
index 3ded90f3fd5..c5b5094b195 100644
--- a/app/finders/applications_finder.rb
+++ b/app/finders/applications_finder.rb
@@ -17,6 +17,6 @@ class ApplicationsFinder
def by_id(applications)
return applications unless params[:id]
- Doorkeeper::Application.find_by(id: params[:id]) # rubocop: disable CodeReuse/ActiveRecord
+ applications.find_by(id: params[:id]) # rubocop: disable CodeReuse/ActiveRecord
end
end
diff --git a/app/finders/award_emojis_finder.rb b/app/finders/award_emojis_finder.rb
index 7882beb64bf..9ff64637128 100644
--- a/app/finders/award_emojis_finder.rb
+++ b/app/finders/award_emojis_finder.rb
@@ -13,8 +13,7 @@ class AwardEmojisFinder
def execute
awards = awardable.award_emoji
awards = by_name(awards)
- awards = by_awarded_by(awards)
- awards
+ by_awarded_by(awards)
end
private
diff --git a/app/finders/branches_finder.rb b/app/finders/branches_finder.rb
index 2eee90a512a..157c454183a 100644
--- a/app/finders/branches_finder.rb
+++ b/app/finders/branches_finder.rb
@@ -11,8 +11,7 @@ class BranchesFinder < GitRefsFinder
else
branches = repository.branches_sorted_by(sort)
branches = by_search(branches)
- branches = by_names(branches)
- branches
+ by_names(branches)
end
end
diff --git a/app/finders/ci/commit_statuses_finder.rb b/app/finders/ci/commit_statuses_finder.rb
index d49ec7ebb40..c3d0a34d2ff 100644
--- a/app/finders/ci/commit_statuses_finder.rb
+++ b/app/finders/ci/commit_statuses_finder.rb
@@ -21,9 +21,9 @@ module Ci
def latest_commits
strong_memoize(:latest_commits) do
- refs.map do |ref|
+ refs.to_h do |ref|
[ref.name, @repository.commit(ref.dereferenced_target).sha]
- end.to_h
+ end
end
end
diff --git a/app/finders/ci/daily_build_group_report_results_finder.rb b/app/finders/ci/daily_build_group_report_results_finder.rb
index 9e736c70dda..5ac1bbd0670 100644
--- a/app/finders/ci/daily_build_group_report_results_finder.rb
+++ b/app/finders/ci/daily_build_group_report_results_finder.rb
@@ -35,8 +35,7 @@ module Ci
return Ci::DailyBuildGroupReportResult.none unless query_allowed?
collection = Ci::DailyBuildGroupReportResult.by_projects(params[:project])
- collection = filter_report_results(collection)
- collection
+ filter_report_results(collection)
end
private
@@ -51,8 +50,7 @@ module Ci
collection = by_dates(collection)
collection = sort(collection)
- collection = limit_by(collection)
- collection
+ limit_by(collection)
end
def by_coverage(items)
diff --git a/app/finders/ci/pipelines_finder.rb b/app/finders/ci/pipelines_finder.rb
index a77faebb160..e509cf940b8 100644
--- a/app/finders/ci/pipelines_finder.rb
+++ b/app/finders/ci/pipelines_finder.rb
@@ -131,7 +131,7 @@ module Ci
def by_yaml_errors(items)
case Gitlab::Utils.to_boolean(params[:yaml_errors])
when true
- items.where("yaml_errors IS NOT NULL")
+ items.where.not(yaml_errors: nil)
when false
items.where("yaml_errors IS NULL")
else
diff --git a/app/finders/ci/variables_finder.rb b/app/finders/ci/variables_finder.rb
index d933643ffb2..39a1a60596d 100644
--- a/app/finders/ci/variables_finder.rb
+++ b/app/finders/ci/variables_finder.rb
@@ -2,23 +2,23 @@
module Ci
class VariablesFinder
- attr_reader :project, :params
-
- def initialize(project, params)
- @project, @params = project, params
+ def initialize(resource, params)
+ @resource = resource
+ @params = params
raise ArgumentError, 'Please provide params[:key]' if params[:key].blank?
end
def execute
- variables = project.variables
+ variables = resource.variables
variables = by_key(variables)
- variables = by_environment_scope(variables)
- variables
+ by_environment_scope(variables)
end
private
+ attr_reader :resource, :params
+
def by_key(variables)
variables.by_key(params[:key])
end
diff --git a/app/finders/concerns/finder_with_group_hierarchy.rb b/app/finders/concerns/finder_with_group_hierarchy.rb
new file mode 100644
index 00000000000..86ccac19b63
--- /dev/null
+++ b/app/finders/concerns/finder_with_group_hierarchy.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+# Module to include into finders to provide support for querying for
+# objects up and down the group hierarchy. Extracted from LabelsFinder
+#
+# Supports params:
+# :group
+# :group_id
+# :include_ancestor_groups
+# :include_descendant_groups
+module FinderWithGroupHierarchy
+ extend ActiveSupport::Concern
+
+ private
+
+ def item_ids
+ raise NotImplementedError
+ end
+
+ # Gets redacted array of group ids
+ # which can include the ancestors and descendants of the requested group.
+ def group_ids_for(group)
+ strong_memoize(:group_ids) do
+ groups = groups_to_include(group)
+
+ # Because we are sure that all groups are in the same hierarchy tree
+ # we can preset root group for all of them to optimize permission checks
+ Group.preset_root_ancestor_for(groups)
+
+ groups_user_can_read_items(groups).map(&:id)
+ end
+ end
+
+ def groups_to_include(group)
+ groups = [group]
+
+ groups += group.ancestors if include_ancestor_groups?
+ groups += group.descendants if include_descendant_groups?
+
+ groups
+ end
+
+ def include_ancestor_groups?
+ params[:include_ancestor_groups]
+ end
+
+ def include_descendant_groups?
+ params[:include_descendant_groups]
+ end
+
+ def group?
+ params[:group].present? || params[:group_id].present?
+ end
+
+ def group
+ strong_memoize(:group) { params[:group].presence || Group.find(params[:group_id]) }
+ end
+
+ def read_permission
+ raise NotImplementedError
+ end
+
+ def authorized_to_read_item?(item_parent)
+ return true if skip_authorization
+
+ Ability.allowed?(current_user, read_permission, item_parent)
+ end
+
+ def groups_user_can_read_items(groups)
+ DeclarativePolicy.user_scope do
+ groups.select { |group| authorized_to_read_item?(group) }
+ end
+ end
+end
diff --git a/app/finders/concerns/merged_at_filter.rb b/app/finders/concerns/merged_at_filter.rb
index 581bcca3c25..e44354f36d1 100644
--- a/app/finders/concerns/merged_at_filter.rb
+++ b/app/finders/concerns/merged_at_filter.rb
@@ -10,7 +10,7 @@ module MergedAtFilter
mr_metrics_scope = mr_metrics_scope.merged_after(merged_after) if merged_after.present?
mr_metrics_scope = mr_metrics_scope.merged_before(merged_before) if merged_before.present?
- items.join_metrics.merge(mr_metrics_scope)
+ join_metrics(items, mr_metrics_scope)
end
def merged_after
@@ -20,4 +20,22 @@ module MergedAtFilter
def merged_before
params[:merged_before]
end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ #
+ # This join optimizes merged_at queries when the finder is invoked for a project by moving
+ # the target_project_id condition from merge_requests table to merge_request_metrics table.
+ def join_metrics(items, mr_metrics_scope)
+ scope = if project_id = items.where_values_hash["target_project_id"]
+ # removing the original merge_requests.target_project_id condition
+ items = items.unscope(where: :target_project_id)
+ # adding the target_project_id condition to merge_request_metrics
+ items.join_metrics(project_id)
+ else
+ items.join_metrics
+ end
+
+ scope.merge(mr_metrics_scope)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/finders/concerns/packages/finder_helper.rb b/app/finders/concerns/packages/finder_helper.rb
index 30bc0ff7909..39c018818d1 100644
--- a/app/finders/concerns/packages/finder_helper.rb
+++ b/app/finders/concerns/packages/finder_helper.rb
@@ -11,22 +11,26 @@ module Packages
def packages_visible_to_user(user, within_group:)
return ::Packages::Package.none unless within_group
- return ::Packages::Package.none unless Ability.allowed?(user, :read_package, within_group)
+ return ::Packages::Package.none unless Ability.allowed?(user, :read_group, within_group)
- projects = projects_visible_to_reporters(user, within_group.self_and_descendants.select(:id))
+ projects = projects_visible_to_reporters(user, within_group: within_group)
::Packages::Package.for_projects(projects.select(:id))
end
def projects_visible_to_user(user, within_group:)
return ::Project.none unless within_group
- return ::Project.none unless Ability.allowed?(user, :read_package, within_group)
+ return ::Project.none unless Ability.allowed?(user, :read_group, within_group)
- projects_visible_to_reporters(user, within_group.self_and_descendants.select(:id))
+ projects_visible_to_reporters(user, within_group: within_group)
end
- def projects_visible_to_reporters(user, namespace_ids)
- ::Project.in_namespace(namespace_ids)
- .public_or_visible_to_user(user, ::Gitlab::Access::REPORTER)
+ def projects_visible_to_reporters(user, within_group:)
+ if user.is_a?(DeployToken) && Feature.enabled?(:packages_finder_helper_deploy_token)
+ user.accessible_projects
+ else
+ within_group.all_projects
+ .public_or_visible_to_user(user, ::Gitlab::Access::REPORTER)
+ end
end
def package_type
diff --git a/app/finders/context_commits_finder.rb b/app/finders/context_commits_finder.rb
index de89a556ee0..d623854ada4 100644
--- a/app/finders/context_commits_finder.rb
+++ b/app/finders/context_commits_finder.rb
@@ -11,9 +11,7 @@ class ContextCommitsFinder
def execute
commits = init_collection
- commits = filter_existing_commits(commits)
-
- commits
+ filter_existing_commits(commits)
end
private
@@ -21,19 +19,15 @@ class ContextCommitsFinder
attr_reader :project, :merge_request, :search, :limit, :offset
def init_collection
- commits =
- if search.present?
- search_commits
- else
- project.repository.commits(merge_request.target_branch, { limit: limit, offset: offset })
- end
-
- commits
+ if search.present?
+ search_commits
+ else
+ project.repository.commits(merge_request.target_branch, { limit: limit, offset: offset })
+ end
end
def filter_existing_commits(commits)
commits.select! { |commit| already_included_ids.exclude?(commit.id) }
-
commits
end
diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb
index 89a28d9dfb8..ae26fc14ad5 100644
--- a/app/finders/deployments_finder.rb
+++ b/app/finders/deployments_finder.rb
@@ -33,9 +33,7 @@ class DeploymentsFinder
items = by_environment(items)
items = by_status(items)
items = preload_associations(items)
- items = sort(items)
-
- items
+ sort(items)
end
private
diff --git a/app/finders/environments_by_deployments_finder.rb b/app/finders/environments_by_deployments_finder.rb
new file mode 100644
index 00000000000..76e1c050ea5
--- /dev/null
+++ b/app/finders/environments_by_deployments_finder.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+class EnvironmentsByDeploymentsFinder
+ attr_reader :project, :current_user, :params
+
+ def initialize(project, current_user, params = {})
+ @project = project
+ @current_user = current_user
+ @params = params
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def execute
+ deployments = project.deployments
+ deployments =
+ if ref
+ deployments_query = params[:with_tags] ? 'ref = :ref OR tag IS TRUE' : 'ref = :ref'
+ deployments.where(deployments_query, ref: ref.to_s)
+ elsif commit
+ deployments.where(sha: commit.sha)
+ else
+ deployments.none
+ end
+
+ environment_ids = deployments
+ .group(:environment_id)
+ .select(:environment_id)
+
+ environments = project.environments.available
+ .where(id: environment_ids)
+
+ if params[:find_latest]
+ find_one(environments.order_by_last_deployed_at_desc)
+ else
+ find_all(environments.order_by_last_deployed_at.to_a)
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ def find_one(environments)
+ [environments.find { |environment| valid_environment?(environment) }].compact
+ end
+
+ def find_all(environments)
+ environments.select { |environment| valid_environment?(environment) }
+ end
+
+ def valid_environment?(environment)
+ # Go in order of cost: SQL calls are cheaper than Gitaly calls
+ return false unless Ability.allowed?(current_user, :read_environment, environment)
+
+ return false if ref && params[:recently_updated] && !environment.recently_updated_on_branch?(ref)
+ return false if ref && commit && !environment.includes_commit?(commit)
+
+ true
+ end
+
+ def ref
+ params[:ref].try(:to_s)
+ end
+
+ def commit
+ params[:commit]
+ end
+end
diff --git a/app/finders/environments_finder.rb b/app/finders/environments_finder.rb
index 32ca1a42db7..c64e850f440 100644
--- a/app/finders/environments_finder.rb
+++ b/app/finders/environments_finder.rb
@@ -6,81 +6,22 @@ class EnvironmentsFinder
InvalidStatesError = Class.new(StandardError)
def initialize(project, current_user, params = {})
- @project, @current_user, @params = project, current_user, params
+ @project = project
+ @current_user = current_user
+ @params = params
end
- # rubocop: disable CodeReuse/ActiveRecord
def execute
- deployments = project.deployments
- deployments =
- if ref
- deployments_query = params[:with_tags] ? 'ref = :ref OR tag IS TRUE' : 'ref = :ref'
- deployments.where(deployments_query, ref: ref.to_s)
- elsif commit
- deployments.where(sha: commit.sha)
- else
- deployments.none
- end
-
- environment_ids = deployments
- .group(:environment_id)
- .select(:environment_id)
-
- environments = project.environments.available
- .where(id: environment_ids)
-
- if params[:find_latest]
- find_one(environments.order_by_last_deployed_at_desc)
- else
- find_all(environments.order_by_last_deployed_at.to_a)
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- # This method will eventually take the place of `#execute` as an
- # efficient way to get relevant environment entries.
- # Currently, `#execute` method has a serious technical debt and
- # we will likely rework on it in the future.
- # See more https://gitlab.com/gitlab-org/gitlab-foss/issues/63381
- def find
environments = project.environments
environments = by_name(environments)
environments = by_search(environments)
# Raises InvalidStatesError if params[:states] contains invalid states.
- environments = by_states(environments)
-
- environments
+ by_states(environments)
end
private
- def find_one(environments)
- [environments.find { |environment| valid_environment?(environment) }].compact
- end
-
- def find_all(environments)
- environments.select { |environment| valid_environment?(environment) }
- end
-
- def valid_environment?(environment)
- # Go in order of cost: SQL calls are cheaper than Gitaly calls
- return false unless Ability.allowed?(current_user, :read_environment, environment)
-
- return false if ref && params[:recently_updated] && !environment.recently_updated_on_branch?(ref)
- return false if ref && commit && !environment.includes_commit?(commit)
-
- true
- end
-
- def ref
- params[:ref].try(:to_s)
- end
-
- def commit
- params[:commit]
- end
-
def by_name(environments)
if params[:name].present?
environments.for_name(params[:name])
diff --git a/app/finders/git_refs_finder.rb b/app/finders/git_refs_finder.rb
index 2289b34e562..11af659d37c 100644
--- a/app/finders/git_refs_finder.rb
+++ b/app/finders/git_refs_finder.rb
@@ -33,15 +33,21 @@ class GitRefsFinder
end
def filter_refs_with_prefix(refs, prefix)
- refs.select { |ref| ref.name.upcase.starts_with?(prefix.upcase) }
+ prefix = prefix.downcase
+
+ refs.select { |ref| ref.name.downcase.starts_with?(prefix) }
end
def filter_refs_with_suffix(refs, suffix)
- refs.select { |ref| ref.name.upcase.ends_with?(suffix.upcase) }
+ suffix = suffix.downcase
+
+ refs.select { |ref| ref.name.downcase.ends_with?(suffix) }
end
def filter_refs_by_name(refs, term)
- refs.select { |ref| ref.name.upcase.include?(term.upcase) }
+ term = term.downcase
+
+ refs.select { |ref| ref.name.downcase.include?(term) }
end
def set_exact_match_as_first_result(matches, term)
diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb
index 2417b1e0771..a6ecd835527 100644
--- a/app/finders/group_members_finder.rb
+++ b/app/finders/group_members_finder.rb
@@ -21,28 +21,13 @@ class GroupMembersFinder < UnionFinder
end
def execute(include_relations: DEFAULT_RELATIONS)
- group_members = group_members_list
- relations = []
+ return filter_members(group_members_list) if include_relations == [:direct]
- return filter_members(group_members) if include_relations == [:direct]
+ groups = groups_by_relations(include_relations)
+ return GroupMember.none unless groups
- relations << group_members if include_relations.include?(:direct)
+ members = all_group_members(groups).distinct_on_user_with_max_access_level
- if include_relations.include?(:inherited) && group.parent
- parents_members = relation_group_members(group.ancestors)
-
- relations << parents_members
- end
-
- if include_relations.include?(:descendants)
- descendant_members = relation_group_members(group.descendants)
-
- relations << descendant_members
- end
-
- return GroupMember.none if relations.empty?
-
- members = find_union(relations, GroupMember)
filter_members(members)
end
@@ -50,6 +35,25 @@ class GroupMembersFinder < UnionFinder
attr_reader :user, :group
+ def groups_by_relations(include_relations)
+ case include_relations.sort
+ when [:inherited]
+ group.ancestors
+ when [:descendants]
+ group.descendants
+ when [:direct, :inherited]
+ group.self_and_ancestors
+ when [:descendants, :direct]
+ group.self_and_descendants
+ when [:descendants, :inherited]
+ find_union([group.ancestors, group.descendants], Group)
+ when [:descendants, :direct, :inherited]
+ group.self_and_hierarchy
+ else
+ nil
+ end
+ end
+
def filter_members(members)
members = members.search(params[:search]) if params[:search].present?
members = members.sort_by_attribute(params[:sort]) if params[:sort].present?
@@ -69,17 +73,13 @@ class GroupMembersFinder < UnionFinder
group.members
end
- def relation_group_members(relation)
- all_group_members(relation).non_minimal_access
+ def all_group_members(groups)
+ members_of_groups(groups).non_minimal_access
end
- # rubocop: disable CodeReuse/ActiveRecord
- def all_group_members(relation)
- GroupMember.non_request
- .where(source_id: relation.select(:id))
- .where.not(user_id: group.users.select(:id))
+ def members_of_groups(groups)
+ GroupMember.non_request.of_groups(groups)
end
- # rubocop: enable CodeReuse/ActiveRecord
end
GroupMembersFinder.prepend_if_ee('EE::GroupMembersFinder')
diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb
index 8362e782ad1..dfdf821e3f0 100644
--- a/app/finders/group_projects_finder.rb
+++ b/app/finders/group_projects_finder.rb
@@ -48,8 +48,7 @@ class GroupProjectsFinder < ProjectsFinder
def filter_projects(collection)
projects = super
- projects = by_feature_availability(projects)
- projects
+ by_feature_availability(projects)
end
def limit(collection)
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 2409dc9d77d..40a4e2b4f26 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -119,20 +119,18 @@ class IssuableFinder
# https://www.postgresql.org/docs/current/static/queries-with.html
items = by_search(items)
- items = sort(items)
-
- items
+ sort(items)
end
def filter_items(items)
+ # Selection by group is already covered by `by_project` and `projects` for project-based issuables
+ # Group-based issuables have their own group filter methods
items = by_project(items)
- items = by_group(items)
items = by_scope(items)
items = by_created_at(items)
items = by_updated_at(items)
items = by_closed_at(items)
items = by_state(items)
- items = by_group(items)
items = by_assignee(items)
items = by_author(items)
items = by_non_archived(items)
@@ -244,7 +242,7 @@ class IssuableFinder
# These are "helper" params that modify the results, like :in and :search. They usually come in at the top-level
# params, but if they do come in inside the `:not` params, the inner ones should take precedence.
- not_helpers = params.slice(*NEGATABLE_PARAMS_HELPER_KEYS).merge(params[:not].slice(*NEGATABLE_PARAMS_HELPER_KEYS))
+ not_helpers = params.slice(*NEGATABLE_PARAMS_HELPER_KEYS).merge(params[:not].to_h.slice(*NEGATABLE_PARAMS_HELPER_KEYS))
not_helpers.each do |key, value|
not_params[key] = value unless not_params[key].present?
end
@@ -320,11 +318,6 @@ class IssuableFinder
end
end
- def by_group(items)
- # Selection by group is already covered by `by_project` and `projects`
- items
- end
-
# rubocop: disable CodeReuse/ActiveRecord
def by_project(items)
if params.project?
@@ -400,8 +393,6 @@ class IssuableFinder
# We want CE users to be able to say "Issues not assigned to either PersonA nor PersonB"
if not_params.assignees.present?
items.not_assigned_to(not_params.assignees)
- elsif not_params.assignee_id? || not_params.assignee_username? # assignee not found
- items.none
else
items
end
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 5c9010ee3e0..e1a334413f8 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -47,6 +47,13 @@ class IssuesFinder < IssuableFinder
# rubocop: disable CodeReuse/ActiveRecord
def with_confidentiality_access_check
return Issue.all if params.user_can_see_all_confidential_issues?
+
+ # If already filtering by assignee we can skip confidentiality since a user
+ # can always see confidential issues assigned to them. This is just an
+ # optimization since a very common usecase of this Finder is to load the
+ # count of issues assigned to the user for the header bar.
+ return Issue.all if current_user && params.assignees.include?(current_user)
+
return Issue.where('issues.confidential IS NOT TRUE') if params.user_cannot_see_confidential_issues?
Issue.where('
@@ -74,8 +81,7 @@ class IssuesFinder < IssuableFinder
issues = super
issues = by_due_date(issues)
issues = by_confidential(issues)
- issues = by_issue_types(issues)
- issues
+ by_issue_types(issues)
end
def by_confidential(items)
diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb
index bedd6891d02..ecd6270ed47 100644
--- a/app/finders/labels_finder.rb
+++ b/app/finders/labels_finder.rb
@@ -2,6 +2,7 @@
class LabelsFinder < UnionFinder
prepend FinderWithCrossProjectAccess
+ include FinderWithGroupHierarchy
include FinderMethods
include Gitlab::Utils::StrongMemoize
@@ -14,7 +15,7 @@ class LabelsFinder < UnionFinder
def execute(skip_authorization: false)
@skip_authorization = skip_authorization
- items = find_union(label_ids, Label) || Label.none
+ items = find_union(item_ids, Label) || Label.none
items = with_title(items)
items = by_subscription(items)
items = by_search(items)
@@ -26,8 +27,8 @@ class LabelsFinder < UnionFinder
attr_reader :current_user, :params, :skip_authorization
# rubocop: disable CodeReuse/ActiveRecord
- def label_ids
- label_ids = []
+ def item_ids
+ item_ids = []
if project?
if project
@@ -35,25 +36,25 @@ class LabelsFinder < UnionFinder
labels_table = Label.arel_table
group_ids = group_ids_for(project.group)
- label_ids << Label.where(
+ item_ids << Label.where(
labels_table[:type].eq('GroupLabel').and(labels_table[:group_id].in(group_ids)).or(
labels_table[:type].eq('ProjectLabel').and(labels_table[:project_id].eq(project.id))
)
)
else
- label_ids << project.labels
+ item_ids << project.labels
end
end
else
if group?
- label_ids << Label.where(group_id: group_ids_for(group))
+ item_ids << Label.where(group_id: group_ids_for(group))
end
- label_ids << Label.where(group_id: projects.group_ids)
- label_ids << Label.where(project_id: ids_user_can_read_labels(projects)) unless only_group_labels?
+ item_ids << Label.where(group_id: projects.group_ids)
+ item_ids << Label.where(project_id: ids_user_can_read_labels(projects)) unless only_group_labels?
end
- label_ids
+ item_ids
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -94,49 +95,6 @@ class LabelsFinder < UnionFinder
params[:subscribed] == 'true'
end
- # Gets redacted array of group ids
- # which can include the ancestors and descendants of the requested group.
- def group_ids_for(group)
- strong_memoize(:group_ids) do
- groups = groups_to_include(group)
-
- # Because we are sure that all groups are in the same hierarchy tree
- # we can preset root group for all of them to optimize permission checks
- Group.preset_root_ancestor_for(groups)
-
- groups_user_can_read_labels(groups).map(&:id)
- end
- end
-
- def groups_to_include(group)
- groups = [group]
-
- groups += group.ancestors if include_ancestor_groups?
- groups += group.descendants if include_descendant_groups?
-
- groups
- end
-
- def include_ancestor_groups?
- params[:include_ancestor_groups]
- end
-
- def include_descendant_groups?
- params[:include_descendant_groups]
- end
-
- def group?
- params[:group].present? || params[:group_id].present?
- end
-
- def group
- strong_memoize(:group) { params[:group].presence || Group.find(params[:group_id]) }
- end
-
- def project?
- params[:project].present? || params[:project_id].present?
- end
-
def projects?
params[:project_ids]
end
@@ -153,12 +111,16 @@ class LabelsFinder < UnionFinder
params[:title] || params[:name]
end
+ def project?
+ params[:project].present? || params[:project_id].present?
+ end
+
def project
return @project if defined?(@project)
if project?
@project = params[:project] || Project.find(params[:project_id])
- @project = nil unless authorized_to_read_labels?(@project)
+ @project = nil unless authorized_to_read_item?(@project)
else
@project = nil
end
@@ -191,16 +153,8 @@ class LabelsFinder < UnionFinder
end
# rubocop: enable CodeReuse/ActiveRecord
- def authorized_to_read_labels?(label_parent)
- return true if skip_authorization
-
- Ability.allowed?(current_user, :read_label, label_parent)
- end
-
- def groups_user_can_read_labels(groups)
- DeclarativePolicy.user_scope do
- groups.select { |group| authorized_to_read_labels?(group) }
- end
+ def read_permission
+ :read_label
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/finders/merge_request/metrics_finder.rb b/app/finders/merge_request/metrics_finder.rb
index d93e53d1636..1a3732bbdf9 100644
--- a/app/finders/merge_request/metrics_finder.rb
+++ b/app/finders/merge_request/metrics_finder.rb
@@ -14,9 +14,7 @@ class MergeRequest::MetricsFinder
items = init_collection
items = by_target_project(items)
items = by_merged_after(items)
- items = by_merged_before(items)
-
- items
+ by_merged_before(items)
end
private
diff --git a/app/finders/merge_requests/by_approvals_finder.rb b/app/finders/merge_requests/by_approvals_finder.rb
index e6ab1467f06..94f13468327 100644
--- a/app/finders/merge_requests/by_approvals_finder.rb
+++ b/app/finders/merge_requests/by_approvals_finder.rb
@@ -60,14 +60,14 @@ module MergeRequests
ids.first.to_s.downcase == label || usernames.map(&:downcase).include?(label)
end
- # Merge Requests without any approval
+ # Merge requests without any approval
#
# @param [ActiveRecord::Relation] items
def without_approvals(items)
items.without_approvals
end
- # Merge Requests with any number of approvals
+ # Merge requests with any number of approvals
#
# @param [ActiveRecord::Relation] items the activerecord relation
def with_any_approvals(items)
@@ -76,14 +76,14 @@ module MergeRequests
])
end
- # Merge Requests approved by given usernames
+ # Merge requests approved by given usernames
#
# @param [ActiveRecord::Relation] items the activerecord relation
def find_approved_by_names(items)
items.approved_by_users_with_usernames(*usernames)
end
- # Merge Requests approved by given user IDs
+ # Merge requests approved by given user IDs
#
# @param [ActiveRecord::Relation] items the activerecord relation
def find_approved_by_ids(items)
diff --git a/app/finders/merge_requests/oldest_per_commit_finder.rb b/app/finders/merge_requests/oldest_per_commit_finder.rb
index 5360f301036..5da7a08e36c 100644
--- a/app/finders/merge_requests/oldest_per_commit_finder.rb
+++ b/app/finders/merge_requests/oldest_per_commit_finder.rb
@@ -18,8 +18,8 @@ module MergeRequests
mapping = {}
shas = commits.map(&:id)
- # To include merge requests by the commit SHA, we don't need to go through
- # any diff rows.
+ # To include merge requests by the merge/squash SHA, we don't need to go
+ # through any diff rows.
#
# We can't squeeze all this into a single query, as the diff based data
# relies on a GROUP BY. On the other hand, retrieving MRs by their merge
@@ -27,12 +27,17 @@ module MergeRequests
@project
.merge_requests
.preload_target_project
- .by_merge_commit_sha(shas)
+ .by_merge_or_squash_commit_sha(shas)
.each do |mr|
- # Merge SHAs can't be in the merge request itself. It _is_ possible a
- # newer merge request includes the merge commit, but in that case we
- # still want the oldest merge request.
- mapping[mr.merge_commit_sha] = mr
+ # Merge/squash SHAs can't be in the merge request itself. It _is_
+ # possible a newer merge request includes the commit, but in that case
+ # we still want the oldest merge request.
+ #
+ # It's also possible that a merge request produces both a squashed
+ # commit and a merge commit. In that case we want to store the mapping
+ # for both the SHAs.
+ mapping[mr.squash_commit_sha] = mr if mr.squash_commit_sha
+ mapping[mr.merge_commit_sha] = mr if mr.merge_commit_sha
end
remaining = shas - mapping.keys
diff --git a/app/finders/metrics/dashboards/annotations_finder.rb b/app/finders/metrics/dashboards/annotations_finder.rb
index c42b8bf40e5..e97704738ea 100644
--- a/app/finders/metrics/dashboards/annotations_finder.rb
+++ b/app/finders/metrics/dashboards/annotations_finder.rb
@@ -4,7 +4,8 @@ module Metrics
module Dashboards
class AnnotationsFinder
def initialize(dashboard:, params:)
- @dashboard, @params = dashboard, params
+ @dashboard = dashboard
+ @params = params
end
def execute
diff --git a/app/finders/metrics/users_starred_dashboards_finder.rb b/app/finders/metrics/users_starred_dashboards_finder.rb
index 7244c51f9a7..2ef706c1b11 100644
--- a/app/finders/metrics/users_starred_dashboards_finder.rb
+++ b/app/finders/metrics/users_starred_dashboards_finder.rb
@@ -3,7 +3,9 @@
module Metrics
class UsersStarredDashboardsFinder
def initialize(user:, project:, params: {})
- @user, @project, @params = user, project, params
+ @user = user
+ @project = project
+ @params = params
end
def execute
diff --git a/app/finders/namespaces/projects_finder.rb b/app/finders/namespaces/projects_finder.rb
index a6d98015e9d..bac5328d077 100644
--- a/app/finders/namespaces/projects_finder.rb
+++ b/app/finders/namespaces/projects_finder.rb
@@ -39,8 +39,7 @@ module Namespaces
def filter_projects(collection)
collection = by_ids(collection)
- collection = by_similarity(collection)
- collection
+ by_similarity(collection)
end
def by_ids(items)
diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb
index 1a3f011d9eb..96966001e85 100644
--- a/app/finders/notes_finder.rb
+++ b/app/finders/notes_finder.rb
@@ -17,6 +17,7 @@ class NotesFinder
# target_id: integer
# last_fetched_at: time
# search: string
+ # sort: string
#
def initialize(current_user, params = {})
@project = params[:project]
@@ -29,8 +30,7 @@ class NotesFinder
notes = init_collection
notes = since_fetch_at(notes)
notes = notes.with_notes_filter(@params[:notes_filter]) if notes_filter?
-
- notes.fresh
+ sort(notes)
end
def target
@@ -173,6 +173,14 @@ class NotesFinder
def notes_filter?
@params[:notes_filter].present?
end
+
+ def sort(notes)
+ sort = @params[:sort].presence
+
+ return notes.fresh unless sort
+
+ notes.order_by(sort)
+ end
end
NotesFinder.prepend_if_ee('EE::NotesFinder')
diff --git a/app/finders/packages/conan/package_file_finder.rb b/app/finders/packages/conan/package_file_finder.rb
index edf35388a36..a1ebf9f40fa 100644
--- a/app/finders/packages/conan/package_file_finder.rb
+++ b/app/finders/packages/conan/package_file_finder.rb
@@ -8,8 +8,7 @@ module Packages
def package_files
files = super
files = by_conan_file_type(files)
- files = by_conan_package_reference(files)
- files
+ by_conan_package_reference(files)
end
def by_conan_file_type(files)
diff --git a/app/finders/packages/debian/distributions_finder.rb b/app/finders/packages/debian/distributions_finder.rb
index e64b6bdfec1..a5ac9f7e2f7 100644
--- a/app/finders/packages/debian/distributions_finder.rb
+++ b/app/finders/packages/debian/distributions_finder.rb
@@ -4,15 +4,15 @@ module Packages
module Debian
class DistributionsFinder
def initialize(container, params = {})
- @container, @params = container, params
+ @container = container
+ @params = params
end
def execute
collection = relation.with_container(container)
collection = by_codename(collection)
collection = by_suite(collection)
- collection = by_codename_or_suite(collection)
- collection
+ by_codename_or_suite(collection)
end
private
diff --git a/app/finders/packages/go/package_finder.rb b/app/finders/packages/go/package_finder.rb
new file mode 100644
index 00000000000..4573417d11f
--- /dev/null
+++ b/app/finders/packages/go/package_finder.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Packages
+ module Go
+ class PackageFinder
+ delegate :exists?, to: :candidates
+
+ def initialize(project, module_name, module_version)
+ @project = project
+ @module_name = module_name
+ @module_version = module_version
+ end
+
+ def execute
+ candidates.first
+ end
+
+ private
+
+ def candidates
+ @project
+ .packages
+ .golang
+ .with_name(@module_name)
+ .with_version(@module_version)
+ end
+ end
+ end
+end
diff --git a/app/finders/packages/go/version_finder.rb b/app/finders/packages/go/version_finder.rb
index 8e2fab8ba35..6ee02b8c6f6 100644
--- a/app/finders/packages/go/version_finder.rb
+++ b/app/finders/packages/go/version_finder.rb
@@ -23,7 +23,8 @@ module Packages
when String
if pseudo_version? target
semver = parse_semver(target)
- commit = pseudo_version_commit(@mod.project, semver)
+ version = parse_pseudo_version(semver)
+ commit = validate_pseudo_version(@mod.project, version)
Packages::Go::ModuleVersion.new(@mod, :pseudo, commit, name: target, semver: semver)
else
@mod.version_by(ref: target)
diff --git a/app/finders/packages/group_packages_finder.rb b/app/finders/packages/group_packages_finder.rb
index db5161d6e16..8771bf90e75 100644
--- a/app/finders/packages/group_packages_finder.rb
+++ b/app/finders/packages/group_packages_finder.rb
@@ -32,8 +32,7 @@ module Packages
packages = filter_with_version(packages)
packages = filter_by_package_type(packages)
packages = filter_by_package_name(packages)
- packages = filter_by_status(packages)
- packages
+ filter_by_status(packages)
end
def group_projects_visible_to_current_user
diff --git a/app/finders/packages/maven/package_finder.rb b/app/finders/packages/maven/package_finder.rb
index ba3d4631f55..eefcdaba3c8 100644
--- a/app/finders/packages/maven/package_finder.rb
+++ b/app/finders/packages/maven/package_finder.rb
@@ -3,13 +3,15 @@
module Packages
module Maven
class PackageFinder
- attr_reader :path, :current_user, :project, :group
+ include ::Packages::FinderHelper
+ include Gitlab::Utils::StrongMemoize
- def initialize(path, current_user, project: nil, group: nil)
+ def initialize(path, current_user, project: nil, group: nil, order_by_package_file: false)
@path = path
@current_user = current_user
@project = project
@group = group
+ @order_by_package_file = order_by_package_file
end
def execute
@@ -23,9 +25,9 @@ module Packages
private
def base
- if project
+ if @project
packages_for_a_single_project
- elsif group
+ elsif @group
packages_for_multiple_projects
else
::Packages::Package.none
@@ -33,8 +35,13 @@ module Packages
end
def packages_with_path
- matching_packages = base.only_maven_packages_with_path(path)
- matching_packages = matching_packages.order_by_package_file if versionless_package?(matching_packages)
+ matching_packages = base.only_maven_packages_with_path(@path, use_cte: @group.present?)
+
+ if group_level_improvements?
+ matching_packages = matching_packages.order_by_package_file if @order_by_package_file
+ else
+ matching_packages = matching_packages.order_by_package_file if versionless_package?(matching_packages)
+ end
matching_packages
end
@@ -48,19 +55,29 @@ module Packages
# Produces a query that retrieves packages from a single project.
def packages_for_a_single_project
- project.packages
+ @project.packages
end
# Produces a query that retrieves packages from multiple projects that
# the current user can view within a group.
def packages_for_multiple_projects
- ::Packages::Package.for_projects(projects_visible_to_current_user)
+ if group_level_improvements?
+ packages_visible_to_user(@current_user, within_group: @group)
+ else
+ ::Packages::Package.for_projects(projects_visible_to_current_user)
+ end
end
# Returns the projects that the current user can view within a group.
def projects_visible_to_current_user
- group.all_projects
- .public_or_visible_to_user(current_user)
+ @group.all_projects
+ .public_or_visible_to_user(@current_user)
+ end
+
+ def group_level_improvements?
+ strong_memoize(:group_level_improvements) do
+ Feature.enabled?(:maven_packages_group_level_improvements, default_enabled: :yaml)
+ end
end
end
end
diff --git a/app/finders/packages/package_file_finder.rb b/app/finders/packages/package_file_finder.rb
index d015f4adfa6..792ffa0591b 100644
--- a/app/finders/packages/package_file_finder.rb
+++ b/app/finders/packages/package_file_finder.rb
@@ -21,9 +21,7 @@ class Packages::PackageFileFinder
def package_files
files = package.package_files
- files = by_file_name(files)
-
- files
+ by_file_name(files)
end
def by_file_name(files)
diff --git a/app/finders/packages/packages_finder.rb b/app/finders/packages/packages_finder.rb
index bd9e62e3f2a..840cbbf7b9d 100644
--- a/app/finders/packages/packages_finder.rb
+++ b/app/finders/packages/packages_finder.rb
@@ -22,8 +22,7 @@ module Packages
packages = filter_by_package_type(packages)
packages = filter_by_package_name(packages)
packages = filter_by_status(packages)
- packages = order_packages(packages)
- packages
+ order_packages(packages)
end
private
diff --git a/app/finders/pending_todos_finder.rb b/app/finders/pending_todos_finder.rb
index c21d90c9182..d79a2340379 100644
--- a/app/finders/pending_todos_finder.rb
+++ b/app/finders/pending_todos_finder.rb
@@ -11,23 +11,22 @@
# change the various `by_*` methods in this finder, without having to touch
# everything that uses it.
class PendingTodosFinder
- attr_reader :current_user, :params
+ attr_reader :users, :params
- # current_user - The user to retrieve the todos for.
+ # users - The list of users to retrieve the todos for.
# params - A Hash containing columns and values to use for filtering todos.
- def initialize(current_user, params = {})
- @current_user = current_user
+ def initialize(users, params = {})
+ @users = users
@params = params
end
def execute
- todos = current_user.todos.pending
+ todos = Todo.for_user(users)
+ todos = todos.pending
todos = by_project(todos)
todos = by_target_id(todos)
todos = by_target_type(todos)
- todos = by_commit_id(todos)
-
- todos
+ by_commit_id(todos)
end
def by_project(todos)
diff --git a/app/finders/projects/export_job_finder.rb b/app/finders/projects/export_job_finder.rb
index c26a7a3f1a6..c270feb23dc 100644
--- a/app/finders/projects/export_job_finder.rb
+++ b/app/finders/projects/export_job_finder.rb
@@ -12,9 +12,7 @@ module Projects
def execute
export_jobs = project.export_jobs
- export_jobs = by_status(export_jobs)
-
- export_jobs
+ by_status(export_jobs)
end
private
diff --git a/app/finders/projects/prometheus/alerts_finder.rb b/app/finders/projects/prometheus/alerts_finder.rb
index 3e3b72647c5..2105516db5f 100644
--- a/app/finders/projects/prometheus/alerts_finder.rb
+++ b/app/finders/projects/prometheus/alerts_finder.rb
@@ -30,9 +30,7 @@ module Projects
relation = by_environment(relation)
relation = by_metric(relation)
relation = by_id(relation)
- relation = ordered(relation)
-
- relation
+ ordered(relation)
end
private
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index f1df4fbb0d8..893e89daa3c 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -83,8 +83,7 @@ class ProjectsFinder < UnionFinder
collection = by_deleted_status(collection)
collection = by_last_activity_after(collection)
collection = by_last_activity_before(collection)
- collection = by_repository_storage(collection)
- collection
+ by_repository_storage(collection)
end
def collection_with_user
@@ -131,7 +130,7 @@ class ProjectsFinder < UnionFinder
public_visibility_levels = Gitlab::VisibilityLevel.levels_for_user(current_user)
- !public_visibility_levels.include?(params[:visibility_level])
+ !public_visibility_levels.include?(params[:visibility_level].to_i)
end
def owned_projects?
diff --git a/app/finders/prometheus_metrics_finder.rb b/app/finders/prometheus_metrics_finder.rb
index 84a071abbd5..152ee73ef26 100644
--- a/app/finders/prometheus_metrics_finder.rb
+++ b/app/finders/prometheus_metrics_finder.rb
@@ -36,9 +36,7 @@ class PrometheusMetricsFinder
metrics = by_common(metrics)
metrics = by_ordered(metrics)
metrics = by_identifier(metrics)
- metrics = by_id(metrics)
-
- metrics
+ by_id(metrics)
end
private
diff --git a/app/finders/protected_branches_finder.rb b/app/finders/protected_branches_finder.rb
index 68e8d2a9f54..a452a1f993b 100644
--- a/app/finders/protected_branches_finder.rb
+++ b/app/finders/protected_branches_finder.rb
@@ -20,9 +20,7 @@ class ProtectedBranchesFinder
def execute
protected_branches = project.limited_protected_branches(LIMIT)
- protected_branches = by_name(protected_branches)
-
- protected_branches
+ by_name(protected_branches)
end
private
diff --git a/app/finders/releases_finder.rb b/app/finders/releases_finder.rb
index da72178169e..0cfa4310ab7 100644
--- a/app/finders/releases_finder.rb
+++ b/app/finders/releases_finder.rb
@@ -20,8 +20,7 @@ class ReleasesFinder
releases = get_releases
releases = by_tag(releases)
releases = releases.preloaded if preload
- releases = order_releases(releases)
- releases
+ order_releases(releases)
end
private
diff --git a/app/finders/repositories/branch_names_finder.rb b/app/finders/repositories/branch_names_finder.rb
new file mode 100644
index 00000000000..5bb67425aa5
--- /dev/null
+++ b/app/finders/repositories/branch_names_finder.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Repositories
+ class BranchNamesFinder
+ attr_reader :repository, :params
+
+ def initialize(repository, params = {})
+ @repository = repository
+ @params = params
+ end
+
+ def execute
+ return unless search
+
+ repository.search_branch_names(search)
+ end
+
+ private
+
+ def search
+ @params[:search].presence
+ end
+ end
+end
diff --git a/app/finders/repositories/changelog_tag_finder.rb b/app/finders/repositories/changelog_tag_finder.rb
new file mode 100644
index 00000000000..3c110e6c65d
--- /dev/null
+++ b/app/finders/repositories/changelog_tag_finder.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+module Repositories
+ # A finder class for getting the tag of the last release before a given
+ # version, used when generating changelogs.
+ #
+ # Imagine a project with the following tags:
+ #
+ # * v1.0.0
+ # * v1.1.0
+ # * v2.0.0
+ #
+ # If the version supplied is 2.1.0, the tag returned will be v2.0.0. And when
+ # the version is 1.1.1, or 1.2.0, the returned tag will be v1.1.0.
+ #
+ # To obtain the tags, this finder requires a regular expression (using the re2
+ # syntax) to be provided. This regex must produce the following named
+ # captures:
+ #
+ # - major (required)
+ # - minor (required)
+ # - patch (required)
+ # - pre
+ # - meta
+ #
+ # If the `pre` group has a value, the tag is ignored. If any of the required
+ # capture groups don't have a value, the tag is also ignored.
+ class ChangelogTagFinder
+ def initialize(project, regex: Gitlab::Changelog::Config::DEFAULT_TAG_REGEX)
+ @project = project
+ @regex = regex
+ end
+
+ def execute(new_version)
+ tags = {}
+ versions = [new_version]
+
+ begin
+ regex = Gitlab::UntrustedRegexp.new(@regex)
+ rescue RegexpError => ex
+ # The error messages produced by default are not very helpful, so we
+ # raise a better one here. We raise the specific error here so its
+ # message is displayed in the API (where we catch this specific
+ # error).
+ raise(
+ Gitlab::Changelog::Error,
+ "The regular expression to use for finding the previous tag for a version is invalid: #{ex.message}"
+ )
+ end
+
+ @project.repository.tags.each do |tag|
+ matches = regex.match(tag.name)
+
+ next unless matches
+
+ # When using this class for generating changelog data for a range of
+ # commits, we want to compare against the tag of the last _stable_
+ # release; not some random RC that came after that.
+ next if matches[:pre]
+
+ major = matches[:major]
+ minor = matches[:minor]
+ patch = matches[:patch]
+ build = matches[:meta]
+
+ next unless major && minor && patch
+
+ version = "#{major}.#{minor}.#{patch}"
+ version += "+#{build}" if build
+
+ tags[version] = tag
+ versions << version
+ end
+
+ VersionSorter.sort!(versions)
+
+ index = versions.index(new_version)
+
+ tags[versions[index - 1]] if index&.positive?
+ end
+ end
+end
diff --git a/app/finders/repositories/previous_tag_finder.rb b/app/finders/repositories/previous_tag_finder.rb
deleted file mode 100644
index b5e786c30e9..00000000000
--- a/app/finders/repositories/previous_tag_finder.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-# frozen_string_literal: true
-
-module Repositories
- # A finder class for getting the tag of the last release before a given
- # version.
- #
- # Imagine a project with the following tags:
- #
- # * v1.0.0
- # * v1.1.0
- # * v2.0.0
- #
- # If the version supplied is 2.1.0, the tag returned will be v2.0.0. And when
- # the version is 1.1.1, or 1.2.0, the returned tag will be v1.1.0.
- #
- # This finder expects that all tags to consider meet the following
- # requirements:
- #
- # * They start with the letter "v" followed by a version, or immediately start
- # with a version
- # * They use semantic versioning for the version format
- #
- # Tags not meeting these requirements are ignored.
- class PreviousTagFinder
- TAG_REGEX = /\Av?(?<version>#{Gitlab::Regex.unbounded_semver_regex})\z/.freeze
-
- def initialize(project)
- @project = project
- end
-
- def execute(new_version)
- tags = {}
- versions = [new_version]
-
- @project.repository.tags.each do |tag|
- matches = tag.name.match(TAG_REGEX)
-
- next unless matches
-
- # When using this class for generating changelog data for a range of
- # commits, we want to compare against the tag of the last _stable_
- # release; not some random RC that came after that.
- next if matches[:prerelease]
-
- version = matches[:version]
- tags[version] = tag
- versions << version
- end
-
- VersionSorter.sort!(versions)
-
- index = versions.index(new_version)
-
- tags[versions[index - 1]] if index&.positive?
- end
- end
-end
diff --git a/app/finders/tags_finder.rb b/app/finders/tags_finder.rb
index fd58f478b45..d9848d027cf 100644
--- a/app/finders/tags_finder.rb
+++ b/app/finders/tags_finder.rb
@@ -7,7 +7,6 @@ class TagsFinder < GitRefsFinder
def execute
tags = repository.tags_sorted_by(sort)
- tags = by_search(tags)
- tags
+ by_search(tags)
end
end
diff --git a/app/finders/user_group_notification_settings_finder.rb b/app/finders/user_group_notification_settings_finder.rb
index a6f6769116f..4ad9d1d7bf4 100644
--- a/app/finders/user_group_notification_settings_finder.rb
+++ b/app/finders/user_group_notification_settings_finder.rb
@@ -14,6 +14,8 @@ class UserGroupNotificationSettingsFinder
@loaded_groups_with_ancestors = groups_with_ancestors.index_by(&:id)
@loaded_notification_settings = user.notification_settings_for_groups(groups_with_ancestors).preload_source_route.index_by(&:source_id)
+ preload_emails_disabled
+
groups.map do |group|
find_notification_setting_for(group)
end
@@ -45,4 +47,19 @@ class UserGroupNotificationSettingsFinder
parent_setting.level != NotificationSetting.levels[:global] || parent_setting.notification_email.present?
end
+
+ # This method preloads the `emails_disabled` strong memoized method for the given groups.
+ #
+ # For each group, look up the ancestor hierarchy and look for any group where emails_disabled is true.
+ # The lookup is implemented with an EXISTS subquery, so we can look up the ancestor chain for each group individually.
+ # The query will return groups where at least one ancestor has the `emails_disabled` set to true.
+ #
+ # After the query, we set the instance variable.
+ def preload_emails_disabled
+ group_ids_with_disabled_email = Group.ids_with_disabled_email(groups.to_a)
+
+ groups.each do |group|
+ group.emails_disabled_memoized = group_ids_with_disabled_email.include?(group.id) if group.parent_id
+ end
+ end
end
diff --git a/app/finders/users_star_projects_finder.rb b/app/finders/users_star_projects_finder.rb
index 49c4e087b4b..7a7587c8631 100644
--- a/app/finders/users_star_projects_finder.rb
+++ b/app/finders/users_star_projects_finder.rb
@@ -15,9 +15,7 @@ class UsersStarProjectsFinder
stars = UsersStarProject.all
stars = by_project(stars)
stars = by_search(stars)
- stars = filter_visible_profiles(stars)
-
- stars
+ filter_visible_profiles(stars)
end
private
diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb
index 7ab5dc36e4a..8369d0e120f 100644
--- a/app/graphql/gitlab_schema.rb
+++ b/app/graphql/gitlab_schema.rb
@@ -12,7 +12,6 @@ class GitlabSchema < GraphQL::Schema
use GraphQL::Pagination::Connections
use BatchLoader::GraphQL
- use Gitlab::Graphql::Authorize
use Gitlab::Graphql::Pagination::Connections
use Gitlab::Graphql::GenericTracing
use Gitlab::Graphql::Timeout, max_seconds: Gitlab.config.gitlab.graphql_timeout
@@ -32,9 +31,10 @@ class GitlabSchema < GraphQL::Schema
class << self
def multiplex(queries, **kwargs)
- kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context])
+ kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context]) unless kwargs.key?(:max_complexity)
queries.each do |query|
+ query[:max_complexity] ||= max_query_complexity(kwargs[:context]) unless query.key?(:max_complexity)
query[:max_depth] = max_query_depth(kwargs[:context])
end
@@ -111,6 +111,7 @@ class GitlabSchema < GraphQL::Schema
#
# Options:
# * :expected_type [Class] - the type of object this GlobalID should refer to.
+ # * :expected_type [[Class]] - array of the types of object this GlobalID should refer to.
#
# e.g.
#
@@ -120,14 +121,14 @@ class GitlabSchema < GraphQL::Schema
# gid.model_class == ::Project
# ```
def parse_gid(global_id, ctx = {})
- expected_type = ctx[:expected_type]
+ expected_types = Array(ctx[:expected_type])
gid = GlobalID.parse(global_id)
raise Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab ID." unless gid
- if expected_type && !gid.model_class.ancestors.include?(expected_type)
- vars = { global_id: global_id, expected_type: expected_type }
- msg = _('%{global_id} is not a valid ID for %{expected_type}.') % vars
+ if expected_types.any? && expected_types.none? { |type| gid.model_class.ancestors.include?(type) }
+ vars = { global_id: global_id, expected_types: expected_types.join(', ') }
+ msg = _('%{global_id} is not a valid ID for %{expected_types}.') % vars
raise Gitlab::Graphql::Errors::ArgumentError, msg
end
diff --git a/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb b/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb
index 32ca6de9b96..ea1502d4b62 100644
--- a/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb
+++ b/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb
@@ -8,7 +8,7 @@ module Mutations
ADMIN_MESSAGE = 'You must be an admin to use this mutation'
- Labkit::Context::KNOWN_KEYS.each do |key|
+ Gitlab::ApplicationContext::KNOWN_KEYS.each do |key|
argument key,
GraphQL::STRING_TYPE,
required: false,
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/create.rb b/app/graphql/mutations/alert_management/prometheus_integration/create.rb
index 87e6bc46937..c6dc85dc07c 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/create.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/create.rb
@@ -18,7 +18,7 @@ module Mutations
argument :api_url, GraphQL::STRING_TYPE,
required: true,
- description: 'Endpoint at which prometheus can be queried.'
+ description: 'Endpoint at which Prometheus can be queried.'
def resolve(args)
project = authorized_find!(args[:project_path])
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/update.rb b/app/graphql/mutations/alert_management/prometheus_integration/update.rb
index 62fb81bca5a..7594766176f 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/update.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/update.rb
@@ -16,7 +16,7 @@ module Mutations
argument :api_url, GraphQL::STRING_TYPE,
required: false,
- description: "Endpoint at which prometheus can be queried."
+ description: "Endpoint at which Prometheus can be queried."
def resolve(args)
integration = authorized_find!(id: args[:id])
diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb
index ac5ddc5bd4c..1f18a37fcb9 100644
--- a/app/graphql/mutations/base_mutation.rb
+++ b/app/graphql/mutations/base_mutation.rb
@@ -2,13 +2,14 @@
module Mutations
class BaseMutation < GraphQL::Schema::RelayClassicMutation
- prepend Gitlab::Graphql::Authorize::AuthorizeResource
+ include Gitlab::Graphql::Authorize::AuthorizeResource
prepend Gitlab::Graphql::CopyFieldDescription
prepend ::Gitlab::Graphql::GlobalIDCompatibility
ERROR_MESSAGE = 'You cannot perform write operations on a read-only instance'
field_class ::Types::BaseField
+ argument_class ::Types::BaseArgument
field :errors, [GraphQL::STRING_TYPE],
null: false,
@@ -28,11 +29,29 @@ module Mutations
end
def ready?(**args)
- if Gitlab::Database.read_only?
- raise Gitlab::Graphql::Errors::ResourceNotAvailable, ERROR_MESSAGE
- else
- true
+ raise_resource_not_available_error! ERROR_MESSAGE if Gitlab::Database.read_only?
+
+ true
+ end
+
+ def load_application_object(argument, lookup_as_type, id, context)
+ ::Gitlab::Graphql::Lazy.new { super }.catch(::GraphQL::UnauthorizedError) do |e|
+ Gitlab::ErrorTracking.track_exception(e)
+ # The default behaviour is to abort processing and return nil for the
+ # entire mutation field, but not set any top-level errors. We prefer to
+ # at least say that something went wrong.
+ raise_resource_not_available_error!
end
end
+
+ def self.authorized?(object, context)
+ # we never provide an object to mutations, but we do need to have a user.
+ context[:current_user].present? && !context[:current_user].blocked?
+ end
+
+ # See: AuthorizeResource#authorized_resource?
+ def self.authorization
+ @authorization ||= ::Gitlab::Graphql::Authorize::ObjectAuthorization.new(authorize)
+ end
end
end
diff --git a/app/graphql/mutations/boards/issues/issue_move_list.rb b/app/graphql/mutations/boards/issues/issue_move_list.rb
index 096ac89db1c..f32205643da 100644
--- a/app/graphql/mutations/boards/issues/issue_move_list.rb
+++ b/app/graphql/mutations/boards/issues/issue_move_list.rb
@@ -52,13 +52,10 @@ module Mutations
super
end
- def resolve(board:, **args)
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/-/issues/247861')
+ def resolve(board:, project_path:, iid:, **args)
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/247861')
- raise_resource_not_available_error! unless board
- authorize_board!(board)
-
- issue = authorized_find!(project_path: args[:project_path], iid: args[:iid])
+ issue = authorized_find!(project_path: project_path, iid: iid)
move_params = { id: issue.id, board_id: board.id }.merge(move_arguments(args))
move_issue(board, issue, move_params)
@@ -84,12 +81,6 @@ module Mutations
def move_arguments(args)
args.slice(:from_list_id, :to_list_id, :move_after_id, :move_before_id)
end
-
- def authorize_board!(board)
- return if Ability.allowed?(current_user, :read_issue_board, board.resource_parent)
-
- raise_resource_not_available_error!
- end
end
end
end
diff --git a/app/graphql/mutations/ci/ci_cd_settings_update.rb b/app/graphql/mutations/ci/ci_cd_settings_update.rb
index 6b7750ee860..d7451babaea 100644
--- a/app/graphql/mutations/ci/ci_cd_settings_update.rb
+++ b/app/graphql/mutations/ci/ci_cd_settings_update.rb
@@ -17,13 +17,23 @@ module Mutations
required: false,
description: 'Indicates if the latest artifact should be kept for this project.'
+ field :ci_cd_settings,
+ Types::Ci::CiCdSettingType,
+ null: false,
+ description: 'The CI/CD settings after mutation.'
+
def resolve(full_path:, **args)
project = authorized_find!(full_path)
settings = project.ci_cd_settings
settings.update(args)
- { errors: errors_on_object(settings) }
+ {
+ ci_cd_settings: settings,
+ errors: errors_on_object(settings)
+ }
end
end
end
end
+
+Mutations::Ci::CiCdSettingsUpdate.prepend_if_ee('::EE::Mutations::Ci::CiCdSettingsUpdate')
diff --git a/app/graphql/mutations/concerns/mutations/assignable.rb b/app/graphql/mutations/concerns/mutations/assignable.rb
index f6f4b744f4e..d3ab0a1779a 100644
--- a/app/graphql/mutations/concerns/mutations/assignable.rb
+++ b/app/graphql/mutations/concerns/mutations/assignable.rb
@@ -13,19 +13,15 @@ module Mutations
argument :operation_mode,
Types::MutationOperationModeEnum,
required: false,
+ default_value: Types::MutationOperationModeEnum.default_mode,
description: 'The operation to perform. Defaults to REPLACE.'
end
- def resolve(project_path:, iid:, assignee_usernames:, operation_mode: Types::MutationOperationModeEnum.enum[:replace])
+ def resolve(project_path:, iid:, assignee_usernames:, operation_mode:)
resource = authorized_find!(project_path: project_path, iid: iid)
+ users = new_assignees(resource, assignee_usernames)
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/issues/36098') if resource.is_a?(MergeRequest)
-
- update_service_class.new(
- resource.project,
- current_user,
- assignee_ids: assignee_ids(resource, assignee_usernames, operation_mode)
- ).execute(resource)
+ assign!(resource, users, operation_mode)
{
resource.class.name.underscore.to_sym => resource,
@@ -35,18 +31,32 @@ module Mutations
private
- def assignee_ids(resource, usernames, operation_mode)
- assignee_ids = []
- assignee_ids += resource.assignees.map(&:id) if Types::MutationOperationModeEnum.enum.values_at(:remove, :append).include?(operation_mode)
- user_ids = UsersFinder.new(current_user, username: usernames).execute.map(&:id)
+ def assign!(resource, users, operation_mode)
+ update_service_class.new(
+ resource.project,
+ current_user,
+ assignee_ids: assignee_ids(resource, users, operation_mode)
+ ).execute(resource)
+ end
- if operation_mode == Types::MutationOperationModeEnum.enum[:remove]
- assignee_ids -= user_ids
- else
- assignee_ids |= user_ids
- end
+ def new_assignees(resource, usernames)
+ UsersFinder.new(current_user, username: usernames).execute.to_a
+ end
+
+ def assignee_ids(resource, users, mode)
+ transform_list(mode, resource, users.map(&:id))
+ end
+
+ def current_assignee_ids(resource)
+ resource.assignees.map(&:id)
+ end
- assignee_ids
+ def transform_list(mode, resource, new_values)
+ case mode
+ when 'REPLACE' then new_values
+ when 'APPEND' then current_assignee_ids(resource) | new_values
+ when 'REMOVE' then current_assignee_ids(resource) - new_values
+ end
end
end
end
diff --git a/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb b/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb
index ba644eff36c..3c5f077110c 100644
--- a/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb
+++ b/app/graphql/mutations/concerns/mutations/can_mutate_spammable.rb
@@ -1,64 +1,51 @@
# frozen_string_literal: true
module Mutations
- # This concern can be mixed into a mutation to provide support for spam checking,
- # and optionally support the workflow to allow clients to display and solve CAPTCHAs.
+ # This concern is deprecated and will be deleted in 14.6
+ #
+ # Use the SpamProtection concern instead.
module CanMutateSpammable
extend ActiveSupport::Concern
- include Spam::Concerns::HasSpamActionResponseFields
- # NOTE: The arguments and fields are intentionally named with 'captcha' instead of 'recaptcha',
- # so that they can be applied to future alternative CAPTCHA implementations other than
- # reCAPTCHA (e.g. FriendlyCaptcha) without having to change the names and descriptions in the API.
+ DEPRECATION_NOTICE = {
+ reason: 'Use spam protection with HTTP headers instead',
+ milestone: '13.11'
+ }.freeze
+
included do
argument :captcha_response, GraphQL::STRING_TYPE,
required: false,
+ deprecated: DEPRECATION_NOTICE,
description: 'A valid CAPTCHA response value obtained by using the provided captchaSiteKey with a CAPTCHA API to present a challenge to be solved on the client. Required to resubmit if the previous operation returned "NeedsCaptchaResponse: true".'
argument :spam_log_id, GraphQL::INT_TYPE,
required: false,
+ deprecated: DEPRECATION_NOTICE,
description: 'The spam log ID which must be passed along with a valid CAPTCHA response for the operation to be completed. Required to resubmit if the previous operation returned "NeedsCaptchaResponse: true".'
field :spam,
GraphQL::BOOLEAN_TYPE,
null: true,
+ deprecated: DEPRECATION_NOTICE,
description: 'Indicates whether the operation was detected as definite spam. There is no option to resubmit the request with a CAPTCHA response.'
field :needs_captcha_response,
GraphQL::BOOLEAN_TYPE,
null: true,
+ deprecated: DEPRECATION_NOTICE,
description: 'Indicates whether the operation was detected as possible spam and not completed. If CAPTCHA is enabled, the request must be resubmitted with a valid CAPTCHA response and spam_log_id included for the operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true.'
field :spam_log_id,
GraphQL::INT_TYPE,
null: true,
+ deprecated: DEPRECATION_NOTICE,
description: 'The spam log ID which must be passed along with a valid CAPTCHA response for an operation to be completed. Included only when an operation was not completed because "NeedsCaptchaResponse" is true.'
field :captcha_site_key,
GraphQL::STRING_TYPE,
null: true,
+ deprecated: DEPRECATION_NOTICE,
description: 'The CAPTCHA site key which must be used to render a challenge for the user to solve to obtain a valid captchaResponse value. Included only when an operation was not completed because "NeedsCaptchaResponse" is true.'
end
-
- private
-
- # additional_spam_params -> hash
- #
- # Used from a spammable mutation's #resolve method to generate
- # the required additional spam/recaptcha params which must be merged into the params
- # passed to the constructor of a service, where they can then be used in the service
- # to perform spam checking via SpamActionService.
- #
- # Also accesses the #context of the mutation's Resolver superclass to obtain the request.
- #
- # Example:
- #
- # existing_args.merge!(additional_spam_params)
- def additional_spam_params
- {
- api: true,
- request: context[:request]
- }
- end
end
end
diff --git a/app/graphql/mutations/concerns/mutations/spam_protection.rb b/app/graphql/mutations/concerns/mutations/spam_protection.rb
new file mode 100644
index 00000000000..d765da23a4b
--- /dev/null
+++ b/app/graphql/mutations/concerns/mutations/spam_protection.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Mutations
+ # This concern can be mixed into a mutation to provide support for spam checking,
+ # and optionally support the workflow to allow clients to display and solve CAPTCHAs.
+ module SpamProtection
+ extend ActiveSupport::Concern
+ include Spam::Concerns::HasSpamActionResponseFields
+
+ SpamActionError = Class.new(GraphQL::ExecutionError)
+ NeedsCaptchaResponseError = Class.new(SpamActionError)
+ SpamDisallowedError = Class.new(SpamActionError)
+
+ NEEDS_CAPTCHA_RESPONSE_MESSAGE = "Request denied. Solve CAPTCHA challenge and retry"
+ SPAM_DISALLOWED_MESSAGE = "Request denied. Spam detected"
+
+ private
+
+ # additional_spam_params -> hash
+ #
+ # Used from a spammable mutation's #resolve method to generate
+ # the required additional spam/CAPTCHA params which must be merged into the params
+ # passed to the constructor of a service, where they can then be used in the service
+ # to perform spam checking via SpamActionService.
+ #
+ # Also accesses the #context of the mutation's Resolver superclass to obtain the request.
+ #
+ # Example:
+ #
+ # existing_args.merge!(additional_spam_params)
+ def additional_spam_params
+ {
+ api: true,
+ request: context[:request]
+ }
+ end
+
+ def spam_action_response(object)
+ fields = spam_action_response_fields(object)
+
+ # If the SpamActionService detected something as spam,
+ # this is non-recoverable and the needs_captcha_response
+ # should not be considered
+ kind = if fields[:spam]
+ :spam
+ elsif fields[:needs_captcha_response]
+ :needs_captcha_response
+ end
+
+ [kind, fields]
+ end
+
+ def check_spam_action_response!(object)
+ kind, fields = spam_action_response(object)
+
+ case kind
+ when :needs_captcha_response
+ fields.delete :spam
+ raise NeedsCaptchaResponseError.new(NEEDS_CAPTCHA_RESPONSE_MESSAGE, extensions: fields)
+ when :spam
+ raise SpamDisallowedError.new(SPAM_DISALLOWED_MESSAGE, extensions: { spam: true })
+ else
+ nil
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/container_repositories/destroy_tags.rb b/app/graphql/mutations/container_repositories/destroy_tags.rb
index 636ceccee04..12d65f604b8 100644
--- a/app/graphql/mutations/container_repositories/destroy_tags.rb
+++ b/app/graphql/mutations/container_repositories/destroy_tags.rb
@@ -3,7 +3,7 @@
module Mutations
module ContainerRepositories
class DestroyTags < ::Mutations::ContainerRepositories::DestroyBase
- LIMIT = 20.freeze
+ LIMIT = 20
TOO_MANY_TAGS_ERROR_MESSAGE = "Number of tags is greater than #{LIMIT}"
diff --git a/app/graphql/mutations/issues/move.rb b/app/graphql/mutations/issues/move.rb
index 3f97325c921..0f2af99bf61 100644
--- a/app/graphql/mutations/issues/move.rb
+++ b/app/graphql/mutations/issues/move.rb
@@ -11,7 +11,7 @@ module Mutations
description: 'The project to move the issue to.'
def resolve(project_path:, iid:, target_project_path:)
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/-/issues/267762')
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/20816')
issue = authorized_find!(project_path: project_path, iid: iid)
source_project = issue.project
diff --git a/app/graphql/mutations/issues/set_assignees.rb b/app/graphql/mutations/issues/set_assignees.rb
index a4d1c755b53..8413c89b010 100644
--- a/app/graphql/mutations/issues/set_assignees.rb
+++ b/app/graphql/mutations/issues/set_assignees.rb
@@ -7,6 +7,19 @@ module Mutations
include Assignable
+ def assign!(issue, users, mode)
+ permitted, forbidden = users.partition { |u| u.can?(:read_issue, issue) }
+
+ super(issue, permitted, mode)
+
+ forbidden.each do |user|
+ issue.errors.add(
+ :assignees,
+ "Cannot assign #{user.to_reference} to #{issue.to_reference}"
+ )
+ end
+ end
+
def update_service_class
::Issues::UpdateService
end
diff --git a/app/graphql/mutations/merge_requests/accept.rb b/app/graphql/mutations/merge_requests/accept.rb
index 540be7098ac..da94dcd8890 100644
--- a/app/graphql/mutations/merge_requests/accept.rb
+++ b/app/graphql/mutations/merge_requests/accept.rb
@@ -42,7 +42,8 @@ module Mutations
description: 'Squash commits on the source branch before merge.'
def resolve(project_path:, iid:, **args)
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42317')
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/4796')
+
merge_request = authorized_find!(project_path: project_path, iid: iid)
project = merge_request.target_project
merge_params = args.compact.with_indifferent_access
diff --git a/app/graphql/mutations/merge_requests/set_assignees.rb b/app/graphql/mutations/merge_requests/set_assignees.rb
index 548c6b55a85..dc96523685e 100644
--- a/app/graphql/mutations/merge_requests/set_assignees.rb
+++ b/app/graphql/mutations/merge_requests/set_assignees.rb
@@ -8,7 +8,7 @@ module Mutations
include Assignable
def update_service_class
- ::MergeRequests::UpdateService
+ ::MergeRequests::UpdateAssigneesService
end
end
end
diff --git a/app/graphql/mutations/release_asset_links/delete.rb b/app/graphql/mutations/release_asset_links/delete.rb
new file mode 100644
index 00000000000..dd450f36cdd
--- /dev/null
+++ b/app/graphql/mutations/release_asset_links/delete.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Mutations
+ module ReleaseAssetLinks
+ class Delete < BaseMutation
+ graphql_name 'ReleaseAssetLinkDelete'
+
+ authorize :destroy_release
+
+ ReleaseAssetLinkID = ::Types::GlobalIDType[::Releases::Link]
+
+ argument :id, ReleaseAssetLinkID,
+ required: true,
+ description: 'ID of the release asset link to delete.'
+
+ field :link,
+ Types::ReleaseAssetLinkType,
+ null: true,
+ description: 'The deleted release asset link.'
+
+ def resolve(id:)
+ link = authorized_find!(id)
+
+ unless link.destroy
+ return { link: nil, errors: link.errors.full_messages }
+ end
+
+ { link: link, errors: [] }
+ end
+
+ def find_object(id)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ReleaseAssetLinkID.coerce_isolated_input(id)
+
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index 7f2dd448b8b..e9b45294659 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -5,6 +5,7 @@ module Mutations
class Create < BaseMutation
include ServiceCompatibility
include CanMutateSpammable
+ include Mutations::SpamProtection
authorize :create_snippet
@@ -56,12 +57,12 @@ module Mutations
end
snippet = service_response.payload[:snippet]
- with_spam_action_response_fields(snippet) do
- {
- snippet: service_response.success? ? snippet : nil,
- errors: errors_on_object(snippet)
- }
- end
+ check_spam_action_response!(snippet)
+
+ {
+ snippet: service_response.success? ? snippet : nil,
+ errors: errors_on_object(snippet)
+ }
end
private
diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb
index 9f9f8bca848..b9b9b13eebb 100644
--- a/app/graphql/mutations/snippets/update.rb
+++ b/app/graphql/mutations/snippets/update.rb
@@ -5,6 +5,7 @@ module Mutations
class Update < Base
include ServiceCompatibility
include CanMutateSpammable
+ include Mutations::SpamProtection
graphql_name 'UpdateSnippet'
@@ -45,12 +46,12 @@ module Mutations
end
snippet = service_response.payload[:snippet]
- with_spam_action_response_fields(snippet) do
- {
- snippet: service_response.success? ? snippet : snippet.reset,
- errors: errors_on_object(snippet)
- }
- end
+ check_spam_action_response!(snippet)
+
+ {
+ snippet: service_response.success? ? snippet : snippet.reset,
+ errors: errors_on_object(snippet)
+ }
end
private
diff --git a/app/graphql/queries/pipelines/get_pipeline_details.query.graphql b/app/graphql/queries/pipelines/get_pipeline_details.query.graphql
index 92323923266..959bf7dc91d 100644
--- a/app/graphql/queries/pipelines/get_pipeline_details.query.graphql
+++ b/app/graphql/queries/pipelines/get_pipeline_details.query.graphql
@@ -27,6 +27,7 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) {
__typename
id
iid
+ usesNeeds
downstream {
__typename
nodes {
diff --git a/app/graphql/resolvers/alert_management/http_integrations_resolver.rb b/app/graphql/resolvers/alert_management/http_integrations_resolver.rb
index 94a72bca7c7..abc54614a59 100644
--- a/app/graphql/resolvers/alert_management/http_integrations_resolver.rb
+++ b/app/graphql/resolvers/alert_management/http_integrations_resolver.rb
@@ -3,19 +3,39 @@
module Resolvers
module AlertManagement
class HttpIntegrationsResolver < BaseResolver
- alias_method :project, :synchronized_object
+ include ::Gitlab::Graphql::Laziness
+
+ alias_method :project, :object
+
+ argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration],
+ required: false,
+ description: 'ID of the integration.'
type Types::AlertManagement::HttpIntegrationType.connection_type, null: true
- def resolve(**args)
- http_integrations
+ def resolve(id: nil)
+ return [] unless Ability.allowed?(current_user, :admin_operations, project)
+
+ if id
+ integrations_by(gid: id)
+ else
+ http_integrations
+ end
end
private
- def http_integrations
- return [] unless Ability.allowed?(current_user, :admin_operations, project)
+ def integrations_by(gid:)
+ id = Types::GlobalIDType[::AlertManagement::HttpIntegration].coerce_isolated_input(gid)
+ object = GitlabSchema.find_by_gid(id)
+
+ defer { object }.then do |integration|
+ ret = integration if project == integration&.project
+ Array.wrap(ret)
+ end
+ end
+ def http_integrations
::AlertManagement::HttpIntegrationsFinder.new(project, {}).execute
end
end
diff --git a/app/graphql/resolvers/alert_management/integrations_resolver.rb b/app/graphql/resolvers/alert_management/integrations_resolver.rb
index 4d1fe367277..cb7e73c2d1a 100644
--- a/app/graphql/resolvers/alert_management/integrations_resolver.rb
+++ b/app/graphql/resolvers/alert_management/integrations_resolver.rb
@@ -3,27 +3,60 @@
module Resolvers
module AlertManagement
class IntegrationsResolver < BaseResolver
- alias_method :project, :synchronized_object
+ include ::Gitlab::Graphql::Laziness
+
+ alias_method :project, :object
+
+ argument :id, ::Types::GlobalIDType,
+ required: false,
+ description: 'ID of the integration.'
type Types::AlertManagement::IntegrationType.connection_type, null: true
- def resolve(**args)
- http_integrations + prometheus_integrations
+ def resolve(id: nil)
+ if id
+ integrations_by(gid: id)
+ else
+ http_integrations + prometheus_integrations
+ end
end
private
+ def integrations_by(gid:)
+ object = GitlabSchema.object_from_id(gid, expected_type: expected_integration_types)
+ defer { object }.then do |integration|
+ ret = integration if project == integration&.project
+ Array.wrap(ret)
+ end
+ end
+
def prometheus_integrations
- return [] unless Ability.allowed?(current_user, :admin_project, project)
+ return [] unless prometheus_integrations_allowed?
Array(project.prometheus_service)
end
def http_integrations
- return [] unless Ability.allowed?(current_user, :admin_operations, project)
+ return [] unless http_integrations_allowed?
::AlertManagement::HttpIntegrationsFinder.new(project, {}).execute
end
+
+ def prometheus_integrations_allowed?
+ Ability.allowed?(current_user, :admin_project, project)
+ end
+
+ def http_integrations_allowed?
+ Ability.allowed?(current_user, :admin_operations, project)
+ end
+
+ def expected_integration_types
+ [].tap do |types|
+ types << ::AlertManagement::HttpIntegration if http_integrations_allowed?
+ types << ::PrometheusService if prometheus_integrations_allowed?
+ end
+ end
end
end
end
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index 67bba079512..48563633d11 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -39,9 +39,7 @@ module Resolvers
as_single << block
# Have we been called after defining the single version of this resolver?
- if @single.present?
- @single.instance_exec(&block)
- end
+ @single.instance_exec(&block) if @single.present?
end
def self.as_single
@@ -90,7 +88,7 @@ module Resolvers
def self.last
parent = self
- @last ||= Class.new(self.single) do
+ @last ||= Class.new(single) do
type parent.singular_type, null: true
def select_result(results)
@@ -138,16 +136,6 @@ 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
- ::Gitlab::Graphql::Lazy.force(object)
- end
- end
-
def single?
false
end
@@ -160,5 +148,13 @@ module Resolvers
def select_result(results)
results
end
+
+ def self.authorization
+ @authorization ||= ::Gitlab::Graphql::Authorize::ObjectAuthorization.new(try(:required_permissions))
+ end
+
+ def self.authorized?(object, context)
+ authorization.ok?(object, context[:current_user])
+ end
end
end
diff --git a/app/graphql/resolvers/blobs_resolver.rb b/app/graphql/resolvers/blobs_resolver.rb
new file mode 100644
index 00000000000..d006769bd4b
--- /dev/null
+++ b/app/graphql/resolvers/blobs_resolver.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class BlobsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type Types::Tree::BlobType.connection_type, null: true
+ authorize :download_code
+ calls_gitaly!
+
+ alias_method :repository, :object
+
+ argument :paths, [GraphQL::STRING_TYPE],
+ required: true,
+ description: 'Array of desired blob paths.'
+ argument :ref, GraphQL::STRING_TYPE,
+ required: false,
+ default_value: nil,
+ description: 'The commit ref to get the blobs from. Default value is HEAD.'
+
+ # We fetch blobs from Gitaly efficiently but it still scales O(N) with the
+ # number of paths being fetched, so apply a scaling limit to that.
+ def self.resolver_complexity(args, child_complexity:)
+ super + (args[:paths] || []).size
+ end
+
+ def resolve(paths:, ref:)
+ authorize!(repository.container)
+
+ return [] if repository.empty?
+
+ ref ||= repository.root_ref
+
+ repository.blobs_at(paths.map { |path| [ref, path] })
+ end
+ end
+end
diff --git a/app/graphql/resolvers/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb
index e66f7b97b40..0b699006626 100644
--- a/app/graphql/resolvers/board_lists_resolver.rb
+++ b/app/graphql/resolvers/board_lists_resolver.rb
@@ -3,13 +3,12 @@
module Resolvers
class BoardListsResolver < BaseResolver
include BoardIssueFilterable
- prepend ManualAuthorization
include Gitlab::Graphql::Authorize::AuthorizeResource
+ include LooksAhead
type Types::BoardListType, null: true
- extras [:lookahead]
-
authorize :read_issue_board_list
+ authorizes_object!
argument :id, Types::GlobalIDType[List],
required: false,
@@ -21,15 +20,11 @@ module Resolvers
alias_method :board, :object
- def resolve(lookahead: nil, id: nil, issue_filters: {})
- authorize!(board)
-
+ def resolve_with_lookahead(id: nil, issue_filters: {})
lists = board_lists(id)
context.scoped_set!(:issue_filters, issue_filters(issue_filters))
- if load_preferences?(lookahead)
- List.preload_preferences_for_user(lists, current_user)
- end
+ List.preload_preferences_for_user(lists, current_user) if load_preferences?
offset_pagination(lists)
end
@@ -46,9 +41,8 @@ module Resolvers
service.execute(board, create_default_lists: false)
end
- def load_preferences?(lookahead)
- lookahead&.selection(:edges)&.selection(:node)&.selects?(:collapsed) ||
- lookahead&.selection(:nodes)&.selects?(:collapsed)
+ def load_preferences?
+ node_selection&.selects?(:collapsed)
end
def extract_list_id(gid)
diff --git a/app/graphql/resolvers/board_resolver.rb b/app/graphql/resolvers/board_resolver.rb
index 637d690e4cd..85362ab1422 100644
--- a/app/graphql/resolvers/board_resolver.rb
+++ b/app/graphql/resolvers/board_resolver.rb
@@ -2,7 +2,7 @@
module Resolvers
class BoardResolver < BaseResolver.single
- alias_method :parent, :synchronized_object
+ alias_method :parent, :object
type Types::BoardType, null: true
diff --git a/app/graphql/resolvers/ci/config_resolver.rb b/app/graphql/resolvers/ci/config_resolver.rb
index f8670649e48..252c9d3acf0 100644
--- a/app/graphql/resolvers/ci/config_resolver.rb
+++ b/app/graphql/resolvers/ci/config_resolver.rb
@@ -7,6 +7,10 @@ module Resolvers
include ResolvesProject
type Types::Ci::Config::ConfigType, null: true
+ description <<~MD
+ Linted and processed contents of a CI config.
+ Should not be requested more than once per request.
+ MD
authorize :read_pipeline
@@ -55,7 +59,7 @@ module Resolvers
name: job[:name],
stage: job[:stage],
group_name: CommitStatus.new(name: job[:name]).group_name,
- needs: job.dig(:needs) || [],
+ needs: job[:needs] || [],
allow_failure: job[:allow_failure],
before_script: job[:before_script],
script: job[:script],
diff --git a/app/graphql/resolvers/ci/jobs_resolver.rb b/app/graphql/resolvers/ci/jobs_resolver.rb
index dd565094017..5ae9e721cc8 100644
--- a/app/graphql/resolvers/ci/jobs_resolver.rb
+++ b/app/graphql/resolvers/ci/jobs_resolver.rb
@@ -11,7 +11,18 @@ module Resolvers
required: false,
description: 'Filter jobs by the type of security report they produce.'
- def resolve(security_report_types: [])
+ argument :statuses, [::Types::Ci::JobStatusEnum],
+ required: false,
+ description: 'Filter jobs by status.'
+
+ def resolve(statuses: nil, security_report_types: [])
+ jobs = init_collection(security_report_types)
+ jobs = jobs.with_status(statuses) if statuses.present?
+
+ jobs
+ end
+
+ def init_collection(security_report_types)
if security_report_types.present?
::Security::SecurityJobsFinder.new(
pipeline: pipeline,
diff --git a/app/graphql/resolvers/ci/pipeline_stages_resolver.rb b/app/graphql/resolvers/ci/pipeline_stages_resolver.rb
index 98170e0cd2e..a458e873935 100644
--- a/app/graphql/resolvers/ci/pipeline_stages_resolver.rb
+++ b/app/graphql/resolvers/ci/pipeline_stages_resolver.rb
@@ -16,7 +16,7 @@ module Resolvers
def preloads
{
- statuses: [:needs]
+ jobs: { latest_statuses: [:needs] }
}
end
end
diff --git a/app/graphql/resolvers/ci/runner_platforms_resolver.rb b/app/graphql/resolvers/ci/runner_platforms_resolver.rb
index 9677c5139b4..f120e94b67b 100644
--- a/app/graphql/resolvers/ci/runner_platforms_resolver.rb
+++ b/app/graphql/resolvers/ci/runner_platforms_resolver.rb
@@ -3,7 +3,8 @@
module Resolvers
module Ci
class RunnerPlatformsResolver < BaseResolver
- type Types::Ci::RunnerPlatformType, null: false
+ type Types::Ci::RunnerPlatformType.connection_type, null: true
+ description 'Supported runner platforms.'
def resolve(**args)
runner_instructions.map do |platform, data|
diff --git a/app/graphql/resolvers/ci/runner_setup_resolver.rb b/app/graphql/resolvers/ci/runner_setup_resolver.rb
index ac2a56b89a7..9166999b400 100644
--- a/app/graphql/resolvers/ci/runner_setup_resolver.rb
+++ b/app/graphql/resolvers/ci/runner_setup_resolver.rb
@@ -3,30 +3,37 @@
module Resolvers
module Ci
class RunnerSetupResolver < BaseResolver
+ ACCESS_DENIED = 'User is not authorized to register a runner for the specified resource!'
+
type Types::Ci::RunnerSetupType, null: true
+ description 'Runner setup instructions.'
- argument :platform, GraphQL::STRING_TYPE,
- required: true,
- description: 'Platform to generate the instructions for.'
+ argument :platform,
+ type: 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 :architecture,
+ type: 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 :project_id,
+ type: ::Types::GlobalIDType[::Project],
+ required: false,
+ deprecated: { reason: 'No longer used', milestone: '13.11' },
+ description: 'Project to register the runner for.'
- argument :group_id, ::Types::GlobalIDType[::Group],
- required: false,
- description: 'Group to register the runner for.'
+ argument :group_id,
+ type: ::Types::GlobalIDType[::Group],
+ required: false,
+ deprecated: { reason: 'No longer used', milestone: '13.11' },
+ 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,
- **target_param(args)
+ arch: architecture
)
{
@@ -34,11 +41,15 @@ module Resolvers
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')
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, ACCESS_DENIED if access_denied?(instructions)
end
private
+ def access_denied?(instructions)
+ instructions.errors.include?('Gitlab::Access::AccessDeniedError')
+ end
+
def other_install_instructions(platform)
Gitlab::Ci::RunnerInstructions::OTHER_ENVIRONMENTS[platform.to_sym][:installation_instructions_url]
end
diff --git a/app/graphql/resolvers/ci/test_report_summary_resolver.rb b/app/graphql/resolvers/ci/test_report_summary_resolver.rb
new file mode 100644
index 00000000000..22db70f032a
--- /dev/null
+++ b/app/graphql/resolvers/ci/test_report_summary_resolver.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ci
+ class TestReportSummaryResolver < BaseResolver
+ type ::Types::Ci::TestReportSummaryType, null: true
+
+ alias_method :pipeline, :object
+
+ def resolve(**args)
+ TestReportSummarySerializer
+ .new(project: pipeline.project, current_user: @current_user)
+ .represent(pipeline.test_report_summary)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/ci/test_suite_resolver.rb b/app/graphql/resolvers/ci/test_suite_resolver.rb
new file mode 100644
index 00000000000..90cc30b1281
--- /dev/null
+++ b/app/graphql/resolvers/ci/test_suite_resolver.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ci
+ class TestSuiteResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type ::Types::Ci::TestSuiteType, null: true
+ authorize :read_build
+ authorizes_object!
+
+ alias_method :pipeline, :object
+
+ argument :build_ids, [GraphQL::ID_TYPE],
+ required: true,
+ description: 'IDs of the builds used to run the test suite.'
+
+ def resolve(build_ids:)
+ builds = pipeline.latest_builds.id_in(build_ids).presence
+ return unless builds
+
+ TestSuiteSerializer
+ .new(project: pipeline.project, current_user: @current_user)
+ .represent(load_test_suite_data(builds), details: true)
+ end
+
+ private
+
+ def load_test_suite_data(builds)
+ suite = builds.sum do |build|
+ build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new)
+ end
+
+ Gitlab::Ci::Reports::TestFailureHistory.new(suite.failed.values, pipeline.project).load!
+
+ suite
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/concerns/board_issue_filterable.rb b/app/graphql/resolvers/concerns/board_issue_filterable.rb
index 1541738f46c..3484a1cc4ba 100644
--- a/app/graphql/resolvers/concerns/board_issue_filterable.rb
+++ b/app/graphql/resolvers/concerns/board_issue_filterable.rb
@@ -7,10 +7,10 @@ module BoardIssueFilterable
def issue_filters(args)
filters = args.to_h
+
set_filter_values(filters)
if filters[:not]
- filters[:not] = filters[:not].to_h
set_filter_values(filters[:not])
end
@@ -18,6 +18,17 @@ module BoardIssueFilterable
end
def set_filter_values(filters)
+ filter_by_assignee(filters)
+ end
+
+ def filter_by_assignee(filters)
+ if filters[:assignee_username] && filters[:assignee_wildcard_id]
+ raise ::Gitlab::Graphql::Errors::ArgumentError, 'Incompatible arguments: assigneeUsername, assigneeWildcardId.'
+ end
+
+ if filters[:assignee_wildcard_id]
+ filters[:assignee_id] = filters.delete(:assignee_wildcard_id)
+ end
end
end
diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
index 84b0dafe213..0ff3997f3bc 100644
--- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
+++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
@@ -12,10 +12,10 @@ module IssueResolverArguments
argument :iids, [GraphQL::STRING_TYPE],
required: false,
description: 'List of IIDs of issues. For example, [1, 2].'
- argument :label_name, GraphQL::STRING_TYPE.to_list_type,
+ argument :label_name, [GraphQL::STRING_TYPE, null: true],
required: false,
description: 'Labels applied to this issue.'
- argument :milestone_title, GraphQL::STRING_TYPE.to_list_type,
+ argument :milestone_title, [GraphQL::STRING_TYPE, null: true],
required: false,
description: 'Milestone applied to this issue.'
argument :author_username, GraphQL::STRING_TYPE,
@@ -23,7 +23,8 @@ module IssueResolverArguments
description: 'Username of the author of the issue.'
argument :assignee_username, GraphQL::STRING_TYPE,
required: false,
- description: 'Username of a user assigned to the issue.'
+ description: 'Username of a user assigned to the issue.',
+ deprecated: { reason: 'Use `assigneeUsernames`', milestone: '13.11' }
argument :assignee_usernames, [GraphQL::STRING_TYPE],
required: false,
description: 'Usernames of users assigned to the issue.'
@@ -55,6 +56,10 @@ module IssueResolverArguments
as: :issue_types,
description: 'Filter issues by the given issue types.',
required: false
+ argument :not, Types::Issues::NegatedIssueFilterInputType,
+ description: 'List of negated params.',
+ prepare: ->(negated_args, ctx) { negated_args.to_h },
+ required: false
end
def resolve_with_lookahead(**args)
@@ -69,11 +74,22 @@ module IssueResolverArguments
args[:iids] ||= [args.delete(:iid)].compact if args[:iid]
args[:attempt_project_search_optimizations] = true if args[:search].present?
+ prepare_assignee_username_params(args)
+
finder = IssuesFinder.new(current_user, args)
continue_issue_resolve(parent, finder, **args)
end
+ def ready?(**args)
+ if args.slice(*mutually_exclusive_assignee_username_args).compact.size > 1
+ arg_str = mutually_exclusive_assignee_username_args.map { |x| x.to_s.camelize(:lower) }.join(', ')
+ raise Gitlab::Graphql::Errors::ArgumentError, "only one of [#{arg_str}] arguments is allowed at the same time."
+ end
+
+ super
+ end
+
class_methods do
def resolver_complexity(args, child_complexity:)
complexity = super
@@ -82,4 +98,15 @@ module IssueResolverArguments
complexity
end
end
+
+ private
+
+ def prepare_assignee_username_params(args)
+ args[:assignee_username] = args.delete(:assignee_usernames) if args[:assignee_usernames].present?
+ args[:not][:assignee_username] = args[:not].delete(:assignee_usernames) if args.dig(:not, :assignee_usernames).present?
+ end
+
+ def mutually_exclusive_assignee_username_args
+ [:assignee_usernames, :assignee_username]
+ end
end
diff --git a/app/graphql/resolvers/concerns/looks_ahead.rb b/app/graphql/resolvers/concerns/looks_ahead.rb
index 77a85edfba6..644b2a11460 100644
--- a/app/graphql/resolvers/concerns/looks_ahead.rb
+++ b/app/graphql/resolvers/concerns/looks_ahead.rb
@@ -15,12 +15,7 @@ module LooksAhead
end
def apply_lookahead(query)
- selection = node_selection
-
- includes = preloads.each.flat_map do |name, requirements|
- selection&.selects?(name) ? requirements : []
- end
- all_preloads = (unconditional_includes + includes).uniq
+ all_preloads = (unconditional_includes + filtered_preloads).uniq
return query if all_preloads.empty?
@@ -37,6 +32,14 @@ module LooksAhead
{}
end
+ def filtered_preloads
+ selection = node_selection
+
+ preloads.each.flat_map do |name, requirements|
+ selection&.selects?(name) ? requirements : []
+ end
+ end
+
def node_selection
return unless lookahead
diff --git a/app/graphql/resolvers/concerns/manual_authorization.rb b/app/graphql/resolvers/concerns/manual_authorization.rb
deleted file mode 100644
index 182110b9594..00000000000
--- a/app/graphql/resolvers/concerns/manual_authorization.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-# TODO: remove this entirely when framework authorization is released
-# See: https://gitlab.com/gitlab-org/gitlab/-/issues/290216
-module ManualAuthorization
- def resolve(**args)
- super
- rescue ::Gitlab::Graphql::Errors::ResourceNotAvailable
- nil
- end
-end
diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
index 31444b0c592..75f1ee478a8 100644
--- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb
+++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb
@@ -50,7 +50,8 @@ module ResolvesMergeRequests
approved_by: [:approved_by_users],
milestone: [:milestone],
security_auto_fix: [:author],
- head_pipeline: [:merge_request_diff, { head_pipeline: [:merge_request] }]
+ head_pipeline: [:merge_request_diff, { head_pipeline: [:merge_request] }],
+ timelogs: [:timelogs]
}
end
end
diff --git a/app/graphql/resolvers/concerns/resolves_snippets.rb b/app/graphql/resolvers/concerns/resolves_snippets.rb
index 445f3567b1d..8de85c074ec 100644
--- a/app/graphql/resolvers/concerns/resolves_snippets.rb
+++ b/app/graphql/resolvers/concerns/resolves_snippets.rb
@@ -4,7 +4,7 @@ module ResolvesSnippets
extend ActiveSupport::Concern
included do
- type Types::SnippetType.connection_type, null: false
+ type Types::SnippetType.connection_type, null: true
argument :ids, [::Types::GlobalIDType[::Snippet]],
required: false,
diff --git a/app/graphql/resolvers/echo_resolver.rb b/app/graphql/resolvers/echo_resolver.rb
index 0c7dad622cf..a09b0a1fd87 100644
--- a/app/graphql/resolvers/echo_resolver.rb
+++ b/app/graphql/resolvers/echo_resolver.rb
@@ -5,8 +5,10 @@ module Resolvers
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.'
+ argument :text,
+ type: GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Text to echo back.'
def resolve(text:)
username = current_user&.username
diff --git a/app/graphql/resolvers/environments_resolver.rb b/app/graphql/resolvers/environments_resolver.rb
index ed3395d05aa..df04e70e250 100644
--- a/app/graphql/resolvers/environments_resolver.rb
+++ b/app/graphql/resolvers/environments_resolver.rb
@@ -21,7 +21,7 @@ module Resolvers
def resolve(**args)
return unless project.present?
- EnvironmentsFinder.new(project, context[:current_user], args).find
+ EnvironmentsFinder.new(project, context[:current_user], args).execute
rescue EnvironmentsFinder::InvalidStatesError => exception
raise Gitlab::Graphql::Errors::ArgumentError, exception.message
end
diff --git a/app/graphql/resolvers/group_members_resolver.rb b/app/graphql/resolvers/group_members_resolver.rb
index 36e1977b756..d3662b08cdf 100644
--- a/app/graphql/resolvers/group_members_resolver.rb
+++ b/app/graphql/resolvers/group_members_resolver.rb
@@ -13,12 +13,6 @@ module Resolvers
private
- def preloads
- {
- user: [:user, :source]
- }
- end
-
def finder_class
GroupMembersFinder
end
diff --git a/app/graphql/resolvers/group_merge_requests_resolver.rb b/app/graphql/resolvers/group_merge_requests_resolver.rb
index 2bad974daf7..34a4c67bc56 100644
--- a/app/graphql/resolvers/group_merge_requests_resolver.rb
+++ b/app/graphql/resolvers/group_merge_requests_resolver.rb
@@ -4,7 +4,7 @@ module Resolvers
class GroupMergeRequestsResolver < MergeRequestsResolver
include GroupIssuableResolver
- alias_method :group, :synchronized_object
+ alias_method :group, :object
type Types::MergeRequestType.connection_type, null: true
diff --git a/app/graphql/resolvers/group_milestones_resolver.rb b/app/graphql/resolvers/group_milestones_resolver.rb
index 179283fd7b7..31280b36278 100644
--- a/app/graphql/resolvers/group_milestones_resolver.rb
+++ b/app/graphql/resolvers/group_milestones_resolver.rb
@@ -1,22 +1,40 @@
# frozen_string_literal: true
-# rubocop:disable Graphql/ResolverType (inherited from MilestonesResolver)
module Resolvers
class GroupMilestonesResolver < MilestonesResolver
argument :include_descendants, GraphQL::BOOLEAN_TYPE,
required: false,
- description: 'Also return milestones in all subgroups and subprojects.'
+ description: 'Include milestones from all subgroups and subprojects.'
+ argument :include_ancestors, GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: 'Include milestones from all parent groups.'
type Types::MilestoneType.connection_type, null: true
private
def parent_id_parameters(args)
- return { group_ids: parent.id } unless args[:include_descendants].present?
+ include_ancestors = args[:include_ancestors].present?
+ include_descendants = args[:include_descendants].present?
+ return { group_ids: parent.id } unless include_ancestors || include_descendants
+
+ group_ids = if include_ancestors && include_descendants
+ parent.self_and_hierarchy
+ elsif include_ancestors
+ parent.self_and_ancestors
+ else
+ parent.self_and_descendants
+ end
+
+ project_ids = if include_descendants
+ group_projects.with_issues_or_mrs_available_for_user(current_user)
+ else
+ nil
+ end
{
- group_ids: parent.self_and_descendants.public_or_visible_to_user(current_user).select(:id),
- project_ids: group_projects.with_issues_or_mrs_available_for_user(current_user)
+ group_ids: group_ids.public_or_visible_to_user(current_user).select(:id),
+ project_ids: project_ids
}
end
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index ac3bdda0f12..7a67f115abf 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -44,7 +44,8 @@ module Resolvers
{
alert_management_alert: [:alert_management_alert],
labels: [:labels],
- assignees: [:assignees]
+ assignees: [:assignees],
+ timelogs: [:timelogs]
}
end
diff --git a/app/graphql/resolvers/members_resolver.rb b/app/graphql/resolvers/members_resolver.rb
index 76c3ae936ee..2b731d54cdd 100644
--- a/app/graphql/resolvers/members_resolver.rb
+++ b/app/graphql/resolvers/members_resolver.rb
@@ -21,6 +21,12 @@ module Resolvers
private
+ def preloads
+ {
+ user: [:user, :source]
+ }
+ end
+
def finder_class
# override in subclass
end
diff --git a/app/graphql/resolvers/merge_request_resolver.rb b/app/graphql/resolvers/merge_request_resolver.rb
index 8fd33c6626e..c431d079beb 100644
--- a/app/graphql/resolvers/merge_request_resolver.rb
+++ b/app/graphql/resolvers/merge_request_resolver.rb
@@ -4,14 +4,14 @@ module Resolvers
class MergeRequestResolver < BaseResolver.single
include ResolvesMergeRequests
- alias_method :project, :synchronized_object
+ alias_method :project, :object
type ::Types::MergeRequestType, null: true
argument :iid, GraphQL::STRING_TYPE,
- required: true,
- as: :iids,
- description: 'IID of the merge request, for example `1`.'
+ required: true,
+ as: :iids,
+ description: 'IID of the merge request, for example `1`.'
def no_results_possible?(args)
project.nil?
diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb
index ecbdaaa3f55..a9eea4ae4b8 100644
--- a/app/graphql/resolvers/merge_requests_resolver.rb
+++ b/app/graphql/resolvers/merge_requests_resolver.rb
@@ -3,42 +3,49 @@
module Resolvers
class MergeRequestsResolver < BaseResolver
include ResolvesMergeRequests
+ extend ::Gitlab::Graphql::NegatableArguments
type ::Types::MergeRequestType.connection_type, null: true
- alias_method :project, :synchronized_object
+ alias_method :project, :object
def self.accept_assignee
argument :assignee_username, GraphQL::STRING_TYPE,
- required: false,
- description: 'Username of the assignee.'
+ required: false,
+ description: 'Username of the assignee.'
end
def self.accept_author
argument :author_username, GraphQL::STRING_TYPE,
- required: false,
- description: 'Username of the author.'
+ required: false,
+ description: 'Username of the author.'
end
def self.accept_reviewer
argument :reviewer_username, GraphQL::STRING_TYPE,
- required: false,
- description: 'Username of the reviewer.'
+ required: false,
+ description: 'Username of the reviewer.'
end
argument :iids, [GraphQL::STRING_TYPE],
- required: false,
- description: 'Array of IIDs of merge requests, for example `[1, 2]`.'
+ required: false,
+ description: 'Array of IIDs of merge requests, for example `[1, 2]`.'
argument :source_branches, [GraphQL::STRING_TYPE],
required: false,
as: :source_branch,
- description: 'Array of source branch names. All resolved merge requests will have one of these branches as their source.'
+ description: <<~DESC
+ Array of source branch names.
+ All resolved merge requests will have one of these branches as their source.
+ DESC
argument :target_branches, [GraphQL::STRING_TYPE],
required: false,
as: :target_branch,
- description: 'Array of target branch names. All resolved merge requests will have one of these branches as their target.'
+ description: <<~DESC
+ Array of target branch names.
+ All resolved merge requests will have one of these branches as their target.
+ DESC
argument :state, ::Types::MergeRequestStateEnum,
required: false,
@@ -62,6 +69,16 @@ module Resolvers
required: false,
default_value: :created_desc
+ negated do
+ argument :labels, [GraphQL::STRING_TYPE],
+ required: false,
+ as: :label_name,
+ description: 'Array of label names. All resolved merge requests will not have these labels.'
+ argument :milestone_title, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Title of the milestone.'
+ end
+
def self.single
::Resolvers::MergeRequestResolver
end
diff --git a/app/graphql/resolvers/metrics/dashboard_resolver.rb b/app/graphql/resolvers/metrics/dashboard_resolver.rb
index a82a4a95254..0669fececd5 100644
--- a/app/graphql/resolvers/metrics/dashboard_resolver.rb
+++ b/app/graphql/resolvers/metrics/dashboard_resolver.rb
@@ -8,15 +8,16 @@ module Resolvers
argument :path, GraphQL::STRING_TYPE,
required: true,
- description: "Path to a file which defines metrics dashboard " \
- "eg: 'config/prometheus/common_metrics.yml'."
+ description: <<~MD
+ Path to a file which defines a metrics dashboard eg: `"config/prometheus/common_metrics.yml"`.
+ MD
alias_method :environment, :object
- def resolve(**args)
+ def resolve(path:)
return unless environment
- ::PerformanceMonitoring::PrometheusDashboard.find_for(**args, **service_params)
+ ::PerformanceMonitoring::PrometheusDashboard.find_for(path: path, **service_params)
end
private
diff --git a/app/graphql/resolvers/milestones_resolver.rb b/app/graphql/resolvers/milestones_resolver.rb
index 9a715e4d08b..c94e3d9e1d8 100644
--- a/app/graphql/resolvers/milestones_resolver.rb
+++ b/app/graphql/resolvers/milestones_resolver.rb
@@ -7,7 +7,7 @@ module Resolvers
argument :ids, [GraphQL::ID_TYPE],
required: false,
- description: 'Array of global milestone IDs, e.g., "gid://gitlab/Milestone/1".'
+ description: 'Array of global milestone IDs, e.g., `"gid://gitlab/Milestone/1"`.'
argument :state, Types::MilestoneStateEnum,
required: false,
@@ -56,7 +56,7 @@ module Resolvers
end
def parent
- synchronized_object
+ object
end
def parent_id_parameters(args)
diff --git a/app/graphql/resolvers/package_details_resolver.rb b/app/graphql/resolvers/package_details_resolver.rb
index e688e34599a..89d79747732 100644
--- a/app/graphql/resolvers/package_details_resolver.rb
+++ b/app/graphql/resolvers/package_details_resolver.rb
@@ -2,12 +2,20 @@
module Resolvers
class PackageDetailsResolver < BaseResolver
- type ::Types::Packages::PackageType, null: true
+ type ::Types::Packages::PackageDetailsType, null: true
argument :id, ::Types::GlobalIDType[::Packages::Package],
required: true,
description: 'The global ID of the package.'
+ def ready?(**args)
+ context[self.class] ||= { executions: 0 }
+ context[self.class][:executions] += 1
+ raise GraphQL::ExecutionError, "Package details can be requested only for one package at a time" if context[self.class][:executions] > 1
+
+ super
+ end
+
def resolve(id:)
# TODO: remove this line when the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
diff --git a/app/graphql/resolvers/project_jobs_resolver.rb b/app/graphql/resolvers/project_jobs_resolver.rb
new file mode 100644
index 00000000000..75068014242
--- /dev/null
+++ b/app/graphql/resolvers/project_jobs_resolver.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class ProjectJobsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+ include LooksAhead
+
+ type ::Types::Ci::JobType.connection_type, null: true
+ authorize :read_build
+ authorizes_object!
+
+ argument :statuses, [::Types::Ci::JobStatusEnum],
+ required: false,
+ description: 'Filter jobs by status.'
+
+ alias_method :project, :object
+
+ def ready?(**args)
+ context[self.class] ||= { executions: 0 }
+ context[self.class][:executions] += 1
+ raise GraphQL::ExecutionError, "Jobs can only be requested for one project at a time" if context[self.class][:executions] > 1
+
+ super
+ end
+
+ def resolve_with_lookahead(statuses: nil)
+ jobs = ::Ci::JobsFinder.new(current_user: current_user, project: project, params: { scope: statuses }).execute
+
+ apply_lookahead(jobs)
+ end
+
+ private
+
+ def preloads
+ {
+ artifacts: [:job_artifacts],
+ pipeline: [:user]
+ }
+ end
+ end
+end
diff --git a/app/graphql/resolvers/project_pipeline_resolver.rb b/app/graphql/resolvers/project_pipeline_resolver.rb
index 8fca6b829c0..aa8808b15ac 100644
--- a/app/graphql/resolvers/project_pipeline_resolver.rb
+++ b/app/graphql/resolvers/project_pipeline_resolver.rb
@@ -31,7 +31,7 @@ module Resolvers
end
else
BatchLoader::GraphQL.for(sha).batch(key: project) do |shas, loader, args|
- finder = ::Ci::PipelinesFinder.new(project, current_user, shas: shas)
+ finder = ::Ci::PipelinesFinder.new(project, current_user, sha: shas)
finder.execute.each { |pipeline| loader.call(pipeline.sha.to_s, pipeline) }
end
diff --git a/app/graphql/resolvers/projects/services_resolver.rb b/app/graphql/resolvers/projects/services_resolver.rb
index f618bf2df77..ec31a7dbe6d 100644
--- a/app/graphql/resolvers/projects/services_resolver.rb
+++ b/app/graphql/resolvers/projects/services_resolver.rb
@@ -3,11 +3,11 @@
module Resolvers
module Projects
class ServicesResolver < BaseResolver
- prepend ManualAuthorization
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::Projects::ServiceType.connection_type, null: true
authorize :admin_project
+ authorizes_object!
argument :active,
GraphQL::BOOLEAN_TYPE,
@@ -20,15 +20,7 @@ module Resolvers
alias_method :project, :object
- def resolve(**args)
- authorize!(project)
-
- services(args[:active], args[:type])
- end
-
- private
-
- def services(active, type)
+ def resolve(active: nil, type: nil)
servs = project.services
servs = servs.by_active_flag(active) unless active.nil?
servs = servs.by_type(type) unless type.blank?
diff --git a/app/graphql/resolvers/repository_branch_names_resolver.rb b/app/graphql/resolvers/repository_branch_names_resolver.rb
new file mode 100644
index 00000000000..45cfe229b2f
--- /dev/null
+++ b/app/graphql/resolvers/repository_branch_names_resolver.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class RepositoryBranchNamesResolver < BaseResolver
+ type ::GraphQL::STRING_TYPE, null: false
+
+ calls_gitaly!
+
+ argument :search_pattern, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'The pattern to search for branch names by.'
+
+ def resolve(search_pattern:)
+ Repositories::BranchNamesFinder.new(object, search: search_pattern).execute
+ end
+ end
+end
diff --git a/app/graphql/resolvers/snippets/blobs_resolver.rb b/app/graphql/resolvers/snippets/blobs_resolver.rb
index 569b82149d3..4328d38d485 100644
--- a/app/graphql/resolvers/snippets/blobs_resolver.rb
+++ b/app/graphql/resolvers/snippets/blobs_resolver.rb
@@ -3,12 +3,12 @@
module Resolvers
module Snippets
class BlobsResolver < BaseResolver
- prepend ManualAuthorization
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::Snippets::BlobType.connection_type, null: true
authorize :read_snippet
calls_gitaly!
+ authorizes_object!
alias_method :snippet, :object
@@ -17,7 +17,6 @@ module Resolvers
description: 'Paths of the blobs.'
def resolve(paths: [])
- authorize!(snippet)
return [snippet.blob] if snippet.empty_repo?
if paths.empty?
diff --git a/app/graphql/resolvers/timelog_resolver.rb b/app/graphql/resolvers/timelog_resolver.rb
new file mode 100644
index 00000000000..aebd04259ee
--- /dev/null
+++ b/app/graphql/resolvers/timelog_resolver.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class TimelogResolver < BaseResolver
+ include LooksAhead
+
+ type ::Types::TimelogType.connection_type, null: false
+
+ argument :start_date, Types::TimeType,
+ required: false,
+ description: 'List time logs within a date range where the logged date is equal to or after startDate.'
+
+ argument :end_date, Types::TimeType,
+ required: false,
+ description: 'List time logs within a date range where the logged date is equal to or before endDate.'
+
+ argument :start_time, Types::TimeType,
+ required: false,
+ description: 'List time-logs within a time range where the logged time is equal to or after startTime.'
+
+ argument :end_time, Types::TimeType,
+ required: false,
+ description: 'List time-logs within a time range where the logged time is equal to or before endTime.'
+
+ def resolve_with_lookahead(**args)
+ return Timelog.none unless timelogs_available_for_user?
+
+ validate_params_presence!(args)
+ transformed_args = transform_args(args)
+ validate_time_difference!(transformed_args)
+
+ find_timelogs(transformed_args)
+ end
+
+ private
+
+ def preloads
+ {
+ note: [:note]
+ }
+ end
+
+ def find_timelogs(args)
+ apply_lookahead(group.timelogs(args[:start_time], args[:end_time]))
+ end
+
+ def timelogs_available_for_user?
+ group&.user_can_access_group_timelogs?(context[:current_user])
+ end
+
+ def validate_params_presence!(args)
+ message = case time_params_count(args)
+ when 0
+ 'Start and End arguments must be present'
+ when 1
+ 'Both Start and End arguments must be present'
+ when 2
+ validate_duplicated_args(args)
+ when 3 || 4
+ 'Only Time or Date arguments must be present'
+ end
+
+ raise_argument_error(message) if message
+ end
+
+ def validate_time_difference!(args)
+ message = if args[:end_time] < args[:start_time]
+ 'Start argument must be before End argument'
+ elsif args[:end_time] - args[:start_time] > 60.days
+ 'The time range period cannot contain more than 60 days'
+ end
+
+ raise_argument_error(message) if message
+ end
+
+ def transform_args(args)
+ return args if args.keys == [:start_time, :end_time]
+
+ time_args = args.except(:start_date, :end_date)
+
+ if time_args.empty?
+ time_args[:start_time] = args[:start_date].beginning_of_day
+ time_args[:end_time] = args[:end_date].end_of_day
+ elsif time_args.key?(:start_time)
+ time_args[:end_time] = args[:end_date].end_of_day
+ elsif time_args.key?(:end_time)
+ time_args[:start_time] = args[:start_date].beginning_of_day
+ end
+
+ time_args
+ end
+
+ def time_params_count(args)
+ [:start_time, :end_time, :start_date, :end_date].count { |param| args.key?(param) }
+ end
+
+ def validate_duplicated_args(args)
+ if args.key?(:start_time) && args.key?(:start_date) ||
+ args.key?(:end_time) && args.key?(:end_date)
+ 'Both Start and End arguments must be present'
+ end
+ end
+
+ def raise_argument_error(message)
+ raise Gitlab::Graphql::Errors::ArgumentError, message
+ end
+
+ def group
+ @group ||= object.respond_to?(:sync) ? object.sync : object
+ end
+ end
+end
diff --git a/app/graphql/resolvers/user_merge_requests_resolver_base.rb b/app/graphql/resolvers/user_merge_requests_resolver_base.rb
index 47967fe69f9..221a43f691d 100644
--- a/app/graphql/resolvers/user_merge_requests_resolver_base.rb
+++ b/app/graphql/resolvers/user_merge_requests_resolver_base.rb
@@ -4,16 +4,24 @@ module Resolvers
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_path,
+ type: GraphQL::STRING_TYPE,
+ required: false,
+ description: <<~DESC
+ The full-path of the project the authored merge requests should be in.
+ Incompatible with projectId.
+ DESC
- 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.'
+ argument :project_id,
+ type: ::Types::GlobalIDType[::Project],
+ required: false,
+ description: <<~DESC
+ The global ID of the project the authored merge requests should be in.
+ Incompatible with projectPath.
+ DESC
attr_reader :project
- alias_method :user, :synchronized_object
+ alias_method :user, :object
def ready?(project_id: nil, project_path: nil, **args)
return early_return unless can_read_profile?
@@ -22,8 +30,7 @@ module Resolvers
load_project(project_path, project_id)
return early_return unless can_read_project?
elsif args[:iids].present?
- raise ::Gitlab::Graphql::Errors::ArgumentError,
- 'iids requires projectPath or projectId'
+ raise ::Gitlab::Graphql::Errors::ArgumentError, 'iids requires projectPath or projectId'
end
super(**args)
diff --git a/app/graphql/resolvers/user_starred_projects_resolver.rb b/app/graphql/resolvers/user_starred_projects_resolver.rb
index db420b3d116..a8abe759f27 100644
--- a/app/graphql/resolvers/user_starred_projects_resolver.rb
+++ b/app/graphql/resolvers/user_starred_projects_resolver.rb
@@ -2,11 +2,11 @@
module Resolvers
class UserStarredProjectsResolver < BaseResolver
- type Types::ProjectType, null: true
+ type Types::ProjectType.connection_type, null: true
argument :search, GraphQL::STRING_TYPE,
- required: false,
- description: 'Search query.'
+ required: false,
+ description: 'Search query.'
alias_method :user, :object
diff --git a/app/graphql/resolvers/users/snippets_resolver.rb b/app/graphql/resolvers/users/snippets_resolver.rb
index e8048b9deb9..ee1727aadbe 100644
--- a/app/graphql/resolvers/users/snippets_resolver.rb
+++ b/app/graphql/resolvers/users/snippets_resolver.rb
@@ -5,6 +5,7 @@ module Resolvers
module Users
class SnippetsResolver < BaseResolver
include ResolvesSnippets
+ include Gitlab::Allowable
alias_method :user, :object
@@ -14,6 +15,12 @@ module Resolvers
private
+ def resolve_snippets(_args)
+ return Snippet.none unless Ability.allowed?(current_user, :read_user_profile, user)
+
+ super
+ end
+
def snippet_finder_params(args)
super.merge(author: user)
end
diff --git a/app/graphql/types/base_argument.rb b/app/graphql/types/base_argument.rb
index 4ad9e8c0e40..ff9a5a0611d 100644
--- a/app/graphql/types/base_argument.rb
+++ b/app/graphql/types/base_argument.rb
@@ -4,8 +4,10 @@ module Types
class BaseArgument < GraphQL::Schema::Argument
include GitlabStyleDeprecations
+ attr_reader :deprecation
+
def initialize(*args, **kwargs, &block)
- kwargs = gitlab_deprecation(kwargs)
+ @deprecation = gitlab_deprecation(kwargs)
super(*args, **kwargs, &block)
end
diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb
index 4d470aceca4..518a902a5d7 100644
--- a/app/graphql/types/base_enum.rb
+++ b/app/graphql/types/base_enum.rb
@@ -21,12 +21,23 @@ module Types
graphql_name(enum_mod.name) if use_name
description(enum_mod.description) if use_description
- enum_mod.definition.each { |key, content| value(key.to_s.upcase, **content) }
+ enum_mod.definition.each do |key, content|
+ value(key.to_s.upcase, **content)
+ end
+ end
+
+ # Helper to define an enum member for each element of a Rails AR enum
+ def from_rails_enum(enum, description:)
+ enum.each_key do |name|
+ value name.to_s.upcase,
+ value: name,
+ description: format(description, name: name)
+ end
end
def value(*args, **kwargs, &block)
enum[args[0].downcase] = kwargs[:value] || args[0]
- kwargs = gitlab_deprecation(kwargs)
+ gitlab_deprecation(kwargs)
super(*args, **kwargs, &block)
end
@@ -36,6 +47,18 @@ module Types
def enum
@enum_values ||= {}.with_indifferent_access
end
+
+ def authorization
+ @authorization ||= ::Gitlab::Graphql::Authorize::ObjectAuthorization.new(authorize)
+ end
+
+ def authorize(*abilities)
+ @abilities = abilities
+ end
+
+ def authorized?(object, context)
+ authorization.ok?(object, context[:current_user])
+ end
end
end
end
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
index 78ab6890923..7c939f94dde 100644
--- a/app/graphql/types/base_field.rb
+++ b/app/graphql/types/base_field.rb
@@ -2,28 +2,30 @@
module Types
class BaseField < GraphQL::Schema::Field
- prepend Gitlab::Graphql::Authorize
include GitlabStyleDeprecations
argument_class ::Types::BaseArgument
DEFAULT_COMPLEXITY = 1
+ attr_reader :deprecation
+
def initialize(**kwargs, &block)
@calls_gitaly = !!kwargs.delete(:calls_gitaly)
@constant_complexity = kwargs[:complexity].is_a?(Integer) && kwargs[:complexity] > 0
@requires_argument = !!kwargs.delete(:requires_argument)
+ @authorize = Array.wrap(kwargs.delete(:authorize))
kwargs[:complexity] = field_complexity(kwargs[:resolver_class], kwargs[:complexity])
@feature_flag = kwargs[:feature_flag]
kwargs = check_feature_flag(kwargs)
- kwargs = gitlab_deprecation(kwargs)
+ @deprecation = gitlab_deprecation(kwargs)
super(**kwargs, &block)
# We want to avoid the overhead of this in prod
extension ::Gitlab::Graphql::CallsGitaly::FieldExtension if Gitlab.dev_or_test_env?
-
extension ::Gitlab::Graphql::Present::FieldExtension
+ extension ::Gitlab::Graphql::Authorize::ConnectionFilterExtension
end
def may_call_gitaly?
@@ -34,6 +36,19 @@ module Types
@requires_argument || arguments.values.any? { |argument| argument.type.non_null? }
end
+ # By default fields authorize against the current object, but that is not how our
+ # resolvers work - they use declarative permissions to authorize fields
+ # manually (so we make them opt in).
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/300922
+ # (separate out authorize into permissions on the object, and on the
+ # resolved values)
+ # We do not support argument authorization in our schema. If/when we do,
+ # we should call `super` here, to apply argument authorization checks.
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/324647
+ def authorized?(object, args, ctx)
+ field_authorized?(object, ctx) && resolver_authorized?(object, ctx)
+ end
+
def base_complexity
complexity = DEFAULT_COMPLEXITY
complexity += 1 if calls_gitaly?
@@ -58,6 +73,26 @@ module Types
attr_reader :feature_flag
+ def field_authorized?(object, ctx)
+ authorization.ok?(object, ctx[:current_user])
+ end
+
+ # Historically our resolvers have used declarative permission checks only
+ # for _what they resolved_, not the _object they resolved these things from_
+ # We preserve these semantics here, and only apply resolver authorization
+ # if the resolver has opted in.
+ def resolver_authorized?(object, ctx)
+ if @resolver_class && @resolver_class.try(:authorizes_object?)
+ @resolver_class.authorized?(object, ctx)
+ else
+ true
+ end
+ end
+
+ def authorization
+ @authorization ||= ::Gitlab::Graphql::Authorize::ObjectAuthorization.new(@authorize)
+ end
+
def feature_documentation_message(key, description)
"#{description} Available only when feature flag `#{key}` is enabled."
end
diff --git a/app/graphql/types/base_interface.rb b/app/graphql/types/base_interface.rb
index 4b1f3193136..c21c95876be 100644
--- a/app/graphql/types/base_interface.rb
+++ b/app/graphql/types/base_interface.rb
@@ -5,5 +5,11 @@ module Types
include GraphQL::Schema::Interface
field_class ::Types::BaseField
+
+ definition_methods do
+ def authorized?(object, context)
+ resolve_type(object, context).authorized?(object, context)
+ end
+ end
end
end
diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb
index 9c36c83d4a3..cd677e50d28 100644
--- a/app/graphql/types/base_object.rb
+++ b/app/graphql/types/base_object.rb
@@ -19,6 +19,14 @@ module Types
GitlabSchema.id_from_object(object)
end
+ def self.authorization
+ @authorization ||= ::Gitlab::Graphql::Authorize::ObjectAuthorization.new(authorize)
+ end
+
+ def self.authorized?(object, context)
+ authorization.ok?(object, context[:current_user])
+ end
+
def current_user
context[:current_user]
end
diff --git a/app/graphql/types/base_union.rb b/app/graphql/types/base_union.rb
index 30a5668c0bb..aeafbf85020 100644
--- a/app/graphql/types/base_union.rb
+++ b/app/graphql/types/base_union.rb
@@ -2,5 +2,8 @@
module Types
class BaseUnion < GraphQL::Schema::Union
+ def self.authorized?(object, context)
+ resolve_type(object, context).authorized?(object, context)
+ end
end
end
diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb
index f33f3f5e537..42d8eecc366 100644
--- a/app/graphql/types/board_type.rb
+++ b/app/graphql/types/board_type.rb
@@ -20,6 +20,12 @@ module Types
field :hide_closed_list, type: GraphQL::BOOLEAN_TYPE, null: true,
description: 'Whether or not closed list is hidden.'
+ field :created_at, Types::TimeType, null: false,
+ description: 'Timestamp of when the board was created.'
+
+ field :updated_at, Types::TimeType, null: false,
+ description: 'Timestamp of when the board was last updated.'
+
field :lists,
Types::BoardListType.connection_type,
null: true,
diff --git a/app/graphql/types/boards/assignee_wildcard_id_enum.rb b/app/graphql/types/boards/assignee_wildcard_id_enum.rb
new file mode 100644
index 00000000000..ba9058a78d9
--- /dev/null
+++ b/app/graphql/types/boards/assignee_wildcard_id_enum.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ module Boards
+ class AssigneeWildcardIdEnum < BaseEnum
+ graphql_name 'AssigneeWildcardId'
+ description 'Assignee ID wildcard values'
+
+ value 'NONE', 'No assignee is assigned.'
+ value 'ANY', 'An assignee is assigned.'
+ end
+ end
+end
diff --git a/app/graphql/types/boards/board_issuable_input_base_type.rb b/app/graphql/types/boards/board_issuable_input_base_type.rb
new file mode 100644
index 00000000000..2cd057347d6
--- /dev/null
+++ b/app/graphql/types/boards/board_issuable_input_base_type.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Types
+ module Boards
+ # Common arguments that we can be used to filter boards epics and issues
+ class BoardIssuableInputBaseType < BaseInputObject
+ argument :label_name, [GraphQL::STRING_TYPE, null: true],
+ required: false,
+ description: 'Filter by label name.'
+
+ argument :author_username, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Filter by author username.'
+
+ argument :my_reaction_emoji, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Filter by reaction emoji applied by the current user.'
+ end
+ end
+end
diff --git a/app/graphql/types/boards/board_issue_input_base_type.rb b/app/graphql/types/boards/board_issue_input_base_type.rb
index b762cef6e58..7cf2dcb9c82 100644
--- a/app/graphql/types/boards/board_issue_input_base_type.rb
+++ b/app/graphql/types/boards/board_issue_input_base_type.rb
@@ -2,30 +2,19 @@
module Types
module Boards
- class BoardIssueInputBaseType < BaseInputObject
- argument :label_name, GraphQL::STRING_TYPE.to_list_type,
- required: false,
- description: 'Filter by label name.'
-
+ # rubocop: disable Graphql/AuthorizeTypes
+ class BoardIssueInputBaseType < BoardIssuableInputBaseType
argument :milestone_title, GraphQL::STRING_TYPE,
required: false,
description: 'Filter by milestone title.'
- argument :assignee_username, GraphQL::STRING_TYPE.to_list_type,
+ argument :assignee_username, [GraphQL::STRING_TYPE, null: true],
required: false,
description: 'Filter by assignee username.'
- argument :author_username, GraphQL::STRING_TYPE,
- required: false,
- description: 'Filter by author username.'
-
argument :release_tag, GraphQL::STRING_TYPE,
required: false,
description: 'Filter by release tag.'
-
- argument :my_reaction_emoji, GraphQL::STRING_TYPE,
- required: false,
- description: 'Filter by reaction emoji.'
end
end
end
diff --git a/app/graphql/types/boards/board_issue_input_type.rb b/app/graphql/types/boards/board_issue_input_type.rb
index 9cc0f484a16..8c0e37e5cb7 100644
--- a/app/graphql/types/boards/board_issue_input_type.rb
+++ b/app/graphql/types/boards/board_issue_input_type.rb
@@ -2,19 +2,24 @@
module Types
module Boards
- class NegatedBoardIssueInputType < BoardIssueInputBaseType
- end
-
class BoardIssueInputType < BoardIssueInputBaseType
graphql_name 'BoardIssueInput'
argument :not, NegatedBoardIssueInputType,
required: false,
- description: 'List of negated params. Warning: this argument is experimental and a subject to change in future.'
+ prepare: ->(negated_args, ctx) { negated_args.to_h },
+ description: <<~MD
+ List of negated arguments.
+ Warning: this argument is experimental and a subject to change in future.
+ MD
argument :search, GraphQL::STRING_TYPE,
required: false,
description: 'Search query for issue title or description.'
+
+ argument :assignee_wildcard_id, ::Types::Boards::AssigneeWildcardIdEnum,
+ required: false,
+ description: 'Filter by assignee wildcard. Incompatible with assigneeUsername.'
end
end
end
diff --git a/app/graphql/types/boards/negated_board_issue_input_type.rb b/app/graphql/types/boards/negated_board_issue_input_type.rb
new file mode 100644
index 00000000000..a0fab2ae969
--- /dev/null
+++ b/app/graphql/types/boards/negated_board_issue_input_type.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Types
+ module Boards
+ class NegatedBoardIssueInputType < BoardIssueInputBaseType
+ end
+ end
+end
+
+Types::Boards::NegatedBoardIssueInputType.prepend_if_ee('::EE::Types::Boards::NegatedBoardIssueInputType')
diff --git a/app/graphql/types/ci/job_status_enum.rb b/app/graphql/types/ci/job_status_enum.rb
new file mode 100644
index 00000000000..ec80b1f4776
--- /dev/null
+++ b/app/graphql/types/ci/job_status_enum.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ class JobStatusEnum < BaseEnum
+ graphql_name 'CiJobStatus'
+
+ ::Ci::HasStatus::AVAILABLE_STATUSES.each do |status|
+ value status.upcase,
+ description: "A job that is #{status.tr('_', ' ')}.",
+ value: status
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index c86337eea89..94a256fed3d 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -6,27 +6,74 @@ module Types
graphql_name 'CiJob'
authorize :read_commit_status
+ connection_type_class(Types::CountableConnectionType)
+
+ field :id, ::Types::GlobalIDType[::CommitStatus].as('JobID'), null: true,
+ description: 'ID of the job.'
field :pipeline, Types::Ci::PipelineType, null: true,
description: 'Pipeline the job belongs to.'
field :name, GraphQL::STRING_TYPE, null: true,
description: 'Name of the job.'
field :needs, BuildNeedType.connection_type, null: true,
description: 'References to builds that must complete before the jobs run.'
- field :detailed_status, Types::Ci::DetailedStatusType, null: true,
- description: 'Detailed status of the job.'
+ field :status,
+ type: ::Types::Ci::JobStatusEnum,
+ null: true,
+ description: "Status of the job."
+ field :stage, Types::Ci::StageType, null: true,
+ description: 'Stage of the job.'
+ field :allow_failure, ::GraphQL::BOOLEAN_TYPE, null: false,
+ description: 'Whether this job is allowed to fail.'
+ field :duration, GraphQL::INT_TYPE, null: true,
+ description: 'Duration of the job in seconds.'
+ field :tags, [GraphQL::STRING_TYPE], null: true,
+ description: 'Tags for the current job.'
+
+ # Life-cycle timestamps:
+ field :created_at, Types::TimeType, null: false,
+ description: "When the job was created."
+ field :queued_at, Types::TimeType, null: true,
+ description: 'When the job was enqueued and marked as pending.'
+ field :started_at, Types::TimeType, null: true,
+ description: 'When the job was started.'
+ field :finished_at, Types::TimeType, null: true,
+ description: 'When a job has finished running.'
field :scheduled_at, Types::TimeType, null: true,
description: 'Schedule for the build.'
+
+ field :detailed_status, Types::Ci::DetailedStatusType, null: true,
+ description: 'Detailed status of the job.'
field :artifacts, Types::Ci::JobArtifactType.connection_type, null: true,
description: 'Artifacts generated by the job.'
- field :finished_at, Types::TimeType, null: true,
- description: 'When a job has finished running.'
- field :duration, GraphQL::INT_TYPE, null: true,
- description: 'Duration of the job in seconds.'
+ field :short_sha, type: GraphQL::STRING_TYPE, null: false,
+ description: 'Short SHA1 ID of the commit.'
+ field :scheduling_type, GraphQL::STRING_TYPE, null: true,
+ description: 'Type of pipeline scheduling. Value is `dag` if the pipeline uses the `needs` keyword, and `stage` otherwise.'
+ field :commit_path, GraphQL::STRING_TYPE, null: true,
+ description: 'Path to the commit that triggered the job.'
+ field :ref_name, GraphQL::STRING_TYPE, null: true,
+ description: 'Ref name of the job.'
+ field :ref_path, GraphQL::STRING_TYPE, null: true,
+ description: 'Path to the ref.'
+ field :playable, GraphQL::BOOLEAN_TYPE, null: false, method: :playable?,
+ description: 'Indicates the job can be played.'
+ field :retryable, GraphQL::BOOLEAN_TYPE, null: false, method: :retryable?,
+ description: 'Indicates the job can be retried.'
+ field :cancelable, GraphQL::BOOLEAN_TYPE, null: false, method: :cancelable?,
+ description: 'Indicates the job can be canceled.'
+ field :active, GraphQL::BOOLEAN_TYPE, null: false, method: :active?,
+ description: 'Indicates the job is active.'
+ field :coverage, GraphQL::FLOAT_TYPE, null: true,
+ description: 'Coverage level of the job.'
def pipeline
Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Pipeline, object.pipeline_id).find
end
+ def tags
+ object.tags.map(&:name) if object.is_a?(::Ci::Build)
+ end
+
def detailed_status
object.detailed_status(context[:current_user])
end
@@ -36,6 +83,46 @@ module Types
object.job_artifacts
end
end
+
+ def stage
+ ::Gitlab::Graphql::Lazy.with_value(pipeline) do |pl|
+ BatchLoader::GraphQL.for([pl, object.stage]).batch do |ids, loader|
+ by_pipeline = ids
+ .group_by(&:first)
+ .transform_values { |grp| grp.map(&:second) }
+
+ by_pipeline.each do |p, names|
+ p.stages.by_name(names).each { |s| loader.call([p, s.name], s) }
+ end
+ end
+ end
+ end
+
+ # This class is a secret union!
+ # TODO: turn this into an actual union, so that fields can be referenced safely!
+ def id
+ return unless object.id.present?
+
+ model_name = object.type || ::CommitStatus.name
+ id = object.id
+ Gitlab::GlobalId.build(model_name: model_name, id: id)
+ end
+
+ def commit_path
+ ::Gitlab::Routing.url_helpers.project_commit_path(object.project, object.sha)
+ end
+
+ def ref_name
+ object&.ref
+ end
+
+ def ref_path
+ ::Gitlab::Routing.url_helpers.project_commits_path(object.project, ref_name)
+ end
+
+ def coverage
+ object&.coverage
+ end
end
end
end
diff --git a/app/graphql/types/ci/pipeline_config_source_enum.rb b/app/graphql/types/ci/pipeline_config_source_enum.rb
index e1575cb2f99..96c8a5f2941 100644
--- a/app/graphql/types/ci/pipeline_config_source_enum.rb
+++ b/app/graphql/types/ci/pipeline_config_source_enum.rb
@@ -4,7 +4,8 @@ module Types
module Ci
class PipelineConfigSourceEnum < BaseEnum
::Enums::Ci::Pipeline.config_sources.keys.each do |state_symbol|
- value state_symbol.to_s.upcase, value: state_symbol.to_s
+ description = state_symbol == :auto_devops_source ? "Auto DevOps source." : "#{state_symbol.to_s.titleize.capitalize}." # This is needed to avoid failure in doc lint
+ value state_symbol.to_s.upcase, value: state_symbol.to_s, description: description
end
end
end
diff --git a/app/graphql/types/ci/pipeline_status_enum.rb b/app/graphql/types/ci/pipeline_status_enum.rb
index c19ddf5bb25..e0b2020dcc1 100644
--- a/app/graphql/types/ci/pipeline_status_enum.rb
+++ b/app/graphql/types/ci/pipeline_status_enum.rb
@@ -4,7 +4,9 @@ module Types
module Ci
class PipelineStatusEnum < BaseEnum
::Ci::Pipeline.all_state_names.each do |state_symbol|
- value state_symbol.to_s.upcase, value: state_symbol.to_s
+ value state_symbol.to_s.upcase,
+ description: ::Ci::Pipeline::STATUSES_DESCRIPTION[state_symbol],
+ value: state_symbol.to_s
end
end
end
diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb
index 49be200a788..2e83f6c1f5a 100644
--- a/app/graphql/types/ci/pipeline_type.rb
+++ b/app/graphql/types/ci/pipeline_type.rb
@@ -81,6 +81,20 @@ module Types
description: 'Jobs belonging to the pipeline.',
resolver: ::Resolvers::Ci::JobsResolver
+ field :job,
+ type: ::Types::Ci::JobType,
+ null: true,
+ description: 'A specific job in this pipeline, either by name or ID.' do
+ argument :id,
+ type: ::Types::GlobalIDType[::CommitStatus],
+ required: false,
+ description: 'ID of the job.'
+ argument :name,
+ type: ::GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Name of the job.'
+ end
+
field :source_job, Types::Ci::JobType, null: true,
description: 'Job where pipeline was triggered from.'
@@ -104,8 +118,24 @@ module Types
field :active, GraphQL::BOOLEAN_TYPE, null: false, method: :active?,
description: 'Indicates if the pipeline is active.'
+ field :uses_needs, GraphQL::BOOLEAN_TYPE, null: true,
+ method: :uses_needs?,
+ description: 'Indicates if the pipeline has jobs with `needs` dependencies.'
+
+ field :test_report_summary,
+ Types::Ci::TestReportSummaryType,
+ null: false,
+ description: 'Summary of the test report generated by the pipeline.',
+ resolver: Resolvers::Ci::TestReportSummaryResolver
+
+ field :test_suite,
+ Types::Ci::TestSuiteType,
+ null: true,
+ description: 'A specific test suite in a pipeline test report.',
+ resolver: Resolvers::Ci::TestSuiteResolver
+
def detailed_status
- object.detailed_status(context[:current_user])
+ object.detailed_status(current_user)
end
def user
@@ -119,6 +149,19 @@ module Types
def path
::Gitlab::Routing.url_helpers.project_pipeline_path(object.project, object)
end
+
+ def job(id: nil, name: nil)
+ raise ::Gitlab::Graphql::Errors::ArgumentError, 'One of id or name is required' unless id || name
+
+ if id
+ id = ::Types::GlobalIDType[::CommitStatus].coerce_isolated_input(id) if id
+ pipeline.statuses.id_in(id.model_id)
+ else
+ pipeline.statuses.by_name(name)
+ end.take # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ alias_method :pipeline, :object
end
end
end
diff --git a/app/graphql/types/ci/recent_failures_type.rb b/app/graphql/types/ci/recent_failures_type.rb
new file mode 100644
index 00000000000..eeff7222762
--- /dev/null
+++ b/app/graphql/types/ci/recent_failures_type.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class RecentFailuresType < BaseObject
+ graphql_name 'RecentFailures'
+ description 'Recent failure history of a test case.'
+
+ connection_type_class(Types::CountableConnectionType)
+
+ field :count, GraphQL::INT_TYPE, null: true,
+ description: 'Number of times the test case has failed in the past 14 days.'
+
+ field :base_branch, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the base branch of the project.'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb
index 836f2430890..56b4f248697 100644
--- a/app/graphql/types/ci/stage_type.rb
+++ b/app/graphql/types/ci/stage_type.rb
@@ -12,10 +12,13 @@ module Types
extras: [:lookahead],
description: 'Group of jobs for the stage.'
field :detailed_status, Types::Ci::DetailedStatusType, null: true,
- description: 'Detailed status of the stage.'
+ description: 'Detailed status of the stage.'
+ field :jobs, Ci::JobType.connection_type, null: true,
+ description: 'Jobs for the stage.',
+ method: 'latest_statuses'
def detailed_status
- object.detailed_status(context[:current_user])
+ object.detailed_status(current_user)
end
# Issues one query per pipeline
@@ -33,6 +36,34 @@ module Types
jobs_for_pipeline(pl, indexed.keys, include_needs).each do |stage_id, statuses|
key = indexed[stage_id]
groups = ::Ci::Group.fabricate(project, key.stage, statuses)
+
+ if Feature.enabled?(:ci_no_empty_groups, project)
+ groups.each do |group|
+ rejected = group.jobs.reject { |job| Ability.allowed?(current_user, :read_commit_status, job) }
+ group.jobs.select! { |job| Ability.allowed?(current_user, :read_commit_status, job) }
+ next unless group.jobs.empty?
+
+ exc = StandardError.new('Empty Ci::Group')
+ traces = rejected.map do |job|
+ trace = []
+ policy = Ability.policy_for(current_user, job)
+ policy.debug(:read_commit_status, trace)
+ trace
+ end
+ extra = {
+ current_user_id: current_user&.id,
+ project_id: project.id,
+ pipeline_id: pl.id,
+ stage_id: stage_id,
+ group_name: group.name,
+ rejected_job_ids: rejected.map(&:id),
+ rejected_traces: traces
+ }
+ Gitlab::ErrorTracking.track_exception(exc, extra)
+ end
+ groups.reject! { |group| group.jobs.empty? }
+ end
+
loader.call(key, groups)
end
end
diff --git a/app/graphql/types/ci/test_case_status_enum.rb b/app/graphql/types/ci/test_case_status_enum.rb
new file mode 100644
index 00000000000..6a5f8bc2a59
--- /dev/null
+++ b/app/graphql/types/ci/test_case_status_enum.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ class TestCaseStatusEnum < BaseEnum
+ graphql_name 'TestCaseStatus'
+
+ ::Gitlab::Ci::Reports::TestCase::STATUS_TYPES.each do |status|
+ value status,
+ description: "Test case that has a status of #{status}.",
+ value: status
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/test_case_type.rb b/app/graphql/types/ci/test_case_type.rb
new file mode 100644
index 00000000000..9cc3f918125
--- /dev/null
+++ b/app/graphql/types/ci/test_case_type.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class TestCaseType < BaseObject
+ graphql_name 'TestCase'
+ description 'Test case in pipeline test report.'
+
+ connection_type_class(Types::CountableConnectionType)
+
+ field :status, Types::Ci::TestCaseStatusEnum, null: true,
+ description: "Status of the test case (#{::Gitlab::Ci::Reports::TestCase::STATUS_TYPES.join(', ')})."
+
+ field :name, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the test case.'
+
+ field :classname, GraphQL::STRING_TYPE, null: true,
+ description: 'Classname of the test case.'
+
+ field :execution_time, GraphQL::FLOAT_TYPE, null: true,
+ description: 'Test case execution time in seconds.'
+
+ field :file, GraphQL::STRING_TYPE, null: true,
+ description: 'Path to the file of the test case.'
+
+ field :attachment_url, GraphQL::STRING_TYPE, null: true,
+ description: 'URL of the test case attachment file.'
+
+ field :system_output, GraphQL::STRING_TYPE, null: true,
+ description: 'System output of the test case.'
+
+ field :stack_trace, GraphQL::STRING_TYPE, null: true,
+ description: 'Stack trace of the test case.'
+
+ field :recent_failures, Types::Ci::RecentFailuresType, null: true,
+ description: 'Recent failure history of the test case on the base branch.'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/ci/test_report_summary_type.rb b/app/graphql/types/ci/test_report_summary_type.rb
new file mode 100644
index 00000000000..87207c8a765
--- /dev/null
+++ b/app/graphql/types/ci/test_report_summary_type.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ # This is presented through `PipelineType` that has its own authorization
+ class TestReportSummaryType < BaseObject
+ graphql_name 'TestReportSummary'
+ description 'Test report for a pipeline'
+
+ field :total, Types::Ci::TestReportTotalType, null: false,
+ description: 'Total report statistics for a pipeline test report.'
+
+ field :test_suites, Types::Ci::TestSuiteSummaryType.connection_type, null: false,
+ description: 'Test suites belonging to a pipeline test report.'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/ci/test_report_total_type.rb b/app/graphql/types/ci/test_report_total_type.rb
new file mode 100644
index 00000000000..1123734adc3
--- /dev/null
+++ b/app/graphql/types/ci/test_report_total_type.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class TestReportTotalType < BaseObject
+ graphql_name 'TestReportTotal'
+ description 'Total test report statistics.'
+
+ field :time, GraphQL::FLOAT_TYPE, null: true,
+ description: 'Total duration of the tests.'
+
+ field :count, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of the test cases.'
+
+ field :success, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of test cases that succeeded.'
+
+ field :failed, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of test cases that failed.'
+
+ field :skipped, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of test cases that were skipped.'
+
+ field :error, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of test cases that had an error.'
+
+ field :suite_error, GraphQL::STRING_TYPE, null: true,
+ description: 'Test suite error message.'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/ci/test_suite_summary_type.rb b/app/graphql/types/ci/test_suite_summary_type.rb
new file mode 100644
index 00000000000..a80a9179cb4
--- /dev/null
+++ b/app/graphql/types/ci/test_suite_summary_type.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class TestSuiteSummaryType < BaseObject
+ graphql_name 'TestSuiteSummary'
+ description 'Test suite summary in a pipeline test report.'
+
+ connection_type_class(Types::CountableConnectionType)
+
+ field :name, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the test suite.'
+
+ field :total_time, GraphQL::FLOAT_TYPE, null: true,
+ description: 'Total duration of the tests in the test suite.'
+
+ field :total_count, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of the test cases in the test suite.'
+
+ field :success_count, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of test cases that succeeded in the test suite.'
+
+ field :failed_count, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of test cases that failed in the test suite.'
+
+ field :skipped_count, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of test cases that were skipped in the test suite.'
+
+ field :error_count, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of test cases that had an error.'
+
+ field :suite_error, GraphQL::STRING_TYPE, null: true,
+ description: 'Test suite error message.'
+
+ field :build_ids, [GraphQL::ID_TYPE], null: true,
+ description: 'IDs of the builds used to run the test suite.'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/ci/test_suite_type.rb b/app/graphql/types/ci/test_suite_type.rb
new file mode 100644
index 00000000000..7d4c01da81b
--- /dev/null
+++ b/app/graphql/types/ci/test_suite_type.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class TestSuiteType < BaseObject
+ graphql_name 'TestSuite'
+ description 'Test suite in a pipeline test report.'
+
+ connection_type_class(Types::CountableConnectionType)
+
+ field :name, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the test suite.'
+
+ field :total_time, GraphQL::FLOAT_TYPE, null: true,
+ description: 'Total duration of the tests in the test suite.'
+
+ field :total_count, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of the test cases in the test suite.'
+
+ field :success_count, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of test cases that succeeded in the test suite.'
+
+ field :failed_count, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of test cases that failed in the test suite.'
+
+ field :skipped_count, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of test cases that were skipped in the test suite.'
+
+ field :error_count, GraphQL::INT_TYPE, null: true,
+ description: 'Total number of test cases that had an error.'
+
+ field :suite_error, GraphQL::STRING_TYPE, null: true,
+ description: 'Test suite error message.'
+
+ field :test_cases, Types::Ci::TestCaseType.connection_type, null: true,
+ description: 'Test cases in the test suite.'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/concerns/find_closest.rb b/app/graphql/types/concerns/find_closest.rb
new file mode 100644
index 00000000000..1d76e872364
--- /dev/null
+++ b/app/graphql/types/concerns/find_closest.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module FindClosest
+ # Find the closest node of a given type above this node, and return the domain object
+ def closest_parent(type, parent)
+ parent = parent.try(:parent) while parent && parent.object.class != type
+ return unless parent
+
+ parent.object.object
+ end
+end
diff --git a/app/graphql/types/concerns/gitlab_style_deprecations.rb b/app/graphql/types/concerns/gitlab_style_deprecations.rb
index ad195354930..802562ed958 100644
--- a/app/graphql/types/concerns/gitlab_style_deprecations.rb
+++ b/app/graphql/types/concerns/gitlab_style_deprecations.rb
@@ -7,25 +7,21 @@ module GitlabStyleDeprecations
private
+ # Mutate the arguments, returns the deprecation
def gitlab_deprecation(kwargs)
if kwargs[:deprecation_reason].present?
raise ArgumentError, 'Use `deprecated` property instead of `deprecation_reason`. ' \
'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-fields-arguments-and-enum-values'
end
- deprecation = kwargs.delete(:deprecated)
- return kwargs unless deprecation
+ deprecation = ::Gitlab::Graphql::Deprecation.parse(kwargs.delete(:deprecated))
+ return unless deprecation
- milestone, reason = deprecation.values_at(:milestone, :reason).map(&:presence)
+ raise ArgumentError, "Bad deprecation. #{deprecation.errors.full_messages.to_sentence}" unless deprecation.valid?
- raise ArgumentError, 'Please provide a `milestone` within `deprecated`' unless milestone
- raise ArgumentError, 'Please provide a `reason` within `deprecated`' unless reason
- raise ArgumentError, '`milestone` must be a `String`' unless milestone.is_a?(String)
+ kwargs[:deprecation_reason] = deprecation.deprecation_reason
+ kwargs[:description] = deprecation.edit_description(kwargs[:description])
- deprecated_in = "Deprecated in #{milestone}"
- kwargs[:deprecation_reason] = "#{reason}. #{deprecated_in}."
- kwargs[:description] += " #{deprecated_in}: #{reason}." if kwargs[:description]
-
- kwargs
+ deprecation
end
end
diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb
index 750bd1bfe8d..79061df7282 100644
--- a/app/graphql/types/global_id_type.rb
+++ b/app/graphql/types/global_id_type.rb
@@ -23,7 +23,7 @@ module Types
A global identifier.
A global identifier represents an object uniquely across the application.
- An example of such an identifier is "gid://gitlab/User/1".
+ An example of such an identifier is `"gid://gitlab/User/1"`.
Global identifiers are encoded as strings.
DESC
@@ -67,6 +67,17 @@ module Types
graphql_name
end
+ define_singleton_method(:as) do |new_name|
+ if @renamed && graphql_name != new_name
+ raise "Conflicting names for ID of #{model_class.name}: " \
+ "#{graphql_name} and #{new_name}"
+ end
+
+ @renamed = true
+ graphql_name(new_name)
+ self
+ end
+
define_singleton_method(:coerce_result) do |gid, ctx|
global_id = ::Gitlab::GlobalId.as_global_id(gid, model_name: model_class.name)
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 7a84e76657b..a44281b2bdf 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -8,39 +8,65 @@ module Types
expose_permissions Types::PermissionTypes::Group
- field :web_url, GraphQL::STRING_TYPE, null: false,
+ field :web_url,
+ type: GraphQL::STRING_TYPE,
+ null: false,
description: 'Web URL of the group.'
- field :avatar_url, GraphQL::STRING_TYPE, null: true,
+ field :avatar_url,
+ type: GraphQL::STRING_TYPE,
+ null: true,
description: 'Avatar URL of the group.'
- field :custom_emoji, Types::CustomEmojiType.connection_type, null: true,
+ field :custom_emoji,
+ type: 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,
+ field :share_with_group_lock,
+ type: GraphQL::BOOLEAN_TYPE,
+ null: true,
description: 'Indicates if sharing a project with another group within this group is prevented.'
- field :project_creation_level, GraphQL::STRING_TYPE, null: true, method: :project_creation_level_str,
+ field :project_creation_level,
+ type: GraphQL::STRING_TYPE,
+ null: true,
+ method: :project_creation_level_str,
description: 'The permission level required to create projects in the group.'
- field :subgroup_creation_level, GraphQL::STRING_TYPE, null: true, method: :subgroup_creation_level_str,
+ field :subgroup_creation_level,
+ type: GraphQL::STRING_TYPE,
+ null: true,
+ method: :subgroup_creation_level_str,
description: 'The permission level required to create subgroups within the group.'
- field :require_two_factor_authentication, GraphQL::BOOLEAN_TYPE, null: true,
+ field :require_two_factor_authentication,
+ type: GraphQL::BOOLEAN_TYPE,
+ null: true,
description: 'Indicates if all users in this group are required to set up two-factor authentication.'
- field :two_factor_grace_period, GraphQL::INT_TYPE, null: true,
+ field :two_factor_grace_period,
+ type: GraphQL::INT_TYPE,
+ null: true,
description: 'Time before two-factor authentication is enforced.'
- field :auto_devops_enabled, GraphQL::BOOLEAN_TYPE, null: true,
+ field :auto_devops_enabled,
+ type: GraphQL::BOOLEAN_TYPE,
+ null: true,
description: 'Indicates whether Auto DevOps is enabled for all projects within this group.'
- field :emails_disabled, GraphQL::BOOLEAN_TYPE, null: true,
+ field :emails_disabled,
+ type: GraphQL::BOOLEAN_TYPE,
+ null: true,
description: 'Indicates if a group has email notifications disabled.'
- field :mentions_disabled, GraphQL::BOOLEAN_TYPE, null: true,
+ field :mentions_disabled,
+ type: GraphQL::BOOLEAN_TYPE,
+ null: true,
description: 'Indicates if a group is disabled from getting mentioned.'
- field :parent, GroupType, null: true,
+ field :parent,
+ type: GroupType,
+ null: true,
description: 'Parent group.'
field :issues,
@@ -55,7 +81,7 @@ module Types
description: 'Merge requests for projects in this group.',
resolver: Resolvers::GroupMergeRequestsResolver
- field :milestones, Types::MilestoneType.connection_type, null: true,
+ field :milestones,
description: 'Milestones of the group.',
resolver: Resolvers::GroupMilestonesResolver
@@ -76,9 +102,10 @@ module Types
Types::LabelType,
null: true,
description: 'A label available on this group.' do
- argument :title, GraphQL::STRING_TYPE,
- required: true,
- description: 'Title of the label.'
+ argument :title,
+ type: GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Title of the label.'
end
field :group_members,
@@ -92,7 +119,9 @@ module Types
resolver: Resolvers::ContainerRepositoriesResolver,
authorize: :read_container_image
- field :container_repositories_count, GraphQL::INT_TYPE, null: false,
+ field :container_repositories_count,
+ type: GraphQL::INT_TYPE,
+ null: false,
description: 'Number of container repositories in the group.'
field :packages,
@@ -114,6 +143,12 @@ module Types
description: 'Labels available on this group.',
resolver: Resolvers::GroupLabelsResolver
+ field :timelogs, ::Types::TimelogType.connection_type, null: false,
+ description: 'Time logged on issues in the group and its subgroups.',
+ extras: [:lookahead],
+ complexity: 5,
+ resolver: ::Resolvers::TimelogResolver
+
def avatar_url
object.avatar_url(only_path: false)
end
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index f15ab69f2d4..34c824fe9fb 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -124,6 +124,9 @@ module Types
field :create_note_email, GraphQL::STRING_TYPE, null: true,
description: 'User specific email address for the issue.'
+ field :timelogs, Types::TimelogType.connection_type, null: false,
+ description: 'Timelogs on the issue.'
+
def author
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
end
diff --git a/app/graphql/types/issues/negated_issue_filter_input_type.rb b/app/graphql/types/issues/negated_issue_filter_input_type.rb
new file mode 100644
index 00000000000..10bf6f21792
--- /dev/null
+++ b/app/graphql/types/issues/negated_issue_filter_input_type.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Types
+ module Issues
+ class NegatedIssueFilterInputType < BaseInputObject
+ graphql_name 'NegatedIssueFilterInput'
+
+ argument :iids, [GraphQL::STRING_TYPE],
+ required: false,
+ description: 'List of IIDs of issues to exclude. For example, [1, 2].'
+ argument :label_name, [GraphQL::STRING_TYPE],
+ required: false,
+ description: 'Labels not applied to this issue.'
+ argument :milestone_title, [GraphQL::STRING_TYPE],
+ required: false,
+ description: 'Milestone not applied to this issue.'
+ argument :assignee_usernames, [GraphQL::STRING_TYPE],
+ required: false,
+ description: 'Usernames of users not assigned to the issue.'
+ argument :assignee_id, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'ID of a user not assigned to the issues.'
+ end
+ end
+end
+
+Types::Issues::NegatedIssueFilterInputType.prepend_if_ee('::EE::Types::Issues::NegatedIssueFilterInputType')
diff --git a/app/graphql/types/jira_users_mapping_input_type.rb b/app/graphql/types/jira_users_mapping_input_type.rb
index 61e3240ecf3..32640b9cb17 100644
--- a/app/graphql/types/jira_users_mapping_input_type.rb
+++ b/app/graphql/types/jira_users_mapping_input_type.rb
@@ -5,12 +5,12 @@ module Types
graphql_name 'JiraUsersMappingInputType'
argument :jira_account_id,
- GraphQL::STRING_TYPE,
- required: true,
- description: 'Jira account ID of the user.'
+ GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Jira account ID of the user.'
argument :gitlab_id,
- GraphQL::INT_TYPE,
- required: false,
- description: 'Id of the GitLab user.'
+ GraphQL::INT_TYPE,
+ required: false,
+ description: 'ID of the GitLab user.'
end
end
diff --git a/app/graphql/types/merge_request_review_state_enum.rb b/app/graphql/types/merge_request_review_state_enum.rb
new file mode 100644
index 00000000000..45f97758425
--- /dev/null
+++ b/app/graphql/types/merge_request_review_state_enum.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ class MergeRequestReviewStateEnum < BaseEnum
+ graphql_name 'MergeRequestReviewState'
+ description 'State of a review of a GitLab merge request.'
+
+ from_rails_enum(::MergeRequestReviewer.states,
+ description: "The merge request is %{name}.")
+ end
+end
diff --git a/app/graphql/types/merge_request_state_enum.rb b/app/graphql/types/merge_request_state_enum.rb
index a2d7bd0306c..bcf18b836de 100644
--- a/app/graphql/types/merge_request_state_enum.rb
+++ b/app/graphql/types/merge_request_state_enum.rb
@@ -5,6 +5,6 @@ module Types
graphql_name 'MergeRequestState'
description 'State of a GitLab merge request'
- value 'merged', description: "Merge Request has been merged."
+ value 'merged', description: "Merge request has been merged."
end
end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 449286915f2..c8ccf9d8aff 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -132,7 +132,10 @@ module Types
description: 'The milestone of the merge request.'
field :assignees, Types::UserType.connection_type, null: true, complexity: 5,
description: 'Assignees of the merge request.'
- field :reviewers, Types::UserType.connection_type, null: true, complexity: 5,
+ field :reviewers,
+ type: Types::MergeRequests::ReviewerType.connection_type,
+ null: true,
+ complexity: 5,
description: 'Users from whom a review has been requested.'
field :author, Types::UserType, null: true,
description: 'User who created this merge request.'
@@ -183,6 +186,8 @@ module Types
description: 'Selected auto merge strategy.'
field :merge_user, Types::UserType, null: true,
description: 'User who merged this merge request.'
+ field :timelogs, Types::TimelogType.connection_type, null: false,
+ description: 'Timelogs on the merge request.'
def approved_by
object.approved_by_users
diff --git a/app/graphql/types/merge_requests/reviewer_type.rb b/app/graphql/types/merge_requests/reviewer_type.rb
new file mode 100644
index 00000000000..09ced39844a
--- /dev/null
+++ b/app/graphql/types/merge_requests/reviewer_type.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Types
+ module MergeRequests
+ class ReviewerType < ::Types::UserType
+ include FindClosest
+
+ graphql_name 'MergeRequestReviewer'
+ description 'A user from whom a merge request review has been requested.'
+ authorize :read_user
+
+ field :merge_request_interaction,
+ type: ::Types::UserMergeRequestInteractionType,
+ null: true,
+ extras: [:parent],
+ description: "Details of this user's interactions with the merge request."
+
+ def merge_request_interaction(parent:)
+ merge_request = closest_parent(::Types::MergeRequestType, parent)
+ return unless merge_request
+
+ Users::MergeRequestInteraction.new(user: object, merge_request: merge_request)
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb
index c3816116e2b..91a5109c748 100644
--- a/app/graphql/types/milestone_type.rb
+++ b/app/graphql/types/milestone_type.rb
@@ -14,6 +14,9 @@ module Types
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the milestone.'
+ field :iid, GraphQL::ID_TYPE, null: false,
+ description: "Internal ID of the milestone."
+
field :title, GraphQL::STRING_TYPE, null: false,
description: 'Title of the milestone.'
diff --git a/app/graphql/types/mutation_operation_mode_enum.rb b/app/graphql/types/mutation_operation_mode_enum.rb
index 75c1d7cd4a6..08214eebc7e 100644
--- a/app/graphql/types/mutation_operation_mode_enum.rb
+++ b/app/graphql/types/mutation_operation_mode_enum.rb
@@ -10,5 +10,13 @@ module Types
value 'REPLACE', 'Performs a replace operation.'
value 'APPEND', 'Performs an append operation.'
value 'REMOVE', 'Performs a removal operation.'
+
+ def self.default_mode
+ enum[:replace]
+ end
+
+ def self.transform_modes
+ enum.values_at(:remove, :append)
+ end
end
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 76ffddf416f..5a9c7b32deb 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -68,6 +68,7 @@ module Types
mount_mutation Mutations::Releases::Delete
mount_mutation Mutations::ReleaseAssetLinks::Create
mount_mutation Mutations::ReleaseAssetLinks::Update
+ mount_mutation Mutations::ReleaseAssetLinks::Delete
mount_mutation Mutations::Terraform::State::Delete
mount_mutation Mutations::Terraform::State::Lock
mount_mutation Mutations::Terraform::State::Unlock
diff --git a/app/graphql/types/packages/conan/file_metadatum_type.rb b/app/graphql/types/packages/conan/file_metadatum_type.rb
new file mode 100644
index 00000000000..97d5abe6ba4
--- /dev/null
+++ b/app/graphql/types/packages/conan/file_metadatum_type.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ module Packages
+ module Conan
+ class FileMetadatumType < BaseObject
+ graphql_name 'ConanFileMetadata'
+ description 'Conan file metadata'
+
+ implements Types::Packages::FileMetadataType
+
+ authorize :read_package
+
+ field :id, ::Types::GlobalIDType[::Packages::Conan::FileMetadatum], null: false, description: 'ID of the metadatum.'
+ field :recipe_revision, GraphQL::STRING_TYPE, null: false, description: 'Revision of the Conan recipe.'
+ field :package_revision, GraphQL::STRING_TYPE, null: true, description: 'Revision of the package.'
+ field :conan_package_reference, GraphQL::STRING_TYPE, null: true, description: 'Reference of the Conan package.'
+ field :conan_file_type, ::Types::Packages::Conan::MetadatumFileTypeEnum, null: false, description: 'Type of the Conan file.'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/packages/conan/metadatum_file_type_enum.rb b/app/graphql/types/packages/conan/metadatum_file_type_enum.rb
new file mode 100644
index 00000000000..d8ec3a44d4d
--- /dev/null
+++ b/app/graphql/types/packages/conan/metadatum_file_type_enum.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ module Packages
+ module Conan
+ class MetadatumFileTypeEnum < BaseEnum
+ graphql_name 'ConanMetadatumFileTypeEnum'
+ description 'Conan file types'
+
+ ::Packages::Conan::FileMetadatum.conan_file_types.keys.each do |file|
+ value file.upcase, value: file, description: "A #{file.humanize(capitalize: false)} type."
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/packages/conan/metadatum_type.rb b/app/graphql/types/packages/conan/metadatum_type.rb
new file mode 100644
index 00000000000..00b84235d27
--- /dev/null
+++ b/app/graphql/types/packages/conan/metadatum_type.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ module Packages
+ module Conan
+ class MetadatumType < BaseObject
+ graphql_name 'ConanMetadata'
+ description 'Conan metadata'
+
+ authorize :read_package
+
+ field :id, ::Types::GlobalIDType[::Packages::Conan::Metadatum], null: false, description: 'ID of the metadatum.'
+ field :created_at, Types::TimeType, null: false, description: 'Date of creation.'
+ field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.'
+ field :package_username, GraphQL::STRING_TYPE, null: false, description: 'Username of the Conan package.'
+ field :package_channel, GraphQL::STRING_TYPE, null: false, description: 'Channel of the Conan package.'
+ field :recipe, GraphQL::STRING_TYPE, null: false, description: 'Recipe of the Conan package.'
+ field :recipe_path, GraphQL::STRING_TYPE, null: false, description: 'Recipe path of the Conan package.'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/packages/file_metadata_type.rb b/app/graphql/types/packages/file_metadata_type.rb
new file mode 100644
index 00000000000..46ccb424218
--- /dev/null
+++ b/app/graphql/types/packages/file_metadata_type.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Types
+ module Packages
+ module FileMetadataType
+ include ::Types::BaseInterface
+ graphql_name 'PackageFileMetadata'
+ description 'Represents metadata associated with a Package file'
+
+ field :created_at, ::Types::TimeType, null: false, description: 'Date of creation.'
+ field :updated_at, ::Types::TimeType, null: false, description: 'Date of most recent update.'
+
+ def self.resolve_type(object, context)
+ case object
+ when ::Packages::Conan::FileMetadatum
+ ::Types::Packages::Conan::FileMetadatumType
+ else
+ # NOTE: This method must be kept in sync with `PackageFileType#file_metadata`,
+ # which must never produce data that this discriminator cannot handle.
+ raise 'Unsupported file metadata type'
+ end
+ end
+
+ orphan_types Types::Packages::Conan::FileMetadatumType
+ end
+ end
+end
diff --git a/app/graphql/types/packages/metadata_type.rb b/app/graphql/types/packages/metadata_type.rb
index 26c43b51a69..4ab6707df88 100644
--- a/app/graphql/types/packages/metadata_type.rb
+++ b/app/graphql/types/packages/metadata_type.rb
@@ -6,12 +6,14 @@ module Types
graphql_name 'PackageMetadata'
description 'Represents metadata associated with a Package'
- possible_types ::Types::Packages::Composer::MetadatumType
+ possible_types ::Types::Packages::Composer::MetadatumType, ::Types::Packages::Conan::MetadatumType
def self.resolve_type(object, context)
case object
when ::Packages::Composer::Metadatum
::Types::Packages::Composer::MetadatumType
+ when ::Packages::Conan::Metadatum
+ ::Types::Packages::Conan::MetadatumType
else
# NOTE: This method must be kept in sync with `PackageWithoutVersionsType#metadata`,
# which must never produce data that this discriminator cannot handle.
diff --git a/app/graphql/types/packages/package_details_type.rb b/app/graphql/types/packages/package_details_type.rb
new file mode 100644
index 00000000000..510b7e2ba41
--- /dev/null
+++ b/app/graphql/types/packages/package_details_type.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Types
+ module Packages
+ class PackageDetailsType < PackageType
+ graphql_name 'PackageDetailsType'
+ description 'Represents a package details in the Package Registry. Note that this type is in beta and susceptible to changes'
+ authorize :read_package
+
+ field :versions, ::Types::Packages::PackageType.connection_type, null: true,
+ description: 'The other versions of the package.'
+
+ field :package_files, Types::Packages::PackageFileType.connection_type, null: true, description: 'Package files.'
+
+ def versions
+ object.versions
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/packages/package_file_type.rb b/app/graphql/types/packages/package_file_type.rb
new file mode 100644
index 00000000000..e9e38559626
--- /dev/null
+++ b/app/graphql/types/packages/package_file_type.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Types
+ module Packages
+ class PackageFileType < BaseObject
+ graphql_name 'PackageFile'
+ description 'Represents a package file'
+ authorize :read_package
+
+ field :id, ::Types::GlobalIDType[::Packages::PackageFile], null: false, description: 'ID of the file.'
+ field :created_at, Types::TimeType, null: false, description: 'The created date.'
+ field :updated_at, Types::TimeType, null: false, description: 'The updated date.'
+ field :size, GraphQL::STRING_TYPE, null: false, description: 'Size of the package file.'
+ field :file_name, GraphQL::STRING_TYPE, null: false, description: 'Name of the package file.'
+ field :download_path, GraphQL::STRING_TYPE, null: false, description: 'Download path of the package file.'
+ field :file_md5, GraphQL::STRING_TYPE, null: true, description: 'Md5 of the package file.'
+ field :file_sha1, GraphQL::STRING_TYPE, null: true, description: 'Sha1 of the package file.'
+ field :file_sha256, GraphQL::STRING_TYPE, null: true, description: 'Sha256 of the package file.'
+ field :file_metadata, Types::Packages::FileMetadataType, null: true,
+ description: 'File metadata.'
+
+ # NOTE: This method must be kept in sync with the union
+ # type: `Types::Packages::FileMetadataType`.
+ #
+ # `Types::Packages::FileMetadataType.resolve_type(metadata, ctx)` must never raise.
+ def file_metadata
+ case object.package.package_type
+ when 'conan'
+ object.conan_file_metadatum
+ else
+ nil
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/packages/package_type.rb b/app/graphql/types/packages/package_type.rb
index 331898a1e84..a263ca1577a 100644
--- a/app/graphql/types/packages/package_type.rb
+++ b/app/graphql/types/packages/package_type.rb
@@ -2,13 +2,52 @@
module Types
module Packages
- class PackageType < PackageWithoutVersionsType
+ class PackageType < ::Types::BaseObject
graphql_name 'Package'
- description 'Represents a package in the Package Registry'
+ description 'Represents a package in the Package Registry. Note that this type is in beta and susceptible to changes'
+
authorize :read_package
- field :versions, ::Types::Packages::PackageWithoutVersionsType.connection_type, null: true,
- description: 'The other versions of the package.'
+ field :id, ::Types::GlobalIDType[::Packages::Package], null: false,
+ description: 'ID of the package.'
+
+ field :name, GraphQL::STRING_TYPE, null: false, description: 'Name of the package.'
+ field :created_at, Types::TimeType, null: false, description: 'Date of creation.'
+ field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.'
+ field :version, GraphQL::STRING_TYPE, null: true, description: 'Version string.'
+ field :package_type, Types::Packages::PackageTypeEnum, null: false, description: 'Package type.'
+ field :tags, Types::Packages::PackageTagType.connection_type, null: true, description: 'Package tags.'
+ field :project, Types::ProjectType, null: false, description: 'Project where the package is stored.'
+ field :pipelines, Types::Ci::PipelineType.connection_type, null: true,
+ description: 'Pipelines that built the package.'
+ field :metadata, Types::Packages::MetadataType, null: true,
+ description: 'Package metadata.'
+ field :versions, ::Types::Packages::PackageType.connection_type, null: true,
+ description: 'The other versions of the package.',
+ deprecated: { reason: 'This field is now only returned in the PackageDetailsType', milestone: '13.11' }
+
+ def project
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find
+ end
+
+ def versions
+ []
+ end
+
+ # NOTE: This method must be kept in sync with the union
+ # type: `Types::Packages::MetadataType`.
+ #
+ # `Types::Packages::MetadataType.resolve_type(metadata, ctx)` must never raise.
+ def metadata
+ case object.package_type
+ when 'composer'
+ object.composer_metadatum
+ when 'conan'
+ object.conan_metadatum
+ else
+ nil
+ end
+ end
end
end
end
diff --git a/app/graphql/types/packages/package_without_versions_type.rb b/app/graphql/types/packages/package_without_versions_type.rb
deleted file mode 100644
index 9c6bb37e6cc..00000000000
--- a/app/graphql/types/packages/package_without_versions_type.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-module Types
- module Packages
- class PackageWithoutVersionsType < ::Types::BaseObject
- graphql_name 'PackageWithoutVersions'
- description 'Represents a version of a package in the Package Registry'
-
- authorize :read_package
-
- field :id, ::Types::GlobalIDType[::Packages::Package], null: false,
- description: 'ID of the package.'
-
- field :name, GraphQL::STRING_TYPE, null: false, description: 'Name of the package.'
- field :created_at, Types::TimeType, null: false, description: 'Date of creation.'
- field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.'
- field :version, GraphQL::STRING_TYPE, null: true, description: 'Version string.'
- field :package_type, Types::Packages::PackageTypeEnum, null: false, description: 'Package type.'
- field :tags, Types::Packages::PackageTagType.connection_type, null: true, description: 'Package tags.'
- field :project, Types::ProjectType, null: false, description: 'Project where the package is stored.'
- field :pipelines, Types::Ci::PipelineType.connection_type, null: true,
- description: 'Pipelines that built the package.'
- field :metadata, Types::Packages::MetadataType, null: true,
- description: 'Package metadata.'
-
- def project
- Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find
- end
-
- # NOTE: This method must be kept in sync with the union
- # type: `Types::Packages::MetadataType`.
- #
- # `Types::Packages::MetadataType.resolve_type(metadata, ctx)` must never raise.
- def metadata
- case object.package_type
- when 'composer'
- object.composer_metadatum
- else
- nil
- end
- end
- end
- end
-end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 9a3f2e311e6..21534f40499 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -183,6 +183,12 @@ module Types
description: 'Packages of the project.',
resolver: Resolvers::ProjectPackagesResolver
+ field :jobs,
+ Types::Ci::JobType.connection_type,
+ null: true,
+ description: 'Jobs of a project. This field can only be resolved for one project in any single request.',
+ resolver: Resolvers::ProjectJobsResolver
+
field :pipelines,
null: true,
description: 'Build pipelines of the project.',
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 74818bfcd42..8af0db644dd 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -55,7 +55,10 @@ module Types
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.'
+ argument :id,
+ type: ::Types::GlobalIDType[::ContainerRepository],
+ required: true,
+ description: 'The global ID of the container repository.'
end
field :package,
@@ -72,9 +75,7 @@ module Types
description: 'Find users.',
resolver: Resolvers::UsersResolver
- field :echo, GraphQL::STRING_TYPE, null: false,
- description: 'Text to echo back.',
- resolver: Resolvers::EchoResolver
+ field :echo, resolver: Resolvers::EchoResolver
field :issue, Types::IssueType,
null: true,
@@ -82,11 +83,16 @@ module Types
argument :id, ::Types::GlobalIDType[::Issue], required: true, description: 'The global ID of the Issue.'
end
- field :instance_statistics_measurements, Types::Admin::Analytics::UsageTrends::MeasurementType.connection_type,
+ field :instance_statistics_measurements,
+ type: Types::Admin::Analytics::UsageTrends::MeasurementType.connection_type,
null: true,
description: 'Get statistics on the instance.',
- deprecated: { reason: 'This field was renamed. Use the `usageTrendsMeasurements` field instead', milestone: '13.10' },
- resolver: Resolvers::Admin::Analytics::UsageTrends::MeasurementsResolver
+ resolver: Resolvers::Admin::Analytics::UsageTrends::MeasurementsResolver,
+ deprecated: {
+ reason: :renamed,
+ replacement: 'Query.usageTrendsMeasurements',
+ milestone: '13.10'
+ }
field :usage_trends_measurements, Types::Admin::Analytics::UsageTrends::MeasurementType.connection_type,
null: true,
@@ -97,18 +103,10 @@ module Types
null: true,
description: 'CI related settings that apply to the entire instance.'
- field :runner_platforms, Types::Ci::RunnerPlatformType.connection_type,
- 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
+ field :runner_platforms, resolver: Resolvers::Ci::RunnerPlatformsResolver
+ field :runner_setup, resolver: Resolvers::Ci::RunnerSetupResolver
- field :ci_config, Types::Ci::Config::ConfigType, null: true,
- description: 'Get linted and processed contents of a CI config. Should not be requested more than once per request.',
- resolver: Resolvers::Ci::ConfigResolver,
- complexity: 126 # AUTHENTICATED_COMPLEXITY / 2 + 1
+ field :ci_config, resolver: Resolvers::Ci::ConfigResolver, complexity: 126 # AUTHENTICATED_COMPLEXITY / 2 + 1
def design_management
DesignManagementObject.new(nil)
diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb
new file mode 100644
index 00000000000..912fc5f643a
--- /dev/null
+++ b/app/graphql/types/repository/blob_type.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+module Types
+ module Repository
+ # rubocop: disable Graphql/AuthorizeTypes
+ # This is presented through `Repository` that has its own authorization
+ class BlobType < BaseObject
+ present_using BlobPresenter
+
+ graphql_name 'RepositoryBlob'
+
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID of the blob.'
+
+ field :oid, GraphQL::STRING_TYPE, null: false, method: :id,
+ description: 'OID of the blob.'
+
+ field :path, GraphQL::STRING_TYPE, null: false,
+ description: 'Path of the blob.'
+
+ field :name, GraphQL::STRING_TYPE,
+ description: 'Blob name.',
+ null: true
+
+ field :mode, type: GraphQL::STRING_TYPE,
+ description: 'Blob mode.',
+ null: true
+
+ field :lfs_oid, GraphQL::STRING_TYPE, null: true,
+ calls_gitaly: true,
+ description: 'LFS OID of the blob.'
+
+ field :web_path, GraphQL::STRING_TYPE, null: true,
+ description: 'Web path of the blob.'
+
+ def lfs_oid
+ Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(object.repository, object.id).find
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb
index e319a5f3124..963a4296c4f 100644
--- a/app/graphql/types/repository_type.rb
+++ b/app/graphql/types/repository_type.rb
@@ -14,5 +14,10 @@ module Types
description: 'Indicates a corresponding Git repository exists on disk.'
field :tree, Types::Tree::TreeType, null: true, resolver: Resolvers::TreeResolver, calls_gitaly: true,
description: 'Tree of the repository.'
+ field :blobs, Types::Repository::BlobType.connection_type, null: true, resolver: Resolvers::BlobsResolver, calls_gitaly: true,
+ description: 'Blobs contained within the repository'
+ field :branch_names, [GraphQL::STRING_TYPE], null: true, calls_gitaly: true,
+ complexity: 170, description: 'Names of branches available in this repository that match the search pattern.',
+ resolver: Resolvers::RepositoryBranchNamesResolver
end
end
diff --git a/app/graphql/types/sort_enum.rb b/app/graphql/types/sort_enum.rb
index ff994039b6d..cc04394004d 100644
--- a/app/graphql/types/sort_enum.rb
+++ b/app/graphql/types/sort_enum.rb
@@ -7,10 +7,34 @@ module Types
# Deprecated, as we prefer uppercase enums
# https://gitlab.com/groups/gitlab-org/-/epics/1838
- value 'updated_desc', 'Updated at descending order.', value: :updated_desc, deprecated: { reason: 'Use UPDATED_DESC', milestone: '13.5' }
- value 'updated_asc', 'Updated at ascending order.', value: :updated_asc, deprecated: { reason: 'Use UPDATED_ASC', milestone: '13.5' }
- value 'created_desc', 'Created at descending order.', value: :created_desc, deprecated: { reason: 'Use CREATED_DESC', milestone: '13.5' }
- value 'created_asc', 'Created at ascending order.', value: :created_asc, deprecated: { reason: 'Use CREATED_ASC', milestone: '13.5' }
+ value 'updated_desc', 'Updated at descending order.',
+ value: :updated_desc,
+ deprecated: {
+ reason: :renamed,
+ replacement: 'UPDATED_DESC',
+ milestone: '13.5'
+ }
+ value 'updated_asc', 'Updated at ascending order.',
+ value: :updated_asc,
+ deprecated: {
+ reason: :renamed,
+ replacement: 'UPDATED_ASC',
+ milestone: '13.5'
+ }
+ value 'created_desc', 'Created at descending order.',
+ value: :created_desc,
+ deprecated: {
+ reason: :renamed,
+ replacement: 'CREATED_DESC',
+ milestone: '13.5'
+ }
+ value 'created_asc', 'Created at ascending order.',
+ value: :created_asc,
+ deprecated: {
+ reason: :renamed,
+ replacement: 'CREATED_ASC',
+ milestone: '13.5'
+ }
value 'UPDATED_DESC', 'Updated at descending order.', value: :updated_desc
value 'UPDATED_ASC', 'Updated at ascending order.', value: :updated_asc
diff --git a/app/graphql/types/timelog_type.rb b/app/graphql/types/timelog_type.rb
new file mode 100644
index 00000000000..465e3c492bc
--- /dev/null
+++ b/app/graphql/types/timelog_type.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Types
+ class TimelogType < BaseObject
+ graphql_name 'Timelog'
+
+ authorize :read_group_timelogs
+
+ field :spent_at,
+ Types::TimeType,
+ null: true,
+ description: 'Timestamp of when the time tracked was spent at.'
+
+ field :time_spent,
+ GraphQL::INT_TYPE,
+ null: false,
+ description: 'The time spent displayed in seconds.'
+
+ field :user,
+ Types::UserType,
+ null: false,
+ description: 'The user that logged the time.'
+
+ field :issue,
+ Types::IssueType,
+ null: true,
+ description: 'The issue that logged time was added to.'
+
+ field :note,
+ Types::Notes::NoteType,
+ null: true,
+ description: 'The note where the quick action to add the logged time was executed.'
+
+ def user
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.user_id).find
+ end
+
+ def issue
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Issue, object.issue_id).find
+ end
+ end
+end
diff --git a/app/graphql/types/user_merge_request_interaction_type.rb b/app/graphql/types/user_merge_request_interaction_type.rb
new file mode 100644
index 00000000000..5ff0d79f13e
--- /dev/null
+++ b/app/graphql/types/user_merge_request_interaction_type.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Types
+ class UserMergeRequestInteractionType < BaseObject
+ graphql_name 'UserMergeRequestInteraction'
+ description <<~MD
+ Information about a merge request given a specific user.
+
+ This object has two parts to its state: a `User` and a `MergeRequest`. All
+ fields relate to interactions between the two entities.
+ MD
+
+ authorize :read_merge_request
+
+ field :can_merge,
+ type: ::GraphQL::BOOLEAN_TYPE,
+ null: false,
+ calls_gitaly: true,
+ method: :can_merge?,
+ description: 'Whether this user can merge this merge request.'
+
+ field :can_update,
+ type: ::GraphQL::BOOLEAN_TYPE,
+ null: false,
+ method: :can_update?,
+ description: 'Whether this user can update this merge request.'
+
+ field :review_state,
+ ::Types::MergeRequestReviewStateEnum,
+ null: true,
+ description: 'The state of the review by this user.'
+
+ field :reviewed,
+ type: ::GraphQL::BOOLEAN_TYPE,
+ null: false,
+ method: :reviewed?,
+ description: 'Whether this user has provided a review for this merge request.'
+
+ field :approved,
+ type: ::GraphQL::BOOLEAN_TYPE,
+ null: false,
+ method: :approved?,
+ description: 'Whether this user has approved this merge request.'
+ end
+end
+
+::Types::UserMergeRequestInteractionType.prepend_if_ee('EE::Types::UserMergeRequestInteractionType')
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
index 2cc7d379240..3d7db80ae11 100644
--- a/app/graphql/types/user_type.rb
+++ b/app/graphql/types/user_type.rb
@@ -3,6 +3,7 @@
module Types
class UserType < BaseObject
graphql_name 'User'
+ description 'Representation of a GitLab user.'
authorize :read_user
@@ -10,61 +11,87 @@ module Types
expose_permissions Types::PermissionTypes::User
- field :id, GraphQL::ID_TYPE, null: false,
+ field :id,
+ type: GraphQL::ID_TYPE,
+ null: false,
description: 'ID of the user.'
- field :bot, GraphQL::BOOLEAN_TYPE, null: false,
+ field :bot,
+ type: GraphQL::BOOLEAN_TYPE,
+ null: false,
description: 'Indicates if the user is a bot.',
method: :bot?
- field :username, GraphQL::STRING_TYPE, null: false,
+ field :username,
+ type: GraphQL::STRING_TYPE,
+ null: false,
description: 'Username of the user. Unique within this instance of GitLab.'
- field :name, GraphQL::STRING_TYPE, null: false,
+ field :name,
+ type: GraphQL::STRING_TYPE,
+ null: false,
description: 'Human-readable name of the user.'
- field :state, Types::UserStateEnum, null: false,
+ field :state,
+ type: Types::UserStateEnum,
+ null: false,
description: 'State of the user.'
- field :email, GraphQL::STRING_TYPE, null: true,
+ field :email,
+ type: GraphQL::STRING_TYPE,
+ null: true,
description: 'User email.', method: :public_email,
- deprecated: { reason: 'Use public_email', milestone: '13.7' }
- field :public_email, GraphQL::STRING_TYPE, null: true,
+ deprecated: { reason: :renamed, replacement: 'User.publicEmail', milestone: '13.7' }
+ field :public_email,
+ type: GraphQL::STRING_TYPE,
+ null: true,
description: "User's public email."
- field :avatar_url, GraphQL::STRING_TYPE, null: true,
+ field :avatar_url,
+ type: GraphQL::STRING_TYPE,
+ null: true,
description: "URL of the user's avatar."
- field :web_url, GraphQL::STRING_TYPE, null: false,
+ field :web_url,
+ type: GraphQL::STRING_TYPE,
+ null: false,
description: 'Web URL of the user.'
- field :web_path, GraphQL::STRING_TYPE, null: false,
+ field :web_path,
+ type: GraphQL::STRING_TYPE,
+ null: false,
description: 'Web path of the user.'
- field :todos, Types::TodoType.connection_type, null: false,
+ field :todos,
resolver: Resolvers::TodoResolver,
description: 'To-do items of the user.'
- field :group_memberships, Types::GroupMemberType.connection_type, null: true,
+ field :group_memberships,
+ type: Types::GroupMemberType.connection_type,
+ null: true,
description: 'Group memberships of the user.'
- field :group_count, GraphQL::INT_TYPE, null: true,
+ field :group_count,
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 :location, ::GraphQL::STRING_TYPE, null: true,
+ field :status,
+ type: Types::UserStatusType,
+ null: true,
+ description: 'User status.'
+ field :location,
+ type: ::GraphQL::STRING_TYPE,
+ null: true,
description: 'The location of the user.'
- field :project_memberships, Types::ProjectMemberType.connection_type, null: true,
+ field :project_memberships,
+ type: Types::ProjectMemberType.connection_type,
+ null: true,
description: 'Project memberships of the user.'
- field :starred_projects, Types::ProjectType.connection_type, null: true,
+ field :starred_projects,
description: 'Projects starred by the user.',
resolver: Resolvers::UserStarredProjectsResolver
# Merge request field: MRs can be authored, assigned, or assigned-for-review:
field :authored_merge_requests,
resolver: Resolvers::AuthoredMergeRequestsResolver,
- description: 'Merge Requests authored by the user.'
+ description: 'Merge requests authored by the user.'
field :assigned_merge_requests,
resolver: Resolvers::AssignedMergeRequestsResolver,
- description: 'Merge Requests assigned to the user.'
+ description: 'Merge requests assigned to the user.'
field :review_requested_merge_requests,
resolver: Resolvers::ReviewRequestedMergeRequestsResolver,
- description: 'Merge Requests assigned to the user for review.'
+ description: 'Merge requests assigned to the user for review.'
field :snippets,
- Types::SnippetType.connection_type,
- null: true,
description: 'Snippets authored by the user.',
resolver: Resolvers::Users::SnippetsResolver
field :callouts,
diff --git a/app/helpers/analytics/unique_visits_helper.rb b/app/helpers/analytics/unique_visits_helper.rb
index 337a5dc9536..4aa8907f578 100644
--- a/app/helpers/analytics/unique_visits_helper.rb
+++ b/app/helpers/analytics/unique_visits_helper.rb
@@ -16,7 +16,7 @@ module Analytics
def track_visit(target_id)
return unless visitor_id
- Gitlab::Analytics::UniqueVisits.new.track_visit(visitor_id, target_id)
+ Gitlab::Analytics::UniqueVisits.new.track_visit(target_id, values: visitor_id)
end
class_methods do
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index 3ae9f93a27a..65feea4f6e0 100644
--- a/app/helpers/appearances_helper.rb
+++ b/app/helpers/appearances_helper.rb
@@ -84,3 +84,4 @@ module AppearancesHelper
end
AppearancesHelper.prepend_if_ee('EE::AppearancesHelper')
+AppearancesHelper.prepend_if_jh('JH::AppearancesHelper')
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 9af45daaca4..a2ef2f1207c 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -165,7 +165,7 @@ module ApplicationHelper
css_classes = [short_format ? 'js-short-timeago' : 'js-timeago']
css_classes << html_class unless html_class.blank?
- element = content_tag :time, l(time, format: "%b %d, %Y"),
+ content_tag :time, l(time, format: "%b %d, %Y"),
class: css_classes.join(' '),
title: l(time.to_time.in_time_zone, format: :timeago_tooltip),
datetime: time.to_time.getutc.iso8601,
@@ -174,8 +174,6 @@ module ApplicationHelper
placement: placement,
container: 'body'
}
-
- element
end
def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false)
@@ -194,10 +192,16 @@ module ApplicationHelper
end
end
- def promo_host
+ # This needs to be used outside of Rails
+ def self.promo_host
'about.gitlab.com'
end
+ # Convenient method for Rails helper
+ def promo_host
+ ApplicationHelper.promo_host
+ end
+
def promo_url
'https://' + promo_host
end
@@ -281,6 +285,7 @@ module ApplicationHelper
def page_class
class_names = []
class_names << 'issue-boards-page gl-overflow-auto' if current_controller?(:boards)
+ class_names << 'epic-boards-page' if current_controller?(:epic_boards)
class_names << 'environment-logs-page' if current_controller?(:logs)
class_names << 'with-performance-bar' if performance_bar_enabled?
class_names << system_message_class
@@ -405,3 +410,4 @@ module ApplicationHelper
end
ApplicationHelper.prepend_if_ee('EE::ApplicationHelper')
+ApplicationHelper.prepend_if_jh('JH::ApplicationHelper')
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 085fbfd08da..504ebb5606e 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -179,6 +179,7 @@ module ApplicationSettingsHelper
def visible_attributes
[
:abuse_notification_email,
+ :admin_mode,
:after_sign_out_path,
:after_sign_up_text,
:akismet_api_key,
@@ -228,6 +229,9 @@ module ApplicationSettingsHelper
:email_author_in_body,
:enabled_git_access_protocol,
:enforce_terms,
+ :external_pipeline_validation_service_timeout,
+ :external_pipeline_validation_service_token,
+ :external_pipeline_validation_service_url,
:first_day_of_week,
:force_pages_access_control,
:gitaly_timeout_default,
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index 8d22bda279f..09f91f350bd 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -24,11 +24,7 @@ module AvatarsHelper
def avatar_icon_for_email(email = nil, size = nil, scale = 2, only_path: true)
return gravatar_icon(email, size, scale) if email.nil?
- if Feature.enabled?(:avatar_cache_for_email, @current_user, type: :development)
- Gitlab::AvatarCache.by_email(email, size, scale, only_path) do
- avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path: only_path)
- end
- else
+ Gitlab::AvatarCache.by_email(email, size, scale, only_path) do
avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path: only_path)
end
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 41bbd0fddd5..3144686bba9 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -41,20 +41,20 @@ module BlobHelper
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))
+ def ide_fork_and_edit_path(project = @project, ref = @ref, path = @path, with_notice: true)
+ fork_path_for_current_user(project, ide_edit_path(project, ref, path), with_notice: with_notice)
end
def fork_and_edit_path(project = @project, ref = @ref, path = @path, options = {})
fork_path_for_current_user(project, edit_blob_path(project, ref, path, options))
end
- def fork_path_for_current_user(project, path)
+ def fork_path_for_current_user(project, path, with_notice: true)
return unless current_user
project_forks_path(project,
namespace_key: current_user.namespace&.id,
- continue: edit_blob_fork_params(path))
+ continue: edit_blob_fork_params(path, with_notice: with_notice))
end
def encode_ide_path(path)
@@ -330,12 +330,12 @@ module BlobHelper
blob if blob&.readable_text?
end
- def edit_blob_fork_params(path)
+ def edit_blob_fork_params(path, with_notice: true)
{
to: path,
- notice: edit_in_new_fork_notice,
- notice_now: edit_in_new_fork_notice_now
- }
+ notice: (edit_in_new_fork_notice if with_notice),
+ notice_now: (edit_in_new_fork_notice_now if with_notice)
+ }.compact
end
def edit_modify_file_fork_params(action)
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index b8c2255ab7e..49963d14934 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -14,7 +14,8 @@ module BoardsHelper
root_path: root_path,
full_path: full_path,
bulk_update_path: @bulk_issues_path,
- can_update: (!!can?(current_user, :admin_issue, board)).to_s,
+ can_update: can_update?.to_s,
+ can_admin_list: can_admin_list?.to_s,
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s,
recent_boards_endpoint: recent_boards_path,
parent: current_board_parent.model_name.param_key,
@@ -88,6 +89,14 @@ module BoardsHelper
@current_board_parent ||= @group || @project
end
+ def can_update?
+ can?(current_user, :admin_issue, board)
+ end
+
+ def can_admin_list?
+ can?(current_user, :admin_issue_board_list, current_board_parent)
+ end
+
def can_admin_issue?
can?(current_user, :admin_issue, current_board_parent)
end
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index ea24f469ffa..1b00f583b55 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -25,6 +25,7 @@ module ButtonHelper
button_text = data[:button_text] || ''
hide_tooltip = data[:hide_tooltip] || false
hide_button_icon = data[:hide_button_icon] || false
+ item_prop = data[:itemprop] || nil
# This supports code in app/assets/javascripts/copy_to_clipboard.js that
# works around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM.
@@ -49,7 +50,8 @@ module ButtonHelper
data: data,
type: :button,
title: title,
- aria: { label: title }
+ aria: { label: title },
+ itemprop: item_prop
}
content_tag :button, button_attributes do
diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb
index ec17eccf693..a0d169c1358 100644
--- a/app/helpers/ci/jobs_helper.rb
+++ b/app/helpers/ci/jobs_helper.rb
@@ -18,6 +18,21 @@ module Ci
"retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs')
}
end
+
+ def job_counts
+ {
+ "all" => limited_counter_with_delimiter(@all_builds),
+ "pending" => limited_counter_with_delimiter(@all_builds.pending),
+ "running" => limited_counter_with_delimiter(@all_builds.running),
+ "finished" => limited_counter_with_delimiter(@all_builds.finished)
+ }
+ end
+
+ def job_statuses
+ statuses = Ci::HasStatus::AVAILABLE_STATUSES
+
+ statuses.to_h { |status| [status, status.upcase] }
+ end
end
end
diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb
index a71b0f4a83a..ceb18d90c92 100644
--- a/app/helpers/ci/pipeline_editor_helper.rb
+++ b/app/helpers/ci/pipeline_editor_helper.rb
@@ -7,5 +7,23 @@ module Ci
def can_view_pipeline_editor?(project)
can_collaborate_with_project?(project)
end
+
+ def js_pipeline_editor_data(project)
+ {
+ "ci-config-path": project.ci_config_path_or_default,
+ "commit-sha" => project.commit ? project.commit.sha : '',
+ "default-branch" => project.default_branch,
+ "empty-state-illustration-path" => image_path('illustrations/empty-state/empty-dag-md.svg'),
+ "initial-branch-name": params[:branch_name],
+ "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'),
+ "new-merge-request-path" => namespace_project_new_merge_request_path,
+ "project-path" => project.path,
+ "project-full-path" => project.full_path,
+ "project-namespace" => project.namespace.full_path,
+ "yml-help-page-path" => help_page_path('ci/yaml/README')
+ }
+ end
end
end
+
+Ci::PipelineEditorHelper.prepend_if_ee('EE::Ci::PipelineEditorHelper')
diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb
index 8a6f0821dbb..cabb43f45fd 100644
--- a/app/helpers/ci/pipelines_helper.rb
+++ b/app/helpers/ci/pipelines_helper.rb
@@ -30,6 +30,46 @@ module Ci
project.has_ci? && project.builds_enabled?
end
+ # This list of templates is for the pipeline_empty_state_templates experiment
+ # and will be cleaned up with https://gitlab.com/gitlab-org/gitlab/-/issues/326299
+ def experiment_suggested_ci_templates
+ [
+ { name: 'Android', logo: image_path('illustrations/logos/android.svg') },
+ { name: 'Bash', logo: image_path('illustrations/logos/bash.svg') },
+ { name: 'C++', logo: image_path('illustrations/logos/c_plus_plus.svg') },
+ { name: 'Clojure', logo: image_path('illustrations/logos/clojure.svg') },
+ { name: 'Composer', logo: image_path('illustrations/logos/composer.svg') },
+ { name: 'Crystal', logo: image_path('illustrations/logos/crystal.svg') },
+ { name: 'Dart', logo: image_path('illustrations/logos/dart.svg') },
+ { name: 'Django', logo: image_path('illustrations/logos/django.svg') },
+ { name: 'Docker', logo: image_path('illustrations/logos/docker.svg') },
+ { name: 'Elixir', logo: image_path('illustrations/logos/elixir.svg') },
+ { name: 'iOS-Fastlane', logo: image_path('illustrations/logos/fastlane.svg') },
+ { name: 'Flutter', logo: image_path('illustrations/logos/flutter.svg') },
+ { name: 'Go', logo: image_path('illustrations/logos/go_logo.svg') },
+ { name: 'Gradle', logo: image_path('illustrations/logos/gradle.svg') },
+ { name: 'Grails', logo: image_path('illustrations/logos/grails.svg') },
+ { name: 'dotNET', logo: image_path('illustrations/logos/dotnet.svg') },
+ { name: 'Rails', logo: image_path('illustrations/logos/rails.svg') },
+ { name: 'Julia', logo: image_path('illustrations/logos/julia.svg') },
+ { name: 'Laravel', logo: image_path('illustrations/logos/laravel.svg') },
+ { name: 'Latex', logo: image_path('illustrations/logos/latex.svg') },
+ { name: 'Maven', logo: image_path('illustrations/logos/maven.svg') },
+ { name: 'Mono', logo: image_path('illustrations/logos/mono.svg') },
+ { name: 'Nodejs', logo: image_path('illustrations/logos/node_js.svg') },
+ { name: 'npm', logo: image_path('illustrations/logos/npm.svg') },
+ { name: 'OpenShift', logo: image_path('illustrations/logos/openshift.svg') },
+ { name: 'Packer', logo: image_path('illustrations/logos/packer.svg') },
+ { name: 'PHP', logo: image_path('illustrations/logos/php.svg') },
+ { name: 'Python', logo: image_path('illustrations/logos/python.svg') },
+ { name: 'Ruby', logo: image_path('illustrations/logos/ruby.svg') },
+ { name: 'Rust', logo: image_path('illustrations/logos/rust.svg') },
+ { name: 'Scala', logo: image_path('illustrations/logos/scala.svg') },
+ { name: 'Swift', logo: image_path('illustrations/logos/swift.svg') },
+ { name: 'Terraform', logo: image_path('illustrations/logos/terraform.svg') }
+ ]
+ end
+
private
def warning_markdown(pipeline)
diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb
index ba5d4e8c65a..82347053d6f 100644
--- a/app/helpers/ci/runners_helper.rb
+++ b/app/helpers/ci/runners_helper.rb
@@ -4,18 +4,33 @@ module Ci
module RunnersHelper
include IconsHelper
- def runner_status_icon(runner)
+ def runner_status_icon(runner, size: 16, icon_class: '')
status = runner.status
+
+ title = ''
+ icon = 'warning-solid'
+ span_class = ''
+
case status
when :not_connected
- content_tag(:span, title: _("New runner. Has not connected yet")) do
- sprite_icon("warning-solid", size: 24, css_class: "gl-vertical-align-bottom!")
- end
+ title = s_("Runners|New runner, has not connected yet")
+ icon = 'warning-solid'
+ when :online
+ title = s_("Runners|Runner is online, last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(runner.contacted_at) }
+ icon = 'status-active'
+ span_class = 'gl-text-green-500'
+ when :offline
+ title = s_("Runners|Runner is offline, last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(runner.contacted_at) }
+ icon = 'status-failed'
+ span_class = 'gl-text-red-500'
+ when :paused
+ title = s_("Runners|Runner is paused, last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(runner.contacted_at) }
+ icon = 'status-paused'
+ span_class = 'gl-text-gray-600'
+ end
- when :online, :offline, :paused
- content_tag :span, nil,
- class: "gl-display-inline-block gl-avatar gl-avatar-s16 gl-avatar-circle runner-status runner-status-#{status}",
- title: _("Runner is %{status}, last contact was %{runner_contact} ago") % { status: status, runner_contact: time_ago_in_words(runner.contacted_at) }
+ content_tag(:span, class: span_class, title: title, data: { toggle: 'tooltip', container: 'body', testid: 'runner_status_icon', qa_selector: "runner_status_#{status}_content" }) do
+ sprite_icon(icon, size: size, css_class: icon_class)
end
end
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index cc633df77f9..439628f40c6 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -71,6 +71,8 @@ module ClustersHelper
render_if_exists 'clusters/clusters/health'
when 'apps'
render 'applications'
+ when 'integrations'
+ render 'integrations'
when 'settings'
render 'advanced_settings_container'
else
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index 42e4844cc8d..e7a81eb5629 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -110,16 +110,18 @@ module CommitsHelper
end
end
- def revert_commit_link
- return unless current_user
-
- tag(:div, data: { display_text: 'Revert' }, class: "js-revert-commit-trigger")
- end
-
- def cherry_pick_commit_link
- return unless current_user
-
- tag(:div, data: { display_text: 'Cherry-pick' }, class: "js-cherry-pick-commit-trigger")
+ def commit_options_dropdown_data(project, commit)
+ can_collaborate = current_user && can_collaborate_with_project?(project)
+
+ {
+ new_project_tag_path: new_project_tag_path(project, ref: commit),
+ email_patches_path: project_commit_path(project, commit, format: :patch),
+ plain_diff_path: project_commit_path(project, commit, format: :diff),
+ can_revert: "#{can_collaborate && !commit.has_been_reverted?(current_user)}",
+ can_cherry_pick: can_collaborate.to_s,
+ can_tag: can?(current_user, :push_code, project).to_s,
+ can_email_patches: (commit.parents.length < 2).to_s
+ }
end
def commit_signature_badge_classes(additional_classes)
@@ -137,7 +139,7 @@ module CommitsHelper
def cherry_pick_projects_data(project)
return [] unless Feature.enabled?(:pick_into_project, project, default_enabled: :yaml)
- target_projects(project).map do |project|
+ [project, project.forked_from_project].compact.map do |project|
{
id: project.id.to_s,
name: project.full_path,
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 10c7b4032cf..7bf3cb6230b 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -190,12 +190,8 @@ module DiffHelper
def render_overflow_warning?(diffs_collection)
diff_files = diffs_collection.raw_diff_files
- if diff_files.any?(&:too_large?)
- Gitlab::Metrics.add_event(:diffs_overflow_single_file_limits)
- end
-
diff_files.overflow?.tap do |overflown|
- Gitlab::Metrics.add_event(:diffs_overflow_collection_limits) if overflown
+ log_overflow_limits(diff_files)
end
end
@@ -286,4 +282,18 @@ module DiffHelper
conflicts_service.conflicts.files.index_by(&:our_path)
end
+
+ def log_overflow_limits(diff_files)
+ if diff_files.any?(&:too_large?)
+ Gitlab::Metrics.add_event(:diffs_overflow_single_file_limits)
+ end
+
+ Gitlab::Metrics.add_event(:diffs_overflow_collection_limits) if diff_files.overflow?
+ Gitlab::Metrics.add_event(:diffs_overflow_max_bytes_limits) if diff_files.overflow_max_bytes?
+ Gitlab::Metrics.add_event(:diffs_overflow_max_files_limits) if diff_files.overflow_max_files?
+ Gitlab::Metrics.add_event(:diffs_overflow_max_lines_limits) if diff_files.overflow_max_lines?
+ Gitlab::Metrics.add_event(:diffs_overflow_collapsed_bytes_limits) if diff_files.collapsed_safe_bytes?
+ Gitlab::Metrics.add_event(:diffs_overflow_collapsed_files_limits) if diff_files.collapsed_safe_files?
+ Gitlab::Metrics.add_event(:diffs_overflow_collapsed_lines_limits) if diff_files.collapsed_safe_lines?
+ end
end
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index 45f5281b515..c2f7fa2074c 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -29,7 +29,7 @@ module DropdownsHelper
output << dropdown_filter(options[:placeholder])
end
- output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.key?(:content_class)}") do
+ output << content_tag(:div, data: { qa_selector: "dropdown_list_content" }, class: "dropdown-content #{options[:content_class] if options.key?(:content_class)}") do
capture(&block) if block && !options.key?(:footer_content)
end
@@ -102,7 +102,7 @@ module DropdownsHelper
def dropdown_filter(placeholder, search_id: nil)
content_tag :div, class: "dropdown-input" do
- filter_output = search_field_tag search_id, nil, class: "dropdown-input-field qa-dropdown-input-field", placeholder: placeholder, autocomplete: 'off'
+ filter_output = search_field_tag search_id, nil, data: { qa_selector: "dropdown_input_field" }, class: "dropdown-input-field", placeholder: placeholder, autocomplete: 'off'
filter_output << sprite_icon('search', css_class: 'dropdown-input-search')
filter_output << sprite_icon('close', size: 16, css_class: 'dropdown-input-clear js-dropdown-input-clear')
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index 52b8ac915f1..6b3abb4274e 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -273,7 +273,7 @@ module EventsHelper
def event_user_info(event)
content_tag(:div, class: "event-user-info") do
- concat content_tag(:span, link_to_author(event), class: "author_name")
+ concat content_tag(:span, link_to_author(event), class: "author-name")
concat "&nbsp;".html_safe
concat content_tag(:span, event.author.to_reference, class: "username")
end
diff --git a/app/helpers/git_helper.rb b/app/helpers/git_helper.rb
index 0fb37a69e56..f7c511cdc47 100644
--- a/app/helpers/git_helper.rb
+++ b/app/helpers/git_helper.rb
@@ -4,8 +4,7 @@ module GitHelper
def strip_signature(text)
text = text.gsub(/-----BEGIN PGP SIGNATURE-----(.*)-----END PGP SIGNATURE-----/m, "")
text = text.gsub(/-----BEGIN PGP MESSAGE-----(.*)-----END PGP MESSAGE-----/m, "")
- text = text.gsub(/-----BEGIN SIGNED MESSAGE-----(.*)-----END SIGNED MESSAGE-----/m, "")
- text
+ text.gsub(/-----BEGIN SIGNED MESSAGE-----(.*)-----END SIGNED MESSAGE-----/m, "")
end
def short_sha(text)
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index 6dcdc018a20..48af4793fb0 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -4,6 +4,8 @@
module GitlabRoutingHelper
extend ActiveSupport::Concern
+ include ::ProjectsHelper
+ include ::ApplicationSettingsHelper
include API::Helpers::RelatedResourcesHelpers
included do
Gitlab::Routing.includes_helpers(self)
diff --git a/app/helpers/graph_helper.rb b/app/helpers/graph_helper.rb
index abb3c5a7af8..bcbc67957eb 100644
--- a/app/helpers/graph_helper.rb
+++ b/app/helpers/graph_helper.rb
@@ -23,7 +23,7 @@ module GraphHelper
ratio.to_i
end
- def should_render_deployment_frequency_charts
+ def should_render_dora_charts
false
end
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 62f0c68b0c8..5ce23baa226 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -22,6 +22,9 @@ module GroupsHelper
ldap_group_links#index
hooks#index
pipeline_quota#index
+ applications#index
+ applications#show
+ applications#edit
packages_and_registries#index
]
end
@@ -62,14 +65,6 @@ module GroupsHelper
can?(current_user, :set_emails_disabled, group) && !group.parent&.emails_disabled?
end
- def group_open_issues_count(group)
- if Feature.enabled?(:cached_sidebar_open_issues_count, group, default_enabled: :yaml)
- cached_open_group_issues_count(group)
- else
- number_with_delimiter(group_issues_count(state: 'opened'))
- end
- end
-
def group_issues_count(state:)
IssuesFinder
.new(current_user, group_id: @group.id, state: state, non_archived: true, include_subgroups: true)
@@ -77,18 +72,11 @@ module GroupsHelper
.count
end
- def cached_open_group_issues_count(group)
- count_service = Groups::OpenIssuesCountService
- issues_count = count_service.new(group, current_user).count
-
- if issues_count > count_service::CACHED_COUNT_THRESHOLD
- ActiveSupport::NumberHelper
- .number_to_human(
- issues_count,
- units: { thousand: 'k', million: 'm' }, precision: 1, significant: false, format: '%n%u'
- )
+ def group_open_merge_requests_count(group)
+ if Feature.enabled?(:cached_sidebar_merge_requests_count, group, default_enabled: :yaml)
+ cached_issuables_count(@group, type: :merge_requests)
else
- number_with_delimiter(issues_count)
+ number_with_delimiter(group_merge_requests_count(state: 'opened'))
end
end
@@ -99,6 +87,14 @@ module GroupsHelper
.count
end
+ def cached_issuables_count(group, type: nil)
+ count_service = issuables_count_service_class(type)
+ return unless count_service.present?
+
+ issuables_count = count_service.new(group, current_user).count
+ format_issuables_count(count_service, issuables_count)
+ end
+
def group_dependency_proxy_url(group)
# The namespace path can include uppercase letters, which
# Docker doesn't allow. The proxy expects it to be downcased.
@@ -117,7 +113,9 @@ module GroupsHelper
@has_group_title = true
full_title = []
- group.ancestors.reverse.each_with_index do |parent, index|
+ ancestors = group.ancestors.with_route
+
+ ancestors.reverse_each.with_index do |parent, index|
if index > 0
add_to_breadcrumb_dropdown(group_title_link(parent, hidable: false, show_avatar: true, for_dropdown: true), location: :before)
else
@@ -214,6 +212,10 @@ module GroupsHelper
!multiple_members?(group)
end
+ def render_setting_to_allow_project_access_token_creation?(group)
+ group.root? && current_user.can?(:admin_setting_to_allow_project_access_token_creation, group)
+ end
+
def show_thanks_for_purchase_banner?
params.key?(:purchased_quantity) && params[:purchased_quantity].to_i > 0
end
@@ -303,6 +305,26 @@ module GroupsHelper
def ancestor_locked_and_has_been_overridden(group)
s_("GroupSettings|This setting is applied on %{ancestor_group} and has been overridden on this subgroup.").html_safe % { ancestor_group: ancestor_group(group) }
end
+
+ def issuables_count_service_class(type)
+ if type == :issues
+ Groups::OpenIssuesCountService
+ elsif type == :merge_requests
+ Groups::MergeRequestsCountService
+ end
+ end
+
+ def format_issuables_count(count_service, count)
+ if count > count_service::CACHED_COUNT_THRESHOLD
+ ActiveSupport::NumberHelper
+ .number_to_human(
+ count,
+ units: { thousand: 'k', million: 'm' }, precision: 1, significant: false, format: '%n%u'
+ )
+ else
+ number_with_delimiter(count)
+ end
+ end
end
GroupsHelper.prepend_if_ee('EE::GroupsHelper')
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
index 5f0d513ba35..61d8d0f779d 100644
--- a/app/helpers/ide_helper.rb
+++ b/app/helpers/ide_helper.rb
@@ -16,7 +16,7 @@ module IdeHelper
'branch-name' => @branch,
'file-path' => @path,
'merge-request' => @merge_request,
- 'forked-project' => convert_to_project_entity_json(@forked_project),
+ 'fork-info' => @fork_info&.to_json,
'project' => convert_to_project_entity_json(@project)
}
end
diff --git a/app/helpers/in_product_marketing_helper.rb b/app/helpers/in_product_marketing_helper.rb
index 061404e989d..2574b57a82e 100644
--- a/app/helpers/in_product_marketing_helper.rb
+++ b/app/helpers/in_product_marketing_helper.rb
@@ -49,7 +49,7 @@ module InProductMarketingHelper
trial: [
s_('InProductMarketing|Start a free trial of GitLab Ultimate – no CC required'),
s_('InProductMarketing|Improve app security with a 30-day trial'),
- s_('InProductMarketing|Start with a GitLab Gold free trial')
+ s_('InProductMarketing|Start with a GitLab Ultimate free trial')
],
team: [
s_('InProductMarketing|Invite your colleagues to join in less than one minute'),
@@ -97,8 +97,8 @@ module InProductMarketingHelper
s_('InProductMarketing|Follow our steps')
],
trial: [
- s_('InProductMarketing|...and you can get a free trial of GitLab Gold'),
- s_('InProductMarketing|Try GitLab Gold for free'),
+ s_('InProductMarketing|...and you can get a free trial of GitLab Ultimate'),
+ s_('InProductMarketing|Try GitLab Ultimate for free'),
s_('InProductMarketing|Better code in less time')
],
team: [
@@ -142,7 +142,7 @@ module InProductMarketingHelper
s_('InProductMarketing|Ticketmaster decreased their CI build time by 15X')
], format)
].join("\n"),
- s_("InProductMarketing|We know a thing or two about efficiency and we don't want to keep that to ourselves. Sign up for a free trial of GitLab Gold and your teams will be on it from day one."),
+ s_("InProductMarketing|We know a thing or two about efficiency and we don't want to keep that to ourselves. Sign up for a free trial of GitLab Ultimate and your teams will be on it from day one."),
[
s_('InProductMarketing|Stop wondering and use GitLab to answer questions like:'),
list([
@@ -172,9 +172,9 @@ module InProductMarketingHelper
nil
],
trial: [
- s_('InProductMarketing|Start a GitLab Gold trial today in less than one minute, no credit card required.'),
- s_('InProductMarketing|Get started today with a 30-day GitLab Gold trial, no credit card required.'),
- s_('InProductMarketing|Code owners and required merge approvals are part of the paid tiers of GitLab. You can start a free 30-day trial of GitLab Gold and enable these features in less than 5 minutes with no credit card required.')
+ s_('InProductMarketing|Start a GitLab Ultimate trial today in less than one minute, no credit card required.'),
+ s_('InProductMarketing|Get started today with a 30-day GitLab Ultimate trial, no credit card required.'),
+ s_('InProductMarketing|Code owners and required merge approvals are part of the paid tiers of GitLab. You can start a free 30-day trial of GitLab Ultimate and enable these features in less than 5 minutes with no credit card required.')
],
team: [
s_('InProductMarketing|Invite your colleagues and start shipping code faster.'),
@@ -250,7 +250,7 @@ module InProductMarketingHelper
trial: [
s_('InProductMarketing|Start a trial'),
s_('InProductMarketing|Beef up your security'),
- s_('InProductMarketing|Go for the gold!')
+ s_('InProductMarketing|Start your trial now!')
],
team: [
s_('InProductMarketing|Invite your colleagues today'),
@@ -313,7 +313,8 @@ module InProductMarketingHelper
end
def unsubscribe_link(format)
- link(s_('InProductMarketing|unsubscribe'), '%tag_unsubscribe_url%', format)
+ unsubscribe_url = Gitlab.com? ? '%tag_unsubscribe_url%' : profile_notifications_url
+ link(s_('InProductMarketing|unsubscribe'), unsubscribe_url, format)
end
def link(text, link, format)
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 639a54fa9ec..8ebc773bb25 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -20,13 +20,13 @@ module IssuablesHelper
end
def assignees_label(issuable, include_value: true)
- label = 'Assignee'.pluralize(issuable.assignees.count)
+ assignees = issuable.assignees
if include_value
sanitized_list = sanitize_name(issuable.assignee_list)
- "#{label}: #{sanitized_list}"
+ ns_('NotificationEmail|Assignee: %{users}', 'NotificationEmail|Assignees: %{users}', assignees.count) % { users: sanitized_list }
else
- label
+ ns_('NotificationEmail|Assignee', 'NotificationEmail|Assignees', assignees.count)
end
end
@@ -389,7 +389,8 @@ module IssuablesHelper
severity: issuable[:severity],
timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours,
createNoteEmail: issuable[:create_note_email],
- issuableType: issuable[:type]
+ issuableType: issuable[:type],
+ projectMembersPath: project_project_members_path(@project, sort: :access_level_desc)
}
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 0a9965496b8..0a83e707412 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -163,10 +163,41 @@ module IssuesHelper
}
end
+ def issues_list_data(project, current_user, finder)
+ {
+ calendar_path: url_for(safe_params.merge(calendar_url_options)),
+ can_bulk_update: can?(current_user, :admin_issue, project).to_s,
+ can_edit: can?(current_user, :admin_project, project).to_s,
+ can_import_issues: can?(current_user, :import_issues, @project).to_s,
+ email: current_user&.notification_email,
+ empty_state_svg_path: image_path('illustrations/issues.svg'),
+ endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
+ export_csv_path: export_csv_project_issues_path(project),
+ full_path: project.full_path,
+ has_issues: project_issues(project).exists?.to_s,
+ import_csv_issues_path: import_csv_namespace_project_issues_path,
+ is_signed_in: current_user.present?.to_s,
+ issues_path: project_issues_path(project),
+ jira_integration_path: help_page_url('user/project/integrations/jira', anchor: 'view-jira-issues'),
+ max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes),
+ new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.try(:id), milestone_id: finder.milestones.first.try(:id) }),
+ project_import_jira_path: project_import_jira_path(project),
+ rss_path: url_for(safe_params.merge(rss_url_options)),
+ show_new_issue_link: show_new_issue_link?(project).to_s,
+ sign_in_path: new_user_session_path
+ }
+ end
+
# Overridden in EE
def scoped_labels_available?(parent)
false
end
+
+ def award_emoji_issue_api_path(issue)
+ if Feature.enabled?(:improved_emoji_picker, issue.project, default_enabled: :yaml)
+ api_v4_projects_issues_award_emoji_path(id: issue.project.id, issue_iid: issue.iid)
+ end
+ end
end
IssuesHelper.prepend_if_ee('EE::IssuesHelper')
diff --git a/app/helpers/jira_connect_helper.rb b/app/helpers/jira_connect_helper.rb
index 76a7f785df6..475469a6df9 100644
--- a/app/helpers/jira_connect_helper.rb
+++ b/app/helpers/jira_connect_helper.rb
@@ -6,8 +6,24 @@ module JiraConnectHelper
{
groups_path: api_v4_groups_path(params: { min_access_level: Gitlab::Access::MAINTAINER, skip_groups: skip_groups }),
+ subscriptions: subscriptions.map { |s| serialize_subscription(s) }.to_json,
subscriptions_path: jira_connect_subscriptions_path,
users_path: current_user ? nil : jira_connect_users_path
}
end
+
+ private
+
+ def serialize_subscription(subscription)
+ {
+ group: {
+ name: subscription.namespace.name,
+ avatar_url: subscription.namespace.avatar_url,
+ full_name: subscription.namespace.full_name,
+ description: subscription.namespace.description
+ },
+ created_at: subscription.created_at,
+ unlink_path: jira_connect_subscription_path(subscription)
+ }
+ end
end
diff --git a/app/helpers/learn_gitlab_helper.rb b/app/helpers/learn_gitlab_helper.rb
index f50c1e52bed..81896fb9fa4 100644
--- a/app/helpers/learn_gitlab_helper.rb
+++ b/app/helpers/learn_gitlab_helper.rb
@@ -11,19 +11,20 @@ module LearnGitlabHelper
def onboarding_actions_data(project)
attributes = onboarding_progress(project).attributes.symbolize_keys
- action_urls.map do |action, url|
+ action_urls.to_h do |action, url|
[
action,
url: url,
completed: attributes[OnboardingProgress.column_name(action)].present?,
svg: image_path("learn_gitlab/#{action}.svg")
]
- end.to_h
+ end
end
private
ACTION_ISSUE_IDS = {
+ issue_created: 4,
git_write: 6,
pipeline_created: 7,
merge_request_created: 9,
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 7a798c83b7e..df7fcb0f3da 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -176,13 +176,39 @@ module MergeRequestsHelper
def reviewers_label(merge_request, include_value: true)
reviewers = merge_request.reviewers
- reviewer_label = 'Reviewer'.pluralize(reviewers.count)
if include_value
sanitized_list = sanitize_name(reviewers.map(&:name).to_sentence)
- "#{reviewer_label}: #{sanitized_list}"
+ ns_('NotificationEmail|Reviewer: %{users}', 'NotificationEmail|Reviewers: %{users}', reviewers.count) % { users: sanitized_list }
else
- reviewer_label
+ ns_('NotificationEmail|Reviewer', 'NotificationEmail|Reviewers', reviewers.count)
+ end
+ end
+
+ def diffs_tab_pane_data(project, merge_request, params)
+ {
+ "is-locked": merge_request.discussion_locked?,
+ endpoint: diffs_project_merge_request_path(project, merge_request, 'json', params),
+ endpoint_metadata: @endpoint_metadata_url,
+ endpoint_batch: diffs_batch_project_json_merge_request_path(project, merge_request, 'json', params),
+ endpoint_coverage: @coverage_path,
+ help_page_path: help_page_path('user/discussions/index.md', anchor: 'suggest-changes'),
+ current_user_data: @current_user_data,
+ update_current_user_path: @update_current_user_path,
+ project_path: project_path(merge_request.project),
+ changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg'),
+ is_fluid_layout: fluid_layout.to_s,
+ dismiss_endpoint: user_callouts_path,
+ show_suggest_popover: show_suggest_popover?.to_s,
+ show_whitespace_default: @show_whitespace_default.to_s,
+ file_by_file_default: @file_by_file_default.to_s,
+ default_suggestion_commit_message: default_suggestion_commit_message
+ }
+ end
+
+ def award_emoji_merge_request_api_path(merge_request)
+ if Feature.enabled?(:improved_emoji_picker, merge_request.project, default_enabled: :yaml)
+ api_v4_projects_merge_requests_award_emoji_path(id: merge_request.project.id, merge_request_iid: merge_request.iid)
end
end
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 8cf5cd49322..a4521541bf9 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -56,6 +56,33 @@ module NamespacesHelper
namespaces_options(selected, **options)
end
+ def cascading_namespace_settings_enabled?
+ NamespaceSetting.cascading_settings_feature_enabled?
+ end
+
+ def cascading_namespace_settings_popover_data(attribute, group, settings_path_helper)
+ locked_by_ancestor = group.namespace_settings.public_send("#{attribute}_locked_by_ancestor?") # rubocop:disable GitlabSecurity/PublicSend
+
+ popover_data = {
+ locked_by_application_setting: group.namespace_settings.public_send("#{attribute}_locked_by_application_setting?"), # rubocop:disable GitlabSecurity/PublicSend
+ locked_by_ancestor: locked_by_ancestor
+ }
+
+ if locked_by_ancestor
+ ancestor_namespace = group.namespace_settings.public_send("#{attribute}_locked_ancestor").namespace # rubocop:disable GitlabSecurity/PublicSend
+
+ popover_data[:ancestor_namespace] = {
+ full_name: ancestor_namespace.full_name,
+ path: settings_path_helper.call(ancestor_namespace)
+ }
+ end
+
+ {
+ popover_data: popover_data.to_json,
+ testid: 'cascading-settings-lock-icon'
+ }
+ end
+
private
# Many importers create a temporary Group, so use the real
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index c170e58b4ce..db144f63f92 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -92,10 +92,8 @@ module NavHelper
links << :admin_impersonation
end
- if Feature.enabled?(:user_mode_in_session)
- if current_user_mode.admin_mode?
- links << :admin_mode
- end
+ if Gitlab::CurrentSettings.admin_mode && current_user_mode.admin_mode?
+ links << :admin_mode
end
links
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index 8f365fd0786..04465f7798c 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -50,6 +50,7 @@ module PackagesHelper
def track_package_event(event_name, scope, **args)
::Packages::CreateEventService.new(nil, current_user, event_name: event_name, scope: scope).execute
- track_event(event_name, **args)
+ category = args.delete(:category) || self.class.name
+ ::Gitlab::Tracking.event(category, event_name.to_s, **args)
end
end
diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb
index 8920133734c..6997c8cffda 100644
--- a/app/helpers/page_layout_helper.rb
+++ b/app/helpers/page_layout_helper.rb
@@ -159,13 +159,20 @@ module PageLayoutHelper
end
def user_status_properties(user)
- default_properties = { current_emoji: '', current_message: '', can_set_user_availability: Feature.enabled?(:set_user_availability_status, user, default_enabled: :yaml), default_emoji: UserStatus::DEFAULT_EMOJI }
+ default_properties = {
+ current_emoji: '',
+ current_message: '',
+ can_set_user_availability: Feature.enabled?(:set_user_availability_status, user, default_enabled: :yaml),
+ 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
+ current_availability: user.status.availability.to_s,
+ current_clear_status_after: user.status.clear_status_at.to_s
})
end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 12bc509466e..add6e1eaf6f 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -33,7 +33,7 @@ module PreferencesHelper
groups: _("Your Groups"),
todos: _("Your To-Do List"),
issues: _("Assigned Issues"),
- merge_requests: _("Assigned Merge Requests"),
+ merge_requests: _("Assigned merge requests"),
operations: _("Operations Dashboard")
}.with_indifferent_access.freeze
end
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index 87187e97df4..3219620de71 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -37,4 +37,18 @@ module ProfilesHelper
def user_status_set_to_busy?(status)
status&.availability == availability_values[:busy]
end
+
+ # Overridden in EE::ProfilesHelper#ssh_key_expiration_tooltip
+ def ssh_key_expiration_tooltip(key)
+ return key.errors.full_messages.join(', ') if key.errors.full_messages.any?
+
+ s_('Profiles|Key usable beyond expiration date.') if key.expired?
+ end
+
+ # Overridden in EE::ProfilesHelper#ssh_key_expires_field_description
+ def ssh_key_expires_field_description
+ s_('Profiles|Key can still be used after expiration.')
+ end
end
+
+ProfilesHelper.prepend_ee_mod
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 6c17039a5d9..4be6cd4276b 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -410,6 +410,10 @@ module ProjectsHelper
nav_tabs << :container_registry
end
+ if Feature.enabled?(:infrastructure_registry_page)
+ nav_tabs << :infrastructure_registry
+ end
+
# Pipelines feature is tied to presence of builds
if can?(current_user, :read_build, project)
nav_tabs << :pipelines
@@ -725,14 +729,6 @@ module ProjectsHelper
]
end
- def sidebar_projects_paths
- %w[
- projects#show
- projects#activity
- releases#index
- ]
- end
-
def sidebar_settings_paths
%w[
projects#edit
@@ -750,25 +746,6 @@ module ProjectsHelper
]
end
- def sidebar_repository_paths
- %w[
- tree
- blob
- blame
- edit_tree
- new_tree
- find_file
- commit
- commits
- compare
- projects/repositories
- tags
- branches
- graphs
- network
- ]
- end
-
def sidebar_operations_paths
%w[
environments
@@ -809,10 +786,6 @@ module ProjectsHelper
can?(current_user, :destroy_container_image, project)
end
- 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)
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 86012352c8b..2568917bafc 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -193,7 +193,7 @@ module SearchHelper
{ category: "In this project", label: _("Network"), url: project_network_path(@project, ref) },
{ category: "In this project", label: _("Graph"), url: project_graph_path(@project, ref) },
{ category: "In this project", label: _("Issues"), url: project_issues_path(@project) },
- { category: "In this project", label: _("Merge Requests"), url: project_merge_requests_path(@project) },
+ { category: "In this project", label: _("Merge requests"), url: project_merge_requests_path(@project) },
{ category: "In this project", label: _("Milestones"), url: project_milestones_path(@project) },
{ category: "In this project", label: _("Snippets"), url: project_snippets_path(@project) },
{ category: "In this project", label: _("Members"), url: project_project_members_path(@project) },
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index 14d20e7c622..ffa09cb12fb 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -4,29 +4,29 @@ module ServicesHelper
def service_event_description(event)
case event
when "push", "push_events"
- s_("ProjectService|Event will be triggered by a push to the repository")
+ s_("ProjectService|Trigger event for pushes to the repository.")
when "tag_push", "tag_push_events"
- s_("ProjectService|Event will be triggered when a new tag is pushed to the repository")
+ s_("ProjectService|Trigger event for new tags pushed to the repository.")
when "note", "note_events"
- s_("ProjectService|Event will be triggered when someone adds a comment")
+ s_("ProjectService|Trigger event for new comments.")
when "confidential_note", "confidential_note_events"
- s_("ProjectService|Event will be triggered when someone adds a comment on a confidential issue")
+ s_("ProjectService|Trigger event for new comments on confidential issues.")
when "issue", "issue_events"
- s_("ProjectService|Event will be triggered when an issue is created/updated/closed")
+ s_("ProjectService|Trigger event when an issue is created, updated, or closed.")
when "confidential_issue", "confidential_issue_events"
- s_("ProjectService|Event will be triggered when a confidential issue is created/updated/closed")
+ s_("ProjectService|Trigger event when a confidential issue is created, updated, or closed.")
when "merge_request", "merge_request_events"
- s_("ProjectService|Event will be triggered when a merge request is created/updated/merged")
+ s_("ProjectService|Trigger event when a merge request is created, updated, or merged.")
when "pipeline", "pipeline_events"
- s_("ProjectService|Event will be triggered when a pipeline status changes")
+ s_("ProjectService|Trigger event when a pipeline status changes.")
when "wiki_page", "wiki_page_events"
- s_("ProjectService|Event will be triggered when a wiki page is created/updated")
+ s_("ProjectService|Trigger event when a wiki page is created or updated.")
when "commit", "commit_events"
- s_("ProjectService|Event will be triggered when a commit is created/updated")
+ s_("ProjectService|Trigger event when a commit is created or updated.")
when "deployment"
- s_("ProjectService|Event will be triggered when a deployment starts or finishes")
+ s_("ProjectService|Trigger event when a deployment starts or finishes.")
when "alert"
- s_("ProjectService|Event will be triggered when a new, unique alert is recorded")
+ s_("ProjectService|Trigger event when a new, unique alert is recorded.")
end
end
@@ -86,7 +86,7 @@ module ServicesHelper
end
def integration_form_data(integration, group: nil)
- {
+ form_data = {
id: integration.id,
show_active: integration.show_active_box?.to_s,
activated: (integration.active || integration.new_record?).to_s,
@@ -106,6 +106,19 @@ module ServicesHelper
test_path: scoped_test_integration_path(integration),
reset_path: scoped_reset_integration_path(integration, group: group)
}
+
+ if integration.is_a?(JiraService)
+ form_data[:jira_issue_transition_automatic] = integration.jira_issue_transition_automatic
+ form_data[:jira_issue_transition_id] = integration.jira_issue_transition_id
+ end
+
+ form_data
+ end
+
+ def integration_list_data(integrations)
+ {
+ integrations: integrations.map { |i| serialize_integration(i) }.to_json
+ }
end
def trigger_events_for_service(integration)
@@ -148,6 +161,17 @@ module ServicesHelper
'project'
end
end
+
+ def serialize_integration(integration)
+ {
+ active: integration.operating?,
+ title: integration.title,
+ description: integration.description,
+ updated_at: integration.updated_at,
+ edit_path: scoped_edit_integration_path(integration),
+ name: integration.to_param
+ }
+ end
end
ServicesHelper.prepend_if_ee('EE::ServicesHelper')
diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb
new file mode 100644
index 00000000000..31dfe21671a
--- /dev/null
+++ b/app/helpers/sidebars_helper.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module SidebarsHelper
+ def sidebar_tracking_attributes_by_object(object)
+ case object
+ when Project
+ sidebar_project_tracking_attrs
+ when Group
+ sidebar_group_tracking_attrs
+ when User
+ sidebar_user_profile_tracking_attrs
+ else
+ {}
+ end
+ end
+
+ def project_sidebar_context(project, user, current_ref)
+ context_data = project_sidebar_context_data(project, user, current_ref)
+
+ Sidebars::Projects::Context.new(**context_data)
+ end
+
+ private
+
+ def sidebar_project_tracking_attrs
+ tracking_attrs('projects_side_navigation', 'render', 'projects_side_navigation')
+ end
+
+ def sidebar_group_tracking_attrs
+ tracking_attrs('groups_side_navigation', 'render', 'groups_side_navigation')
+ end
+
+ def sidebar_user_profile_tracking_attrs
+ tracking_attrs('user_side_navigation', 'render', 'user_side_navigation')
+ end
+
+ def project_sidebar_context_data(project, user, current_ref)
+ {
+ current_user: user,
+ container: project,
+ learn_gitlab_experiment_enabled: learn_gitlab_experiment_enabled?(project),
+ current_ref: current_ref
+ }
+ end
+end
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index 36f4fced147..f4af7a5a350 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -11,16 +11,6 @@ module SnippetsHelper
end
end
- def download_raw_snippet_button(snippet)
- link_to(sprite_icon('download'),
- gitlab_raw_snippet_path(snippet, inline: false),
- target: '_blank',
- rel: 'noopener noreferrer',
- class: "btn btn-sm has-tooltip",
- title: 'Download',
- data: { container: 'body' })
- end
-
# Return the path of a snippets index for a user or for a project
#
# @returns String, path to snippet index
@@ -54,7 +44,7 @@ module SnippetsHelper
link_to(external_snippet_icon('doc-code'),
gitlab_raw_snippet_blob_url(snippet, blob.path),
- class: 'btn',
+ class: 'gl-button btn btn-default',
target: '_blank',
rel: 'noopener noreferrer',
title: 'Open raw')
@@ -63,7 +53,7 @@ module SnippetsHelper
def embedded_snippet_download_button(snippet, blob)
link_to(external_snippet_icon('download'),
gitlab_raw_snippet_blob_url(snippet, blob.path, nil, inline: false),
- class: 'btn',
+ class: 'gl-button btn btn-default',
target: '_blank',
title: 'Download',
rel: 'noopener noreferrer')
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 35c8b140bfe..974ec046bbb 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -231,7 +231,7 @@ module SortingHelper
end
def sort_direction_button(reverse_url, reverse_sort, sort_value)
- link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort rspec-reverse-sort'
+ link_class = 'gl-button btn btn-default btn-icon has-tooltip reverse-sort-btn qa-reverse-sort rspec-reverse-sort'
icon = sort_direction_icon(sort_value)
url = reverse_url
diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb
index 959178c47d7..f1e0be3a622 100644
--- a/app/helpers/submodule_helper.rb
+++ b/app/helpers/submodule_helper.rb
@@ -18,7 +18,8 @@ module SubmoduleHelper
end
if url =~ %r{([^/:]+)/([^/]+(?:\.git)?)\Z}
- namespace, project = Regexp.last_match(1), Regexp.last_match(2)
+ namespace = Regexp.last_match(1)
+ project = Regexp.last_match(2)
gitlab_hosts = [Gitlab.config.gitlab.url,
Gitlab.config.gitlab_shell.ssh_path_prefix]
diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb
index e81986d4453..1d3242ca44a 100644
--- a/app/helpers/tab_helper.rb
+++ b/app/helpers/tab_helper.rb
@@ -65,6 +65,13 @@ module TabHelper
# # When `TreeController#index` is requested
# # => '<li>Hello</li>'
#
+ # # Paths, controller and actions can be used at the same time
+ # nav_link(path: 'tree#show', controller: 'admin/appearances') { "Hello" }
+ #
+ # nav_link(path: 'foo#bar', controller: 'tree') { "Hello" }
+ # nav_link(path: 'foo#bar', controller: 'tree', action: 'show') { "Hello" }
+ # nav_link(path: 'foo#bar', action: 'show') { "Hello" }
+ #
# Returns a list item element String
def nav_link(options = {}, &block)
klass = active_nav_link?(options) ? 'active' : ''
@@ -85,34 +92,12 @@ module TabHelper
def active_nav_link?(options)
return false if options[:unless]&.call
- if path = options.delete(:path)
- unless path.respond_to?(:each)
- path = [path]
- end
-
- path.any? do |single_path|
- current_path?(single_path)
- end
- elsif page = options.delete(:page)
- unless page.respond_to?(:each)
- page = [page]
- end
-
- page.any? do |single_page|
- current_page?(single_page)
- end
- else
- c = options.delete(:controller)
- a = options.delete(:action)
+ controller = options.delete(:controller)
+ action = options.delete(:action)
- if c && a
- # When given both options, make sure BOTH are true
- current_controller?(*c) && current_action?(*a)
- else
- # Otherwise check EITHER option
- current_controller?(*c) || current_action?(*a)
- end
- end
+ route_matches_paths?(options.delete(:path)) ||
+ route_matches_pages?(options.delete(:page)) ||
+ route_matches_controllers_and_or_actions?(controller, action)
end
def current_path?(path)
@@ -127,4 +112,26 @@ module TabHelper
'active'
end
end
+
+ private
+
+ def route_matches_paths?(paths)
+ Array(paths).compact.any? do |single_path|
+ current_path?(single_path)
+ end
+ end
+
+ def route_matches_pages?(pages)
+ Array(pages).compact.any? do |single_page|
+ current_page?(single_page)
+ end
+ end
+
+ def route_matches_controllers_and_or_actions?(controller, action)
+ if controller && action
+ current_controller?(*controller) && current_action?(*action)
+ else
+ current_controller?(*controller) || current_action?(*action)
+ end
+ end
end
diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb
index bbf8cf7dac3..e034a985b50 100644
--- a/app/helpers/timeboxes_helper.rb
+++ b/app/helpers/timeboxes_helper.rb
@@ -115,17 +115,6 @@ module TimeboxesHelper
end
end
- def milestones_filter_dropdown_path
- project = @target_project || @project
- if project
- project_milestones_path(project, :json)
- elsif @group
- group_milestones_path(@group, :json)
- else
- dashboard_milestones_path(:json)
- end
- end
-
def milestone_time_for(date, date_type)
title = date_type == :start ? "Start date" : "End date"
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 7b0e0df8998..e9a0fef06c4 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -157,21 +157,11 @@ module TodosHelper
]
end
- def todo_projects_options
- projects = current_user.authorized_projects.sorted_by_activity.non_archived.with_route
-
- projects = projects.map do |project|
- { id: project.id, text: project.full_name }
- end
-
- projects.unshift({ id: '', text: 'Any Project' }).to_json
- end
-
def todo_types_options
[
{ id: '', text: 'Any Type' },
{ id: 'Issue', text: 'Issue' },
- { id: 'MergeRequest', text: 'Merge Request' },
+ { id: 'MergeRequest', text: 'Merge request' },
{ id: 'DesignManagement::Design', text: 'Design' },
{ id: 'AlertManagement::Alert', text: 'Alert' }
]
@@ -240,14 +230,6 @@ module TodosHelper
false
end
end
-
- def todo_group_options
- groups = current_user.authorized_groups.with_route.map do |group|
- { id: group.id, text: group.full_name }
- end
-
- groups.unshift({ id: '', text: 'Any Group' }).to_json
- end
end
TodosHelper.prepend_if_ee('EE::TodosHelper')
diff --git a/app/helpers/tracking_helper.rb b/app/helpers/tracking_helper.rb
index 221d9692661..7957038c21e 100644
--- a/app/helpers/tracking_helper.rb
+++ b/app/helpers/tracking_helper.rb
@@ -1,13 +1,13 @@
# frozen_string_literal: true
module TrackingHelper
- def tracking_attrs(label, event, property)
+ def tracking_attrs(label, action, property)
return {} unless tracking_enabled?
{
data: {
track_label: label,
- track_event: event,
+ track_action: action,
track_property: property
}
}
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index b050f533d77..b795851ba30 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -131,6 +131,8 @@ module TreeHelper
def breadcrumb_data_attributes
attrs = {
+ selected_branch: selected_branch,
+ can_push_code: can?(current_user, :push_code, @project).to_s,
can_collaborate: can_collaborate_with_project?(@project).to_s,
new_blob_path: project_new_blob_path(@project, @ref),
upload_path: project_create_blob_path(@project, @ref),
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
index 6a242d000ae..7a90984cd77 100644
--- a/app/helpers/user_callouts_helper.rb
+++ b/app/helpers/user_callouts_helper.rb
@@ -5,7 +5,7 @@ module UserCalloutsHelper
GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration'
GCP_SIGNUP_OFFER = 'gcp_signup_offer'
SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed'
- SERVICE_TEMPLATES_DEPRECATED = 'service_templates_deprecated'
+ SERVICE_TEMPLATES_DEPRECATED_CALLOUT = 'service_templates_deprecated_callout'
TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight'
WEBHOOKS_MOVED = 'webhooks_moved'
CUSTOMIZE_HOMEPAGE = 'customize_homepage'
@@ -41,16 +41,19 @@ module UserCalloutsHelper
!user_dismissed?(SUGGEST_POPOVER_DISMISSED)
end
- def show_service_templates_deprecated?
- !user_dismissed?(SERVICE_TEMPLATES_DEPRECATED)
+ def show_service_templates_deprecated_callout?
+ !Gitlab.com? &&
+ current_user&.admin? &&
+ Service.for_template.active.exists? &&
+ !user_dismissed?(SERVICE_TEMPLATES_DEPRECATED_CALLOUT)
end
def show_webhooks_moved_alert?
!user_dismissed?(WEBHOOKS_MOVED)
end
- def show_customize_homepage_banner?(customize_homepage)
- customize_homepage && !user_dismissed?(CUSTOMIZE_HOMEPAGE)
+ def show_customize_homepage_banner?
+ !user_dismissed?(CUSTOMIZE_HOMEPAGE)
end
def show_feature_flags_new_version?
diff --git a/app/helpers/whats_new_helper.rb b/app/helpers/whats_new_helper.rb
index bbf5bde5904..9362ae1491f 100644
--- a/app/helpers/whats_new_helper.rb
+++ b/app/helpers/whats_new_helper.rb
@@ -5,15 +5,11 @@ module WhatsNewHelper
ReleaseHighlight.most_recent_item_count
end
- def whats_new_storage_key
- most_recent_version = ReleaseHighlight.versions&.first
-
- return unless most_recent_version
-
- ['display-whats-new-notification', most_recent_version].join('-')
+ def whats_new_version_digest
+ ReleaseHighlight.most_recent_version_digest
end
- def whats_new_versions
- ReleaseHighlight.versions
+ def display_whats_new?
+ Gitlab.dev_env_org_or_com? || user_signed_in?
end
end
diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb
index 3f82cf893a0..c2a5ff40852 100644
--- a/app/helpers/wiki_helper.rb
+++ b/app/helpers/wiki_helper.rb
@@ -22,7 +22,7 @@ module WikiHelper
end
def wiki_sidebar_toggle_button
- content_tag :button, class: 'btn btn-default sidebar-toggle js-sidebar-wiki-toggle', role: 'button', type: 'button' do
+ content_tag :button, class: 'gl-button btn btn-default btn-icon sidebar-toggle js-sidebar-wiki-toggle', role: 'button', type: 'button' do
sprite_icon('chevron-double-lg-left')
end
end
@@ -61,7 +61,7 @@ module WikiHelper
def wiki_sort_controls(wiki, sort, direction)
sort ||= Wiki::TITLE_ORDER
- link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort rspec-reverse-sort'
+ link_class = 'gl-button btn btn-default btn-icon has-tooltip reverse-sort-btn qa-reverse-sort rspec-reverse-sort'
reversed_direction = direction == 'desc' ? 'asc' : 'desc'
icon_class = direction == 'desc' ? 'highest' : 'lowest'
diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb
index 28dd1b00292..8785c4cdcbb 100644
--- a/app/helpers/workhorse_helper.rb
+++ b/app/helpers/workhorse_helper.rb
@@ -7,7 +7,7 @@ module WorkhorseHelper
def send_git_blob(repository, blob, inline: true)
headers.store(*Gitlab::Workhorse.send_git_blob(repository, blob))
- headers['Content-Disposition'] = inline ? 'inline' : content_disposition_attachment(repository.project, blob.name)
+ headers['Content-Disposition'] = content_disposition_for_blob(blob, inline)
# If enabled, this will override the values set above
workhorse_set_content_type!
@@ -49,11 +49,9 @@ module WorkhorseHelper
headers[Gitlab::Workhorse::DETECT_HEADER] = "true"
end
- def content_disposition_attachment(project, filename)
- if Feature.enabled?(:attachment_with_filename, project, default_enabled: :yaml)
- ActionDispatch::Http::ContentDisposition.format(disposition: 'attachment', filename: filename)
- else
- 'attachment'
- end
+ def content_disposition_for_blob(blob, inline)
+ return 'inline' if inline
+
+ ActionDispatch::Http::ContentDisposition.format(disposition: 'attachment', filename: blob.name)
end
end
diff --git a/app/mailers/emails/in_product_marketing.rb b/app/mailers/emails/in_product_marketing.rb
index 0be9ec5f915..d21c3d13b10 100644
--- a/app/mailers/emails/in_product_marketing.rb
+++ b/app/mailers/emails/in_product_marketing.rb
@@ -4,8 +4,10 @@ module Emails
module InProductMarketing
include InProductMarketingHelper
- FROM_ADDRESS = 'GitLab <team@gitlab.com>'.freeze
+ FROM_ADDRESS = 'GitLab <team@gitlab.com>'
CUSTOM_HEADERS = {
+ from: FROM_ADDRESS,
+ reply_to: FROM_ADDRESS,
'X-Mailgun-Track' => 'yes',
'X-Mailgun-Track-Clicks' => 'yes',
'X-Mailgun-Track-Opens' => 'yes',
@@ -25,7 +27,8 @@ module Emails
private
def mail_to(to:, subject:)
- mail(to: to, subject: subject, from: FROM_ADDRESS, reply_to: FROM_ADDRESS, **CUSTOM_HEADERS) do |format|
+ custom_headers = Gitlab.com? ? CUSTOM_HEADERS : {}
+ mail(to: to, subject: subject, **custom_headers) do |format|
format.html { render layout: nil }
format.text { render layout: nil }
end
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index f13ba9caee0..f967323f849 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -74,6 +74,30 @@ module Emails
end
end
+ def ssh_key_expired_email(user, fingerprints)
+ return unless user&.active?
+
+ @user = user
+ @fingerprints = fingerprints
+ @target_url = profile_keys_url
+
+ Gitlab::I18n.with_locale(@user.preferred_language) do
+ mail(to: @user.notification_email, subject: subject(_("Your SSH key has expired")))
+ end
+ end
+
+ def ssh_key_expiring_soon_email(user, fingerprints)
+ return unless user&.active?
+
+ @user = user
+ @fingerprints = fingerprints
+ @target_url = profile_keys_url
+
+ Gitlab::I18n.with_locale(@user.preferred_language) do
+ mail(to: @user.notification_email, subject: subject(_("Your SSH key is expiring soon.")))
+ end
+ end
+
def unknown_sign_in_email(user, ip, time)
@user = user
@ip = ip
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index 8f947ea7113..5f5afef350b 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -70,7 +70,7 @@ class Notify < ApplicationMailer
return unless sender = User.find(sender_id)
address = default_sender_address
- address.display_name = sender_name.presence || sender.name
+ address.display_name = sender_name.presence || "#{sender.name} (#{sender.to_reference})"
if send_from_user_email && can_send_from_user_email?(sender)
address.address = sender.email
@@ -178,7 +178,7 @@ class Notify < ApplicationMailer
headers['In-Reply-To'] = message_id(note.references.last)
headers['References'] = note.references.map { |ref| message_id(ref) }
- headers['X-GitLab-Discussion-ID'] = note.discussion.id if note.part_of_discussion?
+ headers['X-GitLab-Discussion-ID'] = note.discussion.id if note.part_of_discussion? || note.can_be_discussion_note?
headers[:subject] = "Re: #{headers[:subject]}" if headers[:subject]
diff --git a/app/models/ability.rb b/app/models/ability.rb
index 514e923c380..ba46a98b951 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -58,7 +58,8 @@ class Ability
def allowed?(user, action, subject = :global, opts = {})
if subject.is_a?(Hash)
- opts, subject = subject, :global
+ opts = subject
+ subject = :global
end
policy = policy_for(user, subject)
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 44d1b6cf907..1bbace791ed 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -42,10 +42,6 @@ class ApplicationRecord < ActiveRecord::Base
false
end
- def self.at_most(count)
- limit(count)
- end
-
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?
@@ -56,9 +52,9 @@ class ApplicationRecord < ActiveRecord::Base
# Start a new transaction with a shorter-than-usual statement timeout. This is
# currently one third of the default 15-second timeout
- def self.with_fast_statement_timeout
+ def self.with_fast_read_statement_timeout(timeout_ms = 5000)
transaction(requires_new: true) do
- connection.exec_query("SET LOCAL statement_timeout = 5000")
+ connection.exec_query("SET LOCAL statement_timeout = #{timeout_ms}")
yield
end
@@ -83,3 +79,5 @@ class ApplicationRecord < ActiveRecord::Base
enum(enum_mod.key => values)
end
end
+
+ApplicationRecord.prepend_if_ee('EE::ApplicationRecordHelpers')
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 44eb2fefb3f..f405f5ca5d3 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -465,6 +465,16 @@ class ApplicationSetting < ApplicationRecord
length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
allow_nil: false
+ validates :admin_mode,
+ inclusion: { in: [true, false], message: _('must be a boolean value') }
+
+ validates :external_pipeline_validation_service_url,
+ addressable_url: true, allow_blank: true
+
+ validates :external_pipeline_validation_service_timeout,
+ allow_nil: true,
+ numericality: { only_integer: true, greater_than: 0 }
+
attr_encrypted :asset_proxy_secret_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
@@ -493,6 +503,7 @@ class ApplicationSetting < ApplicationRecord
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
+ attr_encrypted :external_pipeline_validation_service_token, encryption_options_base_truncated_aes_256_gcm
validates :disable_feed_token,
inclusion: { in: [true, false], message: _('must be a boolean value') }
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index c067199b52c..66a8d1f8105 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -35,6 +35,7 @@ module ApplicationSettingImplementation
class_methods do
def defaults
{
+ admin_mode: false,
after_sign_up_text: nil,
akismet_enabled: false,
allow_local_requests_from_system_hooks: true,
@@ -71,6 +72,9 @@ module ApplicationSettingImplementation
eks_secret_access_key: nil,
email_restrictions_enabled: false,
email_restrictions: nil,
+ external_pipeline_validation_service_timeout: nil,
+ external_pipeline_validation_service_token: nil,
+ external_pipeline_validation_service_url: nil,
first_day_of_week: 0,
gitaly_timeout_default: 55,
gitaly_timeout_fast: 10,
@@ -434,11 +438,14 @@ module ApplicationSettingImplementation
def parse_addr_and_port(str)
case str
when /\A\[(?<address> .* )\]:(?<port> \d+ )\z/x # string like "[::1]:80"
- address, port = $~[:address], $~[:port]
+ address = $~[:address]
+ port = $~[:port]
when /\A(?<address> [^:]+ ):(?<port> \d+ )\z/x # string like "127.0.0.1:80"
- address, port = $~[:address], $~[:port]
+ address = $~[:address]
+ port = $~[:port]
else # string with no port number
- address, port = str, nil
+ address = str
+ port = nil
end
[address, port&.to_i]
diff --git a/app/models/audit_event_archived.rb b/app/models/audit_event_archived.rb
deleted file mode 100644
index 3119f56fbcc..00000000000
--- a/app/models/audit_event_archived.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-
-# This model is not intended to be used.
-# It is a temporary reference to the pre-partitioned
-# audit_events table.
-# Please refer to https://gitlab.com/groups/gitlab-org/-/epics/3206
-# for details.
-class AuditEventArchived < ApplicationRecord
- self.table_name = 'audit_events_archived'
-end
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 8a9db8b45ea..2185233a1ac 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -2,6 +2,7 @@
# Blob is a Rails-specific wrapper around Gitlab::Git::Blob, SnippetBlob and Ci::ArtifactBlob
class Blob < SimpleDelegator
+ include GlobalID::Identification
include Presentable
include BlobLanguageFromGitAttributes
include BlobActiveModel
diff --git a/app/models/blob_viewer/dependency_manager.rb b/app/models/blob_viewer/dependency_manager.rb
index 1be7120a955..a851f22bfcd 100644
--- a/app/models/blob_viewer/dependency_manager.rb
+++ b/app/models/blob_viewer/dependency_manager.rb
@@ -33,8 +33,8 @@ module BlobViewer
@json_data ||= begin
prepare!
Gitlab::Json.parse(blob.data)
- rescue
- {}
+ rescue
+ {}
end
end
diff --git a/app/models/board.rb b/app/models/board.rb
index 418ea67fc6a..b26a9461ffc 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -34,14 +34,6 @@ class Board < ApplicationRecord
project_id.present?
end
- def backlog_list
- lists.merge(List.backlog).take
- end
-
- def closed_list
- lists.merge(List.closed).take
- end
-
def scoped?
false
end
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index 9127dab56a6..04af1145769 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -68,25 +68,6 @@ class BulkImports::Entity < ApplicationRecord
end
end
- def update_tracker_for(relation:, has_next_page:, next_page: nil)
- attributes = {
- relation: relation,
- has_next_page: has_next_page,
- next_page: next_page,
- bulk_import_entity_id: id
- }
-
- trackers.upsert(attributes, unique_by: %i[bulk_import_entity_id relation])
- end
-
- def has_next_page?(relation)
- trackers.find_by(relation: relation)&.has_next_page
- end
-
- def next_page_for(relation)
- trackers.find_by(relation: relation)&.next_page
- end
-
private
def validate_parent_is_a_group
diff --git a/app/models/bulk_imports/stage.rb b/app/models/bulk_imports/stage.rb
new file mode 100644
index 00000000000..050c2c76ce8
--- /dev/null
+++ b/app/models/bulk_imports/stage.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class Stage
+ include Singleton
+
+ CONFIG = {
+ group: {
+ pipeline: BulkImports::Groups::Pipelines::GroupPipeline,
+ stage: 0
+ },
+ subgroups: {
+ pipeline: BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline,
+ stage: 1
+ },
+ members: {
+ pipeline: BulkImports::Groups::Pipelines::MembersPipeline,
+ stage: 1
+ },
+ labels: {
+ pipeline: BulkImports::Groups::Pipelines::LabelsPipeline,
+ stage: 1
+ },
+ milestones: {
+ pipeline: BulkImports::Groups::Pipelines::MilestonesPipeline,
+ stage: 1
+ },
+ badges: {
+ pipeline: BulkImports::Groups::Pipelines::BadgesPipeline,
+ stage: 1
+ },
+ finisher: {
+ pipeline: BulkImports::Groups::Pipelines::EntityFinisher,
+ stage: 2
+ }
+ }.freeze
+
+ def self.pipelines
+ instance.pipelines
+ end
+
+ def self.pipeline_exists?(name)
+ pipelines.any? do |(_, pipeline)|
+ pipeline.to_s == name.to_s
+ end
+ end
+
+ def pipelines
+ @pipelines ||= config
+ .values
+ .sort_by { |entry| entry[:stage] }
+ .map do |entry|
+ [entry[:stage], entry[:pipeline]]
+ end
+ end
+
+ private
+
+ def config
+ @config ||= CONFIG
+ end
+ end
+end
+
+::BulkImports::Stage.prepend_if_ee('::EE::BulkImports::Stage')
diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb
index 182c0bbaa8a..282ba9e19ac 100644
--- a/app/models/bulk_imports/tracker.rb
+++ b/app/models/bulk_imports/tracker.rb
@@ -3,6 +3,8 @@
class BulkImports::Tracker < ApplicationRecord
self.table_name = 'bulk_import_trackers'
+ alias_attribute :pipeline_name, :relation
+
belongs_to :entity,
class_name: 'BulkImports::Entity',
foreign_key: :bulk_import_entity_id,
@@ -16,6 +18,29 @@ class BulkImports::Tracker < ApplicationRecord
validates :stage, presence: true
+ DEFAULT_PAGE_SIZE = 500
+
+ scope :next_pipeline_trackers_for, -> (entity_id) {
+ entity_scope = where(bulk_import_entity_id: entity_id)
+ next_stage_scope = entity_scope.with_status(:created).select('MIN(stage)')
+
+ entity_scope.where(stage: next_stage_scope)
+ }
+
+ def self.stage_running?(entity_id, stage)
+ where(stage: stage, bulk_import_entity_id: entity_id)
+ .with_status(:created, :started)
+ .exists?
+ end
+
+ def pipeline_class
+ unless BulkImports::Stage.pipeline_exists?(pipeline_name)
+ raise NameError.new("'#{pipeline_name}' is not a valid BulkImport Pipeline")
+ end
+
+ pipeline_name.constantize
+ end
+
state_machine :status, initial: :created do
state :created, value: 0
state :started, value: 1
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 824e35a6480..3d8e9f4c126 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -14,8 +14,6 @@ module Ci
BuildArchivedError = Class.new(StandardError)
- ignore_columns :artifacts_file, :artifacts_file_store, :artifacts_metadata, :artifacts_metadata_store, :artifacts_size, :commands, remove_after: '2019-12-15', remove_with: '12.7'
-
belongs_to :project, inverse_of: :builds
belongs_to :runner
belongs_to :trigger_request
@@ -35,6 +33,7 @@ module Ci
}.freeze
DEGRADATION_THRESHOLD_VARIABLE_NAME = 'DEGRADATION_THRESHOLD'
+ RUNNERS_STATUS_CACHE_EXPIRATION = 1.minute
has_one :deployment, as: :deployable, class_name: 'Deployment'
has_one :pending_state, class_name: 'Ci::BuildPendingState', inverse_of: :build
@@ -75,7 +74,14 @@ module Ci
return unless has_environment?
strong_memoize(:persisted_environment) do
- Environment.find_by(name: expanded_environment_name, project: project)
+ # This code path has caused N+1s in the past, since environments are only indirectly
+ # associated to builds and pipelines; see https://gitlab.com/gitlab-org/gitlab/-/issues/326445
+ # We therefore batch-load them to prevent dormant N+1s until we found a proper solution.
+ BatchLoader.for(expanded_environment_name).batch(key: project_id) do |names, loader, args|
+ Environment.where(name: names, project: args[:key]).find_each do |environment|
+ loader.call(environment.name, environment)
+ end
+ end
end
end
@@ -88,8 +94,7 @@ module Ci
validates :ref, presence: true
scope :not_interruptible, -> do
- joins(:metadata).where('ci_builds_metadata.id NOT IN (?)',
- Ci::BuildMetadata.scoped_build.with_interruptible.select(:id))
+ joins(:metadata).where.not('ci_builds_metadata.id' => Ci::BuildMetadata.scoped_build.with_interruptible.select(:id))
end
scope :unstarted, -> { where(runner_id: nil) }
@@ -319,7 +324,7 @@ module Ci
end
end
- before_transition any => [:failed] do |build|
+ after_transition any => [:failed] do |build|
next unless build.project
next unless build.deployment
@@ -372,11 +377,11 @@ module Ci
end
def other_manual_actions
- pipeline.manual_actions.where.not(name: name)
+ pipeline.manual_actions.reject { |action| action.name == self.name }
end
def other_scheduled_actions
- pipeline.scheduled_actions.where.not(name: name)
+ pipeline.scheduled_actions.reject { |action| action.name == self.name }
end
def pages_generator?
@@ -698,7 +703,23 @@ module Ci
end
def any_runners_online?
- project.any_active_runners? { |runner| runner.match_build_if_online?(self) }
+ if Feature.enabled?(:runners_cached_states, project, default_enabled: :yaml)
+ cache_for_online_runners do
+ project.any_online_runners? { |runner| runner.match_build_if_online?(self) }
+ end
+ else
+ project.any_active_runners? { |runner| runner.match_build_if_online?(self) }
+ end
+ end
+
+ def any_runners_available?
+ if Feature.enabled?(:runners_cached_states, project, default_enabled: :yaml)
+ cache_for_available_runners do
+ project.active_runners.exists?
+ end
+ else
+ project.any_active_runners?
+ end
end
def stuck?
@@ -1103,6 +1124,20 @@ module Ci
.to_a
.include?(exit_code)
end
+
+ def cache_for_online_runners(&block)
+ Rails.cache.fetch(
+ ['has-online-runners', id],
+ expires_in: RUNNERS_STATUS_CACHE_EXPIRATION
+ ) { yield }
+ end
+
+ def cache_for_available_runners(&block)
+ Rails.cache.fetch(
+ ['has-available-runners', project.id],
+ expires_in: RUNNERS_STATUS_CACHE_EXPIRATION
+ ) { yield }
+ end
end
end
diff --git a/app/models/ci/build_dependencies.rb b/app/models/ci/build_dependencies.rb
index b50ecf99439..8ae921f1416 100644
--- a/app/models/ci/build_dependencies.rb
+++ b/app/models/ci/build_dependencies.rb
@@ -21,8 +21,7 @@ module Ci
deps = model_class.where(pipeline_id: processable.pipeline_id).latest
deps = from_previous_stages(deps)
deps = from_needs(deps)
- deps = from_dependencies(deps)
- deps
+ from_dependencies(deps)
end
# Dependencies from the same parent-pipeline hierarchy excluding
diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb
index d4f9f78a1ac..7e03d709f24 100644
--- a/app/models/ci/build_trace_chunk.rb
+++ b/app/models/ci/build_trace_chunk.rb
@@ -30,9 +30,9 @@ module Ci
fog: 3
}.freeze
- STORE_TYPES = DATA_STORES.keys.map do |store|
+ STORE_TYPES = DATA_STORES.keys.to_h do |store|
[store, "Ci::BuildTraceChunks::#{store.capitalize}".constantize]
- end.to_h.freeze
+ end.freeze
enum data_store: DATA_STORES
diff --git a/app/models/ci/build_trace_chunks/redis.rb b/app/models/ci/build_trace_chunks/redis.rb
index 58d50b39c11..003ec107895 100644
--- a/app/models/ci/build_trace_chunks/redis.rb
+++ b/app/models/ci/build_trace_chunks/redis.rb
@@ -4,7 +4,7 @@ module Ci
module BuildTraceChunks
class Redis
CHUNK_REDIS_TTL = 1.week
- LUA_APPEND_CHUNK = <<~EOS.freeze
+ LUA_APPEND_CHUNK = <<~EOS
local key, new_data, offset = KEYS[1], ARGV[1], ARGV[2]
local length = new_data:len()
local expire = #{CHUNK_REDIS_TTL.seconds}
diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb
index 4ba09fd8152..47b91fcf2ce 100644
--- a/app/models/ci/group.rb
+++ b/app/models/ci/group.rb
@@ -22,6 +22,13 @@ module Ci
@jobs = jobs
end
+ def ==(other)
+ other.present? && other.is_a?(self.class) &&
+ project == other.project &&
+ stage == other.stage &&
+ name == other.name
+ end
+
def status
strong_memoize(:status) do
status_struct.status
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index d5e88f2be5b..50e21a1c323 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -131,8 +131,6 @@ module Ci
update_project_statistics project_statistics_name: :build_artifacts_size
scope :not_expired, -> { where('expire_at IS NULL OR expire_at > ?', Time.current) }
- scope :with_files_stored_locally, -> { where(file_store: ::JobArtifactUploader::Store::LOCAL) }
- scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) }
scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) }
scope :for_job_name, ->(name) { joins(:job).where(ci_builds: { name: name }) }
@@ -292,8 +290,12 @@ module Ci
end
end
+ def archived_trace_exists?
+ file&.file&.exists?
+ end
+
def self.archived_trace_exists_for?(job_id)
- where(job_id: job_id).trace.take&.file&.file&.exists?
+ where(job_id: job_id).trace.take&.archived_trace_exists?
end
def self.max_artifact_size(type:, project:)
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index b63ec0c8a97..c9ab69317e1 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -286,9 +286,11 @@ module Ci
end
after_transition any => [:failed] do |pipeline|
- next unless pipeline.auto_devops_source?
+ pipeline.run_after_commit do
+ ::Gitlab::Ci::Pipeline::Metrics.pipeline_failure_reason_counter.increment(reason: pipeline.failure_reason)
- pipeline.run_after_commit { AutoDevops::DisableWorker.perform_async(pipeline.id) }
+ AutoDevops::DisableWorker.perform_async(pipeline.id) if pipeline.auto_devops_source?
+ end
end
end
@@ -309,6 +311,7 @@ module Ci
scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) }
scope :created_before_id, -> (id) { where('ci_pipelines.id < ?', id) }
scope :before_pipeline, -> (pipeline) { created_before_id(pipeline.id).outside_pipeline_family(pipeline) }
+ scope :eager_load_project, -> { eager_load(project: [:route, { namespace: :route }]) }
scope :outside_pipeline_family, ->(pipeline) do
where.not(id: pipeline.same_family_pipeline_ids)
@@ -393,26 +396,13 @@ module Ci
# given we simply get the latest pipelines for the commits, regardless
# of what refs the pipelines belong to.
def self.latest_pipeline_per_commit(commits, ref = nil)
- p1 = arel_table
- p2 = arel_table.alias
-
- # This LEFT JOIN will filter out all but the newest row for every
- # combination of (project_id, sha) or (project_id, sha, ref) if a ref is
- # given.
- cond = p1[:sha].eq(p2[:sha])
- .and(p1[:project_id].eq(p2[:project_id]))
- .and(p1[:id].lt(p2[:id]))
-
- cond = cond.and(p1[:ref].eq(p2[:ref])) if ref
- join = p1.join(p2, Arel::Nodes::OuterJoin).on(cond)
+ sql = select('DISTINCT ON (sha) *')
+ .where(sha: commits)
+ .order(:sha, id: :desc)
- relation = where(sha: commits)
- .where(p2[:id].eq(nil))
- .joins(join.join_sources)
+ sql = sql.where(ref: ref) if ref
- relation = relation.where(ref: ref) if ref
-
- relation.each_with_object({}) do |pipeline, hash|
+ sql.each_with_object({}) do |pipeline, hash|
hash[pipeline.sha] = pipeline
end
end
@@ -445,6 +435,10 @@ module Ci
@auto_devops_pipelines_completed_total ||= Gitlab::Metrics.counter(:auto_devops_pipelines_completed_total, 'Number of completed auto devops pipelines')
end
+ def uses_needs?
+ builds.where(scheduling_type: :dag).any?
+ end
+
def stages_count
statuses.select(:stage).distinct.count
end
@@ -510,6 +504,12 @@ module Ci
end
end
+ def git_author_full_text
+ strong_memoize(:git_author_full_text) do
+ commit.try(:author_full_text)
+ end
+ end
+
def git_commit_message
strong_memoize(:git_commit_message) do
commit.try(:message)
@@ -573,10 +573,18 @@ module Ci
end
def cancel_running(retries: nil)
- retry_optimistic_lock(cancelable_statuses, retries, name: 'ci_pipeline_cancel_running') do |cancelable|
- cancelable.find_each do |job|
- yield(job) if block_given?
- job.cancel
+ commit_status_relations = [:project, :pipeline]
+ ci_build_relations = [:deployment, :taggings]
+
+ retry_optimistic_lock(cancelable_statuses, retries, name: 'ci_pipeline_cancel_running') do |cancelables|
+ cancelables.find_in_batches do |batch|
+ ActiveRecord::Associations::Preloader.new.preload(batch, commit_status_relations)
+ ActiveRecord::Associations::Preloader.new.preload(batch.select { |job| job.is_a?(Ci::Build) }, ci_build_relations)
+
+ batch.each do |job|
+ yield(job) if block_given?
+ job.cancel
+ end
end
end
end
@@ -664,7 +672,9 @@ module Ci
end
def has_kubernetes_active?
- project.deployment_platform&.active?
+ strong_memoize(:has_kubernetes_active) do
+ project.deployment_platform&.active?
+ end
end
def freeze_period?
@@ -822,6 +832,7 @@ module Ci
variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s)
variables.append(key: 'CI_COMMIT_REF_PROTECTED', value: (!!protected_ref?).to_s)
variables.append(key: 'CI_COMMIT_TIMESTAMP', value: git_commit_timestamp.to_s)
+ variables.append(key: 'CI_COMMIT_AUTHOR', value: git_author_full_text.to_s)
# legacy variables
variables.append(key: 'CI_BUILD_REF', value: sha)
diff --git a/app/models/ci/pipeline_artifact.rb b/app/models/ci/pipeline_artifact.rb
index f538a4cd808..9dfe4252e95 100644
--- a/app/models/ci/pipeline_artifact.rb
+++ b/app/models/ci/pipeline_artifact.rb
@@ -57,3 +57,5 @@ module Ci
end
end
end
+
+Ci::PipelineArtifact.prepend_ee_mod
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
index 2fae077dd87..3c17246bc34 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -7,6 +7,7 @@ module Ci
include StripAttribute
include Schedulable
include Limitable
+ include EachBatch
self.limit_name = 'ci_pipeline_schedules'
self.limit_scope = :project
@@ -28,6 +29,7 @@ module Ci
scope :active, -> { where(active: true) }
scope :inactive, -> { where(active: false) }
scope :preloaded, -> { preload(:owner, project: [:route]) }
+ scope :owned_by, ->(user) { where(owner: user) }
accepts_nested_attributes_for :variables, allow_destroy: true
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index 0ad1ed2fce8..3b61840805a 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -165,7 +165,13 @@ module Ci
end
def all_dependencies
- dependencies.all
+ if Feature.enabled?(:preload_associations_jobs_request_api_endpoint, project, default_enabled: :yaml)
+ strong_memoize(:all_dependencies) do
+ dependencies.all
+ end
+ else
+ dependencies.all
+ end
end
private
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index d1a20bc93c3..05126853e0f 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -45,8 +45,6 @@ module Ci
FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze
MINUTES_COST_FACTOR_FIELDS = %i[public_projects_minutes_cost_factor private_projects_minutes_cost_factor].freeze
- ignore_column :is_shared, remove_after: '2019-12-15', remove_with: '12.6'
-
has_many :builds
has_many :runner_projects, inverse_of: :runner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :runner_projects
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 03a97355574..9dd75150ac7 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -14,11 +14,20 @@ module Ci
has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id
has_many :latest_statuses, -> { ordered.latest }, class_name: 'CommitStatus', foreign_key: :stage_id
+ has_many :retried_statuses, -> { ordered.retried }, class_name: 'CommitStatus', foreign_key: :stage_id
has_many :processables, class_name: 'Ci::Processable', foreign_key: :stage_id
has_many :builds, foreign_key: :stage_id
has_many :bridges, foreign_key: :stage_id
scope :ordered, -> { order(position: :asc) }
+ scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) }
+ scope :by_name, ->(names) { where(name: names) }
+ scope :with_latest_and_retried_statuses, -> do
+ includes(
+ latest_statuses: [:pipeline, project: :namespace],
+ retried_statuses: [:pipeline, project: :namespace]
+ )
+ end
with_options unless: :importing? do
validates :project, presence: true
@@ -35,7 +44,7 @@ module Ci
next if position.present?
self.position = statuses.select(:stage_idx)
- .where('stage_idx IS NOT NULL')
+ .where.not(stage_idx: nil)
.group(:stage_idx)
.order('COUNT(*) DESC')
.first&.stage_idx.to_i
diff --git a/app/models/ci/test_case.rb b/app/models/ci/test_case.rb
deleted file mode 100644
index 19ecc177436..00000000000
--- a/app/models/ci/test_case.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# 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
deleted file mode 100644
index 8867b954240..00000000000
--- a/app/models/ci/test_case_failure.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# 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/ci/unit_test.rb b/app/models/ci/unit_test.rb
new file mode 100644
index 00000000000..81623b4f6ad
--- /dev/null
+++ b/app/models/ci/unit_test.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Ci
+ class UnitTest < ApplicationRecord
+ extend Gitlab::Ci::Model
+
+ MAX_NAME_SIZE = 255
+ MAX_SUITE_NAME_SIZE = 255
+
+ validates :project, :key_hash, :name, :suite_name, presence: true
+
+ has_many :unit_test_failures, class_name: 'Ci::UnitTestFailure'
+
+ 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, unit_test_attrs)
+ # Insert records first. Existing ones will be skipped.
+ insert_all(build_insert_attrs(project, unit_test_attrs))
+
+ # Find all matching records now that we are sure they all are persisted.
+ by_project_and_keys(project, gather_keys(unit_test_attrs))
+ end
+
+ private
+
+ def build_insert_attrs(project, unit_test_attrs)
+ # 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.
+ unit_test_attrs.map do |attrs|
+ attrs.merge(
+ project_id: project.id,
+ name: attrs[:name].truncate(MAX_NAME_SIZE),
+ suite_name: attrs[:suite_name].truncate(MAX_SUITE_NAME_SIZE)
+ )
+ end
+ end
+
+ def gather_keys(unit_test_attrs)
+ unit_test_attrs.map { |attrs| attrs[:key_hash] }
+ end
+ end
+ end
+end
diff --git a/app/models/ci/unit_test_failure.rb b/app/models/ci/unit_test_failure.rb
new file mode 100644
index 00000000000..653a56bd2b3
--- /dev/null
+++ b/app/models/ci/unit_test_failure.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Ci
+ class UnitTestFailure < ApplicationRecord
+ extend Gitlab::Ci::Model
+
+ REPORT_WINDOW = 14.days
+
+ validates :unit_test, :build, :failed_at, presence: true
+
+ belongs_to :unit_test, class_name: "Ci::UnitTest", foreign_key: :unit_test_id
+ belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id
+
+ def self.recent_failures_count(project:, unit_test_keys:, date_range: REPORT_WINDOW.ago..Time.current)
+ joins(:unit_test)
+ .where(
+ ci_unit_tests: {
+ project_id: project.id,
+ key_hash: unit_test_keys
+ },
+ ci_unit_test_failures: {
+ failed_at: date_range
+ }
+ )
+ .group(:key_hash)
+ .count('ci_unit_test_failures.id')
+ end
+ end
+end
diff --git a/app/models/clusters/agent_token.rb b/app/models/clusters/agent_token.rb
index 9d79887b574..d42279502c5 100644
--- a/app/models/clusters/agent_token.rb
+++ b/app/models/clusters/agent_token.rb
@@ -2,17 +2,45 @@
module Clusters
class AgentToken < ApplicationRecord
+ include RedisCacheable
include TokenAuthenticatable
+
add_authentication_token_field :token, encrypted: :required, token_generator: -> { Devise.friendly_token(50) }
+ cached_attr_reader :last_contacted_at
self.table_name = 'cluster_agent_tokens'
+ # The `UPDATE_USED_COLUMN_EVERY` defines how often the token DB entry can be updated
+ UPDATE_USED_COLUMN_EVERY = (40.minutes..55.minutes).freeze
+
belongs_to :agent, class_name: 'Clusters::Agent', optional: false
belongs_to :created_by_user, class_name: 'User', optional: true
before_save :ensure_token
validates :description, length: { maximum: 1024 }
- validates :name, presence: true, length: { maximum: 255 }, on: :create
+ validates :name, presence: true, length: { maximum: 255 }
+
+ def track_usage
+ track_values = { last_used_at: Time.current.utc }
+
+ cache_attributes(track_values)
+
+ # Use update_column so updated_at is skipped
+ update_columns(track_values) if can_update_track_values?
+ end
+
+ private
+
+ def can_update_track_values?
+ # Use a random threshold to prevent beating DB updates.
+ last_used_at_max_age = Random.rand(UPDATE_USED_COLUMN_EVERY)
+
+ real_last_used_at = read_attribute(:last_used_at)
+
+ # Handle too many updates from high token traffic
+ real_last_used_at.nil? ||
+ (Time.current - real_last_used_at) >= last_used_at_max_age
+ end
end
end
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index 55a9a0ccb81..b9c136abab4 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Prometheus < ApplicationRecord
- include PrometheusAdapter
+ include ::Clusters::Concerns::PrometheusClient
VERSION = '10.4.1'
@@ -32,7 +32,7 @@ module Clusters
end
state_machine :status do
- after_transition any => [:installed] do |application|
+ after_transition any => [:installed, :externally_installed] do |application|
application.run_after_commit do
Clusters::Applications::ActivateServiceWorker
.perform_async(application.cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass
@@ -58,14 +58,6 @@ module Clusters
'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive'
end
- def service_name
- 'prometheus-prometheus-server'
- end
-
- def service_port
- 80
- end
-
def install_command
helm_command_module::InstallCommand.new(
name: name,
@@ -106,29 +98,6 @@ module Clusters
files.merge('values.yaml': replaced_values)
end
- def prometheus_client
- return unless kube_client
-
- proxy_url = kube_client.proxy_url('service', service_name, service_port, Gitlab::Kubernetes::Helm::NAMESPACE)
-
- # ensures headers containing auth data are appended to original k8s client options
- options = kube_client.rest_client.options
- .merge(prometheus_client_default_options)
- .merge(headers: kube_client.headers)
- Gitlab::PrometheusClient.new(proxy_url, options)
- rescue Kubeclient::HttpError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ENETUNREACH
- # If users have mistakenly set parameters or removed the depended clusters,
- # `proxy_url` could raise an exception because gitlab can not communicate with the cluster.
- # Since `PrometheusAdapter#can_query?` is eargely loaded on environement pages in gitlab,
- # we need to silence the exceptions
- end
-
- def configured?
- kube_client.present? && available?
- rescue Gitlab::UrlBlocker::BlockedUrlError
- false
- end
-
def generate_alert_manager_token!
unless alert_manager_token.present?
update!(alert_manager_token: generate_token)
@@ -146,10 +115,6 @@ module Clusters
.perform_async(cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass
end
- def kube_client
- cluster&.kubeclient&.core_client
- end
-
def install_knative_metrics
return [] unless cluster.application_knative_available?
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 8a49d476ba7..bc80bcd0b06 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.26.0'
+ VERSION = '0.27.0'
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index a34d8a6b98d..a1e2aa194a0 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -51,6 +51,8 @@ module Clusters
has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', inverse_of: :cluster, autosave: true
+ has_one :integration_prometheus, class_name: 'Clusters::Integrations::Prometheus', inverse_of: :cluster
+
def self.has_one_cluster_application(name) # rubocop:disable Naming/PredicateName
application = APPLICATIONS[name.to_s]
has_one application.association_name, class_name: application.to_s, inverse_of: :cluster # rubocop:disable Rails/ReflectionClassName
@@ -100,7 +102,6 @@ module Clusters
delegate :rbac?, to: :platform_kubernetes, prefix: true, allow_nil: true
delegate :available?, to: :application_helm, prefix: true, allow_nil: true
delegate :available?, to: :application_ingress, prefix: true, allow_nil: true
- delegate :available?, to: :application_prometheus, prefix: true, allow_nil: true
delegate :available?, to: :application_knative, prefix: true, allow_nil: true
delegate :available?, to: :application_elastic_stack, prefix: true, allow_nil: true
delegate :external_ip, to: :application_ingress, prefix: true, allow_nil: true
@@ -148,6 +149,9 @@ module Clusters
scope :with_management_project, -> { where.not(management_project: nil) }
scope :for_project_namespace, -> (namespace_id) { joins(:projects).where(projects: { namespace_id: namespace_id }) }
+
+ # with_application_prometheus scope is deprecated, and scheduled for removal
+ # in %14.0. See https://gitlab.com/groups/gitlab-org/-/epics/4280
scope :with_application_prometheus, -> { includes(:application_prometheus).joins(:application_prometheus) }
scope :with_project_http_integrations, -> (project_ids) do
conditions = { projects: :alert_management_http_integrations }
@@ -276,6 +280,10 @@ module Clusters
public_send(association_name) || public_send("build_#{association_name}") # rubocop:disable GitlabSecurity/PublicSend
end
+ def find_or_build_integration_prometheus
+ integration_prometheus || build_integration_prometheus
+ end
+
def provider
if gcp?
provider_gcp
@@ -361,8 +369,12 @@ module Clusters
end
end
+ def application_prometheus_available?
+ integration_prometheus&.available? || application_prometheus&.available?
+ end
+
def prometheus_adapter
- application_prometheus
+ integration_prometheus || application_prometheus
end
private
diff --git a/app/models/clusters/clusters_hierarchy.rb b/app/models/clusters/clusters_hierarchy.rb
index c9c18d8c96a..125783e6ee1 100644
--- a/app/models/clusters/clusters_hierarchy.rb
+++ b/app/models/clusters/clusters_hierarchy.rb
@@ -16,7 +16,7 @@ module Clusters
model
.unscoped
- .where('clusters.id IS NOT NULL')
+ .where.not('clusters.id' => nil)
.with
.recursive(cte.to_arel)
.from(cte_alias)
diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb
index 95ac95448dd..7485ee079ce 100644
--- a/app/models/clusters/concerns/application_status.rb
+++ b/app/models/clusters/concerns/application_status.rb
@@ -9,6 +9,7 @@ module Clusters
scope :available, -> do
where(
status: [
+ self.state_machines[:status].states[:externally_installed].value,
self.state_machines[:status].states[:installed].value,
self.state_machines[:status].states[:updated].value
]
@@ -28,6 +29,7 @@ module Clusters
state :uninstalling, value: 7
state :uninstall_errored, value: 8
state :uninstalled, value: 10
+ state :externally_installed, value: 11
# Used for applications that are pre-installed by the cluster,
# e.g. Knative in GCP Cloud Run enabled clusters
@@ -37,7 +39,7 @@ module Clusters
state :pre_installed, value: 9
event :make_externally_installed do
- transition any => :installed
+ transition any => :externally_installed
end
event :make_externally_uninstalled do
@@ -79,7 +81,7 @@ module Clusters
transition [:scheduled] => :uninstalling
end
- before_transition any => [:scheduled, :installed, :uninstalled] do |application, _|
+ before_transition any => [:scheduled, :installed, :uninstalled, :externally_installed] do |application, _|
application.status_reason = nil
end
@@ -114,7 +116,7 @@ module Clusters
end
def available?
- pre_installed? || installed? || updated?
+ pre_installed? || installed? || externally_installed? || updated?
end
def update_in_progress?
diff --git a/app/models/clusters/concerns/application_version.rb b/app/models/clusters/concerns/application_version.rb
index 6c0b014662c..dab0bd23e2e 100644
--- a/app/models/clusters/concerns/application_version.rb
+++ b/app/models/clusters/concerns/application_version.rb
@@ -5,11 +5,17 @@ module Clusters
module ApplicationVersion
extend ActiveSupport::Concern
+ EXTERNAL_VERSION = 'EXTERNALLY_INSTALLED'
+
included do
state_machine :status do
before_transition any => [:installed, :updated] do |application|
application.version = application.class.const_get(:VERSION, false)
end
+
+ before_transition any => [:externally_installed] do |application|
+ application.version = EXTERNAL_VERSION
+ end
end
end
diff --git a/app/models/clusters/concerns/prometheus_client.rb b/app/models/clusters/concerns/prometheus_client.rb
new file mode 100644
index 00000000000..10cb307addd
--- /dev/null
+++ b/app/models/clusters/concerns/prometheus_client.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Concerns
+ module PrometheusClient
+ extend ActiveSupport::Concern
+
+ included do
+ include PrometheusAdapter
+
+ def service_name
+ 'prometheus-prometheus-server'
+ end
+
+ def service_port
+ 80
+ end
+
+ def prometheus_client
+ return unless kube_client
+
+ proxy_url = kube_client.proxy_url('service', service_name, service_port, Gitlab::Kubernetes::Helm::NAMESPACE)
+
+ # ensures headers containing auth data are appended to original k8s client options
+ options = kube_client.rest_client.options
+ .merge(prometheus_client_default_options)
+ .merge(headers: kube_client.headers)
+ Gitlab::PrometheusClient.new(proxy_url, options)
+ rescue Kubeclient::HttpError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ENETUNREACH
+ # If users have mistakenly set parameters or removed the depended clusters,
+ # `proxy_url` could raise an exception because gitlab can not communicate with the cluster.
+ # Since `PrometheusAdapter#can_query?` is eargely loaded on environement pages in gitlab,
+ # we need to silence the exceptions
+ end
+
+ def configured?
+ kube_client.present? && available?
+ rescue Gitlab::UrlBlocker::BlockedUrlError
+ false
+ end
+
+ private
+
+ def kube_client
+ cluster&.kubeclient&.core_client
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/integrations/prometheus.rb b/app/models/clusters/integrations/prometheus.rb
new file mode 100644
index 00000000000..1496d8ff1dd
--- /dev/null
+++ b/app/models/clusters/integrations/prometheus.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Integrations
+ class Prometheus < ApplicationRecord
+ include ::Clusters::Concerns::PrometheusClient
+
+ self.table_name = 'clusters_integration_prometheus'
+ self.primary_key = :cluster_id
+
+ belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id
+
+ validates :cluster, presence: true
+ validates :enabled, inclusion: { in: [true, false] }
+
+ def available?
+ enabled?
+ end
+ end
+ end
+end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index bf168aaacc5..5c3e3685c64 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -62,7 +62,8 @@ class Commit
collection.sort do |a, b|
operands = [a, b].tap { |o| o.reverse! if sort == 'desc' }
- attr1, attr2 = operands.first.public_send(order_by), operands.second.public_send(order_by) # rubocop:disable PublicSend
+ attr1 = operands.first.public_send(order_by) # rubocop:disable GitlabSecurity/PublicSend
+ attr2 = operands.second.public_send(order_by) # rubocop:disable GitlabSecurity/PublicSend
# use case insensitive comparison for string values
order_by.in?(%w[email name]) ? attr1.casecmp(attr2) : attr1 <=> attr2
@@ -222,6 +223,14 @@ class Commit
end
end
+ def author_full_text
+ return unless author_name && author_email
+
+ strong_memoize(:author_full_text) do
+ "#{author_name} <#{author_email}>"
+ end
+ end
+
# Returns full commit message if title is truncated (greater than 99 characters)
# otherwise returns commit message without first line
def description
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 524429bf12a..e989129209a 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -55,6 +55,8 @@ class CommitStatus < ApplicationRecord
scope :for_ref, -> (ref) { where(ref: ref) }
scope :by_name, -> (name) { where(name: name) }
scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) }
+ scope :eager_load_pipeline, -> { eager_load(:pipeline, project: { namespace: :route }) }
+ scope :with_pipeline, -> { joins(:pipeline) }
scope :for_project_paths, -> (paths) do
where(project: Project.where_full_path_in(Array(paths)))
@@ -179,14 +181,9 @@ class CommitStatus < ApplicationRecord
end
after_transition any => :failed do |commit_status|
- next unless commit_status.project
-
- # rubocop: disable CodeReuse/ServiceClass
commit_status.run_after_commit do
- MergeRequests::AddTodoWhenBuildFailsService
- .new(project, nil).execute(self)
+ ::Gitlab::Ci::Pipeline::Metrics.job_failure_reason_counter.increment(reason: commit_status.failure_reason)
end
- # rubocop: enable CodeReuse/ServiceClass
end
end
@@ -210,26 +207,7 @@ class CommitStatus < ApplicationRecord
end
def group_name
- simplified_commit_status_group_name_feature_flag = Gitlab::SafeRequestStore.fetch("project:#{project_id}:simplified_commit_status_group_name") do
- Feature.enabled?(:simplified_commit_status_group_name, project, default_enabled: false)
- end
-
- if simplified_commit_status_group_name_feature_flag
- # Only remove one or more [...] "X/Y" "X Y" from the end of build names.
- # More about the regular expression logic: https://docs.gitlab.com/ee/ci/jobs/#group-jobs-in-a-pipeline
-
- name.to_s.sub(%r{([\b\s:]+((\[.*\])|(\d+[\s:\/\\]+\d+)))+\s*\z}, '').strip
- else
- # Prior implementation, remove [...] "X/Y" "X Y" from the beginning and middle of build names
- # 'rspec:linux: 1/10' => 'rspec:linux'
- common_name = name.to_s.gsub(%r{\b\d+[\s:\/\\]+\d+\s*}, '')
-
- # 'rspec:linux: [aws, max memory]' => 'rspec:linux', 'rspec:linux: [aws]' => 'rspec:linux'
- common_name.gsub!(%r{: \[.*\]\s*\z}, '')
-
- common_name.strip!
- common_name
- end
+ name.to_s.sub(%r{([\b\s:]+((\[.*\])|(\d+[\s:\/\\]+\d+)))+\s*\z}, '').strip
end
def failed_but_allowed?
@@ -293,7 +271,8 @@ class CommitStatus < ApplicationRecord
end
def update_older_statuses_retried!
- self.class
+ pipeline
+ .statuses
.latest
.where(name: name)
.where.not(id: id)
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index c106c08c04a..fdc418029be 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -131,7 +131,6 @@ module Avatarable
def clear_avatar_caches
return unless respond_to?(:verified_emails) && verified_emails.any? && avatar_changed?
- return unless Feature.enabled?(:avatar_cache_for_email, self, type: :development)
Gitlab::AvatarCache.delete_by_email(*verified_emails)
end
diff --git a/app/models/concerns/boards/listable.rb b/app/models/concerns/boards/listable.rb
index d6863e87261..b9827a79422 100644
--- a/app/models/concerns/boards/listable.rb
+++ b/app/models/concerns/boards/listable.rb
@@ -13,6 +13,7 @@ module Boards
scope :ordered, -> { order(:list_type, :position) }
scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) }
scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) }
+ scope :without_types, ->(list_types) { where.not(list_type: list_types) }
class << self
def preload_preferences_for_user(lists, user)
diff --git a/app/models/concerns/bulk_member_access_load.rb b/app/models/concerns/bulk_member_access_load.rb
index f44ad474cd5..e252ca36629 100644
--- a/app/models/concerns/bulk_member_access_load.rb
+++ b/app/models/concerns/bulk_member_access_load.rb
@@ -13,13 +13,7 @@ module BulkMemberAccessLoad
raise 'Block is mandatory' unless block_given?
resource_ids = resource_ids.uniq
- key = max_member_access_for_resource_key(resource_klass, memoization_index)
- access = {}
-
- if Gitlab::SafeRequestStore.active?
- Gitlab::SafeRequestStore[key] ||= {}
- access = Gitlab::SafeRequestStore[key]
- end
+ access = load_access_hash(resource_klass, memoization_index)
# Look up only the IDs we need
resource_ids -= access.keys
@@ -39,10 +33,28 @@ module BulkMemberAccessLoad
access
end
+ def merge_value_to_request_store(resource_klass, resource_id, memoization_index, value)
+ max_member_access_for_resource_ids(resource_klass, [resource_id], memoization_index) do
+ { resource_id => value }
+ end
+ end
+
private
def max_member_access_for_resource_key(klass, memoization_index)
"max_member_access_for_#{klass.name.underscore.pluralize}:#{memoization_index}"
end
+
+ def load_access_hash(resource_klass, memoization_index)
+ key = max_member_access_for_resource_key(resource_klass, memoization_index)
+
+ access = {}
+ if Gitlab::SafeRequestStore.active?
+ Gitlab::SafeRequestStore[key] ||= {}
+ access = Gitlab::SafeRequestStore[key]
+ end
+
+ access
+ end
end
end
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index 45944401c2d..34c1b6d25a4 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -56,12 +56,12 @@ module CacheMarkdownField
# Update every applicable column in a row if any one is invalidated, as we only store
# one version per row
def refresh_markdown_cache
- updates = cached_markdown_fields.markdown_fields.map do |markdown_field|
+ updates = cached_markdown_fields.markdown_fields.to_h do |markdown_field|
[
cached_markdown_fields.html_field(markdown_field),
rendered_field_content(markdown_field)
]
- end.to_h
+ end
updates['cached_markdown_version'] = latest_cached_markdown_version
diff --git a/app/models/concerns/cascading_namespace_setting_attribute.rb b/app/models/concerns/cascading_namespace_setting_attribute.rb
new file mode 100644
index 00000000000..2b4a108a9a0
--- /dev/null
+++ b/app/models/concerns/cascading_namespace_setting_attribute.rb
@@ -0,0 +1,241 @@
+# frozen_string_literal: true
+
+#
+# Cascading attributes enables managing settings in a flexible way.
+#
+# - Instance administrator can define an instance-wide default setting, or
+# lock the setting to prevent change by group owners.
+# - Group maintainers/owners can define a default setting for their group, or
+# lock the setting to prevent change by sub-group maintainers/owners.
+#
+# Behavior:
+#
+# - When a group does not have a value (value is `nil`), cascade up the
+# hierarchy to find the first non-nil value.
+# - Settings can be locked at any level to prevent groups/sub-groups from
+# overriding.
+# - If the setting isn't locked, the default can be overridden.
+# - An instance administrator or group maintainer/owner can push settings values
+# to groups/sub-groups to override existing values, even when the setting
+# is not otherwise locked.
+#
+module CascadingNamespaceSettingAttribute
+ extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ class_methods do
+ def cascading_settings_feature_enabled?
+ ::Feature.enabled?(:cascading_namespace_settings, default_enabled: true)
+ end
+
+ private
+
+ # Facilitates the cascading lookup of values and,
+ # similar to Rails' `attr_accessor`, defines convenience methods such as
+ # a reader, writer, and validators.
+ #
+ # Example: `cascading_attr :delayed_project_removal`
+ #
+ # Public methods defined:
+ # - `delayed_project_removal`
+ # - `delayed_project_removal=`
+ # - `delayed_project_removal_locked?`
+ # - `delayed_project_removal_locked_by_ancestor?`
+ # - `delayed_project_removal_locked_by_application_setting?`
+ # - `delayed_project_removal?` (only defined for boolean attributes)
+ # - `delayed_project_removal_locked_ancestor` - Returns locked namespace settings object (only namespace_id)
+ #
+ # Defined validators ensure attribute value cannot be updated if locked by
+ # an ancestor or application settings.
+ #
+ # Requires database columns be present in both `namespace_settings` and
+ # `application_settings`.
+ def cascading_attr(*attributes)
+ attributes.map(&:to_sym).each do |attribute|
+ # public methods
+ define_attr_reader(attribute)
+ define_attr_writer(attribute)
+ define_lock_methods(attribute)
+ alias_boolean(attribute)
+
+ # private methods
+ define_validator_methods(attribute)
+ define_after_update(attribute)
+
+ validate :"#{attribute}_changeable?"
+ validate :"lock_#{attribute}_changeable?"
+
+ after_update :"clear_descendant_#{attribute}_locks", if: -> { saved_change_to_attribute?("lock_#{attribute}", to: true) }
+ end
+ end
+
+ # The cascading attribute reader method handles lookups
+ # with the following criteria:
+ #
+ # 1. Returns the dirty value, if the attribute has changed.
+ # 2. Return locked ancestor value.
+ # 3. Return locked instance-level application settings value.
+ # 4. Return this namespace's attribute, if not nil.
+ # 5. Return value from nearest ancestor where value is not nil.
+ # 6. Return instance-level application setting.
+ def define_attr_reader(attribute)
+ define_method(attribute) do
+ strong_memoize(attribute) do
+ next self[attribute] unless self.class.cascading_settings_feature_enabled?
+
+ next self[attribute] if will_save_change_to_attribute?(attribute)
+ next locked_value(attribute) if cascading_attribute_locked?(attribute)
+ next self[attribute] unless self[attribute].nil?
+
+ cascaded_value = cascaded_ancestor_value(attribute)
+ next cascaded_value unless cascaded_value.nil?
+
+ application_setting_value(attribute)
+ end
+ end
+ end
+
+ def define_attr_writer(attribute)
+ define_method("#{attribute}=") do |value|
+ clear_memoization(attribute)
+
+ super(value)
+ end
+ end
+
+ def define_lock_methods(attribute)
+ define_method("#{attribute}_locked?") do
+ cascading_attribute_locked?(attribute)
+ end
+
+ define_method("#{attribute}_locked_by_ancestor?") do
+ locked_by_ancestor?(attribute)
+ end
+
+ define_method("#{attribute}_locked_by_application_setting?") do
+ locked_by_application_setting?(attribute)
+ end
+
+ define_method("#{attribute}_locked_ancestor") do
+ locked_ancestor(attribute)
+ end
+ end
+
+ def alias_boolean(attribute)
+ return unless Gitlab::Database.exists? && type_for_attribute(attribute).type == :boolean
+
+ alias_method :"#{attribute}?", attribute
+ end
+
+ # Defines two validations - one for the cascadable attribute itself and one
+ # for the lock attribute. Only allows the respective value to change if
+ # an ancestor has not already locked the value.
+ def define_validator_methods(attribute)
+ define_method("#{attribute}_changeable?") do
+ return unless cascading_attribute_changed?(attribute)
+ return unless cascading_attribute_locked?(attribute)
+
+ errors.add(attribute, s_('CascadingSettings|cannot be changed because it is locked by an ancestor'))
+ end
+
+ define_method("lock_#{attribute}_changeable?") do
+ return unless cascading_attribute_changed?("lock_#{attribute}")
+
+ if cascading_attribute_locked?(attribute)
+ return errors.add(:"lock_#{attribute}", s_('CascadingSettings|cannot be changed because it is locked by an ancestor'))
+ end
+
+ # Don't allow locking a `nil` attribute.
+ # Even if the value being locked is currently cascaded from an ancestor,
+ # it should be copied to this record to avoid the ancestor changing the
+ # value unexpectedly later.
+ return unless self[attribute].nil? && public_send("lock_#{attribute}?") # rubocop:disable GitlabSecurity/PublicSend
+
+ errors.add(attribute, s_('CascadingSettings|cannot be nil when locking the attribute'))
+ end
+
+ private :"#{attribute}_changeable?", :"lock_#{attribute}_changeable?"
+ end
+
+ # When a particular group locks the attribute, clear all sub-group locks
+ # since the higher lock takes priority.
+ def define_after_update(attribute)
+ define_method("clear_descendant_#{attribute}_locks") do
+ self.class.where(namespace_id: descendants).update_all("lock_#{attribute}" => false)
+ end
+
+ private :"clear_descendant_#{attribute}_locks"
+ end
+ end
+
+ private
+
+ def locked_value(attribute)
+ ancestor = locked_ancestor(attribute)
+ return ancestor.read_attribute(attribute) if ancestor
+
+ Gitlab::CurrentSettings.public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def locked_ancestor(attribute)
+ return unless self.class.cascading_settings_feature_enabled?
+ return unless namespace.has_parent?
+
+ strong_memoize(:"#{attribute}_locked_ancestor") do
+ self.class
+ .select(:namespace_id, "lock_#{attribute}", attribute)
+ .where(namespace_id: namespace_ancestor_ids)
+ .where(self.class.arel_table["lock_#{attribute}"].eq(true))
+ .limit(1).load.first
+ end
+ end
+
+ def locked_by_ancestor?(attribute)
+ return false unless self.class.cascading_settings_feature_enabled?
+
+ locked_ancestor(attribute).present?
+ end
+
+ def locked_by_application_setting?(attribute)
+ return false unless self.class.cascading_settings_feature_enabled?
+
+ Gitlab::CurrentSettings.public_send("lock_#{attribute}") # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def cascading_attribute_locked?(attribute)
+ locked_by_ancestor?(attribute) || locked_by_application_setting?(attribute)
+ end
+
+ def cascading_attribute_changed?(attribute)
+ public_send("#{attribute}_changed?") # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def cascaded_ancestor_value(attribute)
+ return unless namespace.has_parent?
+
+ # rubocop:disable GitlabSecurity/SqlInjection
+ self.class
+ .select(attribute)
+ .joins("join unnest(ARRAY[#{namespace_ancestor_ids.join(',')}]) with ordinality t(namespace_id, ord) USING (namespace_id)")
+ .where("#{attribute} IS NOT NULL")
+ .order('t.ord')
+ .limit(1).first&.read_attribute(attribute)
+ # rubocop:enable GitlabSecurity/SqlInjection
+ end
+
+ def application_setting_value(attribute)
+ Gitlab::CurrentSettings.public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def namespace_ancestor_ids
+ strong_memoize(:namespace_ancestor_ids) do
+ namespace.self_and_ancestors(hierarchy_order: :asc).pluck(:id).reject { |id| id == namespace_id }
+ end
+ end
+
+ def descendants
+ strong_memoize(:descendants) do
+ namespace.descendants.pluck(:id)
+ end
+ end
+end
diff --git a/app/models/concerns/ci/artifactable.rb b/app/models/concerns/ci/artifactable.rb
index cbe7d3b6abb..0d29955268f 100644
--- a/app/models/concerns/ci/artifactable.rb
+++ b/app/models/concerns/ci/artifactable.rb
@@ -4,8 +4,10 @@ module Ci
module Artifactable
extend ActiveSupport::Concern
- NotSupportedAdapterError = Class.new(StandardError)
+ include ObjectStorable
+ STORE_COLUMN = :file_store
+ NotSupportedAdapterError = Class.new(StandardError)
FILE_FORMAT_ADAPTERS = {
gzip: Gitlab::Ci::Build::Artifacts::Adapters::GzipStream,
raw: Gitlab::Ci::Build::Artifacts::Adapters::RawStream
@@ -20,6 +22,7 @@ module Ci
scope :expired_before, -> (timestamp) { where(arel_table[:expire_at].lt(timestamp)) }
scope :expired, -> (limit) { expired_before(Time.current).limit(limit) }
+ scope :project_id_in, ->(ids) { where(project_id: ids) }
end
def each_blob(&blk)
@@ -39,3 +42,5 @@ module Ci
end
end
end
+
+Ci::Artifactable.prepend_ee_mod
diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb
index 0412f7a072b..c990da5873a 100644
--- a/app/models/concerns/ci/has_status.rb
+++ b/app/models/concerns/ci/has_status.rb
@@ -16,6 +16,19 @@ module Ci
STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3,
failed: 4, canceled: 5, skipped: 6, manual: 7,
scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze
+ STATUSES_DESCRIPTION = {
+ created: 'Pipeline has been created',
+ waiting_for_resource: 'A resource (for example, a runner) that the pipeline requires to run is unavailable',
+ preparing: 'Pipeline is preparing to run',
+ pending: 'Pipeline has not started running yet',
+ running: 'Pipeline is running',
+ failed: 'At least one stage of the pipeline failed',
+ success: 'Pipeline completed successfully',
+ canceled: 'Pipeline was canceled before completion',
+ skipped: 'Pipeline was skipped',
+ manual: 'Pipeline needs to be manually started',
+ scheduled: 'Pipeline is scheduled to run'
+ }.freeze
UnknownStatusError = Class.new(StandardError)
diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb
index b468415c4c7..829b2a6ef21 100644
--- a/app/models/concerns/counter_attribute.rb
+++ b/app/models/concerns/counter_attribute.rb
@@ -33,7 +33,7 @@ module CounterAttribute
extend AfterCommitQueue
include Gitlab::ExclusiveLeaseHelpers
- LUA_STEAL_INCREMENT_SCRIPT = <<~EOS.freeze
+ LUA_STEAL_INCREMENT_SCRIPT = <<~EOS
local increment_key, flushed_key = KEYS[1], KEYS[2]
local increment_value = redis.call("get", increment_key) or 0
local flushed_value = redis.call("incrby", flushed_key, increment_value)
diff --git a/app/models/concerns/deprecated_assignee.rb b/app/models/concerns/deprecated_assignee.rb
index 7f12ce39c96..3f557ee9b48 100644
--- a/app/models/concerns/deprecated_assignee.rb
+++ b/app/models/concerns/deprecated_assignee.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# This module handles backward compatibility for import/export of Merge Requests after
+# This module handles backward compatibility for import/export of merge requests after
# multiple assignees feature was introduced. Also, it handles the scenarios where
# the #26496 background migration hasn't finished yet.
# Ideally, most of this code should be removed at #59457.
diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb
index 48b4a402974..de17f50cd29 100644
--- a/app/models/concerns/enums/ci/commit_status.rb
+++ b/app/models/concerns/enums/ci/commit_status.rb
@@ -20,6 +20,8 @@ module Enums
scheduler_failure: 11,
data_integrity_failure: 12,
forward_deployment_failure: 13,
+ user_blocked: 14,
+ project_deleted: 15,
insufficient_bridge_permissions: 1_001,
downstream_bridge_project_not_found: 1_002,
invalid_bridge_trigger: 1_003,
diff --git a/app/models/concerns/enums/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb
index f8314d8b429..fdc48d09db2 100644
--- a/app/models/concerns/enums/ci/pipeline.rb
+++ b/app/models/concerns/enums/ci/pipeline.rb
@@ -13,7 +13,9 @@ module Enums
activity_limit_exceeded: 20,
size_limit_exceeded: 21,
job_activity_limit_exceeded: 22,
- deployments_limit_exceeded: 23
+ deployments_limit_exceeded: 23,
+ user_blocked: 24,
+ project_deleted: 25
}
end
diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb
index b9ad78c14fd..774cda2c3e8 100644
--- a/app/models/concerns/has_repository.rb
+++ b/app/models/concerns/has_repository.rb
@@ -77,9 +77,14 @@ module HasRepository
def default_branch_from_preferences
return unless empty_repo?
- group_branch_default_name = group&.default_branch_name if respond_to?(:group)
+ (default_branch_from_group_preferences || Gitlab::CurrentSettings.default_branch_name).presence
+ end
+
+ def default_branch_from_group_preferences
+ return unless respond_to?(:group)
+ return unless group
- (group_branch_default_name || Gitlab::CurrentSettings.default_branch_name).presence
+ group.default_branch_name || group.root_ancestor.default_branch_name
end
def reload_default_branch
diff --git a/app/models/concerns/has_timelogs_report.rb b/app/models/concerns/has_timelogs_report.rb
new file mode 100644
index 00000000000..90f9876de95
--- /dev/null
+++ b/app/models/concerns/has_timelogs_report.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module HasTimelogsReport
+ extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ def timelogs(start_time, end_time)
+ strong_memoize(:timelogs) { timelogs_for(start_time, end_time) }
+ end
+
+ def user_can_access_group_timelogs?(current_user)
+ Ability.allowed?(current_user, :read_group_timelogs, self)
+ end
+
+ private
+
+ def timelogs_for(start_time, end_time)
+ Timelog.between_times(start_time, end_time).for_issues_in_group(self)
+ end
+end
diff --git a/app/models/concerns/integration.rb b/app/models/concerns/integration.rb
index 9d446841a9f..5e53f13be95 100644
--- a/app/models/concerns/integration.rb
+++ b/app/models/concerns/integration.rb
@@ -6,12 +6,12 @@ module Integration
class_methods do
def with_custom_integration_for(integration, page = nil, per = nil)
custom_integration_project_ids = Service
+ .select(:project_id)
.where(type: integration.type)
.where(inherit_from_id: nil)
- .distinct # Required until https://gitlab.com/gitlab-org/gitlab/-/issues/207385
+ .where.not(project_id: nil)
.page(page)
.per(per)
- .pluck(:project_id)
Project.where(id: custom_integration_project_ids)
end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index e1be0665452..1e44321e148 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -65,7 +65,7 @@ module Issuable
has_many :label_links, as: :target, dependent: :destroy, inverse_of: :target # rubocop:disable Cop/ActiveRecordDependent
has_many :labels, through: :label_links
- has_many :todos, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :todos, as: :target
has_one :metrics, inverse_of: model_name.singular.to_sym, autosave: true
@@ -137,6 +137,14 @@ module Issuable
scope :references_project, -> { references(:project) }
scope :non_archived, -> { join_project.where(projects: { archived: false }) }
+ scope :includes_for_bulk_update, -> do
+ associations = %i[author assignees epic group labels metrics project source_project target_project].select do |association|
+ reflect_on_association(association)
+ end
+
+ includes(*associations)
+ end
+
attr_mentionable :title, pipeline: :single_line
attr_mentionable :description
@@ -324,7 +332,7 @@ module Issuable
# This prevents errors when ignored columns are present in the database.
issuable_columns = with_cte ? issue_grouping_columns(use_cte: with_cte) : "#{table_name}.*"
- extra_select_columns = extra_select_columns.unshift("(#{highest_priority}) AS highest_priority")
+ extra_select_columns.unshift("(#{highest_priority}) AS highest_priority")
select(issuable_columns)
.select(extra_select_columns)
@@ -437,7 +445,7 @@ module Issuable
end
def subscribed_without_subscriptions?(user, project)
- participants(user).include?(user)
+ participant?(user)
end
def can_assign_epic?(user)
diff --git a/app/models/concerns/loaded_in_group_list.rb b/app/models/concerns/loaded_in_group_list.rb
index e624b9aa356..59e0ed75d2d 100644
--- a/app/models/concerns/loaded_in_group_list.rb
+++ b/app/models/concerns/loaded_in_group_list.rb
@@ -73,6 +73,10 @@ module LoadedInGroupList
def member_count
@member_count ||= try(:preloaded_member_count) || members.count
end
+
+ def guest_count
+ @guest_count ||= members.guests.count
+ end
end
LoadedInGroupList::ClassMethods.prepend_if_ee('EE::LoadedInGroupList::ClassMethods')
diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb
index ccb334343ff..d42417bb6c1 100644
--- a/app/models/concerns/milestoneable.rb
+++ b/app/models/concerns/milestoneable.rb
@@ -39,11 +39,13 @@ module Milestoneable
private
def milestone_is_valid
- errors.add(:milestone_id, 'is invalid') if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available?
+ errors.add(:milestone_id, 'is invalid') if respond_to?(:milestone_id) && !milestone_available?
end
end
def milestone_available?
+ return true if milestone_id.blank?
+
project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group)
end
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index 5f24564dc56..eaf64f2541d 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Milestoneish
- DISPLAY_ISSUES_LIMIT = 3000
+ DISPLAY_ISSUES_LIMIT = 500
def total_issues_count
@total_issues_count ||= Milestones::IssuesCountService.new(self).count
@@ -15,6 +15,10 @@ module Milestoneish
total_issues_count - closed_issues_count
end
+ def total_merge_requests_count
+ @total_merge_request_count ||= Milestones::MergeRequestsCountService.new(self).count
+ end
+
def complete?
total_issues_count > 0 && total_issues_count == closed_issues_count
end
diff --git a/app/models/concerns/object_storable.rb b/app/models/concerns/object_storable.rb
new file mode 100644
index 00000000000..c13dddc0b88
--- /dev/null
+++ b/app/models/concerns/object_storable.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module ObjectStorable
+ extend ActiveSupport::Concern
+
+ included do
+ scope :with_files_stored_locally, -> { where(klass::STORE_COLUMN => ObjectStorage::Store::LOCAL) }
+ scope :with_files_stored_remotely, -> { where(klass::STORE_COLUMN => ObjectStorage::Store::REMOTE) }
+ end
+end
diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb
index af105629398..acd654bd229 100644
--- a/app/models/concerns/participable.rb
+++ b/app/models/concerns/participable.rb
@@ -56,18 +56,34 @@ module Participable
# This method processes attributes of objects in breadth-first order.
#
# Returns an Array of User instances.
- def participants(current_user = nil)
- all_participants[current_user]
+ def participants(user = nil)
+ filtered_participants_hash[user]
+ end
+
+ # Checks if the user is a participant in a discussion.
+ #
+ # This method processes attributes of objects in breadth-first order.
+ #
+ # Returns a Boolean.
+ def participant?(user)
+ can_read_participable?(user) &&
+ all_participants_hash[user].include?(user)
end
private
- def all_participants
- @all_participants ||= Hash.new do |hash, user|
+ def all_participants_hash
+ @all_participants_hash ||= Hash.new do |hash, user|
hash[user] = raw_participants(user)
end
end
+ def filtered_participants_hash
+ @filtered_participants_hash ||= Hash.new do |hash, user|
+ hash[user] = filter_by_ability(all_participants_hash[user])
+ end
+ end
+
def raw_participants(current_user = nil)
current_user ||= author
ext = Gitlab::ReferenceExtractor.new(project, current_user)
@@ -98,8 +114,6 @@ module Participable
end
participants.merge(ext.users)
-
- filter_by_ability(participants)
end
def filter_by_ability(participants)
@@ -110,6 +124,15 @@ module Participable
Ability.users_that_can_read_project(participants.to_a, project)
end
end
+
+ def can_read_participable?(participant)
+ case self
+ when PersonalSnippet
+ participant.can?(:read_snippet, self)
+ else
+ participant.can?(:read_project, project)
+ end
+ end
end
Participable.prepend_if_ee('EE::Participable')
diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb
index 65195a8d5aa..2828ae4a3a9 100644
--- a/app/models/concerns/protected_ref.rb
+++ b/app/models/concerns/protected_ref.rb
@@ -4,7 +4,7 @@ module ProtectedRef
extend ActiveSupport::Concern
included do
- belongs_to :project
+ belongs_to :project, touch: true
validates :name, presence: true
validates :project, presence: true
diff --git a/app/models/concerns/safe_url.rb b/app/models/concerns/safe_url.rb
index febca7d241f..7dce05bddba 100644
--- a/app/models/concerns/safe_url.rb
+++ b/app/models/concerns/safe_url.rb
@@ -3,12 +3,12 @@
module SafeUrl
extend ActiveSupport::Concern
- def safe_url(usernames_whitelist: [])
+ def safe_url(allowed_usernames: [])
return if url.nil?
uri = URI.parse(url)
uri.password = '*****' if uri.password
- uri.user = '*****' if uri.user && !usernames_whitelist.include?(uri.user)
+ uri.user = '*****' if uri.user && allowed_usernames.exclude?(uri.user)
uri.to_s
rescue URI::Error
end
diff --git a/app/models/concerns/sidebars/container_with_html_options.rb b/app/models/concerns/sidebars/container_with_html_options.rb
new file mode 100644
index 00000000000..12ea366c66a
--- /dev/null
+++ b/app/models/concerns/sidebars/container_with_html_options.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module ContainerWithHtmlOptions
+ # The attributes returned from this method
+ # will be applied to helper methods like
+ # `link_to` or the div containing the container.
+ def container_html_options
+ {
+ aria: { label: title }
+ }.merge(extra_container_html_options)
+ end
+
+ # Classes will override mostly this method
+ # and not `container_html_options`.
+ def extra_container_html_options
+ {}
+ end
+
+ # Attributes to pass to the html_options attribute
+ # in the helper method that sets the active class
+ # on each element.
+ def nav_link_html_options
+ {}
+ end
+
+ def title
+ raise NotImplementedError
+ end
+
+ # The attributes returned from this method
+ # will be applied right next to the title,
+ # for example in the span that renders the title.
+ def title_html_options
+ {}
+ end
+
+ def link
+ raise NotImplementedError
+ end
+ end
+end
diff --git a/app/models/concerns/sidebars/has_active_routes.rb b/app/models/concerns/sidebars/has_active_routes.rb
new file mode 100644
index 00000000000..e7a153f067a
--- /dev/null
+++ b/app/models/concerns/sidebars/has_active_routes.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module HasActiveRoutes
+ # This method will indicate for which paths or
+ # controllers, the menu or menu item should
+ # be set as active.
+ #
+ # The returned values are passed to the `nav_link` helper method,
+ # so the params can be either `path`, `page`, `controller`.
+ # Param 'action' is not supported.
+ def active_routes
+ {}
+ end
+ end
+end
diff --git a/app/models/concerns/sidebars/has_hint.rb b/app/models/concerns/sidebars/has_hint.rb
new file mode 100644
index 00000000000..21dca39dca0
--- /dev/null
+++ b/app/models/concerns/sidebars/has_hint.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# This module has the necessary methods to store
+# hints for menus. Hints are elements displayed
+# when the user hover the menu item.
+module Sidebars
+ module HasHint
+ def show_hint?
+ false
+ end
+
+ def hint_html_options
+ {}
+ end
+ end
+end
diff --git a/app/models/concerns/sidebars/has_icon.rb b/app/models/concerns/sidebars/has_icon.rb
new file mode 100644
index 00000000000..d1a87918285
--- /dev/null
+++ b/app/models/concerns/sidebars/has_icon.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+# This module has the necessary methods to show
+# sprites or images next to the menu item.
+module Sidebars
+ module HasIcon
+ def sprite_icon
+ nil
+ end
+
+ def sprite_icon_html_options
+ {}
+ end
+
+ def image_path
+ nil
+ end
+
+ def image_html_options
+ {}
+ end
+
+ def icon_or_image?
+ sprite_icon || image_path
+ end
+ end
+end
diff --git a/app/models/concerns/sidebars/has_pill.rb b/app/models/concerns/sidebars/has_pill.rb
new file mode 100644
index 00000000000..ad7064fe63d
--- /dev/null
+++ b/app/models/concerns/sidebars/has_pill.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# This module introduces the logic to show the "pill" element
+# next to the menu item, indicating the a count.
+module Sidebars
+ module HasPill
+ def has_pill?
+ false
+ end
+
+ # In this method we will need to provide the query
+ # to retrieve the elements count
+ def pill_count
+ raise NotImplementedError
+ end
+
+ def pill_html_options
+ {}
+ end
+ end
+end
diff --git a/app/models/concerns/sidebars/positionable_list.rb b/app/models/concerns/sidebars/positionable_list.rb
new file mode 100644
index 00000000000..30830d547f3
--- /dev/null
+++ b/app/models/concerns/sidebars/positionable_list.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+# This module handles elements in a list. All elements
+# must have a different class
+module Sidebars
+ module PositionableList
+ def add_element(list, element)
+ list << element
+ end
+
+ def insert_element_before(list, before_element, new_element)
+ index = index_of(list, before_element)
+
+ if index
+ list.insert(index, new_element)
+ else
+ list.unshift(new_element)
+ end
+ end
+
+ def insert_element_after(list, after_element, new_element)
+ index = index_of(list, after_element)
+
+ if index
+ list.insert(index + 1, new_element)
+ else
+ add_element(list, new_element)
+ end
+ end
+
+ private
+
+ def index_of(list, element)
+ list.index { |e| e.is_a?(element) }
+ end
+ end
+end
diff --git a/app/models/concerns/sidebars/renderable.rb b/app/models/concerns/sidebars/renderable.rb
new file mode 100644
index 00000000000..a3976af8515
--- /dev/null
+++ b/app/models/concerns/sidebars/renderable.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Renderable
+ # This method will control whether the menu or menu_item
+ # should be rendered. It will be overriden by specific
+ # classes.
+ def render?
+ true
+ end
+ end
+end
diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb
index 4fe2a0e1827..9f5e9b2bb57 100644
--- a/app/models/concerns/sortable.rb
+++ b/app/models/concerns/sortable.rb
@@ -9,6 +9,7 @@ module Sortable
included do
scope :with_order_id_desc, -> { order(self.arel_table['id'].desc) }
+ scope :with_order_id_asc, -> { order(self.arel_table['id'].asc) }
scope :order_id_desc, -> { reorder(self.arel_table['id'].desc) }
scope :order_id_asc, -> { reorder(self.arel_table['id'].asc) }
scope :order_created_desc, -> { reorder(self.arel_table['created_at'].desc) }
diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb
index 33e9e0e38fb..5a10ea7a248 100644
--- a/app/models/concerns/subscribable.rb
+++ b/app/models/concerns/subscribable.rb
@@ -17,13 +17,37 @@ module Subscribable
def subscribed?(user, project = nil)
return false unless user
- if subscription = subscriptions.find_by(user: user, project: project)
+ if (subscription = lazy_subscription(user, project)&.itself)
subscription.subscribed
else
subscribed_without_subscriptions?(user, project)
end
end
+ def lazy_subscription(user, project = nil)
+ return unless user
+
+ # handle project and group labels as well as issuable subscriptions
+ subscribable_type = self.class.ancestors.include?(Label) ? 'Label' : self.class.name
+ BatchLoader.for(id: id, subscribable_type: subscribable_type, project_id: project&.id).batch do |items, loader|
+ values = items.each_with_object({ ids: Set.new, subscribable_types: Set.new, project_ids: Set.new }) do |item, result|
+ result[:ids] << item[:id]
+ result[:subscribable_types] << item[:subscribable_type]
+ result[:project_ids] << item[:project_id]
+ end
+
+ subscriptions = Subscription.where(subscribable_id: values[:ids], subscribable_type: values[:subscribable_types], project_id: values[:project_ids], user: user)
+
+ subscriptions.each do |subscription|
+ loader.call({
+ id: subscription.subscribable_id,
+ subscribable_type: subscription.subscribable_type,
+ project_id: subscription.project_id
+ }, subscription)
+ end
+ end
+ end
+
# Override this method to define custom logic to consider a subscribable as
# subscribed without an explicit subscription record.
def subscribed_without_subscriptions?(user, project)
@@ -41,8 +65,10 @@ module Subscribable
def toggle_subscription(user, project = nil)
unsubscribe_from_other_levels(user, project)
+ new_value = !subscribed?(user, project)
+
find_or_initialize_subscription(user, project)
- .update(subscribed: !subscribed?(user, project))
+ .update(subscribed: new_value)
end
def subscribe(user, project = nil)
@@ -83,6 +109,8 @@ module Subscribable
end
def find_or_initialize_subscription(user, project)
+ BatchLoader::Executor.clear_current
+
subscriptions
.find_or_initialize_by(user_id: user.id, project_id: project.try(:id))
end
diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb
index 5debfa6f834..d8867177059 100644
--- a/app/models/concerns/taskable.rb
+++ b/app/models/concerns/taskable.rb
@@ -30,7 +30,8 @@ module Taskable
end
def self.get_updated_tasks(old_content:, new_content:)
- old_tasks, new_tasks = get_tasks(old_content), get_tasks(new_content)
+ old_tasks = get_tasks(old_content)
+ new_tasks = get_tasks(new_content)
new_tasks.select.with_index do |new_task, i|
old_task = old_tasks[i]
diff --git a/app/models/concerns/token_authenticatable_strategies/encrypted.rb b/app/models/concerns/token_authenticatable_strategies/encrypted.rb
index 672402ee4d6..50a2613bb10 100644
--- a/app/models/concerns/token_authenticatable_strategies/encrypted.rb
+++ b/app/models/concerns/token_authenticatable_strategies/encrypted.rb
@@ -42,14 +42,14 @@ module TokenAuthenticatableStrategies
return insecure_strategy.get_token(instance) if migrating?
encrypted_token = instance.read_attribute(encrypted_field)
- token = Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token)
+ token = EncryptionHelper.decrypt_token(encrypted_token)
token || (insecure_strategy.get_token(instance) if optional?)
end
def set_token(instance, token)
raise ArgumentError unless token.present?
- instance[encrypted_field] = Gitlab::CryptoHelper.aes256_gcm_encrypt(token)
+ instance[encrypted_field] = EncryptionHelper.encrypt_token(token)
instance[token_field] = token if migrating?
instance[token_field] = nil if optional?
token
@@ -85,16 +85,9 @@ module TokenAuthenticatableStrategies
end
def find_by_encrypted_token(token, unscoped)
- nonce = Feature.enabled?(:dynamic_nonce_creation) ? find_hashed_iv(token) : Gitlab::CryptoHelper::AES256_GCM_IV_STATIC
- encrypted_value = Gitlab::CryptoHelper.aes256_gcm_encrypt(token, nonce: nonce)
-
- relation(unscoped).find_by(encrypted_field => encrypted_value)
- end
-
- def find_hashed_iv(token)
- token_record = TokenWithIv.find_by_plaintext_token(token)
-
- token_record&.iv || Gitlab::CryptoHelper::AES256_GCM_IV_STATIC
+ encrypted_value = EncryptionHelper.encrypt_token(token)
+ token_encrypted_with_static_iv = Gitlab::CryptoHelper.aes256_gcm_encrypt(token)
+ relation(unscoped).find_by(encrypted_field => [encrypted_value, token_encrypted_with_static_iv])
end
def insecure_strategy
diff --git a/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb b/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb
new file mode 100644
index 00000000000..25c050820d6
--- /dev/null
+++ b/app/models/concerns/token_authenticatable_strategies/encryption_helper.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module TokenAuthenticatableStrategies
+ class EncryptionHelper
+ DYNAMIC_NONCE_IDENTIFIER = "|"
+ NONCE_SIZE = 12
+
+ def self.encrypt_token(plaintext_token)
+ Gitlab::CryptoHelper.aes256_gcm_encrypt(plaintext_token)
+ end
+
+ def self.decrypt_token(token)
+ return unless token
+
+ # The pattern of the token is "#{DYNAMIC_NONCE_IDENTIFIER}#{token}#{iv_of_12_characters}"
+ if token.start_with?(DYNAMIC_NONCE_IDENTIFIER) && token.size > NONCE_SIZE + DYNAMIC_NONCE_IDENTIFIER.size
+ token_to_decrypt = token[1...-NONCE_SIZE]
+ iv = token[-NONCE_SIZE..-1]
+
+ Gitlab::CryptoHelper.aes256_gcm_decrypt(token_to_decrypt, nonce: iv)
+ else
+ Gitlab::CryptoHelper.aes256_gcm_decrypt(token)
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb
new file mode 100644
index 00000000000..cf50305faab
--- /dev/null
+++ b/app/models/concerns/vulnerability_finding_helpers.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module VulnerabilityFindingHelpers
+ extend ActiveSupport::Concern
+end
+
+VulnerabilityFindingHelpers.prepend_if_ee('EE::VulnerabilityFindingHelpers')
diff --git a/app/models/concerns/vulnerability_finding_signature_helpers.rb b/app/models/concerns/vulnerability_finding_signature_helpers.rb
new file mode 100644
index 00000000000..f57e3cb0bfb
--- /dev/null
+++ b/app/models/concerns/vulnerability_finding_signature_helpers.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module VulnerabilityFindingSignatureHelpers
+ extend ActiveSupport::Concern
+end
+
+VulnerabilityFindingSignatureHelpers.prepend_if_ee('EE::VulnerabilityFindingSignatureHelpers')
diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb
index db5fd167781..25e3b9fe4f0 100644
--- a/app/models/deploy_key.rb
+++ b/app/models/deploy_key.rb
@@ -13,8 +13,6 @@ class DeployKey < Key
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'
-
accepts_nested_attributes_for :deploy_keys_projects
def private?
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index f000e474605..d3280403bfd 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -45,6 +45,7 @@ class Deployment < ApplicationRecord
scope :active, -> { where(status: %i[created running]) }
scope :older_than, -> (deployment) { where('deployments.id < ?', deployment.id) }
scope :with_deployable, -> { joins('INNER JOIN ci_builds ON ci_builds.id = deployments.deployable_id').preload(:deployable) }
+ scope :with_api_entity_associations, -> { preload({ deployable: { runner: [], tags: [], user: [], job_artifacts_archive: [] } }) }
scope :finished_after, ->(date) { where('finished_at >= ?', date) }
scope :finished_before, ->(date) { where('finished_at < ?', date) }
@@ -93,11 +94,6 @@ class Deployment < ApplicationRecord
after_transition any => :success do |deployment|
deployment.run_after_commit do
Deployments::UpdateEnvironmentWorker.perform_async(id)
- end
- end
-
- after_transition any => FINISHED_STATUSES do |deployment|
- deployment.run_after_commit do
Deployments::LinkMergeRequestWorker.perform_async(id)
end
end
@@ -175,7 +171,7 @@ class Deployment < ApplicationRecord
end
def commit
- project.commit(sha)
+ @commit ||= project.commit(sha)
end
def commit_title
@@ -225,7 +221,7 @@ class Deployment < ApplicationRecord
end
def update_merge_request_metrics!
- return unless environment.update_merge_request_metrics? && success?
+ return unless environment.production? && success?
merge_requests = project.merge_requests
.joins(:metrics)
@@ -243,29 +239,18 @@ class Deployment < ApplicationRecord
def previous_deployment
@previous_deployment ||=
- project.deployments.joins(:environment)
- .where(environments: { name: self.environment.name }, ref: self.ref)
- .where.not(id: self.id)
- .order(id: :desc)
- .take
- end
-
- def previous_environment_deployment
- project
- .deployments
- .success
- .joins(:environment)
- .where(environments: { name: environment.name })
- .where.not(id: self.id)
- .order(id: :desc)
- .take
+ self.class.for_environment(environment_id)
+ .success
+ .where('id < ?', id)
+ .order(id: :desc)
+ .take
end
def stop_action
return unless on_stop.present?
return unless manual_actions
- @stop_action ||= manual_actions.find_by(name: on_stop)
+ @stop_action ||= manual_actions.find { |action| action.name == self.on_stop }
end
def finished_at
diff --git a/app/models/design_management/design_action.rb b/app/models/design_management/design_action.rb
index 22baa916296..43dcce545d2 100644
--- a/app/models/design_management/design_action.rb
+++ b/app/models/design_management/design_action.rb
@@ -29,7 +29,9 @@ module DesignManagement
# - design [DesignManagement::Design]: the design that was changed
# - action [Symbol]: the action that gitaly performed
def initialize(design, action, content = nil)
- @design, @action, @content = design, action, content
+ @design = design
+ @action = action
+ @content = content
validate!
end
diff --git a/app/models/design_management/design_at_version.rb b/app/models/design_management/design_at_version.rb
index 211211144f4..2f045358914 100644
--- a/app/models/design_management/design_at_version.rb
+++ b/app/models/design_management/design_at_version.rb
@@ -18,7 +18,8 @@ module DesignManagement
validate :design_and_version_have_issue_id
def initialize(design: nil, version: nil)
- @design, @version = design, version
+ @design = design
+ @version = version
end
# The ID, needed by GraphQL types and as part of the Lazy-fetch
diff --git a/app/models/design_management/repository.rb b/app/models/design_management/repository.rb
index 985d6317d5d..2b1e6070e6b 100644
--- a/app/models/design_management/repository.rb
+++ b/app/models/design_management/repository.rb
@@ -8,7 +8,7 @@ module DesignManagement
# repository is entirely GitLab-managed rather than user-facing.
#
# Enable all uploaded files to be stored in LFS.
- MANAGED_GIT_ATTRIBUTES = <<~GA.freeze
+ MANAGED_GIT_ATTRIBUTES = <<~GA
/#{DesignManagement.designs_directory}/* filter=lfs diff=lfs merge=lfs -text
GA
diff --git a/app/models/design_management/version.rb b/app/models/design_management/version.rb
index 49aec8b9720..5cfd8f3ec8e 100644
--- a/app/models/design_management/version.rb
+++ b/app/models/design_management/version.rb
@@ -14,7 +14,9 @@ module DesignManagement
attr_reader :sha, :issue_id, :actions
def initialize(sha, issue_id, actions)
- @sha, @issue_id, @actions = sha, issue_id, actions
+ @sha = sha
+ @issue_id = issue_id
+ @actions = actions
end
def message
diff --git a/app/models/environment.rb b/app/models/environment.rb
index d89909a71a2..4ee93b0ba4a 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -11,8 +11,6 @@ class Environment < ApplicationRecord
self.reactive_cache_hard_limit = 10.megabytes
self.reactive_cache_work_type = :external_dependency
- PRODUCTION_ENVIRONMENT_IDENTIFIERS = %w[prod production].freeze
-
belongs_to :project, required: true
use_fast_destroy :all_deployments
@@ -26,13 +24,13 @@ class Environment < ApplicationRecord
has_many :self_managed_prometheus_alert_events, inverse_of: :environment
has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :environment
- has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment'
+ has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment', inverse_of: :environment
has_one :last_deployable, through: :last_deployment, source: 'deployable', source_type: 'CommitStatus'
has_one :last_pipeline, through: :last_deployable, source: 'pipeline'
has_one :last_visible_deployment, -> { visible.distinct_on_environment }, inverse_of: :environment, class_name: 'Deployment'
has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus'
has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline'
- has_one :upcoming_deployment, -> { running.order('deployments.id DESC') }, class_name: 'Deployment'
+ has_one :upcoming_deployment, -> { running.order('deployments.id DESC') }, class_name: 'Deployment', inverse_of: :environment
has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment
before_validation :nullify_external_url
@@ -88,7 +86,7 @@ class Environment < ApplicationRecord
end
scope :for_project, -> (project) { where(project_id: project) }
- scope :for_tier, -> (tier) { where(tier: tier).where('tier IS NOT NULL') }
+ scope :for_tier, -> (tier) { where(tier: tier).where.not(tier: nil) }
scope :with_deployment, -> (sha) { where('EXISTS (?)', Deployment.select(1).where('deployments.environment_id = environments.id').where(sha: sha)) }
scope :unfoldered, -> { where(environment_type: nil) }
scope :with_rank, -> do
@@ -251,10 +249,6 @@ class Environment < ApplicationRecord
last_deployment.try(:created_at)
end
- def update_merge_request_metrics?
- PRODUCTION_ENVIRONMENT_IDENTIFIERS.include?(folder_name.downcase)
- end
-
def ref_path
"refs/#{Repository::REF_ENVIRONMENTS}/#{slug}"
end
diff --git a/app/models/experiment.rb b/app/models/experiment.rb
index ac8b6516d02..7ffb321f2b7 100644
--- a/app/models/experiment.rb
+++ b/app/models/experiment.rb
@@ -21,7 +21,13 @@ class Experiment < ApplicationRecord
# Create or update the recorded experiment_user row for the user in this experiment.
def record_user_and_group(user, group_type, context = {})
experiment_user = experiment_users.find_or_initialize_by(user: user)
- experiment_user.update!(group_type: group_type, context: merged_context(experiment_user, context))
+ experiment_user.assign_attributes(group_type: group_type, context: merged_context(experiment_user, context))
+ # We only call save when necessary because this causes the request to stick to the primary DB
+ # even when the save is a no-op
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/324649
+ experiment_user.save! if experiment_user.changed?
+
+ experiment_user
end
def record_conversion_event_for_user(user, context = {})
@@ -32,7 +38,14 @@ class Experiment < ApplicationRecord
end
def record_group_and_variant!(group, variant)
- experiment_subjects.find_or_initialize_by(group: group).update!(variant: variant)
+ experiment_subject = experiment_subjects.find_or_initialize_by(group: group)
+ experiment_subject.assign_attributes(variant: variant)
+ # We only call save when necessary because this causes the request to stick to the primary DB
+ # even when the save is a no-op
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/324649
+ experiment_subject.save! if experiment_subject.changed?
+
+ experiment_subject
end
private
diff --git a/app/models/external_issue.rb b/app/models/external_issue.rb
index 68b2353556e..36030b80370 100644
--- a/app/models/external_issue.rb
+++ b/app/models/external_issue.rb
@@ -6,7 +6,8 @@ class ExternalIssue
attr_reader :project
def initialize(issue_identifier, project)
- @issue_identifier, @project = issue_identifier, project
+ @issue_identifier = issue_identifier
+ @project = project
end
def to_s
diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb
index ca6857a14b6..330815ab8c1 100644
--- a/app/models/gpg_key.rb
+++ b/app/models/gpg_key.rb
@@ -71,12 +71,12 @@ class GpgKey < ApplicationRecord
end
def emails_with_verified_status
- user_infos.map do |user_info|
+ user_infos.to_h do |user_info|
[
user_info[:email],
user.verified_email?(user_info[:email])
]
- end.to_h
+ end
end
def verified?
diff --git a/app/models/group.rb b/app/models/group.rb
index 9f8a9996f31..2967c1ffc1d 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -16,6 +16,7 @@ class Group < Namespace
include Gitlab::Utils::StrongMemoize
include GroupAPICompatibility
include EachBatch
+ include HasTimelogsReport
ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10
@@ -70,6 +71,7 @@ class Group < Namespace
has_many :group_deploy_keys, through: :group_deploy_keys_groups
has_many :group_deploy_tokens
has_many :deploy_tokens, through: :group_deploy_tokens
+ has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :dependency_proxy_setting, class_name: 'DependencyProxy::GroupSetting'
has_many :dependency_proxy_blobs, class_name: 'DependencyProxy::Blob'
@@ -84,7 +86,7 @@ class Group < Namespace
validate :visibility_level_allowed_by_sub_groups
validate :visibility_level_allowed_by_parent
validate :two_factor_authentication_allowed
- validates :variables, nested_attributes_duplicates: true
+ validates :variables, nested_attributes_duplicates: { scope: :environment_scope }
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
@@ -178,6 +180,25 @@ class Group < Namespace
groups.drop(1).each { |group| group.root_ancestor = root }
end
+ # Returns the ids of the passed group models where the `emails_disabled`
+ # column is set to true anywhere in the ancestor hierarchy.
+ def ids_with_disabled_email(groups)
+ innner_query = Gitlab::ObjectHierarchy
+ .new(Group.where('id = namespaces_with_emails_disabled.id'))
+ .base_and_ancestors
+ .where(emails_disabled: true)
+ .select('1')
+ .limit(1)
+
+ group_ids = Namespace
+ .from('(SELECT * FROM namespaces) as namespaces_with_emails_disabled')
+ .where(namespaces_with_emails_disabled: { id: groups })
+ .where('EXISTS (?)', innner_query)
+ .pluck(:id)
+
+ Set.new(group_ids)
+ end
+
private
def public_to_user_arel(user)
@@ -325,6 +346,10 @@ class Group < Namespace
members_with_parents.owners.exists?(user_id: user)
end
+ def blocked_owners
+ members.blocked.where(access_level: Gitlab::Access::OWNER)
+ end
+
def has_maintainer?(user)
return false unless user
@@ -337,14 +362,29 @@ class Group < Namespace
# Check if user is a last owner of the group.
def last_owner?(user)
- has_owner?(user) && members_with_parents.owners.size == 1
+ has_owner?(user) && single_owner?
+ end
+
+ def member_last_owner?(member)
+ return member.last_owner unless member.last_owner.nil?
+
+ last_owner?(member.user)
+ end
+
+ def single_owner?
+ members_with_parents.owners.size == 1
end
- def last_blocked_owner?(user)
+ def single_blocked_owner?
+ blocked_owners.size == 1
+ end
+
+ def member_last_blocked_owner?(member)
+ return member.last_blocked_owner unless member.last_blocked_owner.nil?
+
return false if members_with_parents.owners.any?
- blocked_owners = members.blocked.where(access_level: Gitlab::Access::OWNER)
- blocked_owners.size == 1 && blocked_owners.exists?(user_id: user)
+ single_blocked_owner? && blocked_owners.exists?(user_id: member.user)
end
def ldap_synced?
@@ -784,13 +824,11 @@ class Group < Namespace
variables = Ci::GroupVariable.where(group: list_of_ids)
variables = variables.unprotected unless project.protected_for?(ref)
- if Feature.enabled?(:scoped_group_variables, self, default_enabled: :yaml)
- variables = if environment
- variables.on_environment(environment)
- else
- variables.where(environment_scope: '*')
- end
- end
+ variables = if environment
+ variables.on_environment(environment)
+ else
+ variables.where(environment_scope: '*')
+ end
variables = variables.group_by(&:group_id)
list_of_ids.reverse.flat_map { |group| variables[group.id] }.compact
diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb
index c735e593da7..b56bac58705 100644
--- a/app/models/internal_id.rb
+++ b/app/models/internal_id.rb
@@ -47,18 +47,10 @@ class InternalId < ApplicationRecord
def update_and_save(&block)
lock!
yield
- update_and_save_counter.increment(usage: usage, changed: last_value_changed?)
save!
last_value
end
- # Instrumentation to track for-update locks
- def update_and_save_counter
- strong_memoize(:update_and_save_counter) do
- Gitlab::Metrics.counter(:gitlab_internal_id_for_update_lock, 'Number of ROW SHARE (FOR UPDATE) locks on individual records from internal_ids')
- end
- end
-
class << self
def track_greatest(subject, scope, usage, new_value, init)
InternalIdGenerator.new(subject, scope, usage, init)
@@ -88,6 +80,8 @@ class InternalId < ApplicationRecord
end
class InternalIdGenerator
+ extend Gitlab::Utils::StrongMemoize
+
# Generate next internal id for a given scope and usage.
#
# For currently supported usages, see #usage enum.
@@ -123,6 +117,8 @@ class InternalId < ApplicationRecord
# 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
+ self.class.internal_id_transactions_increment(operation: :generate, usage: usage)
+
subject.transaction do
# Create a record in internal_ids if one does not yet exist
# and increment its last value
@@ -138,6 +134,8 @@ class InternalId < ApplicationRecord
def reset(value)
return false unless value
+ self.class.internal_id_transactions_increment(operation: :reset, usage: usage)
+
updated =
InternalId
.where(**scope, usage: usage_value)
@@ -152,6 +150,8 @@ class InternalId < ApplicationRecord
#
# Note this will acquire a ROW SHARE lock on the InternalId record
def track_greatest(new_value)
+ self.class.internal_id_transactions_increment(operation: :track_greatest, usage: usage)
+
subject.transaction do
record.track_greatest_and_save!(new_value)
end
@@ -162,6 +162,8 @@ class InternalId < ApplicationRecord
end
def with_lock(&block)
+ self.class.internal_id_transactions_increment(operation: :with_lock, usage: usage)
+
record.with_lock(&block)
end
@@ -197,5 +199,22 @@ class InternalId < ApplicationRecord
rescue ActiveRecord::RecordNotUnique
lookup
end
+
+ def self.internal_id_transactions_increment(operation:, usage:)
+ self.internal_id_transactions_total.increment(
+ operation: operation,
+ usage: usage.to_s,
+ in_transaction: ActiveRecord::Base.connection.transaction_open?.to_s
+ )
+ end
+
+ def self.internal_id_transactions_total
+ strong_memoize(:internal_id_transactions_total) do
+ name = :gitlab_internal_id_transactions_total
+ comment = 'Counts all the internal ids happening within transaction'
+
+ Gitlab::Metrics.counter(name, comment)
+ end
+ end
end
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 2f2d24cbe93..af78466e6a9 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -24,6 +24,8 @@ class Issue < ApplicationRecord
include Todoable
include FromUnion
+ extend ::Gitlab::Utils::Override
+
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
AnyDueDate = DueDateStruct.new('Any Due Date', '').freeze
@@ -88,7 +90,6 @@ class Issue < ApplicationRecord
test_case: 2 ## EE-only
}
- alias_attribute :parent_ids, :project_id
alias_method :issuing_parent, :project
alias_attribute :external_author, :service_desk_reply_to
@@ -113,8 +114,8 @@ class Issue < ApplicationRecord
scope :order_severity_desc, -> { includes(:issuable_severity).order('issuable_severities.severity DESC NULLS LAST') }
scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) }
- scope :with_web_entity_associations, -> { preload(:author, :project) }
- scope :with_api_entity_associations, -> { preload(:timelogs, :assignees, :author, :notes, :labels, project: [:route, { namespace: :route }] ) }
+ scope :with_web_entity_associations, -> { preload(:author, project: [:project_feature, :route, namespace: :route]) }
+ scope :preload_awardable, -> { preload(:award_emoji) }
scope :with_label_attributes, ->(label_attributes) { joins(:labels).where(labels: label_attributes) }
scope :with_alert_management_alerts, -> { joins(:alert_management_alert) }
scope :with_prometheus_alert_events, -> { joins(:issues_prometheus_alert_events) }
@@ -191,7 +192,8 @@ class Issue < ApplicationRecord
end
def self.relative_positioning_query_base(issue)
- in_projects(issue.parent_ids)
+ projects = issue.project.group&.root_ancestor&.all_projects || issue.project
+ in_projects(projects)
end
def self.relative_positioning_parent_column
@@ -342,6 +344,8 @@ class Issue < ApplicationRecord
.preload(preload)
.reorder('issue_link_id')
+ related_issues = yield related_issues if block_given?
+
cross_project_filter = -> (issues) { issues.where(project: project) }
Ability.issues_readable_by_user(related_issues,
current_user,
@@ -446,10 +450,20 @@ class Issue < ApplicationRecord
issue_email_participants.pluck(IssueEmailParticipant.arel_table[:email].lower)
end
+ def issue_assignee_user_ids
+ issue_assignees.pluck(:user_id)
+ end
+
private
+ # Ensure that the metrics association is safely created and respecting the unique constraint on issue_id
+ override :ensure_metrics
def ensure_metrics
- super
+ if !association(:metrics).loaded? || metrics.blank?
+ metrics_record = Issue::Metrics.safe_find_or_create_by(issue: self)
+ self.metrics = metrics_record
+ end
+
metrics.record!
end
diff --git a/app/models/key.rb b/app/models/key.rb
index 18fa8aaaa16..131416d1bee 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -43,6 +43,8 @@ class Key < ApplicationRecord
scope :preload_users, -> { preload(:user) }
scope :for_user, -> (user) { where(user: user) }
scope :order_last_used_at_desc, -> { reorder(::Gitlab::Database.nulls_last_order('last_used_at', 'DESC')) }
+ scope :expired_today_and_not_notified, -> { where(["date(expires_at AT TIME ZONE 'UTC') = CURRENT_DATE AND expiry_notification_delivered_at IS NULL"]) }
+ scope :expiring_soon_and_not_notified, -> { where(["date(expires_at AT TIME ZONE 'UTC') > CURRENT_DATE AND date(expires_at AT TIME ZONE 'UTC') < ? AND before_expiry_notification_delivered_at IS NULL", DAYS_TO_EXPIRE.days.from_now.to_date]) }
def self.regular_keys
where(type: ['Key', nil])
diff --git a/app/models/list.rb b/app/models/list.rb
index e1954ed72c4..d72afbaee69 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -14,7 +14,6 @@ class List < ApplicationRecord
validates :label_id, uniqueness: { scope: :board_id }, if: :label?
scope :preload_associated_models, -> { preload(:board, label: :priorities) }
- scope :without_types, ->(list_types) { where.not(list_type: list_types) }
alias_method :preferences, :list_user_preferences
diff --git a/app/models/member.rb b/app/models/member.rb
index 38574d67cb6..e978552592d 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -137,6 +137,12 @@ class Member < ApplicationRecord
scope :with_source_id, ->(source_id) { where(source_id: source_id) }
scope :including_source, -> { includes(:source) }
+ scope :distinct_on_user_with_max_access_level, -> do
+ distinct_members = select('DISTINCT ON (user_id, invite_email) *')
+ .order('user_id, invite_email, access_level DESC, expires_at DESC, created_at ASC')
+ Member.from(distinct_members, :members)
+ end
+
scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) }
scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) }
scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) }
@@ -278,10 +284,16 @@ class Member < ApplicationRecord
Gitlab::Access.sym_options
end
+ def valid_email?(email)
+ Devise.email_regexp.match?(email)
+ end
+
private
def parse_users_list(source, list)
- emails, user_ids, users = [], [], []
+ emails = []
+ user_ids = []
+ users = []
existing_members = {}
list.each do |item|
@@ -299,6 +311,7 @@ class Member < ApplicationRecord
if user_ids.present?
users.concat(User.where(id: user_ids))
+ # the below will automatically discard invalid user_ids
existing_members = source.members_and_requesters.where(user_id: user_ids).index_by(&:user_id)
end
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index c30f6dc81ee..0f9fdd230ff 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -7,7 +7,7 @@ class GroupMember < Member
SOURCE_TYPE = 'Namespace'
belongs_to :group, foreign_key: 'source_id'
-
+ alias_attribute :namespace_id, :source_id
delegate :update_two_factor_requirement, to: :user
# Make sure group member points only to group as it source
@@ -26,6 +26,8 @@ class GroupMember < Member
after_create :update_two_factor_requirement, unless: :invite?
after_destroy :update_two_factor_requirement, unless: :invite?
+ attr_accessor :last_owner, :last_blocked_owner
+
def self.access_level_roles
Gitlab::Access.options_with_owner
end
diff --git a/app/models/members/last_group_owner_assigner.rb b/app/models/members/last_group_owner_assigner.rb
new file mode 100644
index 00000000000..64decb1df36
--- /dev/null
+++ b/app/models/members/last_group_owner_assigner.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Members
+ class LastGroupOwnerAssigner
+ def initialize(group, members)
+ @group = group
+ @members = members
+ end
+
+ def execute
+ @last_blocked_owner = no_owners_in_heirarchy? && group.single_blocked_owner?
+ @group_single_owner = owners.size == 1
+
+ members.each { |member| set_last_owner(member) }
+ end
+
+ private
+
+ attr_reader :group, :members, :last_blocked_owner, :group_single_owner
+
+ def no_owners_in_heirarchy?
+ owners.empty?
+ end
+
+ def set_last_owner(member)
+ member.last_owner = member.id.in?(owner_ids) && group_single_owner
+ member.last_blocked_owner = member.id.in?(blocked_owner_ids) && last_blocked_owner
+ end
+
+ def owner_ids
+ @owner_ids ||= owners.where(id: member_ids).ids
+ end
+
+ def blocked_owner_ids
+ @blocked_owner_ids ||= group.blocked_owners.where(id: member_ids).ids
+ end
+
+ def member_ids
+ @members_ids ||= members.pluck(:id)
+ end
+
+ def owners
+ @owners ||= group.members_with_parents.owners.load
+ end
+ end
+end
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 833b27756ab..9a86b3a3fd9 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -5,6 +5,8 @@ class ProjectMember < Member
belongs_to :project, foreign_key: 'source_id'
+ delegate :namespace_id, to: :project
+
# Make sure project member points only to project as it source
default_value_for :source_type, SOURCE_TYPE
validates :source_type, format: { with: /\AProject\z/ }
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 7efdd79ae1c..e7f3762b9a3 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -37,7 +37,7 @@ class MergeRequest < ApplicationRecord
SORTING_PREFERENCE_FIELD = :merge_requests_sort
ALLOWED_TO_USE_MERGE_BASE_PIPELINE_FOR_COMPARISON = {
- 'Ci::CompareCodequalityReportsService' => ->(project) { ::Gitlab::Ci::Features.display_codequality_backend_comparison?(project) }
+ 'Ci::CompareCodequalityReportsService' => ->(project) { true }
}.freeze
belongs_to :target_project, class_name: "Project"
@@ -276,6 +276,9 @@ class MergeRequest < ApplicationRecord
scope :by_squash_commit_sha, -> (sha) do
where(squash_commit_sha: sha)
end
+ scope :by_merge_or_squash_commit_sha, -> (sha) do
+ from_union([by_squash_commit_sha(sha), by_merge_commit_sha(sha)])
+ end
scope :by_related_commit_sha, -> (sha) do
from_union(
[
@@ -285,14 +288,20 @@ class MergeRequest < ApplicationRecord
]
)
end
- scope :by_cherry_pick_sha, -> (sha) do
- joins(:notes).where(notes: { commit_id: sha })
- end
scope :join_project, -> { joins(:target_project) }
- scope :join_metrics, -> do
+ scope :join_metrics, -> (target_project_id = nil) do
+ # Do not join the relation twice
+ return self if self.arel.join_sources.any? { |join| join.left.try(:name).eql?(MergeRequest::Metrics.table_name) }
+
query = joins(:metrics)
- query = query.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id]))
- query
+
+ project_condition = if target_project_id
+ MergeRequest::Metrics.arel_table[:target_project_id].eq(target_project_id)
+ else
+ MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id])
+ end
+
+ query.where(project_condition)
end
scope :references_project, -> { references(:target_project) }
scope :with_api_entity_associations, -> {
@@ -304,6 +313,7 @@ class MergeRequest < ApplicationRecord
}
scope :with_csv_entity_associations, -> { preload(:assignees, :approved_by_users, :author, :milestone, metrics: [:merged_by]) }
+ scope :with_jira_integration_associations, -> { preload_routables.preload(:metrics, :assignees, :author) }
scope :by_target_branch_wildcard, ->(wildcard_branch_name) do
where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%'))
@@ -346,7 +356,9 @@ class MergeRequest < ApplicationRecord
scope :preload_metrics, -> (relation) { preload(metrics: relation) }
scope :preload_project_and_latest_diff, -> { preload(:source_project, :latest_merge_request_diff) }
scope :preload_latest_diff_commit, -> { preload(latest_merge_request_diff: :merge_request_diff_commits) }
- scope :with_web_entity_associations, -> { preload(:author, :target_project) }
+ scope :preload_milestoneish_associations, -> { preload_routables.preload(:assignees, :labels) }
+
+ scope :with_web_entity_associations, -> { preload(:author, target_project: [:project_feature, group: [:route, :parent], namespace: :route]) }
scope :with_auto_merge_enabled, -> do
with_state(:opened).where(auto_merge_enabled: true)
@@ -1302,11 +1314,8 @@ class MergeRequest < ApplicationRecord
message.join("\n\n")
end
- # Returns the oldest multi-line commit message, or the MR title if none found
def default_squash_commit_message
- strong_memoize(:default_squash_commit_message) do
- first_multiline_commit&.safe_message || title
- end
+ title
end
# Returns the oldest multi-line commit
@@ -1358,11 +1367,11 @@ class MergeRequest < ApplicationRecord
def environments_for(current_user, latest: false)
return [] unless diff_head_commit
- envs = EnvironmentsFinder.new(target_project, current_user,
+ envs = EnvironmentsByDeploymentsFinder.new(target_project, current_user,
ref: target_branch, commit: diff_head_commit, with_tags: true, find_latest: latest).execute
if source_project
- envs.concat EnvironmentsFinder.new(source_project, current_user,
+ envs.concat EnvironmentsByDeploymentsFinder.new(source_project, current_user,
ref: source_branch, commit: diff_head_commit, find_latest: latest).execute
end
@@ -1555,8 +1564,6 @@ class MergeRequest < ApplicationRecord
end
def has_codequality_reports?
- return false unless ::Gitlab::Ci::Features.display_codequality_backend_comparison?(project)
-
actual_head_pipeline&.has_reports?(Ci::JobArtifact.codequality_reports)
end
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index aa4ddfede99..4cf0e423a15 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -89,6 +89,10 @@ class Milestone < ApplicationRecord
.order(:project_id, :group_id, :due_date).select('DISTINCT ON (project_id, group_id) id')
end
+ def self.with_web_entity_associations
+ preload(:group, project: [:project_feature, group: [:parent], namespace: :route])
+ end
+
def participants
User.joins(assigned_issues: :milestone).where("milestones.id = ?", id).distinct
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 3f7ccdb977e..455429608b4 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -13,6 +13,9 @@ class Namespace < ApplicationRecord
include Gitlab::Utils::StrongMemoize
include IgnorableColumns
include Namespaces::Traversal::Recursive
+ include Namespaces::Traversal::Linear
+
+ ignore_column :delayed_project_removal, remove_with: '14.1', remove_after: '2021-05-22'
# Prevent users from creating unreasonably deep level of nesting.
# The number 20 was taken based on maximum nesting level of
@@ -43,6 +46,9 @@ class Namespace < ApplicationRecord
has_one :aggregation_schedule, class_name: 'Namespace::AggregationSchedule'
has_one :package_setting_relation, inverse_of: :namespace, class_name: 'PackageSetting'
+ has_one :admin_note, inverse_of: :namespace
+ accepts_nested_attributes_for :admin_note, update_only: true
+
validates :owner, presence: true, unless: ->(n) { n.type == "Group" }
validates :name,
presence: true,
@@ -83,11 +89,11 @@ class Namespace < ApplicationRecord
before_destroy(prepend: true) { prepare_for_destroy }
after_destroy :rm_dir
- before_save :ensure_delayed_project_removal_assigned_to_namespace_settings, if: :delayed_project_removal_changed?
-
scope :for_user, -> { where('type IS NULL') }
scope :sort_by_type, -> { order(Gitlab::Database.nulls_first_order(:type)) }
scope :include_route, -> { includes(:route) }
+ scope :by_parent, -> (parent) { where(parent_id: parent) }
+ scope :filter_by_path, -> (query) { where('lower(path) = :query', query: query.downcase) }
scope :with_statistics, -> do
joins('LEFT JOIN project_statistics ps ON ps.namespace_id = namespaces.id')
@@ -107,7 +113,7 @@ class Namespace < ApplicationRecord
# Make sure that the name is same as strong_memoize name in root_ancestor
# method
- attr_writer :root_ancestor
+ attr_writer :root_ancestor, :emails_disabled_memoized
class << self
def by_path(path)
@@ -235,7 +241,7 @@ class Namespace < ApplicationRecord
# any ancestor can disable emails for all descendants
def emails_disabled?
- strong_memoize(:emails_disabled) do
+ strong_memoize(:emails_disabled_memoized) do
if parent_id
self_and_ancestors.where(emails_disabled: true).exists?
else
@@ -260,13 +266,8 @@ class Namespace < ApplicationRecord
# Includes projects from this namespace and projects from all subgroups
# that belongs to this namespace
def all_projects
- return Project.where(namespace: self) if user?
-
- if Feature.enabled?(:recursive_namespace_lookup_as_inner_join, self)
- Project.joins("INNER JOIN (#{self_and_descendants.select(:id).to_sql}) namespaces ON namespaces.id=projects.namespace_id")
- else
- Project.where(namespace: self_and_descendants)
- end
+ namespace = user? ? self : self_and_descendants
+ Project.where(namespace: namespace)
end
# Includes pipelines from this namespace and pipelines from all subgroups
@@ -288,8 +289,13 @@ class Namespace < ApplicationRecord
false
end
+ # Deprecated, use #licensed_feature_available? instead. Remove once Namespace#feature_available? isn't used anymore.
+ def feature_available?(feature)
+ licensed_feature_available?(feature)
+ end
+
# Overridden in EE::Namespace
- def feature_available?(_feature)
+ def licensed_feature_available?(_feature)
false
end
@@ -347,6 +353,10 @@ class Namespace < ApplicationRecord
Plan.default
end
+ def paid?
+ root? && actual_plan.paid?
+ end
+
def actual_limits
# We default to PlanLimits.new otherwise a lot of specs would fail
# On production each plan should already have associated limits record
@@ -412,13 +422,6 @@ class Namespace < ApplicationRecord
private
- def ensure_delayed_project_removal_assigned_to_namespace_settings
- return if Feature.disabled?(:migrate_delayed_project_removal, default_enabled: true)
-
- self.namespace_settings || build_namespace_settings
- namespace_settings.delayed_project_removal = delayed_project_removal
- end
-
def all_projects_with_pages
if all_projects.pages_metadata_not_migrated.exists?
Gitlab::BackgroundMigration::MigratePagesMetadata.new.perform_on_relation(
diff --git a/app/models/namespace/admin_note.rb b/app/models/namespace/admin_note.rb
new file mode 100644
index 00000000000..3de809d60be
--- /dev/null
+++ b/app/models/namespace/admin_note.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class Namespace::AdminNote < ApplicationRecord
+ belongs_to :namespace, inverse_of: :admin_note
+ validates :namespace, presence: true
+ validates :note, length: { maximum: 1000 }
+end
diff --git a/app/models/namespace/traversal_hierarchy.rb b/app/models/namespace/traversal_hierarchy.rb
index cfb6cfdde74..28cf55f7486 100644
--- a/app/models/namespace/traversal_hierarchy.rb
+++ b/app/models/namespace/traversal_hierarchy.rb
@@ -34,17 +34,20 @@ class Namespace
sql = """
UPDATE namespaces
SET traversal_ids = cte.traversal_ids
- FROM (#{recursive_traversal_ids}) as cte
+ FROM (#{recursive_traversal_ids(lock: true)}) as cte
WHERE namespaces.id = cte.id
AND namespaces.traversal_ids <> cte.traversal_ids
"""
Namespace.connection.exec_query(sql)
+ rescue ActiveRecord::Deadlocked
+ db_deadlock_counter.increment(source: 'Namespace#sync_traversal_ids!')
+ raise
end
# Identify all incorrect traversal_ids in the current namespace hierarchy.
- def incorrect_traversal_ids
+ def incorrect_traversal_ids(lock: false)
Namespace
- .joins("INNER JOIN (#{recursive_traversal_ids}) as cte ON namespaces.id = cte.id")
+ .joins("INNER JOIN (#{recursive_traversal_ids(lock: lock)}) as cte ON namespaces.id = cte.id")
.where('namespaces.traversal_ids <> cte.traversal_ids')
end
@@ -55,10 +58,13 @@ class Namespace
#
# Note that the traversal_ids represent a calculated traversal path for the
# namespace and not the value stored within the traversal_ids attribute.
- def recursive_traversal_ids
+ #
+ # Optionally locked with FOR UPDATE to ensure isolation between concurrent
+ # updates of the heirarchy.
+ def recursive_traversal_ids(lock: false)
root_id = Integer(@root.id)
- """
+ sql = <<~SQL
WITH RECURSIVE cte(id, traversal_ids, cycle) AS (
VALUES(#{root_id}, ARRAY[#{root_id}], false)
UNION ALL
@@ -67,7 +73,11 @@ class Namespace
WHERE n.parent_id = cte.id AND NOT cycle
)
SELECT id, traversal_ids FROM cte
- """
+ SQL
+
+ sql += ' FOR UPDATE' if lock
+
+ sql
end
# This is essentially Namespace#root_ancestor which will soon be rewritten
@@ -80,5 +90,9 @@ class Namespace
.reorder(nil)
.find_by(parent_id: nil)
end
+
+ def db_deadlock_counter
+ Gitlab::Metrics.counter(:db_deadlock, 'Counts the times we have deadlocked in the database')
+ end
end
end
diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb
index 50844403d7f..d21f9632e18 100644
--- a/app/models/namespace_setting.rb
+++ b/app/models/namespace_setting.rb
@@ -1,14 +1,20 @@
# frozen_string_literal: true
class NamespaceSetting < ApplicationRecord
+ include CascadingNamespaceSettingAttribute
+
+ cascading_attr :delayed_project_removal
+
belongs_to :namespace, inverse_of: :namespace_settings
validate :default_branch_name_content
validate :allow_mfa_for_group
+ validate :allow_resource_access_token_creation_for_group
before_validation :normalize_default_branch_name
- NAMESPACE_SETTINGS_PARAMS = [:default_branch_name].freeze
+ NAMESPACE_SETTINGS_PARAMS = [:default_branch_name, :delayed_project_removal,
+ :lock_delayed_project_removal, :resource_access_token_creation_allowed].freeze
self.primary_key = :namespace_id
@@ -31,6 +37,12 @@ class NamespaceSetting < ApplicationRecord
errors.add(:allow_mfa_for_subgroups, _('is not allowed since the group is not top-level group.'))
end
end
+
+ def allow_resource_access_token_creation_for_group
+ if namespace&.subgroup? && !resource_access_token_creation_allowed
+ errors.add(:resource_access_token_creation_allowed, _('is not allowed since the group is not top-level group.'))
+ end
+ end
end
NamespaceSetting.prepend_if_ee('EE::NamespaceSetting')
diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb
new file mode 100644
index 00000000000..dd9ca8d9bea
--- /dev/null
+++ b/app/models/namespaces/traversal/linear.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+#
+# Query a recursively defined namespace hierarchy using linear methods through
+# the traversal_ids attribute.
+#
+# Namespace is a nested hierarchy of one parent to many children. A search
+# using only the parent-child relationships is a slow operation. This process
+# was previously optimized using Postgresql recursive common table expressions
+# (CTE) with acceptable performance. However, it lead to slower than possible
+# performance, and resulted in complicated queries that were difficult to make
+# performant.
+#
+# Instead of searching the hierarchy recursively, we store a `traversal_ids`
+# attribute on each node. The `traversal_ids` is an ordered array of Namespace
+# IDs that define the traversal path from the root Namespace to the current
+# Namespace.
+#
+# For example, suppose we have the following Namespaces:
+#
+# GitLab (id: 1) > Engineering (id: 2) > Manage (id: 3) > Access (id: 4)
+#
+# Then `traversal_ids` for group "Access" is [1, 2, 3, 4]
+#
+# And we can match against other Namespace `traversal_ids` such that:
+#
+# - Ancestors are [1], [1, 2], [1, 2, 3]
+# - Descendants are [1, 2, 3, 4, *]
+# - Root is [1]
+# - Hierarchy is [1, *]
+#
+# Note that this search method works so long as the IDs are unique and the
+# traversal path is ordered from root to leaf nodes.
+#
+# We implement this in the database using Postgresql arrays, indexed by a
+# generalized inverted index (gin).
+module Namespaces
+ module Traversal
+ module Linear
+ extend ActiveSupport::Concern
+
+ UnboundedSearch = Class.new(StandardError)
+
+ included do
+ after_create :sync_traversal_ids, if: -> { sync_traversal_ids? }
+ after_update :sync_traversal_ids, if: -> { sync_traversal_ids? && saved_change_to_parent_id? }
+
+ scope :traversal_ids_contains, ->(ids) { where("traversal_ids @> (?)", ids) }
+ end
+
+ def sync_traversal_ids?
+ Feature.enabled?(:sync_traversal_ids, root_ancestor, default_enabled: :yaml)
+ end
+
+ def use_traversal_ids?
+ Feature.enabled?(:use_traversal_ids, root_ancestor, default_enabled: :yaml)
+ end
+
+ def self_and_descendants
+ if use_traversal_ids?
+ lineage(self)
+ else
+ super
+ end
+ end
+
+ private
+
+ # Update the traversal_ids for the full hierarchy.
+ #
+ # NOTE: self.traversal_ids will be stale. Reload for a fresh record.
+ def sync_traversal_ids
+ # Clear any previously memoized root_ancestor as our ancestors have changed.
+ clear_memoization(:root_ancestor)
+
+ Namespace::TraversalHierarchy.for_namespace(root_ancestor).sync_traversal_ids!
+ end
+
+ # Make sure we drop the STI `type = 'Group'` condition for better performance.
+ # Logically equivalent so long as hierarchies remain homogeneous.
+ def without_sti_condition
+ self.class.unscope(where: :type)
+ end
+
+ # Search this namespace's lineage. Bound inclusively by top node.
+ def lineage(top)
+ raise UnboundedSearch.new('Must bound search by a top') unless top
+
+ without_sti_condition
+ .traversal_ids_contains("{#{top.id}}")
+ end
+ end
+ end
+end
diff --git a/app/models/namespaces/traversal/recursive.rb b/app/models/namespaces/traversal/recursive.rb
index d74b7883830..409438f53d2 100644
--- a/app/models/namespaces/traversal/recursive.rb
+++ b/app/models/namespaces/traversal/recursive.rb
@@ -6,10 +6,14 @@ module Namespaces
extend ActiveSupport::Concern
def root_ancestor
- return self if persisted? && parent_id.nil?
+ return self if parent.nil?
- strong_memoize(:root_ancestor) do
- self_and_ancestors.reorder(nil).find_by(parent_id: nil)
+ if persisted?
+ strong_memoize(:root_ancestor) do
+ self_and_ancestors.reorder(nil).find_by(parent_id: nil)
+ end
+ else
+ parent.root_ancestor
end
end
@@ -18,6 +22,7 @@ module Namespaces
object_hierarchy(self.class.where(id: id))
.all_objects
end
+ alias_method :recursive_self_and_hierarchy, :self_and_hierarchy
# Returns all the ancestors of the current namespaces.
def ancestors
@@ -26,6 +31,7 @@ module Namespaces
object_hierarchy(self.class.where(id: parent_id))
.base_and_ancestors
end
+ alias_method :recursive_ancestors, :ancestors
# returns all ancestors upto but excluding the given namespace
# when no namespace is given, all ancestors upto the top are returned
@@ -40,17 +46,20 @@ module Namespaces
object_hierarchy(self.class.where(id: id))
.base_and_ancestors(hierarchy_order: hierarchy_order)
end
+ alias_method :recursive_self_and_ancestors, :self_and_ancestors
# Returns all the descendants of the current namespace.
def descendants
object_hierarchy(self.class.where(parent_id: id))
.base_and_descendants
end
+ alias_method :recursive_descendants, :descendants
def self_and_descendants
object_hierarchy(self.class.where(id: id))
.base_and_descendants
end
+ alias_method :recursive_self_and_descendants, :self_and_descendants
def object_hierarchy(ancestors_base)
Gitlab::ObjectHierarchy.new(ancestors_base, options: { use_distinct: Feature.enabled?(:use_distinct_in_object_hierarchy, self) })
diff --git a/app/models/note.rb b/app/models/note.rb
index fb540d692d1..3e560a09fbd 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -19,6 +19,7 @@ class Note < ApplicationRecord
include Gitlab::SQL::Pattern
include ThrottledTouch
include FromUnion
+ include Sortable
cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true
@@ -103,12 +104,12 @@ class Note < ApplicationRecord
scope :system, -> { where(system: true) }
scope :user, -> { where(system: false) }
scope :common, -> { where(noteable_type: ["", nil]) }
- scope :fresh, -> { order(created_at: :asc, id: :asc) }
+ scope :fresh, -> { order_created_asc.with_order_id_asc }
scope :updated_after, ->(time) { where('updated_at > ?', time) }
scope :with_updated_at, ->(time) { where(updated_at: time) }
- scope :by_updated_at, -> { reorder(:updated_at, :id) }
scope :inc_author_project, -> { includes(:project, :author) }
scope :inc_author, -> { includes(:author) }
+ scope :with_api_entity_associations, -> { preload(:note_diff_file, :author) }
scope :inc_relations_for_view, -> do
includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji,
{ system_note_metadata: :description_version }, :note_diff_file, :diff_note_positions, :suggestions)
@@ -135,6 +136,7 @@ class Note < ApplicationRecord
project: [:project_members, :namespace, { group: [:group_members] }])
end
scope :with_metadata, -> { includes(:system_note_metadata) }
+ scope :with_web_entity_associations, -> { preload(:project, :author, :noteable) }
scope :for_note_or_capitalized_note, ->(text) { where(note: [text, text.capitalize]) }
scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) }
@@ -148,6 +150,8 @@ class Note < ApplicationRecord
after_commit :notify_after_destroy, on: :destroy
class << self
+ extend Gitlab::Utils::Override
+
def model_name
ActiveModel::Name.new(self, nil, 'note')
end
@@ -204,6 +208,17 @@ class Note < ApplicationRecord
def search(query)
fuzzy_search(query, [:note])
end
+
+ # Override the `Sortable` module's `.simple_sorts` to remove name sorting,
+ # as a `Note` does not have any property that correlates to a "name".
+ override :simple_sorts
+ def simple_sorts
+ super.except('name_asc', 'name_desc')
+ end
+
+ def cherry_picked_merge_requests(shas)
+ where(noteable_type: 'MergeRequest', commit_id: shas).select(:noteable_id)
+ end
end
# rubocop: disable CodeReuse/ServiceClass
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index 72813b17501..3d049336d44 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class NotificationSetting < ApplicationRecord
+ include FromUnion
+
enum level: { global: 3, watch: 2, participating: 1, mention: 4, disabled: 0, custom: 5 }
default_value_for :level, NotificationSetting.levels[:global]
@@ -30,6 +32,8 @@ class NotificationSetting < ApplicationRecord
scope :preload_source_route, -> { preload(source: [:route]) }
+ scope :order_by_id_asc, -> { order(id: :asc) }
+
# NOTE: Applicable unfound_translations.rb also needs to be updated when below events are changed.
EMAIL_EVENTS = [
:new_release,
diff --git a/app/models/packages/debian/file_entry.rb b/app/models/packages/debian/file_entry.rb
new file mode 100644
index 00000000000..eb66f4acfa9
--- /dev/null
+++ b/app/models/packages/debian/file_entry.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Packages
+ module Debian
+ class FileEntry
+ include ActiveModel::Model
+
+ DIGESTS = %i[md5 sha1 sha256].freeze
+ FILENAME_REGEX = %r{\A[a-zA-Z0-9][a-zA-Z0-9_.~+-]*\z}.freeze
+
+ attr_accessor :filename,
+ :size,
+ :md5sum,
+ :section,
+ :priority,
+ :sha1sum,
+ :sha256sum,
+ :package_file
+
+ validates :filename, :size, :md5sum, :section, :priority, :sha1sum, :sha256sum, :package_file, presence: true
+ validates :filename, format: { with: FILENAME_REGEX }
+ validate :valid_package_file_digests, if: -> { md5sum.present? && sha1sum.present? && sha256sum.present? && package_file.present? }
+
+ def component
+ return 'main' if section.blank?
+ return 'main' unless section.include?('/')
+
+ section.split('/')[0]
+ end
+
+ private
+
+ def valid_package_file_digests
+ DIGESTS.each do |digest|
+ package_file_digest = package_file["file_#{digest}"]
+ sum = public_send("#{digest}sum") # rubocop:disable GitlabSecurity/PublicSend
+ next if package_file_digest == sum
+
+ errors.add("#{digest}sum".to_sym, "mismatch for #{filename}: #{package_file_digest} != #{sum}")
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/packages/debian/file_metadatum.rb b/app/models/packages/debian/file_metadatum.rb
index 7c9f4f5f3f1..af51f256e18 100644
--- a/app/models/packages/debian/file_metadatum.rb
+++ b/app/models/packages/debian/file_metadatum.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Packages::Debian::FileMetadatum < ApplicationRecord
+ self.primary_key = :package_file_id
+
belongs_to :package_file, inverse_of: :debian_file_metadatum
validates :package_file, presence: true
diff --git a/app/models/packages/dependency.rb b/app/models/packages/dependency.rb
index a32c3c05bb3..ad3944b5f21 100644
--- a/app/models/packages/dependency.rb
+++ b/app/models/packages/dependency.rb
@@ -7,8 +7,8 @@ class Packages::Dependency < ApplicationRecord
validates :name, uniqueness: { scope: :version_pattern }
NAME_VERSION_PATTERN_TUPLE_MATCHING = '(name, version_pattern) = (?, ?)'
- MAX_STRING_LENGTH = 255.freeze
- MAX_CHUNKED_QUERIES_COUNT = 10.freeze
+ MAX_STRING_LENGTH = 255
+ MAX_CHUNKED_QUERIES_COUNT = 10
def self.ids_for_package_names_and_version_patterns(names_and_version_patterns = {}, chunk_size = 50, max_rows_limit = 200)
names_and_version_patterns.reject! { |key, value| key.size > MAX_STRING_LENGTH || value.size > MAX_STRING_LENGTH }
diff --git a/app/models/packages/go/module_version.rb b/app/models/packages/go/module_version.rb
index a50c78f8e69..fd575e6c96c 100644
--- a/app/models/packages/go/module_version.rb
+++ b/app/models/packages/go/module_version.rb
@@ -4,6 +4,7 @@ module Packages
module Go
class ModuleVersion
include Gitlab::Utils::StrongMemoize
+ include Gitlab::Golang
VALID_TYPES = %i[ref commit pseudo].freeze
@@ -81,6 +82,9 @@ module Packages
end
def valid?
+ # assume the module version is valid if a corresponding Package exists
+ return true if ::Packages::Go::PackageFinder.new(mod.project, mod.name, name).exists?
+
@mod.path_valid?(major) && @mod.gomod_valid?(gomod)
end
diff --git a/app/models/packages/maven/metadatum.rb b/app/models/packages/maven/metadatum.rb
index 7aed274216b..471c4b3a392 100644
--- a/app/models/packages/maven/metadatum.rb
+++ b/app/models/packages/maven/metadatum.rb
@@ -19,6 +19,7 @@ class Packages::Maven::Metadatum < ApplicationRecord
validate :maven_package_type
scope :for_package_ids, -> (package_ids) { where(package_id: package_ids) }
+ scope :with_path, ->(path) { where(path: path) }
scope :order_created, -> { reorder('created_at ASC') }
def self.pluck_app_name
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index 993d1123c86..e510432be8f 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -5,6 +5,8 @@ class Packages::Package < ApplicationRecord
include UsageStatistics
include Gitlab::Utils::StrongMemoize
+ DISPLAYABLE_STATUSES = [:default, :error].freeze
+
belongs_to :project
belongs_to :creator, class_name: 'User'
@@ -29,6 +31,7 @@ class Packages::Package < ApplicationRecord
delegate :recipe, :recipe_path, to: :conan_metadatum, prefix: :conan
delegate :codename, :suite, to: :debian_distribution, prefix: :debian_distribution
+ delegate :target_sha, to: :composer_metadatum, prefix: :composer
validates :project, presence: true
validates :name, presence: true
@@ -69,7 +72,7 @@ class Packages::Package < ApplicationRecord
composer: 6, generic: 7, golang: 8, debian: 9,
rubygems: 10 }
- enum status: { default: 0, hidden: 1, processing: 2 }
+ enum status: { default: 0, hidden: 1, processing: 2, error: 3 }
scope :with_name, ->(name) { where(name: name) }
scope :with_name_like, ->(name) { where(arel_table[:name].matches(name)) }
@@ -79,7 +82,7 @@ class Packages::Package < ApplicationRecord
scope :without_version_like, -> (version) { where.not(arel_table[:version].matches(version)) }
scope :with_package_type, ->(package_type) { where(package_type: package_type) }
scope :with_status, ->(status) { where(status: status) }
- scope :displayable, -> { with_status(:default) }
+ scope :displayable, -> { with_status(DISPLAYABLE_STATUSES) }
scope :including_build_info, -> { includes(pipelines: :user) }
scope :including_project_route, -> { includes(project: { namespace: :route }) }
scope :including_tags, -> { includes(:tags) }
@@ -135,13 +138,26 @@ class Packages::Package < ApplicationRecord
after_commit :update_composer_cache, on: :destroy, if: -> { composer? }
def self.for_projects(projects)
- return none unless projects.any?
+ unless Feature.enabled?(:maven_packages_group_level_improvements, default_enabled: :yaml)
+ return none unless projects.any?
+ end
where(project_id: projects)
end
- def self.only_maven_packages_with_path(path)
- joins(:maven_metadatum).where(packages_maven_metadata: { path: path })
+ def self.only_maven_packages_with_path(path, use_cte: false)
+ if use_cte && Feature.enabled?(:maven_metadata_by_path_with_optimization_fence, default_enabled: :yaml)
+ # This is an optimization fence which assumes that looking up the Metadatum record by path (globally)
+ # and then filter down the packages (by project or by group and subgroups) will be cheaper than
+ # looking up all packages within a project or group and filter them by path.
+
+ inner_query = Packages::Maven::Metadatum.where(path: path).select(:id, :package_id)
+ cte = Gitlab::SQL::CTE.new(:maven_metadata_by_path, inner_query)
+ with(cte.to_arel)
+ .joins('INNER JOIN maven_metadata_by_path ON maven_metadata_by_path.package_id=packages_packages.id')
+ else
+ joins(:maven_metadatum).where(packages_maven_metadata: { path: path })
+ end
end
def self.by_name_and_file_name(name, file_name)
diff --git a/app/models/packages/tag.rb b/app/models/packages/tag.rb
index 771d016daed..14a1ae98ed4 100644
--- a/app/models/packages/tag.rb
+++ b/app/models/packages/tag.rb
@@ -4,7 +4,7 @@ class Packages::Tag < ApplicationRecord
validates :package, :name, presence: true
- FOR_PACKAGES_TAGS_LIMIT = 200.freeze
+ FOR_PACKAGES_TAGS_LIMIT = 200
NUGET_TAGS_SEPARATOR = ' ' # https://docs.microsoft.com/en-us/nuget/reference/nuspec#tags
scope :preload_package, -> { preload(:package) }
diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb
index 33771580be2..3285a1f7f4c 100644
--- a/app/models/pages/lookup_path.rb
+++ b/app/models/pages/lookup_path.rb
@@ -50,9 +50,7 @@ module Pages
def zip_source
return unless deployment&.file
- return if deployment.file.file_storage? && !Feature.enabled?(:pages_serve_with_zip_file_protocol, project, default_enabled: true)
-
- return if deployment.migrated? && !Feature.enabled?(:pages_serve_from_migrated_zip, project, default_enabled: true)
+ return if deployment.file.file_storage? && !Feature.enabled?(:pages_serve_with_zip_file_protocol, project, default_enabled: :yaml)
global_id = ::Gitlab::GlobalId.build(deployment, id: deployment.id).to_s
@@ -74,7 +72,7 @@ module Pages
path: File.join(project.full_path, 'public/')
}
rescue LegacyStorageDisabledError => e
- Gitlab::ErrorTracking.track_exception(e)
+ Gitlab::ErrorTracking.track_exception(e, project_id: project.id)
nil
end
diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb
index d67a92af6af..294a4e85d1f 100644
--- a/app/models/pages_deployment.rb
+++ b/app/models/pages_deployment.rb
@@ -14,6 +14,8 @@ class PagesDeployment < ApplicationRecord
scope :older_than, -> (id) { where('id < ?', id) }
scope :migrated_from_legacy_storage, -> { where(file: MIGRATED_FILE_NAME) }
+ scope :with_files_stored_locally, -> { where(file_store: ::ObjectStorage::Store::LOCAL) }
+ scope :with_files_stored_remotely, -> { where(file_store: ::ObjectStorage::Store::REMOTE) }
validates :file, presence: true
validates :file_store, presence: true, inclusion: { in: ObjectStorage::SUPPORTED_STORES }
diff --git a/app/models/preloaders/labels_preloader.rb b/app/models/preloaders/labels_preloader.rb
new file mode 100644
index 00000000000..427f2869aac
--- /dev/null
+++ b/app/models/preloaders/labels_preloader.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Preloaders
+ # This class preloads the `project`, `group`, and subscription associations for the given
+ # labels, user, and project (if provided). A Label can be of type ProjectLabel or GroupLabel
+ # and the preloader supports both.
+ #
+ # Usage:
+ # labels = Label.where(...)
+ # Preloaders::LabelsPreloader.new(labels, current_user, @project).preload_all
+ # labels.first.project # won't fire any query
+ class LabelsPreloader
+ attr_reader :labels, :user, :project
+
+ def initialize(labels, user, project = nil)
+ @labels = labels
+ @user = user
+ @project = project
+ end
+
+ def preload_all
+ preloader = ActiveRecord::Associations::Preloader.new
+
+ preloader.preload(labels.select {|l| l.is_a? ProjectLabel }, { project: [:project_feature, namespace: :route] })
+ preloader.preload(labels.select {|l| l.is_a? GroupLabel }, { group: :route })
+ labels.each do |label|
+ label.lazy_subscription(user)
+ label.lazy_subscription(user, project) if project.present?
+ end
+ end
+ end
+end
+
+Preloaders::LabelsPreloader.prepend_if_ee('EE::Preloaders::LabelsPreloader')
diff --git a/app/models/preloaders/user_max_access_level_in_projects_preloader.rb b/app/models/preloaders/user_max_access_level_in_projects_preloader.rb
new file mode 100644
index 00000000000..671091480ee
--- /dev/null
+++ b/app/models/preloaders/user_max_access_level_in_projects_preloader.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Preloaders
+ # This class preloads the max access level for the user within the given projects and
+ # stores the values in requests store via the ProjectTeam class.
+ class UserMaxAccessLevelInProjectsPreloader
+ def initialize(projects, user)
+ @projects = projects
+ @user = user
+ end
+
+ def execute
+ access_levels = @user
+ .project_authorizations
+ .where(project_id: @projects)
+ .group(:project_id)
+ .maximum(:access_level)
+
+ @projects.each do |project|
+ access_level = access_levels[project.id] || Gitlab::Access::NO_ACCESS
+ ProjectTeam.new(project).write_member_access_for_user_id(@user.id, access_level)
+ end
+ end
+ end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index c52eb95bde8..f03e5293b58 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -36,6 +36,8 @@ class Project < ApplicationRecord
include Integration
include Repositories::CanHousekeepRepository
include EachBatch
+ include GitlabRoutingHelper
+
extend Gitlab::Cache::RequestCache
extend Gitlab::Utils::Override
@@ -219,7 +221,7 @@ class Project < ApplicationRecord
has_one :alerting_setting, inverse_of: :project, class_name: 'Alerting::ProjectAlertingSetting'
has_one :service_desk_setting, class_name: 'ServiceDeskSetting'
- # Merge Requests for target project should be removed with it
+ # Merge requests for target project should be removed with it
has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project
has_many :merge_request_metrics, foreign_key: 'target_project', class_name: 'MergeRequest::Metrics', inverse_of: :target_project
has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest'
@@ -517,7 +519,7 @@ class Project < ApplicationRecord
scope :with_packages, -> { joins(:packages) }
scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) }
scope :personal, ->(user) { where(namespace_id: user.namespace_id) }
- scope :joined, ->(user) { where('namespace_id != ?', user.namespace_id) }
+ scope :joined, ->(user) { where.not(namespace_id: user.namespace_id) }
scope :starred_by, ->(user) { joins(:users_star_projects).where('users_star_projects.user_id': user.id) }
scope :visible_to_user, ->(user) { where(id: user.authorized_projects.select(:id).reorder(nil)) }
scope :visible_to_user_and_access_level, ->(user, access_level) { where(id: user.authorized_projects.where('project_authorizations.access_level >= ?', access_level).select(:id).reorder(nil)) }
@@ -577,7 +579,7 @@ class Project < ApplicationRecord
with_issues_available_for_user(user).or(with_merge_requests_available_for_user(user))
end
scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) }
- scope :with_remote_mirrors, -> { joins(:remote_mirrors).where(remote_mirrors: { enabled: true }).distinct }
+ scope :with_remote_mirrors, -> { joins(:remote_mirrors).where(remote_mirrors: { enabled: true }) }
scope :with_limit, -> (maximum) { limit(maximum) }
scope :with_group_runners_enabled, -> do
@@ -621,7 +623,7 @@ class Project < ApplicationRecord
end
def self.with_web_entity_associations
- preload(:project_feature, :route, :creator, :group, namespace: [:route, :owner])
+ preload(:project_feature, :route, :creator, group: :parent, namespace: [:route, :owner])
end
def self.eager_load_namespace_and_owner
@@ -1368,15 +1370,15 @@ class Project < ApplicationRecord
end
def disabled_services
- return %w(datadog) unless Feature.enabled?(:datadog_ci_integration, self)
+ return %w[datadog hipchat] unless Feature.enabled?(:datadog_ci_integration, self)
- []
+ %w[hipchat]
end
def find_or_initialize_service(name)
return if disabled_services.include?(name)
- find_service(services, name) || build_from_instance_or_template(name) || public_send("build_#{name}_service") # rubocop:disable GitlabSecurity/PublicSend
+ find_service(services, name) || build_from_instance_or_template(name) || build_service(name)
end
# rubocop: disable CodeReuse/ServiceClass
@@ -1713,10 +1715,15 @@ class Project < ApplicationRecord
end
end
+ # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/326989
def any_active_runners?(&block)
active_runners_with_tags.any?(&block)
end
+ def any_online_runners?(&block)
+ online_runners_with_tags.any?(&block)
+ end
+
def valid_runners_token?(token)
self.runners_token && ActiveSupport::SecurityUtils.secure_compare(token, self.runners_token)
end
@@ -1812,7 +1819,7 @@ class Project < ApplicationRecord
# TODO: remove this method https://gitlab.com/gitlab-org/gitlab/-/issues/320775
# rubocop: disable CodeReuse/ServiceClass
def legacy_remove_pages
- return unless Feature.enabled?(:pages_update_legacy_storage, default_enabled: true)
+ return unless ::Settings.pages.local_store.enabled
# Projects with a missing namespace cannot have their pages removed
return unless namespace
@@ -1848,7 +1855,7 @@ class Project < ApplicationRecord
# where().update_all to perform update in the single transaction with check for null
ProjectPagesMetadatum
.where(project_id: id, pages_deployment_id: nil)
- .update_all(pages_deployment_id: deployment.id)
+ .update_all(deployed: deployment.present?, pages_deployment_id: deployment&.id)
end
def write_repository_config(gl_full_path: full_path)
@@ -2145,8 +2152,8 @@ class Project < ApplicationRecord
data = repository.route_map_for(sha)
Gitlab::RouteMap.new(data) if data
- rescue Gitlab::RouteMap::FormatError
- nil
+ rescue Gitlab::RouteMap::FormatError
+ nil
end
end
@@ -2165,17 +2172,18 @@ class Project < ApplicationRecord
end
def default_merge_request_target
- return self unless forked_from_project
- return self unless forked_from_project.merge_requests_enabled?
-
- # When our current visibility is more restrictive than the source project,
- # (e.g., the fork is `private` but the parent is `public`), target the less
- # permissive project
- if visibility_level_value < forked_from_project.visibility_level_value
- self
- else
- forked_from_project
- end
+ return self if project_setting.mr_default_target_self
+ return self unless mr_can_target_upstream?
+
+ forked_from_project
+ end
+
+ def mr_can_target_upstream?
+ # When our current visibility is more restrictive than the upstream project,
+ # (e.g., the fork is `private` but the parent is `public`), don't allow target upstream
+ forked_from_project &&
+ forked_from_project.merge_requests_enabled? &&
+ forked_from_project.visibility_level_value <= visibility_level_value
end
def multiple_issue_boards_available?
@@ -2322,6 +2330,11 @@ class Project < ApplicationRecord
.external_authorization_service_default_label
end
+ # Overridden in EE::Project
+ def licensed_feature_available?(_feature)
+ false
+ end
+
def licensed_features
[]
end
@@ -2584,6 +2597,10 @@ class Project < ApplicationRecord
return Service.build_from_integration(template, project_id: id) if template
end
+ def build_service(name)
+ "#{name}_service".classify.constantize.new(project_id: id)
+ end
+
def services_templates
@services_templates ||= Service.for_template
end
@@ -2734,9 +2751,11 @@ class Project < ApplicationRecord
end
def active_runners_with_tags
- strong_memoize(:active_runners_with_tags) do
- active_runners.with_tags
- end
+ @active_runners_with_tags ||= active_runners.with_tags
+ end
+
+ def online_runners_with_tags
+ @online_runners_with_tags ||= active_runners_with_tags.online
end
end
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index a598bf3f60c..15f6bedfc2e 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -3,6 +3,7 @@
class ProjectFeature < ApplicationRecord
include Featurable
+ # When updating this array, make sure to update rubocop/cop/gitlab/feature_available_usage.rb as well.
FEATURES = %i[
issues
forking
@@ -19,7 +20,7 @@ class ProjectFeature < ApplicationRecord
container_registry
].freeze
- EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance]).freeze
+ EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance, :pages]).freeze
set_available_features(FEATURES)
diff --git a/app/models/project_feature_usage.rb b/app/models/project_feature_usage.rb
index 4f445758653..02051310af7 100644
--- a/app/models/project_feature_usage.rb
+++ b/app/models/project_feature_usage.rb
@@ -20,12 +20,29 @@ class ProjectFeatureUsage < ApplicationRecord
end
def log_jira_dvcs_integration_usage(cloud: true)
- transaction(requires_new: true) do
- save unless persisted?
- touch(self.class.jira_dvcs_integration_field(cloud: cloud))
- end
+ integration_field = self.class.jira_dvcs_integration_field(cloud: cloud)
+
+ # The feature usage is used only once later to query the feature usage in a
+ # long date range. Therefore, we just need to update the timestamp once per
+ # day
+ return if persisted? && updated_today?(integration_field)
+
+ persist_jira_dvcs_usage(integration_field)
+ end
+
+ private
+
+ def updated_today?(integration_field)
+ self[integration_field].present? && self[integration_field].today?
+ end
+
+ def persist_jira_dvcs_usage(integration_field)
+ assign_attributes(integration_field => Time.current)
+ save
rescue ActiveRecord::RecordNotUnique
reset
retry
end
end
+
+ProjectFeatureUsage.prepend_if_ee('EE::ProjectFeatureUsage')
diff --git a/app/models/project_services/asana_service.rb b/app/models/project_services/asana_service.rb
index c4fcdcc05c5..f31bf931a41 100644
--- a/app/models/project_services/asana_service.rb
+++ b/app/models/project_services/asana_service.rb
@@ -3,6 +3,8 @@
require 'asana'
class AsanaService < Service
+ include ActionView::Helpers::UrlHelper
+
prop_accessor :api_key, :restrict_to_branch
validates :api_key, presence: true, if: :activated?
@@ -11,20 +13,12 @@ class AsanaService < Service
end
def description
- s_('AsanaService|Asana - Teamwork without email')
+ s_('AsanaService|Add commit messages as comments to Asana tasks')
end
def help
- 'This service adds commit messages as comments to Asana tasks.
-Once enabled, commit messages are checked for Asana task URLs
-(for example, `https://app.asana.com/0/123456/987654`) or task IDs
-starting with # (for example, `#987654`). Every task ID found will
-get the commit comment added to it.
-
-You can also close a task with a message containing: `fix #123456`.
-
-You can create a Personal Access Token here:
-https://app.asana.com/0/developer-console'
+ docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/asana'), target: '_blank', rel: 'noopener noreferrer'
+ s_('Add commit messages as comments to Asana tasks. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
def self.to_param
@@ -36,13 +30,17 @@ https://app.asana.com/0/developer-console'
{
type: 'text',
name: 'api_key',
- placeholder: s_('AsanaService|User Personal Access Token. User must have access to task, all comments will be attributed to this user.'),
+ title: 'API key',
+ help: s_('AsanaService|User Personal Access Token. User must have access to the task. All comments are attributed to this user.'),
+ # Example Personal Access Token from Asana docs
+ placeholder: '0/68a9e79b868c6789e79a124c30b0',
required: true
},
{
type: 'text',
name: 'restrict_to_branch',
- placeholder: s_('AsanaService|Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches.')
+ title: 'Restrict to branch (optional)',
+ help: s_('AsanaService|Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.')
}
]
end
diff --git a/app/models/project_services/assembla_service.rb b/app/models/project_services/assembla_service.rb
index 60575e45a90..8845fb99605 100644
--- a/app/models/project_services/assembla_service.rb
+++ b/app/models/project_services/assembla_service.rb
@@ -9,7 +9,7 @@ class AssemblaService < Service
end
def description
- 'Project Management Software (Source Commits Endpoint)'
+ _('Manage projects.')
end
def self.to_param
diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb
index 8c1f4fef09b..a892d1a4314 100644
--- a/app/models/project_services/bamboo_service.rb
+++ b/app/models/project_services/bamboo_service.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class BambooService < CiService
+ include ActionView::Helpers::UrlHelper
include ReactiveService
prop_accessor :bamboo_url, :build_key, :username, :password
@@ -31,15 +32,16 @@ class BambooService < CiService
end
def title
- s_('BambooService|Atlassian Bamboo CI')
+ s_('BambooService|Atlassian Bamboo')
end
def description
- s_('BambooService|A continuous integration and build server')
+ s_('BambooService|Use the Atlassian Bamboo CI/CD server with GitLab.')
end
def help
- s_('BambooService|You must set up automatic revision labeling and a repository trigger in Bamboo.')
+ docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bamboo'), target: '_blank', rel: 'noopener noreferrer'
+ s_('BambooService|Use Atlassian Bamboo to run CI/CD pipelines. You must set up automatic revision labeling and a repository trigger in Bamboo. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
def self.to_param
@@ -48,13 +50,32 @@ class BambooService < CiService
def fields
[
- { type: 'text', name: 'bamboo_url',
- placeholder: s_('BambooService|Bamboo root URL like https://bamboo.example.com'), required: true },
- { type: 'text', name: 'build_key',
- placeholder: s_('BambooService|Bamboo build plan key like KEY'), required: true },
- { type: 'text', name: 'username',
- placeholder: s_('BambooService|A user with API access, if applicable') },
- { type: 'password', name: 'password' }
+ {
+ type: 'text',
+ name: 'bamboo_url',
+ title: s_('BambooService|Bamboo URL'),
+ placeholder: s_('https://bamboo.example.com'),
+ help: s_('BambooService|Bamboo service root URL.'),
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'build_key',
+ placeholder: s_('KEY'),
+ help: s_('BambooService|Bamboo build plan key.'),
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'username',
+ help: s_('BambooService|The user with API access to the Bamboo server.')
+ },
+ {
+ type: 'password',
+ name: 'password',
+ non_empty_password_title: s_('ProjectService|Enter new password'),
+ non_empty_password_help: s_('ProjectService|Leave blank to use your current password')
+ }
]
end
diff --git a/app/models/project_services/chat_message/merge_message.rb b/app/models/project_services/chat_message/merge_message.rb
index b9916a54d75..e45bb9b8ce1 100644
--- a/app/models/project_services/chat_message/merge_message.rb
+++ b/app/models/project_services/chat_message/merge_message.rb
@@ -28,7 +28,7 @@ module ChatMessage
def activity
{
- title: "Merge Request #{state_or_action_text} by #{user_combined_name}",
+ title: "Merge request #{state_or_action_text} by #{user_combined_name}",
subtitle: "in #{project_link}",
text: merge_request_link,
image: user_avatar
diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb
index cf7cad09676..4a99842b4d5 100644
--- a/app/models/project_services/chat_notification_service.rb
+++ b/app/models/project_services/chat_notification_service.rb
@@ -61,11 +61,11 @@ class ChatNotificationService < Service
def default_fields
[
- { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}", required: true }.freeze,
- { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }.freeze,
- { type: 'checkbox', name: 'notify_only_broken_pipelines' }.freeze,
+ { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}", required: true }.freeze,
+ { type: 'text', name: 'username', placeholder: 'GitLab-integration' }.freeze,
+ { type: 'checkbox', name: 'notify_only_broken_pipelines', help: 'Do not send notifications for successful pipelines.' }.freeze,
{ type: 'select', name: 'branches_to_be_notified', choices: branch_choices }.freeze,
- { type: 'text', name: 'labels_to_be_notified', placeholder: 'e.g. ~backend', help: 'Only supported for issue, merge request and note events.' }.freeze
+ { type: 'text', name: 'labels_to_be_notified', placeholder: '~backend,~frontend', help: 'Send notifications for issue, merge request, and comment events with the listed labels only. Leave blank to receive notifications for all events.' }.freeze
].freeze
end
diff --git a/app/models/project_services/ci_service.rb b/app/models/project_services/ci_service.rb
index 47106d7bdbb..29edb9ec16f 100644
--- a/app/models/project_services/ci_service.rb
+++ b/app/models/project_services/ci_service.rb
@@ -2,7 +2,7 @@
# Base class for CI services
# List methods you need to implement to get your CI service
-# working with GitLab Merge Requests
+# working with GitLab merge requests
class CiService < Service
default_value_for :category, 'ci'
diff --git a/app/models/project_services/custom_issue_tracker_service.rb b/app/models/project_services/custom_issue_tracker_service.rb
index fc58ba27c3d..aab8661ec55 100644
--- a/app/models/project_services/custom_issue_tracker_service.rb
+++ b/app/models/project_services/custom_issue_tracker_service.rb
@@ -17,9 +17,9 @@ class CustomIssueTrackerService < IssueTrackerService
def fields
[
- { type: 'text', name: 'project_url', placeholder: 'Project url', required: true },
- { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true },
- { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true }
+ { type: 'text', name: 'project_url', title: _('Project URL'), required: true },
+ { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), required: true },
+ { type: 'text', name: 'new_issue_url', title: s_('ProjectService|New issue URL'), required: true }
]
end
end
diff --git a/app/models/project_services/datadog_service.rb b/app/models/project_services/datadog_service.rb
index a48dea71645..9a2d99c46c9 100644
--- a/app/models/project_services/datadog_service.rb
+++ b/app/models/project_services/datadog_service.rb
@@ -78,7 +78,9 @@ class DatadogService < Service
{
type: 'password',
name: 'api_key',
- title: 'API key',
+ title: _('API key'),
+ non_empty_password_title: s_('ProjectService|Enter new API key'),
+ non_empty_password_help: s_('ProjectService|Leave blank to use your current API key'),
help: "<a href=\"#{api_keys_url}\" target=\"_blank\">API key</a> used for authentication with Datadog",
required: true
},
diff --git a/app/models/project_services/discord_service.rb b/app/models/project_services/discord_service.rb
index 37bbb9b8752..d7adf63fde4 100644
--- a/app/models/project_services/discord_service.rb
+++ b/app/models/project_services/discord_service.rb
@@ -3,6 +3,8 @@
require "discordrb/webhooks"
class DiscordService < ChatNotificationService
+ include ActionView::Helpers::UrlHelper
+
ATTACHMENT_REGEX = /: (?<entry>.*?)\n - (?<name>.*)\n*/.freeze
def title
@@ -10,7 +12,7 @@ class DiscordService < ChatNotificationService
end
def description
- s_("DiscordService|Receive event notifications in Discord")
+ s_("DiscordService|Send notifications about project events to a Discord channel.")
end
def self.to_param
@@ -18,13 +20,8 @@ class DiscordService < ChatNotificationService
end
def help
- "This service sends notifications about project events to Discord channels.<br />
- To set up this service:
- <ol>
- <li><a href='https://support.discordapp.com/hc/en-us/articles/228383668-Intro-to-Webhooks'>Setup a custom Incoming Webhook</a>.</li>
- <li>Paste the <strong>Webhook URL</strong> into the field below.</li>
- <li>Select events below to enable notifications.</li>
- </ol>"
+ docs_link = link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/discord_notifications'), target: '_blank', rel: 'noopener noreferrer'
+ s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
def event_field(event)
@@ -36,13 +33,12 @@ class DiscordService < ChatNotificationService
end
def self.supported_events
- %w[push issue confidential_issue merge_request note confidential_note tag_push
- pipeline wiki_page]
+ %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page]
end
def default_fields
[
- { type: "text", name: "webhook", placeholder: "e.g. https://discordapp.com/api/webhooks/…" },
+ { type: "text", name: "webhook", placeholder: "https://discordapp.com/api/webhooks/…", help: "URL to the webhook for the Discord channel." },
{ type: "checkbox", name: "notify_only_broken_pipelines" },
{ type: 'select', name: 'branches_to_be_notified', choices: branch_choices }
]
diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb
index 5a49f780d46..ab1ba768a8f 100644
--- a/app/models/project_services/drone_ci_service.rb
+++ b/app/models/project_services/drone_ci_service.rb
@@ -79,21 +79,25 @@ class DroneCiService < CiService
end
def title
- 'Drone CI'
+ 'Drone'
end
def description
- 'Drone is a Continuous Integration platform built on Docker, written in Go'
+ s_('ProjectService|Run CI/CD pipelines with Drone.')
end
def self.to_param
'drone_ci'
end
+ def help
+ s_('ProjectService|Run CI/CD pipelines with Drone.')
+ end
+
def fields
[
- { type: 'text', name: 'token', placeholder: 'Drone CI project specific token', required: true },
- { type: 'text', name: 'drone_url', placeholder: 'http://drone.example.com', required: true },
+ { type: 'text', name: 'token', help: s_('ProjectService|Token for the Drone project.'), required: true },
+ { type: 'text', name: 'drone_url', title: s_('ProjectService|Drone server URL'), placeholder: 'http://drone.example.com', required: true },
{ type: 'checkbox', name: 'enable_ssl_verification', title: "Enable SSL verification" }
]
end
diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb
index 01d8647d439..cdb69684d16 100644
--- a/app/models/project_services/emails_on_push_service.rb
+++ b/app/models/project_services/emails_on_push_service.rb
@@ -3,10 +3,19 @@
class EmailsOnPushService < Service
include NotificationBranchSelection
+ RECIPIENTS_LIMIT = 750
+
boolean_accessor :send_from_committer_email
boolean_accessor :disable_diffs
prop_accessor :recipients, :branches_to_be_notified
- validates :recipients, presence: true, if: :valid_recipients?
+ validates :recipients, presence: true, if: :validate_recipients?
+ validate :number_of_recipients_within_limit, if: :validate_recipients?
+
+ def self.valid_recipients(recipients)
+ recipients.split.select do |recipient|
+ recipient.include?('@')
+ end.uniq(&:downcase)
+ end
def title
s_('EmailsOnPushService|Emails on push')
@@ -63,11 +72,26 @@ class EmailsOnPushService < Service
domains = Notify.allowed_email_domains.map { |domain| "user@#{domain}" }.join(", ")
[
{ type: 'checkbox', name: 'send_from_committer_email', title: s_("EmailsOnPushService|Send from committer"),
- help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. %{domains}).") % { domains: domains } },
+ help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as %{domains}).") % { domains: domains } },
{ type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"),
help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") },
{ type: 'select', name: 'branches_to_be_notified', choices: branch_choices },
- { type: 'textarea', name: 'recipients', placeholder: s_('EmailsOnPushService|Emails separated by whitespace') }
+ {
+ type: 'textarea',
+ name: 'recipients',
+ placeholder: s_('EmailsOnPushService|tanuki@example.com gitlab@example.com'),
+ help: s_('EmailsOnPushService|Emails separated by whitespace.')
+ }
]
end
+
+ private
+
+ def number_of_recipients_within_limit
+ return if recipients.blank?
+
+ if self.class.valid_recipients(recipients).size > RECIPIENTS_LIMIT
+ errors.add(:recipients, s_("EmailsOnPushService|can't exceed %{recipients_limit}") % { recipients_limit: RECIPIENTS_LIMIT })
+ end
+ end
end
diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb
index 0a09000fff4..c41783d1af4 100644
--- a/app/models/project_services/external_wiki_service.rb
+++ b/app/models/project_services/external_wiki_service.rb
@@ -1,16 +1,16 @@
# frozen_string_literal: true
class ExternalWikiService < Service
+ include ActionView::Helpers::UrlHelper
prop_accessor :external_wiki_url
-
validates :external_wiki_url, presence: true, public_url: true, if: :activated?
def title
- s_('ExternalWikiService|External Wiki')
+ s_('ExternalWikiService|External wiki')
end
def description
- s_('ExternalWikiService|Replaces the link to the internal wiki with a link to an external wiki.')
+ s_('ExternalWikiService|Link to an external wiki from the sidebar.')
end
def self.to_param
@@ -22,12 +22,20 @@ class ExternalWikiService < Service
{
type: 'text',
name: 'external_wiki_url',
- placeholder: s_('ExternalWikiService|The URL of the external Wiki'),
+ title: s_('ExternalWikiService|External wiki URL'),
+ placeholder: s_('ExternalWikiService|https://example.com/xxx/wiki/...'),
+ help: 'Enter the URL to the external wiki.',
required: true
}
]
end
+ def help
+ docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/wiki/index', anchor: 'link-an-external-wiki'), target: '_blank', rel: 'noopener noreferrer'
+
+ s_('Link an external wiki from the project\'s sidebar. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
+ end
+
def execute(_data)
response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true)
response.body if response.code == 200
diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb
index 22c2aebaec3..cd49c6d253d 100644
--- a/app/models/project_services/hipchat_service.rb
+++ b/app/models/project_services/hipchat_service.rb
@@ -39,7 +39,7 @@ class HipchatService < Service
{ type: 'text', name: 'room', placeholder: 'Room name or ID' },
{ type: 'checkbox', name: 'notify' },
{ type: 'select', name: 'color', choices: %w(yellow red green purple gray random) },
- { type: 'text', name: 'api_version',
+ { type: 'text', name: 'api_version', title: _('API version'),
placeholder: 'Leave blank for default (v2)' },
{ type: 'text', name: 'server',
placeholder: 'Leave blank for default. https://hipchat.example.com' },
diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb
index 4a6c8339625..4f1ce16ebb2 100644
--- a/app/models/project_services/irker_service.rb
+++ b/app/models/project_services/irker_service.rb
@@ -6,7 +6,7 @@ class IrkerService < Service
prop_accessor :server_host, :server_port, :default_irc_uri
prop_accessor :recipients, :channels
boolean_accessor :colorize_messages
- validates :recipients, presence: true, if: :valid_recipients?
+ validates :recipients, presence: true, if: :validate_recipients?
before_validation :get_channels
diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb
index 694374e9548..19a5b4a74bb 100644
--- a/app/models/project_services/issue_tracker_service.rb
+++ b/app/models/project_services/issue_tracker_service.rb
@@ -73,9 +73,9 @@ class IssueTrackerService < Service
def fields
[
- { type: 'text', name: 'project_url', placeholder: 'Project url', required: true },
- { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true },
- { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true }
+ { type: 'text', name: 'project_url', title: _('Project URL'), required: true },
+ { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), required: true },
+ { type: 'text', name: 'new_issue_url', title: s_('ProjectService|New issue URL'), required: true }
]
end
diff --git a/app/models/project_services/jenkins_service.rb b/app/models/project_services/jenkins_service.rb
index 63ecfc66877..6a123517b84 100644
--- a/app/models/project_services/jenkins_service.rb
+++ b/app/models/project_services/jenkins_service.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class JenkinsService < CiService
+ include ActionView::Helpers::UrlHelper
+
prop_accessor :jenkins_url, :project_name, :username, :password
before_update :reset_password
@@ -29,7 +31,6 @@ class JenkinsService < CiService
end
def execute(data)
- return if project.disabled_services.include?(to_param)
return unless supported_events.include?(data[:object_kind])
service_hook.execute(data, "#{data[:object_kind]}_hook")
@@ -59,15 +60,16 @@ class JenkinsService < CiService
end
def title
- 'Jenkins CI'
+ 'Jenkins'
end
def description
- 'An extendable open source continuous integration server'
+ s_('An extendable open source CI/CD server.')
end
def help
- "You must have installed the Git Plugin and GitLab Plugin in Jenkins. [More information](#{Gitlab::Routing.url_helpers.help_page_url('integration/jenkins')})"
+ docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('integration/jenkins'), target: '_blank', rel: 'noopener noreferrer'
+ s_('Trigger Jenkins builds when you push to a repository, or when a merge request is created, updated, or merged. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
def self.to_param
@@ -77,15 +79,33 @@ class JenkinsService < CiService
def fields
[
{
- type: 'text', name: 'jenkins_url',
- placeholder: 'Jenkins URL like http://jenkins.example.com'
+ type: 'text',
+ name: 'jenkins_url',
+ title: s_('ProjectService|Jenkins server URL'),
+ required: true,
+ placeholder: 'http://jenkins.example.com',
+ help: s_('The URL of the Jenkins server.')
+ },
+ {
+ type: 'text',
+ name: 'project_name',
+ required: true,
+ placeholder: 'my_project_name',
+ help: s_('The name of the Jenkins project. Copy the name from the end of the URL to the project.')
},
{
- type: 'text', name: 'project_name', placeholder: 'Project Name',
- help: 'The URL-friendly project name. Example: my_project_name'
+ type: 'text',
+ name: 'username',
+ required: true,
+ help: s_('The username for the Jenkins server.')
},
- { type: 'text', name: 'username' },
- { type: 'password', name: 'password' }
+ {
+ type: 'password',
+ name: 'password',
+ help: s_('The password for the Jenkins server.'),
+ non_empty_password_title: s_('ProjectService|Enter new password.'),
+ non_empty_password_help: s_('ProjectService|Leave blank to use your current password.')
+ }
]
end
end
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 5857d86f921..3e14bf44c12 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -31,8 +31,8 @@ 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,
- :vulnerabilities_enabled, :vulnerabilities_issuetype, :proxy_address, :proxy_port, :proxy_username, :proxy_password
+ data_field :username, :password, :url, :api_url, :jira_issue_transition_automatic, :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?
@@ -116,7 +116,7 @@ class JiraService < IssueTrackerService
end
def description
- s_('JiraService|Jira issue tracker')
+ s_('JiraService|Track issues in Jira')
end
def self.to_param
@@ -124,15 +124,37 @@ class JiraService < IssueTrackerService
end
def fields
- transition_id_help_path = help_page_path('user/project/integrations/jira', anchor: 'obtaining-a-transition-id')
- transition_id_help_link_start = '<a href="%{transition_id_help_path}" target="_blank" rel="noopener noreferrer">'.html_safe % { transition_id_help_path: transition_id_help_path }
-
[
- { type: 'text', name: 'url', title: s_('JiraService|Web URL'), placeholder: 'https://jira.example.com', required: true },
- { type: 'text', name: 'api_url', title: s_('JiraService|Jira API URL'), placeholder: s_('JiraService|If different from Web URL') },
- { type: 'text', name: 'username', title: s_('JiraService|Username or Email'), placeholder: s_('JiraService|Use a username for server version and an email for cloud version'), required: true },
- { type: 'password', name: 'password', title: s_('JiraService|Password or API token'), placeholder: s_('JiraService|Use a password for server version and an API token for cloud version'), required: true },
- { type: 'text', name: 'jira_issue_transition_id', title: s_('JiraService|Jira workflow transition IDs'), placeholder: s_('JiraService|For example, 12, 24'), help: s_('JiraService|Set transition IDs for Jira workflow transitions. %{link_start}Learn more%{link_end}'.html_safe % { link_start: transition_id_help_link_start, link_end: '</a>'.html_safe }) }
+ {
+ type: 'text',
+ name: 'url',
+ title: s_('JiraService|Web URL'),
+ placeholder: 'https://jira.example.com',
+ help: s_('JiraService|Base URL of the Jira instance.'),
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'api_url',
+ title: s_('JiraService|Jira API URL'),
+ help: s_('JiraService|If different from Web URL.')
+ },
+ {
+ type: 'text',
+ name: 'username',
+ title: s_('JiraService|Username or Email'),
+ help: s_('JiraService|Use a username for server version and an email for cloud version.'),
+ required: true
+ },
+ {
+ type: 'password',
+ name: 'password',
+ title: s_('JiraService|Password or API token'),
+ non_empty_password_title: s_('JiraService|Enter new password or API token'),
+ non_empty_password_help: s_('JiraService|Leave blank to use your current password or API token.'),
+ help: s_('JiraService|Use a password for server version and an API token for cloud version.'),
+ required: true
+ }
]
end
@@ -159,17 +181,19 @@ class JiraService < IssueTrackerService
# support any events.
end
- def find_issue(issue_key, rendered_fields: false)
- options = {}
- options = options.merge(expand: 'renderedFields') if rendered_fields
+ def find_issue(issue_key, rendered_fields: false, transitions: false)
+ expands = []
+ expands << 'renderedFields' if rendered_fields
+ expands << 'transitions' if transitions
+ options = { expand: expands.join(',') } if expands.any?
- jira_request { client.Issue.find(issue_key, options) }
+ jira_request { client.Issue.find(issue_key, options || {}) }
end
def close_issue(entity, external_issue, current_user)
- issue = find_issue(external_issue.iid)
+ issue = find_issue(external_issue.iid, transitions: jira_issue_transition_automatic)
- return if issue.nil? || has_resolution?(issue) || !jira_issue_transition_id.present?
+ return if issue.nil? || has_resolution?(issue) || !issue_transition_enabled?
commit_id = case entity
when Commit then entity.id
@@ -244,6 +268,10 @@ class JiraService < IssueTrackerService
true
end
+ def issue_transition_enabled?
+ jira_issue_transition_automatic || jira_issue_transition_id.present?
+ end
+
private
def server_info
@@ -264,20 +292,44 @@ class JiraService < IssueTrackerService
# the issue is transitioned at the order given by the user
# if any transition fails it will log the error message and stop the transition sequence
def transition_issue(issue)
- jira_issue_transition_id.scan(Gitlab::Regex.jira_transition_id_regex).each do |transition_id|
- issue.transitions.build.save!(transition: { id: transition_id })
- rescue => error
- log_error(
- "Issue transition failed",
- error: {
- exception_class: error.class.name,
- exception_message: error.message,
- exception_backtrace: Gitlab::BacktraceCleaner.clean_backtrace(error.backtrace)
- },
- client_url: client_url
- )
- return false
+ return transition_issue_to_done(issue) if jira_issue_transition_automatic
+
+ jira_issue_transition_id.scan(Gitlab::Regex.jira_transition_id_regex).all? do |transition_id|
+ transition_issue_to_id(issue, transition_id)
+ end
+ end
+
+ def transition_issue_to_id(issue, transition_id)
+ issue.transitions.build.save!(
+ transition: { id: transition_id }
+ )
+
+ true
+ rescue => error
+ log_error(
+ "Issue transition failed",
+ error: {
+ exception_class: error.class.name,
+ exception_message: error.message,
+ exception_backtrace: Gitlab::BacktraceCleaner.clean_backtrace(error.backtrace)
+ },
+ client_url: client_url
+ )
+
+ false
+ end
+
+ def transition_issue_to_done(issue)
+ transitions = issue.transitions rescue []
+
+ transition = transitions.find do |transition|
+ status = transition&.to&.statusCategory
+ status && status['key'] == 'done'
end
+
+ return false unless transition
+
+ transition_issue_to_id(issue, transition.id)
end
def log_usage(action, user)
diff --git a/app/models/project_services/jira_tracker_data.rb b/app/models/project_services/jira_tracker_data.rb
index 6cbcb1550c1..2c145abf5c9 100644
--- a/app/models/project_services/jira_tracker_data.rb
+++ b/app/models/project_services/jira_tracker_data.rb
@@ -2,20 +2,23 @@
class JiraTrackerData < ApplicationRecord
include Services::DataFields
+ include IgnorableColumns
+
+ ignore_columns %i[
+ encrypted_proxy_address
+ encrypted_proxy_address_iv
+ encrypted_proxy_port
+ encrypted_proxy_port_iv
+ encrypted_proxy_username
+ encrypted_proxy_username_iv
+ encrypted_proxy_password
+ encrypted_proxy_password_iv
+ ], remove_with: '14.0', remove_after: '2021-05-22'
attr_encrypted :url, encryption_options
attr_encrypted :api_url, encryption_options
attr_encrypted :username, encryption_options
attr_encrypted :password, encryption_options
- attr_encrypted :proxy_address, encryption_options
- attr_encrypted :proxy_port, encryption_options
- attr_encrypted :proxy_username, encryption_options
- attr_encrypted :proxy_password, encryption_options
-
- validates :proxy_address, length: { maximum: 2048 }
- validates :proxy_port, length: { maximum: 5 }
- validates :proxy_username, length: { maximum: 255 }
- validates :proxy_password, length: { maximum: 255 }
enum deployment_type: { unknown: 0, server: 1, cloud: 2 }, _prefix: :deployment
end
diff --git a/app/models/project_services/mattermost_service.rb b/app/models/project_services/mattermost_service.rb
index 9cff979fcf2..732a7c32a03 100644
--- a/app/models/project_services/mattermost_service.rb
+++ b/app/models/project_services/mattermost_service.rb
@@ -2,13 +2,14 @@
class MattermostService < ChatNotificationService
include SlackMattermost::Notifier
+ include ActionView::Helpers::UrlHelper
def title
- 'Mattermost notifications'
+ s_('Mattermost notifications')
end
def description
- 'Receive event notifications in Mattermost'
+ s_('Send notifications about project events to Mattermost channels.')
end
def self.to_param
@@ -16,21 +17,15 @@ class MattermostService < ChatNotificationService
end
def help
- 'This service sends notifications about projects events to Mattermost channels.<br />
- To set up this service:
- <ol>
- <li><a href="https://docs.mattermost.com/developer/webhooks-incoming.html#enabling-incoming-webhooks">Enable incoming webhooks</a> in your Mattermost installation.</li>
- <li><a href="https://docs.mattermost.com/developer/webhooks-incoming.html#creating-integrations-using-incoming-webhooks">Add an incoming webhook</a> in your Mattermost team. The default channel can be overridden for each event.</li>
- <li>Paste the webhook <strong>URL</strong> into the field below.</li>
- <li>Select events below to enable notifications. The <strong>Channel handle</strong> and <strong>Username</strong> fields are optional.</li>
- </ol>'
+ docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/mattermost'), target: '_blank', rel: 'noopener noreferrer'
+ s_('Send notifications about project events to Mattermost channels. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
def default_channel_placeholder
- "Channel handle (e.g. town-square)"
+ 'my-channel'
end
def webhook_placeholder
- 'http://mattermost.example.com/hooks/…'
+ 'http://mattermost.example.com/hooks/'
end
end
diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb
index f39d3947e5b..60235a09dcd 100644
--- a/app/models/project_services/mattermost_slash_commands_service.rb
+++ b/app/models/project_services/mattermost_slash_commands_service.rb
@@ -14,7 +14,7 @@ class MattermostSlashCommandsService < SlashCommandsService
end
def description
- "Perform common operations in Mattermost"
+ "Perform common tasks with slash commands."
end
def self.to_param
diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb
index e8e12a9a206..803c1255195 100644
--- a/app/models/project_services/microsoft_teams_service.rb
+++ b/app/models/project_services/microsoft_teams_service.rb
@@ -2,7 +2,7 @@
class MicrosoftTeamsService < ChatNotificationService
def title
- 'Microsoft Teams Notification'
+ 'Microsoft Teams notifications'
end
def description
@@ -14,13 +14,7 @@ class MicrosoftTeamsService < ChatNotificationService
end
def help
- 'This service sends notifications about projects events to Microsoft Teams channels.<br />
- To set up this service:
- <ol>
- <li><a href="https://docs.microsoft.com/en-us/microsoftteams/platform/concepts/connectors/connectors-using#setting-up-a-custom-incoming-webhook">Setup a custom Incoming Webhook using Office 365 Connectors For Microsoft Teams</a>.</li>
- <li>Paste the <strong>Webhook URL</strong> into the field below.</li>
- <li>Select events below to enable notifications.</li>
- </ol>'
+ '<p>Use this service to send notifications about events in GitLab projects to your Microsoft Teams channels. <a href="https://docs.gitlab.com/ee/user/project/integrations/microsoft_teams.html">How do I configure this integration?</a></p>'
end
def webhook_placeholder
@@ -40,8 +34,8 @@ class MicrosoftTeamsService < ChatNotificationService
def default_fields
[
- { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
- { type: 'checkbox', name: 'notify_only_broken_pipelines' },
+ { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}" },
+ { type: 'checkbox', name: 'notify_only_broken_pipelines', help: 'If selected, successful pipelines do not trigger a notification event.' },
{ type: 'select', name: 'branches_to_be_notified', choices: branch_choices }
]
end
diff --git a/app/models/project_services/mock_ci_service.rb b/app/models/project_services/mock_ci_service.rb
index c5e5f4f6400..bd6344c6e1a 100644
--- a/app/models/project_services/mock_ci_service.rb
+++ b/app/models/project_services/mock_ci_service.rb
@@ -21,10 +21,13 @@ class MockCiService < CiService
def fields
[
- { type: 'text',
+ {
+ type: 'text',
name: 'mock_service_url',
+ title: s_('ProjectService|Mock service URL'),
placeholder: 'http://localhost:4004',
- required: true }
+ required: true
+ }
]
end
diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb
index 8af4cd952c9..0a0a41c525c 100644
--- a/app/models/project_services/pipelines_email_service.rb
+++ b/app/models/project_services/pipelines_email_service.rb
@@ -5,7 +5,7 @@ class PipelinesEmailService < Service
prop_accessor :recipients, :branches_to_be_notified
boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch
- validates :recipients, presence: true, if: :valid_recipients?
+ validates :recipients, presence: true, if: :validate_recipients?
def initialize_properties
if properties.nil?
@@ -25,11 +25,11 @@ class PipelinesEmailService < Service
end
def title
- _('Pipelines emails')
+ _('Pipeline status emails')
end
def description
- _('Email the pipelines status to a list of recipients.')
+ _('Email the pipeline status to a list of recipients.')
end
def self.to_param
@@ -64,7 +64,7 @@ class PipelinesEmailService < Service
[
{ type: 'textarea',
name: 'recipients',
- placeholder: _('Emails separated by comma'),
+ help: _('Comma-separated list of email addresses.'),
required: true },
{ type: 'checkbox',
name: 'notify_only_broken_pipelines' },
diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb
index 7324890551c..1781ec7456d 100644
--- a/app/models/project_services/pushover_service.rb
+++ b/app/models/project_services/pushover_service.rb
@@ -20,7 +20,7 @@ class PushoverService < Service
def fields
[
- { type: 'text', name: 'api_key', placeholder: s_('PushoverService|Your application key'), required: true },
+ { type: 'text', name: 'api_key', title: _('API key'), placeholder: s_('PushoverService|Your application key'), required: true },
{ type: 'text', name: 'user_key', placeholder: s_('PushoverService|Your user key'), required: true },
{ type: 'text', name: 'device', placeholder: s_('PushoverService|Leave blank for all active devices') },
{ type: 'select', name: 'priority', required: true, choices:
diff --git a/app/models/project_services/redmine_service.rb b/app/models/project_services/redmine_service.rb
index df78520d65f..26a6cf86bf4 100644
--- a/app/models/project_services/redmine_service.rb
+++ b/app/models/project_services/redmine_service.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class RedmineService < IssueTrackerService
+ include ActionView::Helpers::UrlHelper
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
def title
@@ -8,7 +9,12 @@ class RedmineService < IssueTrackerService
end
def description
- s_('IssueTracker|Redmine issue tracker')
+ s_('IssueTracker|Use Redmine as the issue tracker.')
+ end
+
+ def help
+ docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/redmine'), target: '_blank', rel: 'noopener noreferrer'
+ s_('IssueTracker|Use Redmine as the issue tracker. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
def self.to_param
diff --git a/app/models/project_services/slack_service.rb b/app/models/project_services/slack_service.rb
index f42b3de39d5..7badcc24870 100644
--- a/app/models/project_services/slack_service.rb
+++ b/app/models/project_services/slack_service.rb
@@ -16,7 +16,7 @@ class SlackService < ChatNotificationService
end
def description
- 'Receive event notifications in Slack'
+ 'Send notifications about project events to Slack.'
end
def self.to_param
@@ -24,7 +24,7 @@ class SlackService < ChatNotificationService
end
def default_channel_placeholder
- _('Slack channels (e.g. general, development)')
+ _('general, development')
end
def webhook_placeholder
diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb
index 209b691ef98..6fc24a4778c 100644
--- a/app/models/project_services/teamcity_service.rb
+++ b/app/models/project_services/teamcity_service.rb
@@ -51,27 +51,43 @@ class TeamcityService < CiService
end
def title
- 'JetBrains TeamCity CI'
+ 'JetBrains TeamCity'
end
def description
- 'A continuous integration and build server'
+ s_('ProjectService|Run CI/CD pipelines with JetBrains TeamCity.')
end
def help
- 'You will want to configure monitoring of all branches so merge '\
- 'requests build, that setting is in the vsc root advanced settings.'
+ s_('To run CI/CD pipelines with JetBrains TeamCity, input the GitLab project details in the TeamCity project Version Control Settings.')
end
def fields
[
- { type: 'text', name: 'teamcity_url',
- placeholder: 'TeamCity root URL like https://teamcity.example.com', required: true },
- { type: 'text', name: 'build_type',
- placeholder: 'Build configuration ID', required: true },
- { type: 'text', name: 'username',
- placeholder: 'A user with permissions to trigger a manual build' },
- { type: 'password', name: 'password' }
+ {
+ type: 'text',
+ name: 'teamcity_url',
+ title: s_('ProjectService|TeamCity server URL'),
+ placeholder: 'https://teamcity.example.com',
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'build_type',
+ help: s_('ProjectService|The build configuration ID of the TeamCity project.'),
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'username',
+ help: s_('ProjectService|Must have permission to trigger a manual build in TeamCity.')
+ },
+ {
+ type: 'password',
+ name: 'password',
+ non_empty_password_title: s_('ProjectService|Enter new password'),
+ non_empty_password_help: s_('ProjectService|Leave blank to use your current password')
+ }
]
end
diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb
index 7fb3bde44a5..30abd0159b3 100644
--- a/app/models/project_services/youtrack_service.rb
+++ b/app/models/project_services/youtrack_service.rb
@@ -26,8 +26,8 @@ class YoutrackService < IssueTrackerService
def fields
[
- { type: 'text', name: 'project_url', title: 'Project URL', placeholder: 'Project URL', required: true },
- { type: 'text', name: 'issues_url', title: 'Issue URL', placeholder: 'Issue URL', required: true }
+ { type: 'text', name: 'project_url', title: _('Project URL'), required: true },
+ { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), required: true }
]
end
end
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index 5b7eded00cd..1a3f362e6a1 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -174,6 +174,10 @@ class ProjectTeam
end
end
+ def write_member_access_for_user_id(user_id, project_access_level)
+ merge_value_to_request_store(User, user_id, project.id, project_access_level)
+ end
+
def max_member_access(user_id)
max_member_access_for_user_ids([user_id])[user_id]
end
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index cbbdd091feb..963a6b7774a 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -30,7 +30,7 @@ class ProtectedBranch < ApplicationRecord
end
def self.allow_force_push?(project, ref_name)
- return false unless ::Feature.enabled?(:allow_force_push_to_protected_branches, project)
+ return false unless ::Feature.enabled?(:allow_force_push_to_protected_branches, project, default_enabled: :yaml)
project.protected_branches.allowing_force_push.matching(ref_name).any?
end
diff --git a/app/models/raw_usage_data.rb b/app/models/raw_usage_data.rb
index 06cd4ad3f6c..6fe3b26b58b 100644
--- a/app/models/raw_usage_data.rb
+++ b/app/models/raw_usage_data.rb
@@ -4,7 +4,7 @@ class RawUsageData < ApplicationRecord
validates :payload, presence: true
validates :recorded_at, presence: true, uniqueness: true
- def update_sent_at!
- self.update_column(:sent_at, Time.current)
+ def update_version_metadata!(usage_data_id:)
+ self.update_columns(sent_at: Time.current, version_usage_data_id_value: usage_data_id)
end
end
diff --git a/app/models/release.rb b/app/models/release.rb
index 60c2abcacb3..5ca8f537baa 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -8,7 +8,7 @@ class Release < ApplicationRecord
cache_markdown_field :description
- belongs_to :project
+ belongs_to :project, touch: true
# releases prior to 11.7 have no author
belongs_to :author, class_name: 'User'
diff --git a/app/models/release_highlight.rb b/app/models/release_highlight.rb
index 1efba6380e9..98d9899a349 100644
--- a/app/models/release_highlight.rb
+++ b/app/models/release_highlight.rb
@@ -3,17 +3,6 @@
class ReleaseHighlight
CACHE_DURATION = 1.hour
FILES_PATH = Rails.root.join('data', 'whats_new', '*.yml')
- RELEASE_VERSIONS_IN_A_YEAR = 12
-
- def self.for_version(version:)
- index = self.versions.index(version)
-
- return if index.nil?
-
- page = index + 1
-
- self.paginated(page: page)
- end
def self.paginated(page: 1)
key = self.cache_key("items:page-#{page}")
@@ -82,15 +71,15 @@ class ReleaseHighlight
end
end
- def self.versions
- key = self.cache_key('versions')
+ def self.most_recent_version_digest
+ key = self.cache_key('most_recent_version_digest')
Gitlab::ProcessMemoryCache.cache_backend.fetch(key, expires_in: CACHE_DURATION) do
- versions = self.file_paths.first(RELEASE_VERSIONS_IN_A_YEAR).map do |path|
- /\d*\_(\d*\_\d*)\.yml$/.match(path).captures[0].gsub(/0(?=\d)/, "").tr("_", ".")
- end
+ version = self.paginated&.items&.first&.[]('release')&.to_s
+
+ next if version.nil?
- versions.uniq
+ Digest::SHA256.hexdigest(version)
end
end
diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb
index 880970b72a8..c7387d2197d 100644
--- a/app/models/remote_mirror.rb
+++ b/app/models/remote_mirror.rb
@@ -84,13 +84,7 @@ class RemoteMirror < ApplicationRecord
end
after_transition started: :failed do |remote_mirror|
- Gitlab::Metrics.add_event(:remote_mirrors_failed)
-
- remote_mirror.update(last_update_at: Time.current)
-
- remote_mirror.run_after_commit do
- RemoteMirrorNotificationWorker.perform_async(remote_mirror.id)
- end
+ remote_mirror.send_failure_notifications
end
end
@@ -188,6 +182,24 @@ class RemoteMirror < ApplicationRecord
update_fail!
end
+ # Force the mrror into the retry state
+ def hard_retry!(error_message)
+ update_error_message(error_message)
+ self.update_status = :to_retry
+
+ save!(validate: false)
+ end
+
+ # Force the mirror into the failed state
+ def hard_fail!(error_message)
+ update_error_message(error_message)
+ self.update_status = :failed
+
+ save!(validate: false)
+
+ send_failure_notifications
+ end
+
def url=(value)
super(value) && return unless Gitlab::UrlSanitizer.valid?(value)
@@ -207,7 +219,7 @@ class RemoteMirror < ApplicationRecord
end
def safe_url
- super(usernames_whitelist: %w[git])
+ super(allowed_usernames: %w[git])
end
def bare_url
@@ -239,6 +251,17 @@ class RemoteMirror < ApplicationRecord
last_update_at.present? ? MAX_INCREMENTAL_RUNTIME : MAX_FIRST_RUNTIME
end
+ def send_failure_notifications
+ Gitlab::Metrics.add_event(:remote_mirrors_failed)
+
+ run_after_commit do
+ RemoteMirrorNotificationWorker.perform_async(id)
+ end
+
+ self.last_update_at = Time.current
+ save!(validate: false)
+ end
+
private
def store_credentials
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 84ca8f0c12a..b2efc9b480b 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -288,6 +288,10 @@ class Repository
false
end
+ def search_branch_names(pattern)
+ redis_set_cache.search('branch_names', pattern) { branch_names }
+ end
+
def languages
return [] if empty?
@@ -829,12 +833,6 @@ class Repository
end
end
- def merge_to_ref(user, source_sha, merge_request, target_ref, message, first_parent_ref, allow_conflicts = false)
- branch = merge_request.target_branch
-
- raw.merge_to_ref(user, source_sha, branch, target_ref, message, first_parent_ref, allow_conflicts)
- end
-
def delete_refs(*ref_names)
raw.delete_refs(*ref_names)
end
@@ -995,6 +993,12 @@ class Repository
raw_repository.search_files_by_name(query, ref)
end
+ def search_files_by_wildcard_path(path, ref = 'HEAD')
+ # We need to use RE2 to match Gitaly's regexp engine
+ regexp_string = RE2::Regexp.escape(path).gsub('\*', '.*?')
+ raw_repository.search_files_by_regexp("^#{regexp_string}$", ref)
+ end
+
def copy_gitattributes(ref)
actual_ref = ref || root_ref
begin
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index 4165d3b753f..5d7b3879d75 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -48,7 +48,7 @@ class SentNotification < ApplicationRecord
end
def record_note(note, recipient_id, reply_key = self.reply_key, attrs = {})
- attrs[:in_reply_to_discussion_id] = note.discussion_id if note.part_of_discussion?
+ attrs[:in_reply_to_discussion_id] = note.discussion_id if note.part_of_discussion? || note.can_be_discussion_note?
record(note.noteable, recipient_id, reply_key, attrs)
end
diff --git a/app/models/service.rb b/app/models/service.rb
index c49e0869b21..aadc75ae710 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -11,14 +11,14 @@ class Service < ApplicationRecord
include EachBatch
SERVICE_NAMES = %w[
- asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord
- drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat hipchat irker jira
+ asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord
+ drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email
pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack
].freeze
PROJECT_SPECIFIC_SERVICE_NAMES = %w[
- jenkins
+ datadog jenkins
].freeze
# Fake services to help with local development.
@@ -413,6 +413,10 @@ class Service < ApplicationRecord
!instance? && !group_id
end
+ def project_level?
+ project_id.present?
+ end
+
def parent
project || group
end
@@ -456,7 +460,7 @@ class Service < ApplicationRecord
errors.add(:project_id, 'The service cannot belong to both a project and a group') if project_id && group_id
end
- def valid_recipients?
+ def validate_recipients?
activated? && !importing?
end
end
diff --git a/app/models/sidebars/context.rb b/app/models/sidebars/context.rb
new file mode 100644
index 00000000000..d9ac2705aaf
--- /dev/null
+++ b/app/models/sidebars/context.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# This class stores all the information needed to display and
+# render the sidebar and menus.
+# It usually stores information regarding the context and calculated
+# values where the logic is in helpers.
+module Sidebars
+ class Context
+ attr_reader :current_user, :container
+
+ def initialize(current_user:, container:, **args)
+ @current_user = current_user
+ @container = container
+
+ args.each do |key, value|
+ singleton_class.public_send(:attr_reader, key) # rubocop:disable GitlabSecurity/PublicSend
+ instance_variable_set("@#{key}", value)
+ end
+ end
+ end
+end
diff --git a/app/models/sidebars/menu.rb b/app/models/sidebars/menu.rb
new file mode 100644
index 00000000000..a5c8be2bb31
--- /dev/null
+++ b/app/models/sidebars/menu.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+module Sidebars
+ class Menu
+ extend ::Gitlab::Utils::Override
+ include ::Gitlab::Routing
+ include GitlabRoutingHelper
+ include Gitlab::Allowable
+ include ::Sidebars::HasPill
+ include ::Sidebars::HasIcon
+ include ::Sidebars::PositionableList
+ include ::Sidebars::Renderable
+ include ::Sidebars::ContainerWithHtmlOptions
+ include ::Sidebars::HasActiveRoutes
+
+ attr_reader :context
+ delegate :current_user, :container, to: :@context
+
+ def initialize(context)
+ @context = context
+ @items = []
+
+ configure_menu_items
+ end
+
+ def configure_menu_items
+ # No-op
+ end
+
+ override :render?
+ def render?
+ @items.empty? || renderable_items.any?
+ end
+
+ # Menus might have or not a link
+ override :link
+ def link
+ nil
+ end
+
+ # This method normalizes the information retrieved from the submenus and this menu
+ # Value from menus is something like: [{ path: 'foo', path: 'bar', controller: :foo }]
+ # This method filters the information and returns: { path: ['foo', 'bar'], controller: :foo }
+ def all_active_routes
+ @all_active_routes ||= begin
+ ([active_routes] + renderable_items.map(&:active_routes)).flatten.each_with_object({}) do |pairs, hash|
+ pairs.each do |k, v|
+ hash[k] ||= []
+ hash[k] += Array(v)
+ hash[k].uniq!
+ end
+
+ hash
+ end
+ end
+ end
+
+ def has_items?
+ @items.any?
+ end
+
+ def add_item(item)
+ add_element(@items, item)
+ end
+
+ def insert_item_before(before_item, new_item)
+ insert_element_before(@items, before_item, new_item)
+ end
+
+ def insert_item_after(after_item, new_item)
+ insert_element_after(@items, after_item, new_item)
+ end
+
+ def has_renderable_items?
+ renderable_items.any?
+ end
+
+ def renderable_items
+ @renderable_items ||= @items.select(&:render?)
+ end
+ end
+end
diff --git a/app/models/sidebars/menu_item.rb b/app/models/sidebars/menu_item.rb
new file mode 100644
index 00000000000..7466b31898e
--- /dev/null
+++ b/app/models/sidebars/menu_item.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Sidebars
+ class MenuItem
+ extend ::Gitlab::Utils::Override
+ include ::Gitlab::Routing
+ include GitlabRoutingHelper
+ include Gitlab::Allowable
+ include ::Sidebars::HasIcon
+ include ::Sidebars::HasHint
+ include ::Sidebars::Renderable
+ include ::Sidebars::ContainerWithHtmlOptions
+ include ::Sidebars::HasActiveRoutes
+
+ attr_reader :context
+
+ def initialize(context)
+ @context = context
+ end
+ end
+end
diff --git a/app/models/sidebars/panel.rb b/app/models/sidebars/panel.rb
new file mode 100644
index 00000000000..5c8191ebda3
--- /dev/null
+++ b/app/models/sidebars/panel.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module Sidebars
+ class Panel
+ extend ::Gitlab::Utils::Override
+ include ::Sidebars::PositionableList
+
+ attr_reader :context, :scope_menu, :hidden_menu
+
+ def initialize(context)
+ @context = context
+ @scope_menu = nil
+ @hidden_menu = nil
+ @menus = []
+
+ configure_menus
+ end
+
+ def configure_menus
+ # No-op
+ end
+
+ def add_menu(menu)
+ add_element(@menus, menu)
+ end
+
+ def insert_menu_before(before_menu, new_menu)
+ insert_element_before(@menus, before_menu, new_menu)
+ end
+
+ def insert_menu_after(after_menu, new_menu)
+ insert_element_after(@menus, after_menu, new_menu)
+ end
+
+ def set_scope_menu(scope_menu)
+ @scope_menu = scope_menu
+ end
+
+ def set_hidden_menu(hidden_menu)
+ @hidden_menu = hidden_menu
+ end
+
+ def aria_label
+ raise NotImplementedError
+ end
+
+ def has_renderable_menus?
+ renderable_menus.any?
+ end
+
+ def renderable_menus
+ @renderable_menus ||= @menus.select(&:render?)
+ end
+
+ def container
+ context.container
+ end
+
+ # Auxiliar method that helps with the migration from
+ # regular views to the new logic
+ def render_raw_scope_menu_partial
+ # No-op
+ end
+
+ # Auxiliar method that helps with the migration from
+ # regular views to the new logic.
+ #
+ # Any menu inside this partial will be added after
+ # all the menus added in the `configure_menus`
+ # method.
+ def render_raw_menus_partial
+ # No-op
+ end
+ end
+end
diff --git a/app/models/sidebars/projects/context.rb b/app/models/sidebars/projects/context.rb
new file mode 100644
index 00000000000..4c82309035d
--- /dev/null
+++ b/app/models/sidebars/projects/context.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ class Context < ::Sidebars::Context
+ def initialize(current_user:, container:, **args)
+ super(current_user: current_user, container: container, project: container, **args)
+ end
+ end
+ end
+end
diff --git a/app/models/sidebars/projects/menus/learn_gitlab/menu.rb b/app/models/sidebars/projects/menus/learn_gitlab/menu.rb
new file mode 100644
index 00000000000..4b572846d1a
--- /dev/null
+++ b/app/models/sidebars/projects/menus/learn_gitlab/menu.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ module Menus
+ module LearnGitlab
+ class Menu < ::Sidebars::Menu
+ override :link
+ def link
+ project_learn_gitlab_path(context.project)
+ end
+
+ override :active_routes
+ def active_routes
+ { controller: :learn_gitlab }
+ end
+
+ override :title
+ def title
+ _('Learn GitLab')
+ end
+
+ override :extra_container_html_options
+ def nav_link_html_options
+ { class: 'home' }
+ end
+
+ override :sprite_icon
+ def sprite_icon
+ 'home'
+ end
+
+ override :render?
+ def render?
+ context.learn_gitlab_experiment_enabled
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/sidebars/projects/menus/project_overview/menu.rb b/app/models/sidebars/projects/menus/project_overview/menu.rb
new file mode 100644
index 00000000000..e6aa8ed159f
--- /dev/null
+++ b/app/models/sidebars/projects/menus/project_overview/menu.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ module Menus
+ module ProjectOverview
+ class Menu < ::Sidebars::Menu
+ override :configure_menu_items
+ def configure_menu_items
+ add_item(MenuItems::Details.new(context))
+ add_item(MenuItems::Activity.new(context))
+ add_item(MenuItems::Releases.new(context))
+ end
+
+ override :link
+ def link
+ project_path(context.project)
+ end
+
+ override :extra_container_html_options
+ def extra_container_html_options
+ {
+ class: 'shortcuts-project rspec-project-link'
+ }
+ end
+
+ override :extra_container_html_options
+ def nav_link_html_options
+ { class: 'home' }
+ end
+
+ override :title
+ def title
+ _('Project overview')
+ end
+
+ override :sprite_icon
+ def sprite_icon
+ 'home'
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/sidebars/projects/menus/project_overview/menu_items/activity.rb b/app/models/sidebars/projects/menus/project_overview/menu_items/activity.rb
new file mode 100644
index 00000000000..46d0f0bc43b
--- /dev/null
+++ b/app/models/sidebars/projects/menus/project_overview/menu_items/activity.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ module Menus
+ module ProjectOverview
+ module MenuItems
+ class Activity < ::Sidebars::MenuItem
+ override :link
+ def link
+ activity_project_path(context.project)
+ end
+
+ override :extra_container_html_options
+ def extra_container_html_options
+ {
+ class: 'shortcuts-project-activity'
+ }
+ end
+
+ override :active_routes
+ def active_routes
+ { path: 'projects#activity' }
+ end
+
+ override :title
+ def title
+ _('Activity')
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/sidebars/projects/menus/project_overview/menu_items/details.rb b/app/models/sidebars/projects/menus/project_overview/menu_items/details.rb
new file mode 100644
index 00000000000..c40c2ed8fa2
--- /dev/null
+++ b/app/models/sidebars/projects/menus/project_overview/menu_items/details.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ module Menus
+ module ProjectOverview
+ module MenuItems
+ class Details < ::Sidebars::MenuItem
+ override :link
+ def link
+ project_path(context.project)
+ end
+
+ override :extra_container_html_options
+ def extra_container_html_options
+ {
+ aria: { label: _('Project details') },
+ class: 'shortcuts-project'
+ }
+ end
+
+ override :active_routes
+ def active_routes
+ { path: 'projects#show' }
+ end
+
+ override :title
+ def title
+ _('Details')
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/sidebars/projects/menus/project_overview/menu_items/releases.rb b/app/models/sidebars/projects/menus/project_overview/menu_items/releases.rb
new file mode 100644
index 00000000000..5e8348f4398
--- /dev/null
+++ b/app/models/sidebars/projects/menus/project_overview/menu_items/releases.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ module Menus
+ module ProjectOverview
+ module MenuItems
+ class Releases < ::Sidebars::MenuItem
+ override :link
+ def link
+ project_releases_path(context.project)
+ end
+
+ override :extra_container_html_options
+ def extra_container_html_options
+ {
+ class: 'shortcuts-project-releases'
+ }
+ end
+
+ override :render?
+ def render?
+ can?(context.current_user, :read_release, context.project) && !context.project.empty_repo?
+ end
+
+ override :active_routes
+ def active_routes
+ { controller: :releases }
+ end
+
+ override :title
+ def title
+ _('Releases')
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/sidebars/projects/menus/repository/menu.rb b/app/models/sidebars/projects/menus/repository/menu.rb
new file mode 100644
index 00000000000..f49a0479521
--- /dev/null
+++ b/app/models/sidebars/projects/menus/repository/menu.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ module Menus
+ module Repository
+ class Menu < ::Sidebars::Menu
+ override :configure_menu_items
+ def configure_menu_items
+ add_item(MenuItems::Files.new(context))
+ add_item(MenuItems::Commits.new(context))
+ add_item(MenuItems::Branches.new(context))
+ add_item(MenuItems::Tags.new(context))
+ add_item(MenuItems::Contributors.new(context))
+ add_item(MenuItems::Graphs.new(context))
+ add_item(MenuItems::Compare.new(context))
+ end
+
+ override :link
+ def link
+ project_tree_path(context.project)
+ end
+
+ override :extra_container_html_options
+ def extra_container_html_options
+ {
+ class: 'shortcuts-tree'
+ }
+ end
+
+ override :title
+ def title
+ _('Repository')
+ end
+
+ override :title_html_options
+ def title_html_options
+ {
+ id: 'js-onboarding-repo-link'
+ }
+ end
+
+ override :sprite_icon
+ def sprite_icon
+ 'doc-text'
+ end
+
+ override :render?
+ def render?
+ can?(context.current_user, :download_code, context.project) &&
+ !context.project.empty_repo?
+ end
+ end
+ end
+ end
+ end
+end
+
+Sidebars::Projects::Menus::Repository::Menu.prepend_if_ee('EE::Sidebars::Projects::Menus::Repository::Menu')
diff --git a/app/models/sidebars/projects/menus/repository/menu_items/branches.rb b/app/models/sidebars/projects/menus/repository/menu_items/branches.rb
new file mode 100644
index 00000000000..4a62803dd2b
--- /dev/null
+++ b/app/models/sidebars/projects/menus/repository/menu_items/branches.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ module Menus
+ module Repository
+ module MenuItems
+ class Branches < ::Sidebars::MenuItem
+ override :link
+ def link
+ project_branches_path(context.project)
+ end
+
+ override :extra_container_html_options
+ def extra_container_html_options
+ {
+ id: 'js-onboarding-branches-link'
+ }
+ end
+
+ override :active_routes
+ def active_routes
+ { controller: :branches }
+ end
+
+ override :title
+ def title
+ _('Branches')
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/sidebars/projects/menus/repository/menu_items/commits.rb b/app/models/sidebars/projects/menus/repository/menu_items/commits.rb
new file mode 100644
index 00000000000..647cf89133e
--- /dev/null
+++ b/app/models/sidebars/projects/menus/repository/menu_items/commits.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ module Menus
+ module Repository
+ module MenuItems
+ class Commits < ::Sidebars::MenuItem
+ override :link
+ def link
+ project_commits_path(context.project, context.current_ref)
+ end
+
+ override :extra_container_html_options
+ def extra_container_html_options
+ {
+ id: 'js-onboarding-commits-link'
+ }
+ end
+
+ override :active_routes
+ def active_routes
+ { controller: %w(commit commits) }
+ end
+
+ override :title
+ def title
+ _('Commits')
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/sidebars/projects/menus/repository/menu_items/compare.rb b/app/models/sidebars/projects/menus/repository/menu_items/compare.rb
new file mode 100644
index 00000000000..4812636b63f
--- /dev/null
+++ b/app/models/sidebars/projects/menus/repository/menu_items/compare.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ module Menus
+ module Repository
+ module MenuItems
+ class Compare < ::Sidebars::MenuItem
+ override :link
+ def link
+ project_compare_index_path(context.project, from: context.project.repository.root_ref, to: context.current_ref)
+ end
+
+ override :active_routes
+ def active_routes
+ { controller: :compare }
+ end
+
+ override :title
+ def title
+ _('Compare')
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/sidebars/projects/menus/repository/menu_items/contributors.rb b/app/models/sidebars/projects/menus/repository/menu_items/contributors.rb
new file mode 100644
index 00000000000..d60fd05bb64
--- /dev/null
+++ b/app/models/sidebars/projects/menus/repository/menu_items/contributors.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ module Menus
+ module Repository
+ module MenuItems
+ class Contributors < ::Sidebars::MenuItem
+ override :link
+ def link
+ project_graph_path(context.project, context.current_ref)
+ end
+
+ override :active_routes
+ def active_routes
+ { path: 'graphs#show' }
+ end
+
+ override :title
+ def title
+ _('Contributors')
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/sidebars/projects/menus/repository/menu_items/files.rb b/app/models/sidebars/projects/menus/repository/menu_items/files.rb
new file mode 100644
index 00000000000..4989efe9fa5
--- /dev/null
+++ b/app/models/sidebars/projects/menus/repository/menu_items/files.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ module Menus
+ module Repository
+ module MenuItems
+ class Files < ::Sidebars::MenuItem
+ override :link
+ def link
+ project_tree_path(context.project, context.current_ref)
+ end
+
+ override :active_routes
+ def active_routes
+ { controller: %w[tree blob blame edit_tree new_tree find_file] }
+ end
+
+ override :title
+ def title
+ _('Files')
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/sidebars/projects/menus/repository/menu_items/graphs.rb b/app/models/sidebars/projects/menus/repository/menu_items/graphs.rb
new file mode 100644
index 00000000000..a57021be4d0
--- /dev/null
+++ b/app/models/sidebars/projects/menus/repository/menu_items/graphs.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ module Menus
+ module Repository
+ module MenuItems
+ class Graphs < ::Sidebars::MenuItem
+ override :link
+ def link
+ project_network_path(context.project, context.current_ref)
+ end
+
+ override :active_routes
+ def active_routes
+ { controller: :network }
+ end
+
+ override :title
+ def title
+ _('Graph')
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/sidebars/projects/menus/repository/menu_items/tags.rb b/app/models/sidebars/projects/menus/repository/menu_items/tags.rb
new file mode 100644
index 00000000000..d84bc89b93c
--- /dev/null
+++ b/app/models/sidebars/projects/menus/repository/menu_items/tags.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ module Menus
+ module Repository
+ module MenuItems
+ class Tags < ::Sidebars::MenuItem
+ override :link
+ def link
+ project_tags_path(context.project)
+ end
+
+ override :active_routes
+ def active_routes
+ { controller: :tags }
+ end
+
+ override :title
+ def title
+ _('Tags')
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/sidebars/projects/menus/scope/menu.rb b/app/models/sidebars/projects/menus/scope/menu.rb
new file mode 100644
index 00000000000..3b699083f75
--- /dev/null
+++ b/app/models/sidebars/projects/menus/scope/menu.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ module Menus
+ module Scope
+ class Menu < ::Sidebars::Menu
+ override :link
+ def link
+ project_path(context.project)
+ end
+
+ override :title
+ def title
+ context.project.name
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/sidebars/projects/panel.rb b/app/models/sidebars/projects/panel.rb
new file mode 100644
index 00000000000..ec4fac53a40
--- /dev/null
+++ b/app/models/sidebars/projects/panel.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Sidebars
+ module Projects
+ class Panel < ::Sidebars::Panel
+ override :configure_menus
+ def configure_menus
+ set_scope_menu(Sidebars::Projects::Menus::Scope::Menu.new(context))
+
+ add_menu(Sidebars::Projects::Menus::ProjectOverview::Menu.new(context))
+ add_menu(Sidebars::Projects::Menus::LearnGitlab::Menu.new(context))
+ add_menu(Sidebars::Projects::Menus::Repository::Menu.new(context))
+ end
+
+ override :render_raw_menus_partial
+ def render_raw_menus_partial
+ 'layouts/nav/sidebar/project_menus'
+ end
+
+ override :aria_label
+ def aria_label
+ _('Project navigation')
+ end
+ end
+ end
+end
diff --git a/app/models/timelog.rb b/app/models/timelog.rb
index f4debedb656..c1aa84cbbcd 100644
--- a/app/models/timelog.rb
+++ b/app/models/timelog.rb
@@ -31,9 +31,9 @@ class Timelog < ApplicationRecord
def issuable_id_is_present
if issue_id && merge_request_id
- errors.add(:base, _('Only Issue ID or Merge Request ID is required'))
+ errors.add(:base, _('Only Issue ID or merge request ID is required'))
elsif issuable.nil?
- errors.add(:base, _('Issue or Merge Request ID is required'))
+ errors.add(:base, _('Issue or merge request ID is required'))
end
end
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 176d5e56fc0..c8138587d83 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -148,6 +148,24 @@ class Todo < ApplicationRecord
.order(Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
.order('todos.created_at')
end
+
+ def pluck_user_id
+ pluck(:user_id)
+ end
+
+ # Count todos grouped by user_id and state, using an UNION query
+ # so we can utilize the partial indexes for each state.
+ def count_grouped_by_user_id_and_state
+ grouped_count = select(:user_id, 'count(id) AS count').group(:user_id)
+
+ done = grouped_count.where(state: :done).select("'done' AS state")
+ pending = grouped_count.where(state: :pending).select("'pending' AS state")
+ union = unscoped.from_union([done, pending], remove_duplicates: false)
+
+ connection.select_all(union).each_with_object({}) do |row, counts|
+ counts[[row['user_id'], row['state']]] = row['count']
+ end
+ end
end
def resource_parent
diff --git a/app/models/user.rb b/app/models/user.rb
index 11046bdabe4..507e8cc2cf5 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -103,6 +103,8 @@ class User < ApplicationRecord
# Profile
has_many :keys, -> { regular_keys }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :expired_today_and_unnotified_keys, -> { expired_today_and_not_notified }, class_name: 'Key'
+ has_many :expiring_soon_and_unnotified_keys, -> { expiring_soon_and_not_notified }, class_name: 'Key'
has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
has_many :group_deploy_keys
has_many :gpg_keys
@@ -125,7 +127,7 @@ class User < ApplicationRecord
# Groups
has_many :members
- has_many :group_members, -> { where(requested_at: nil).where("access_level >= ?", Gitlab::Access::GUEST) }, source: 'GroupMember'
+ has_many :group_members, -> { where(requested_at: nil).where("access_level >= ?", Gitlab::Access::GUEST) }, class_name: 'GroupMember'
has_many :groups, through: :group_members
has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }) }, through: :group_members, source: :group
has_many :maintainers_groups, -> { where(members: { access_level: Gitlab::Access::MAINTAINER }) }, through: :group_members, source: :group
@@ -139,7 +141,7 @@ class User < ApplicationRecord
-> { where(members: { access_level: [Gitlab::Access::REPORTER, Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) },
through: :group_members,
source: :group
- has_many :minimal_access_group_members, -> { where(access_level: [Gitlab::Access::MINIMAL_ACCESS]) }, source: 'GroupMember', class_name: 'GroupMember'
+ has_many :minimal_access_group_members, -> { where(access_level: [Gitlab::Access::MINIMAL_ACCESS]) }, class_name: 'GroupMember'
has_many :minimal_access_groups, through: :minimal_access_group_members, source: :group
# Projects
@@ -199,6 +201,8 @@ class User < ApplicationRecord
has_many :reviews, foreign_key: :author_id, inverse_of: :author
+ has_many :in_product_marketing_emails, class_name: '::Users::InProductMarketingEmail'
+
#
# Validations
#
@@ -350,7 +354,8 @@ class User < ApplicationRecord
# this state transition object in order to do a rollback.
# For this reason the tradeoff is to disable this cop.
after_transition any => :blocked do |user|
- Ci::CancelUserPipelinesService.new.execute(user)
+ Ci::DropPipelineService.new.execute_async_for_all(user.pipelines, :user_blocked, user)
+ Ci::DisableUserPipelineSchedulesService.new.execute(user)
end
# rubocop: enable CodeReuse/ServiceClass
end
@@ -390,6 +395,22 @@ class User < ApplicationRecord
.without_impersonation
.expired_today_and_not_notified)
end
+ scope :with_ssh_key_expired_today, -> do
+ includes(:expired_today_and_unnotified_keys)
+ .where('EXISTS (?)',
+ ::Key
+ .select(1)
+ .where('keys.user_id = users.id')
+ .expired_today_and_not_notified)
+ end
+ scope :with_ssh_key_expiring_soon, -> do
+ includes(:expiring_soon_and_unnotified_keys)
+ .where('EXISTS (?)',
+ ::Key
+ .select(1)
+ .where('keys.user_id = users.id')
+ .expiring_soon_and_not_notified)
+ end
scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) }
@@ -743,6 +764,7 @@ class User < ApplicationRecord
u.bio = 'The GitLab support bot used for Service Desk'
u.name = 'GitLab Support Bot'
u.avatar = bot_avatar(image: 'support-bot.png')
+ u.confirmed_at = Time.zone.now
end
end
@@ -1024,7 +1046,7 @@ class User < ApplicationRecord
[
Project.where(namespace: namespace),
Project.joins(:project_authorizations)
- .where("projects.namespace_id <> ?", namespace.id)
+ .where.not('projects.namespace_id' => namespace.id)
.where(project_authorizations: { user_id: id, access_level: Gitlab::Access::OWNER })
],
remove_duplicates: false
@@ -1337,9 +1359,11 @@ class User < ApplicationRecord
end
def public_verified_emails
- emails = verified_emails(include_private_email: false)
- emails << email unless temp_oauth_email?
- emails.uniq
+ strong_memoize(:public_verified_emails) do
+ emails = verified_emails(include_private_email: false)
+ emails << email unless temp_oauth_email?
+ emails.uniq
+ end
end
def any_email?(check_email)
@@ -1595,32 +1619,40 @@ class User < ApplicationRecord
@global_notification_setting
end
+ def count_cache_validity_period
+ if Feature.enabled?(:longer_count_cache_validity, self, default_enabled: :yaml)
+ 24.hours
+ else
+ 20.minutes
+ end
+ end
+
def assigned_open_merge_requests_count(force: false)
- Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force, expires_in: 20.minutes) do
+ Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force, expires_in: count_cache_validity_period) do
MergeRequestsFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count
end
end
def review_requested_open_merge_requests_count(force: false)
- Rails.cache.fetch(['users', id, 'review_requested_open_merge_requests_count'], force: force, expires_in: 20.minutes) do
+ Rails.cache.fetch(['users', id, 'review_requested_open_merge_requests_count'], force: force, expires_in: count_cache_validity_period) do
MergeRequestsFinder.new(self, reviewer_id: id, state: 'opened', non_archived: true).execute.count
end
end
def assigned_open_issues_count(force: false)
- Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: 20.minutes) do
+ Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: count_cache_validity_period) do
IssuesFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count
end
end
def todos_done_count(force: false)
- Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: 20.minutes) do
+ Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: count_cache_validity_period) do
TodosFinder.new(self, state: :done).execute.count
end
end
def todos_pending_count(force: false)
- Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: 20.minutes) do
+ Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: count_cache_validity_period) do
TodosFinder.new(self, state: :pending).execute.count
end
end
@@ -1639,8 +1671,7 @@ class User < ApplicationRecord
def invalidate_cache_counts
invalidate_issue_cache_counts
invalidate_merge_request_cache_counts
- invalidate_todos_done_count
- invalidate_todos_pending_count
+ invalidate_todos_cache_counts
invalidate_personal_projects_count
end
@@ -1653,11 +1684,8 @@ class User < ApplicationRecord
Rails.cache.delete(['users', id, 'review_requested_open_merge_requests_count'])
end
- def invalidate_todos_done_count
+ def invalidate_todos_cache_counts
Rails.cache.delete(['users', id, 'todos_done_count'])
- end
-
- def invalidate_todos_pending_count
Rails.cache.delete(['users', id, 'todos_pending_count'])
end
@@ -1835,10 +1863,12 @@ class User < ApplicationRecord
end
def dismissed_callout?(feature_name:, ignore_dismissal_earlier_than: nil)
- callouts = self.callouts.with_feature_name(feature_name)
- callouts = callouts.with_dismissed_after(ignore_dismissal_earlier_than) if ignore_dismissal_earlier_than
+ callout = callouts_by_feature_name[feature_name]
+
+ return false unless callout
+ return callout.dismissed_after?(ignore_dismissal_earlier_than) if ignore_dismissal_earlier_than
- callouts.any?
+ true
end
# Load the current highest access by looking directly at the user's memberships
@@ -1901,6 +1931,10 @@ class User < ApplicationRecord
private
+ def callouts_by_feature_name
+ @callouts_by_feature_name ||= callouts.index_by(&:feature_name)
+ end
+
def authorized_groups_without_shared_membership
Group.from_union([
groups,
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
index bb5a9dceaeb..0a4db707be6 100644
--- a/app/models/user_callout.rb
+++ b/app/models/user_callout.rb
@@ -17,7 +17,7 @@ class UserCallout < ApplicationRecord
threat_monitoring_info: 11, # EE-only
account_recovery_regular_check: 12, # EE-only
webhooks_moved: 13,
- service_templates_deprecated: 14,
+ service_templates_deprecated_callout: 14,
admin_integrations_moved: 15,
web_ide_alert_dismissed: 16, # no longer in use
active_user_count_threshold: 18, # EE-only
@@ -29,7 +29,8 @@ class UserCallout < ApplicationRecord
registration_enabled_callout: 25,
new_user_signups_cap_reached: 26, # EE-only
unfinished_tag_cleanup_callout: 27,
- eoa_bronze_plan_banner: 28 # EE-only
+ eoa_bronze_plan_banner: 28, # EE-only
+ pipeline_needs_banner: 29
}
validates :user, presence: true
@@ -38,6 +39,7 @@ class UserCallout < ApplicationRecord
uniqueness: { scope: :user_id },
inclusion: { in: UserCallout.feature_names.keys }
- scope :with_feature_name, -> (feature_name) { where(feature_name: UserCallout.feature_names[feature_name]) }
- scope :with_dismissed_after, -> (dismissed_after) { where('dismissed_at > ?', dismissed_after) }
+ def dismissed_after?(dismissed_after)
+ dismissed_at > dismissed_after
+ end
end
diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb
index ef799b01452..6b64f583927 100644
--- a/app/models/user_detail.rb
+++ b/app/models/user_detail.rb
@@ -19,7 +19,7 @@ class UserDetail < ApplicationRecord
# For backward compatibility.
# Older migrations (and their tests) reference the `User.migration_bot` where the `bio` attribute is set.
- # Here we disable writing the markdown cache when the `bio_html` column does not exists.
+ # Here we disable writing the markdown cache when the `bio_html` column does not exist.
override :invalidated_markdown_cache?
def invalidated_markdown_cache?
self.class.column_names.include?('bio_html') && super
diff --git a/app/models/users/in_product_marketing_email.rb b/app/models/users/in_product_marketing_email.rb
new file mode 100644
index 00000000000..195cfe162ac
--- /dev/null
+++ b/app/models/users/in_product_marketing_email.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Users
+ class InProductMarketingEmail < ApplicationRecord
+ include BulkInsertSafe
+
+ belongs_to :user
+
+ validates :user, presence: true
+ validates :track, presence: true
+ validates :series, presence: true
+ validates :user_id, uniqueness: {
+ scope: [:track, :series],
+ message: 'has already been sent'
+ }
+
+ enum track: {
+ create: 0,
+ verify: 1,
+ trial: 2,
+ team: 3
+ }, _suffix: true
+
+ scope :without_track_and_series, -> (track, series) do
+ users = User.arel_table
+ product_emails = arel_table
+
+ join_condition = users[:id].eq(product_emails[:user_id])
+ .and(product_emails[:track]).eq(tracks[track])
+ .and(product_emails[:series]).eq(series)
+
+ arel_join = users.join(product_emails, Arel::Nodes::OuterJoin).on(join_condition)
+
+ joins(arel_join.join_sources)
+ .where(in_product_marketing_emails: { id: nil })
+ .select(Arel.sql("DISTINCT ON(#{users.table_name}.id) #{users.table_name}.*"))
+ end
+
+ scope :for_user_with_track_and_series, -> (user, track, series) do
+ where(user: user, track: track, series: series)
+ end
+
+ def self.save_cta_click(user, track, series)
+ email = for_user_with_track_and_series(user, track, series).take
+
+ email.update(cta_clicked_at: Time.zone.now) if email && email.cta_clicked_at.blank?
+ end
+ end
+end
diff --git a/app/models/users/merge_request_interaction.rb b/app/models/users/merge_request_interaction.rb
new file mode 100644
index 00000000000..35d1d3206b5
--- /dev/null
+++ b/app/models/users/merge_request_interaction.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Users
+ class MergeRequestInteraction
+ def initialize(user:, merge_request:)
+ @user = user
+ @merge_request = merge_request
+ end
+
+ def declarative_policy_subject
+ merge_request
+ end
+
+ def can_merge?
+ merge_request.can_be_merged_by?(user)
+ end
+
+ def can_update?
+ user.can?(:update_merge_request, merge_request)
+ end
+
+ def review_state
+ reviewer&.state
+ end
+
+ def reviewed?
+ reviewer&.reviewed? == true
+ end
+
+ def approved?
+ merge_request.approvals.any? { |app| app.user_id == user.id }
+ end
+
+ private
+
+ def reviewer
+ @reviewer ||= merge_request.merge_request_reviewers.find { |r| r.user_id == user.id }
+ end
+
+ attr_reader :user, :merge_request
+ end
+end
+
+::Users::MergeRequestInteraction.prepend_if_ee('EE::Users::MergeRequestInteraction')
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
index df31c54bd0f..47fe40b0e57 100644
--- a/app/models/wiki.rb
+++ b/app/models/wiki.rb
@@ -160,16 +160,12 @@ class Wiki
end
def find_file(name, version = 'HEAD', load_content: true)
- if Feature.enabled?(:gitaly_find_file, user, default_enabled: :yaml)
- data_limit = load_content ? -1 : 0
- blobs = repository.blobs_at([[version, name]], blob_size_limit: data_limit)
+ data_limit = load_content ? -1 : 0
+ blobs = repository.blobs_at([[version, name]], blob_size_limit: data_limit)
- return if blobs.empty?
+ return if blobs.empty?
- Gitlab::Git::WikiFile.from_blob(blobs.first)
- else
- wiki.file(name, version)
- end
+ Gitlab::Git::WikiFile.new(blobs.first)
end
def create_page(title, content, format = :markdown, message = nil)
@@ -196,10 +192,20 @@ class Wiki
def delete_page(page, message = nil)
return unless page
- wiki.delete_page(page.path, commit_details(:deleted, message, page.title))
- after_wiki_activity
+ if Feature.enabled?(:gitaly_replace_wiki_delete_page, user, default_enabled: :yaml)
+ capture_git_error(:deleted) do
+ repository.delete_file(user, page.path, **multi_commit_options(:deleted, message, page.title))
- true
+ after_wiki_activity
+
+ true
+ end
+ else
+ wiki.delete_page(page.path, commit_details(:deleted, message, page.title))
+ after_wiki_activity
+
+ true
+ end
end
def page_title_and_dir(title)
@@ -276,8 +282,20 @@ class Wiki
private
+ def multi_commit_options(action, message = nil, title = nil)
+ commit_message = build_commit_message(action, message, title)
+ git_user = Gitlab::Git::User.from_gitlab(user)
+
+ {
+ branch_name: repository.root_ref,
+ message: commit_message,
+ author_email: git_user.email,
+ author_name: git_user.name
+ }
+ end
+
def commit_details(action, message = nil, title = nil)
- commit_message = message.presence || default_message(action, title)
+ commit_message = build_commit_message(action, message, title)
git_user = Gitlab::Git::User.from_gitlab(user)
Gitlab::Git::Wiki::CommitDetails.new(user.id,
@@ -287,9 +305,26 @@ class Wiki
commit_message)
end
+ def build_commit_message(action, message, title)
+ message.presence || default_message(action, title)
+ end
+
def default_message(action, title)
"#{user.username} #{action} page: #{title}"
end
+
+ def capture_git_error(action, &block)
+ yield block
+ rescue Gitlab::Git::Index::IndexError,
+ Gitlab::Git::CommitError,
+ Gitlab::Git::PreReceiveError,
+ Gitlab::Git::CommandError,
+ ArgumentError => error
+
+ Gitlab::ErrorTracking.log_exception(error, action: action, wiki_id: id)
+
+ false
+ end
end
Wiki.prepend_if_ee('EE::Wiki')
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index e32a889c906..1c19751cf0d 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -6,7 +6,7 @@ class BasePolicy < DeclarativePolicy::Base
desc "User is an instance admin"
with_options scope: :user, score: 0
condition(:admin) do
- if Feature.enabled?(:user_mode_in_session)
+ if Gitlab::CurrentSettings.admin_mode
Gitlab::Auth::CurrentUserMode.new(@user).admin_mode?
else
@user&.admin?
diff --git a/app/policies/group_member_policy.rb b/app/policies/group_member_policy.rb
index 1dd650c8a90..8a4cae232a0 100644
--- a/app/policies/group_member_policy.rb
+++ b/app/policies/group_member_policy.rb
@@ -4,7 +4,7 @@ class GroupMemberPolicy < BasePolicy
delegate :group
with_scope :subject
- condition(:last_owner) { @subject.group.last_owner?(@subject.user) || @subject.group.last_blocked_owner?(@subject.user) }
+ condition(:last_owner) { @subject.group.member_last_owner?(@subject) || @subject.group.member_last_blocked_owner?(@subject) }
desc "Membership is users' own"
with_score 0
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 53286cf1fdf..fc24525ade7 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -61,7 +61,8 @@ class GroupPolicy < BasePolicy
end
with_scope :subject
- condition(:resource_access_token_available) { resource_access_token_available? }
+ condition(:resource_access_token_feature_available) { resource_access_token_feature_available? }
+ condition(:resource_access_token_creation_allowed) { resource_access_token_creation_allowed? }
with_scope :subject
condition(:has_project_with_service_desk_enabled) { @subject.has_project_with_service_desk_enabled? }
@@ -130,6 +131,7 @@ class GroupPolicy < BasePolicy
enable :read_prometheus
enable :read_package
enable :read_package_settings
+ enable :read_group_timelogs
end
rule { maintainer }.policy do
@@ -212,8 +214,14 @@ class GroupPolicy < BasePolicy
rule { developer & dependency_proxy_available }
.enable :admin_dependency_proxy
- rule { resource_access_token_available & can?(:admin_group) }.policy do
- enable :admin_resource_access_tokens
+ rule { can?(:admin_group) & resource_access_token_feature_available }.policy do
+ enable :read_resource_access_tokens
+ enable :destroy_resource_access_tokens
+ enable :admin_setting_to_allow_project_access_token_creation
+ end
+
+ rule { resource_access_token_creation_allowed & can?(:read_resource_access_tokens) }.policy do
+ enable :create_resource_access_tokens
end
rule { support_bot & has_project_with_service_desk_enabled }.policy do
@@ -241,9 +249,13 @@ class GroupPolicy < BasePolicy
@subject
end
- def resource_access_token_available?
+ def resource_access_token_feature_available?
true
end
+
+ def resource_access_token_creation_allowed?
+ resource_access_token_feature_available? && group.root_ancestor.namespace_settings.resource_access_token_creation_allowed?
+ end
end
GroupPolicy.prepend_if_ee('EE::GroupPolicy')
diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb
index 38f0f165376..d9ea7c38f11 100644
--- a/app/policies/note_policy.rb
+++ b/app/policies/note_policy.rb
@@ -76,7 +76,7 @@ class NotePolicy < BasePolicy
def parent_namespace
strong_memoize(:parent_namespace) do
next if @subject.is_a?(PersonalSnippet)
- next @subject.noteable.group if @subject.noteable&.is_a?(Epic)
+ next @subject.noteable.group if @subject.noteable.is_a?(Epic)
@subject.project
end
diff --git a/app/policies/packages/conan/file_metadatum_policy.rb b/app/policies/packages/conan/file_metadatum_policy.rb
new file mode 100644
index 00000000000..ac1ffb3ea93
--- /dev/null
+++ b/app/policies/packages/conan/file_metadatum_policy.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+module Packages
+ module Conan
+ class FileMetadatumPolicy < BasePolicy
+ delegate { @subject.package_file.package }
+ end
+ end
+end
diff --git a/app/policies/packages/conan/metadatum_policy.rb b/app/policies/packages/conan/metadatum_policy.rb
new file mode 100644
index 00000000000..8622da015c6
--- /dev/null
+++ b/app/policies/packages/conan/metadatum_policy.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+module Packages
+ module Conan
+ class MetadatumPolicy < BasePolicy
+ delegate { @subject.package }
+ end
+ end
+end
diff --git a/app/policies/packages/package_file_policy.rb b/app/policies/packages/package_file_policy.rb
new file mode 100644
index 00000000000..e98f74204e8
--- /dev/null
+++ b/app/policies/packages/package_file_policy.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+module Packages
+ class PackageFilePolicy < BasePolicy
+ delegate { @subject.package }
+ end
+end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index de80f2f72b8..c577c8c8471 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -108,7 +108,8 @@ class ProjectPolicy < BasePolicy
condition(:service_desk_enabled) { @subject.service_desk_enabled? }
with_scope :subject
- condition(:resource_access_token_available) { resource_access_token_available? }
+ condition(:resource_access_token_feature_available) { resource_access_token_feature_available? }
+ condition(:resource_access_token_creation_allowed) { resource_access_token_creation_allowed? }
# We aren't checking `:read_issue` or `:read_merge_request` in this case
# because it could be possible for a user to see an issuable-iid
@@ -259,6 +260,7 @@ class ProjectPolicy < BasePolicy
enable :read_confidential_issues
enable :read_package
enable :read_product_analytics
+ enable :read_group_timelogs
end
# We define `:public_user_access` separately because there are cases in gitlab-ee
@@ -631,11 +633,18 @@ class ProjectPolicy < BasePolicy
rule { project_bot }.enable :project_bot_access
- rule { resource_access_token_available & can?(:admin_project) }.policy do
- enable :admin_resource_access_tokens
+ rule { can?(:admin_project) & resource_access_token_feature_available }.policy do
+ enable :read_resource_access_tokens
+ enable :destroy_resource_access_tokens
end
- rule { can?(:project_bot_access) }.prevent :admin_resource_access_tokens
+ rule { can?(:read_resource_access_tokens) & resource_access_token_creation_allowed }.policy do
+ enable :create_resource_access_tokens
+ end
+
+ rule { can?(:project_bot_access) }.policy do
+ prevent :create_resource_access_tokens
+ end
rule { user_defined_variables_allowed | can?(:maintainer_access) }.policy do
enable :set_pipeline_variables
@@ -719,10 +728,18 @@ class ProjectPolicy < BasePolicy
end
end
- def resource_access_token_available?
+ def resource_access_token_feature_available?
true
end
+ def resource_access_token_creation_allowed?
+ group = project.group
+
+ return true unless group # always enable for projects in personal namespaces
+
+ resource_access_token_feature_available? && group.root_ancestor.namespace_settings.resource_access_token_creation_allowed?
+ end
+
def project
@subject
end
diff --git a/app/policies/timelog_policy.rb b/app/policies/timelog_policy.rb
new file mode 100644
index 00000000000..0598817d4e0
--- /dev/null
+++ b/app/policies/timelog_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class TimelogPolicy < BasePolicy
+ delegate { @subject.issuable.resource_parent }
+end
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 769b793ee75..6978bc46475 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -33,7 +33,11 @@ module Ci
end
def runner_variables
- variables.to_runner_variables
+ if Feature.enabled?(:variable_inside_variable, project)
+ variables.sort_and_expand_all(project, keep_undefined: true).to_runner_variables
+ else
+ variables.to_runner_variables
+ end
end
def refspecs
@@ -51,6 +55,18 @@ module Ci
specs
end
+ # rubocop: disable CodeReuse/ActiveRecord
+ def all_dependencies
+ dependencies = super
+
+ if Feature.enabled?(:preload_associations_jobs_request_api_endpoint, project, default_enabled: :yaml)
+ ActiveRecord::Associations::Preloader.new.preload(dependencies, :job_artifacts_archive)
+ end
+
+ dependencies
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
private
def create_archive(artifacts)
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index 4e955469ddf..a2cdabb912f 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -14,7 +14,9 @@ module Ci
activity_limit_exceeded: 'Pipeline activity limit exceeded!',
size_limit_exceeded: 'Pipeline size limit exceeded!',
job_activity_limit_exceeded: 'Pipeline job activity limit exceeded!',
- deployments_limit_exceeded: 'Pipeline deployments limit exceeded!' }
+ deployments_limit_exceeded: 'Pipeline deployments limit exceeded!',
+ project_deleted: 'The associated project was deleted',
+ user_blocked: 'The user who created this pipeline is blocked' }
end
presents :pipeline
diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb
index 541a6363edd..038fc752255 100644
--- a/app/presenters/clusters/cluster_presenter.rb
+++ b/app/presenters/clusters/cluster_presenter.rb
@@ -49,13 +49,25 @@ module Clusters
end
end
- def show_path
+ def show_path(params: {})
if cluster.project_type?
- project_cluster_path(project, cluster)
+ project_cluster_path(project, cluster, params)
elsif cluster.group_type?
- group_cluster_path(group, cluster)
+ group_cluster_path(group, cluster, params)
elsif cluster.instance_type?
- admin_cluster_path(cluster)
+ admin_cluster_path(cluster, params)
+ else
+ raise NotImplementedError
+ end
+ end
+
+ def integrations_path
+ if cluster.project_type?
+ create_or_update_project_cluster_integration_path(project, cluster)
+ elsif cluster.group_type?
+ create_or_update_group_cluster_integration_path(group, cluster)
+ elsif cluster.instance_type?
+ create_or_update_admin_cluster_integration_path(cluster)
else
raise NotImplementedError
end
diff --git a/app/presenters/clusters/integration_presenter.rb b/app/presenters/clusters/integration_presenter.rb
new file mode 100644
index 00000000000..57608be29b1
--- /dev/null
+++ b/app/presenters/clusters/integration_presenter.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Clusters
+ class IntegrationPresenter < Gitlab::View::Presenter::Delegated
+ presents :integration
+
+ def application_type
+ integration.class.name.demodulize.underscore
+ end
+ end
+end
diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb
index 714dd232efb..c8d3457b04a 100644
--- a/app/presenters/commit_status_presenter.rb
+++ b/app/presenters/commit_status_presenter.rb
@@ -21,7 +21,9 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
bridge_pipeline_is_child_pipeline: 'This job belongs to a child pipeline and cannot create further child pipelines',
downstream_pipeline_creation_failed: 'The downstream pipeline could not be created',
secrets_provider_not_found: 'The secrets provider can not be found',
- reached_max_descendant_pipelines_depth: 'Maximum child pipeline depth has been reached'
+ reached_max_descendant_pipelines_depth: 'You reached the maximum depth of child pipelines',
+ project_deleted: 'The job belongs to a deleted project',
+ user_blocked: 'The user who created this job is blocked'
}.freeze
private_constant :CALLOUT_FAILURE_MESSAGES
diff --git a/app/presenters/dev_ops_report/metric_presenter.rb b/app/presenters/dev_ops_report/metric_presenter.rb
index b31dfd25a87..46b580d1f2a 100644
--- a/app/presenters/dev_ops_report/metric_presenter.rb
+++ b/app/presenters/dev_ops_report/metric_presenter.rb
@@ -38,7 +38,7 @@ module DevOpsReport
),
Card.new(
metric: subject,
- title: 'Merge Requests',
+ title: 'Merge requests',
description: 'per active user',
feature: 'merge_requests',
blog: 'https://8thlight.com/blog/uncle-bob/2013/02/01/The-Humble-Craftsman.html',
diff --git a/app/presenters/packages/detail/package_presenter.rb b/app/presenters/packages/detail/package_presenter.rb
index 9960fb4bf12..6640b0c5e94 100644
--- a/app/presenters/packages/detail/package_presenter.rb
+++ b/app/presenters/packages/detail/package_presenter.rb
@@ -64,7 +64,6 @@ module Packages
id: pipeline_info.id,
sha: pipeline_info.sha,
ref: pipeline_info.ref,
- git_commit_message: pipeline_info.git_commit_message,
user: build_user_info(pipeline_info.user),
project: {
name: pipeline_info.project.name,
diff --git a/app/presenters/packages/npm/package_presenter.rb b/app/presenters/packages/npm/package_presenter.rb
index a3ab10d3913..4e147b4739e 100644
--- a/app/presenters/packages/npm/package_presenter.rb
+++ b/app/presenters/packages/npm/package_presenter.rb
@@ -35,9 +35,7 @@ module Packages
private
def build_package_tags
- Hash[
- package_tags.map { |tag| [tag.name, tag.package.version] }
- ]
+ package_tags.to_h { |tag| [tag.name, tag.package.version] }
end
def build_package_version(package, package_file)
diff --git a/app/presenters/packages/nuget/packages_metadata_presenter.rb b/app/presenters/packages/nuget/packages_metadata_presenter.rb
index 5f22d5dd8a1..5f5859d46c9 100644
--- a/app/presenters/packages/nuget/packages_metadata_presenter.rb
+++ b/app/presenters/packages/nuget/packages_metadata_presenter.rb
@@ -6,7 +6,7 @@ module Packages
include Packages::Nuget::PresenterHelpers
include Gitlab::Utils::StrongMemoize
- COUNT = 1.freeze
+ COUNT = 1
def initialize(packages)
@packages = packages
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 71cbe28de25..aad1c816cf1 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -93,10 +93,6 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
filename_path(repository.license_blob&.name)
end
- def ci_configuration_path
- filename_path(repository.gitlab_ci_yml&.name)
- end
-
def contribution_guide_path
if project && contribution_guide = repository.contribution_guide
project_blob_path(
@@ -131,10 +127,6 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
ide_edit_path(project, default_branch_or_master, 'CONTRIBUTING.md')
end
- def add_ci_yml_path
- add_special_file_path(file_name: ci_config_path_or_default)
- end
-
def add_readme_path
add_special_file_path(file_name: 'README.md')
end
@@ -143,6 +135,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
ide_edit_path(project, default_branch_or_master, 'README.md')
end
+ def add_ci_yml_path
+ add_special_file_path(file_name: ci_config_path_or_default)
+ end
+
def license_short_name
license = repository.license
license&.nickname || license&.name || 'LICENSE'
@@ -384,11 +380,11 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
if cicd_missing?
AnchorData.new(false,
statistic_icon + _('Set up CI/CD'),
- add_ci_yml_path)
+ project_ci_pipeline_editor_path(project))
elsif repository.gitlab_ci_yml.present?
AnchorData.new(false,
statistic_icon('doc-text') + _('CI/CD configuration'),
- ci_configuration_path,
+ project_ci_pipeline_editor_path(project),
'btn-default')
end
end
diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb
index 103c26289bf..13290a8e632 100644
--- a/app/presenters/projects/settings/deploy_keys_presenter.rb
+++ b/app/presenters/projects/settings/deploy_keys_presenter.rb
@@ -14,12 +14,20 @@ module Projects
@key ||= DeployKey.new.tap { |dk| dk.deploy_keys_projects.build }
end
+ # It includes:
+ # - The deploy keys enabled in the project.
def enabled_keys
strong_memoize(:enabled_keys) do
project.deploy_keys.with_projects
end
end
+ # NOTE: This method is redundant. Use `available_project_keys` and `available_public_keys` instead.
+ # It includes:
+ # - Enabled deploy keys in projects that can be accessed by the user.
+ # - Instance-level public deploy keys.
+ # It excludes:
+ # - The deploy keys enabled in the project.
def available_keys
strong_memoize(:available_keys) do
current_user
@@ -29,22 +37,23 @@ module Projects
end
end
+ # It includes:
+ # - Enabled deploy keys in projects that can be accessed by the user.
+ # It excludes:
+ # - The deploy keys enabled in the project
def available_project_keys
strong_memoize(:available_project_keys) do
- current_user
- .project_deploy_keys
- .id_not_in(enabled_keys.select(:id))
- .with_projects
+ current_user.project_deploy_keys.with_projects - enabled_keys
end
end
+ # It includes:
+ # - Instance-level public deploy keys.
+ # It excludes:
+ # - The deploy keys enabled in the project.
def available_public_keys
strong_memoize(:available_public_keys) do
- DeployKey
- .are_public
- .id_not_in(enabled_keys.select(:id))
- .id_not_in(available_project_keys.select(:id))
- .with_projects
+ DeployKey.are_public.with_projects - enabled_keys
end
end
@@ -78,7 +87,7 @@ module Projects
# rubocop: disable CodeReuse/ActiveRecord
def user_readable_project_ids
- project_ids = (available_keys + available_project_keys + available_public_keys)
+ project_ids = (available_project_keys + available_public_keys)
.flat_map { |deploy_key| deploy_key.deploy_keys_projects.map(&:project_id) }
.compact
.uniq
diff --git a/app/presenters/search_service_presenter.rb b/app/presenters/search_service_presenter.rb
index 19a90d002aa..e14446bb2f7 100644
--- a/app/presenters/search_service_presenter.rb
+++ b/app/presenters/search_service_presenter.rb
@@ -9,7 +9,11 @@ class SearchServicePresenter < Gitlab::View::Presenter::Delegated
projects: :with_web_entity_associations,
issues: :with_web_entity_associations,
merge_requests: :with_web_entity_associations,
- epics: :with_web_entity_associations
+ epics: :with_web_entity_associations,
+ notes: :with_web_entity_associations,
+ milestones: :with_web_entity_associations,
+ commits: :with_web_entity_associations,
+ blobs: :with_web_entity_associations
}.freeze
SORT_ENABLED_SCOPES = %w(issues merge_requests).freeze
diff --git a/app/presenters/user_presenter.rb b/app/presenters/user_presenter.rb
index 0028e6d9ef0..7cd94082bac 100644
--- a/app/presenters/user_presenter.rb
+++ b/app/presenters/user_presenter.rb
@@ -13,7 +13,11 @@ class UserPresenter < Gitlab::View::Presenter::Delegated
private
+ def can?(*args)
+ user.can?(*args)
+ end
+
def should_be_private?
- !can?(current_user, :read_user_profile, user)
+ !Ability.allowed?(current_user, :read_user_profile, user)
end
end
diff --git a/app/serializers/README.md b/app/serializers/README.md
index 97e9625eb6f..d83c2061e0b 100644
--- a/app/serializers/README.md
+++ b/app/serializers/README.md
@@ -284,7 +284,7 @@ MyObjectSerializer.new.represent(object.present)
entity requires `request.user` attribute, but the second one wants
`request.current_user`. When it happens that these two entities are used in
the same serialization request, you might need to pass both parameters to
- the serializer, which is obviously not a perfect situation.
+ the serializer, which is not a perfect situation.
When in doubt, pass only `current_user` and `project` if these are required.
diff --git a/app/serializers/admin/user_entity.rb b/app/serializers/admin/user_entity.rb
index 8908d610046..a5cf40a50b9 100644
--- a/app/serializers/admin/user_entity.rb
+++ b/app/serializers/admin/user_entity.rb
@@ -30,3 +30,5 @@ module Admin
end
end
end
+
+Admin::UserEntity.prepend_if_ee('EE::Admin::UserEntity')
diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb
index 7a030372591..3c2c548d049 100644
--- a/app/serializers/build_artifact_entity.rb
+++ b/app/serializers/build_artifact_entity.rb
@@ -15,17 +15,15 @@ class BuildArtifactEntity < Grape::Entity
expose :path do |artifact|
fast_download_project_job_artifacts_path(
- artifact.project,
+ project,
artifact.job,
file_type: artifact.file_type
)
end
- expose :keep_path, if: -> (*) { artifact.expiring? } do |artifact|
- fast_keep_project_job_artifacts_path(artifact.project, artifact.job)
- end
+ private
- expose :browse_path do |artifact|
- fast_browse_project_job_artifacts_path(artifact.project, artifact.job)
+ def project
+ options[:project] || artifact.project
end
end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index 01a8a4ebea9..0ddcad4dcb9 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -28,15 +28,15 @@ class BuildDetailsEntity < JobEntity
expose :artifact, if: -> (*) { can?(current_user, :read_job_artifacts, build) } do
expose :download_path, if: -> (*) { build.locked_artifacts? || build.artifacts? } do |build|
- download_project_job_artifacts_path(project, build)
+ fast_download_project_job_artifacts_path(project, build)
end
expose :browse_path, if: -> (*) { build.locked_artifacts? || build.browsable_artifacts? } do |build|
- browse_project_job_artifacts_path(project, build)
+ fast_browse_project_job_artifacts_path(project, build)
end
expose :keep_path, if: -> (*) { (build.has_expired_locked_archive_artifacts? || build.has_expiring_archive_artifacts?) && can?(current_user, :update_build, build) } do |build|
- keep_project_job_artifacts_path(project, build)
+ fast_keep_project_job_artifacts_path(project, build)
end
expose :expire_at, if: -> (*) { build.artifacts_expire_at.present? } do |build|
@@ -99,7 +99,7 @@ class BuildDetailsEntity < JobEntity
end
expose :available do |build|
- project.any_active_runners?
+ build.any_runners_available?
end
expose :settings_path, if: -> (*) { can_admin_build? } do |build|
diff --git a/app/serializers/ci/group_variable_entity.rb b/app/serializers/ci/group_variable_entity.rb
index e7d0a957082..30c8239541a 100644
--- a/app/serializers/ci/group_variable_entity.rb
+++ b/app/serializers/ci/group_variable_entity.rb
@@ -2,5 +2,6 @@
module Ci
class GroupVariableEntity < Ci::BasicVariableEntity
+ expose :environment_scope
end
end
diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb
index 9865af1e116..b2a544e1125 100644
--- a/app/serializers/diff_file_entity.rb
+++ b/app/serializers/diff_file_entity.rb
@@ -54,7 +54,7 @@ class DiffFileEntity < DiffFileBaseEntity
end
# 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|
+ expose :highlighted_diff_lines, using: DiffLineEntity, if: -> (diff_file, options) { inline_diff_view?(options) && diff_file.text? } do |diff_file|
file = conflict_file(options, diff_file) || diff_file
file.diff_lines_for_serializer
end
@@ -68,7 +68,7 @@ class DiffFileEntity < DiffFileBaseEntity
end
# Used for parallel diffs
- expose :parallel_diff_lines, using: DiffLineParallelEntity, if: -> (diff_file, options) { parallel_diff_view?(options, diff_file) && diff_file.text? }
+ expose :parallel_diff_lines, using: DiffLineParallelEntity, if: -> (diff_file, options) { parallel_diff_view?(options) && diff_file.text? }
expose :code_navigation_path, if: -> (diff_file) { options[:code_navigation_path] } do |diff_file|
options[:code_navigation_path].full_json_path_for(diff_file.new_path)
@@ -76,14 +76,17 @@ class DiffFileEntity < DiffFileBaseEntity
private
- def parallel_diff_view?(options, diff_file)
- # If we're not rendering inline, we must be rendering parallel
- !inline_diff_view?(options, diff_file)
+ def parallel_diff_view?(options)
+ diff_view(options) == :parallel
end
- def inline_diff_view?(options, diff_file)
+ def inline_diff_view?(options)
+ diff_view(options) == :inline
+ end
+
+ def diff_view(options)
# If nothing is present, inline will be the default.
- options.fetch(:diff_view, :inline).to_sym == :inline
+ options.fetch(:diff_view, :inline).to_sym
end
def conflict_file(options, diff_file)
diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb
index bcf6b331192..0dbfe0f0772 100644
--- a/app/serializers/discussion_entity.rb
+++ b/app/serializers/discussion_entity.rb
@@ -2,7 +2,13 @@
class DiscussionEntity < BaseDiscussionEntity
expose :notes do |discussion, opts|
- request.note_entity.represent(discussion.notes, opts.merge(with_base_discussion: false))
+ request.note_entity.represent(
+ discussion.notes,
+ opts.merge(
+ with_base_discussion: false,
+ discussion: discussion
+ )
+ )
end
expose :positions, if: -> (d, _) { display_merge_ref_discussions?(d) } do |discussion|
diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb
index 598ce5f9e4f..2bb9a7e7254 100644
--- a/app/serializers/environment_serializer.rb
+++ b/app/serializers/environment_serializer.rb
@@ -23,7 +23,7 @@ class EnvironmentSerializer < BaseSerializer
latest: super(item.latest, opts) }
end
else
- super(resource, opts)
+ super(batch_load(resource), opts)
end
end
@@ -41,11 +41,59 @@ class EnvironmentSerializer < BaseSerializer
# immediately.
items = @paginator.paginate(items) if paginated?
- environments = resource.where(id: items.map(&:last_id)).index_by(&:id)
+ environments = batch_load(resource.where(id: items.map(&:last_id)))
+ environments_by_id = environments.index_by(&:id)
items.map do |item|
- Item.new(item.folder, item.size, environments[item.last_id])
+ Item.new(item.folder, item.size, environments_by_id[item.last_id])
end
end
+
+ def batch_load(resource)
+ resource = resource.preload(environment_associations)
+
+ resource.all.tap do |environments|
+ environments.each do |environment|
+ # Batch loading the commits of the deployments
+ environment.last_deployment&.commit&.try(:lazy_author)
+ environment.upcoming_deployment&.commit&.try(:lazy_author)
+ end
+ end
+ end
+
+ def environment_associations
+ {
+ last_deployment: deployment_associations,
+ upcoming_deployment: deployment_associations,
+ project: project_associations
+ }
+ end
+
+ def deployment_associations
+ {
+ user: [],
+ cluster: [],
+ project: [],
+ deployable: {
+ user: [],
+ metadata: [],
+ pipeline: {
+ manual_actions: [],
+ scheduled_actions: []
+ },
+ project: project_associations
+ }
+ }
+ end
+
+ def project_associations
+ {
+ project_feature: [],
+ route: [],
+ namespace: :route
+ }
+ end
# rubocop: enable CodeReuse/ActiveRecord
end
+
+EnvironmentSerializer.prepend_if_ee('EE::EnvironmentSerializer')
diff --git a/app/serializers/fork_namespace_entity.rb b/app/serializers/fork_namespace_entity.rb
index abfaf4be811..fc238fa3958 100644
--- a/app/serializers/fork_namespace_entity.rb
+++ b/app/serializers/fork_namespace_entity.rb
@@ -23,7 +23,7 @@ class ForkNamespaceEntity < Grape::Entity
end
expose :relative_path do |namespace|
- polymorphic_path(namespace)
+ group_path(namespace)
end
expose :markdown_description do |namespace|
diff --git a/app/serializers/member_entity.rb b/app/serializers/member_entity.rb
index e8f2bb28d60..6cbdaeea5ea 100644
--- a/app/serializers/member_entity.rb
+++ b/app/serializers/member_entity.rb
@@ -36,6 +36,8 @@ class MemberEntity < Grape::Entity
GroupEntity.represent(member.source, only: [:id, :full_name, :web_url])
end
+ expose :type
+
expose :valid_level_roles, as: :valid_roles
expose :user, if: -> (member) { member.user.present? }, using: MemberUserEntity
diff --git a/app/serializers/member_serializer.rb b/app/serializers/member_serializer.rb
index b34d7f30a58..462f6be5d04 100644
--- a/app/serializers/member_serializer.rb
+++ b/app/serializers/member_serializer.rb
@@ -2,4 +2,10 @@
class MemberSerializer < BaseSerializer
entity MemberEntity
+
+ def represent(members, opts = {})
+ Members::LastGroupOwnerAssigner.new(opts[:group], members).execute unless opts[:source].is_a?(Project)
+
+ super(members, opts)
+ end
end
diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb
index 1db4ec37d4a..52f5b975656 100644
--- a/app/serializers/merge_request_poll_cached_widget_entity.rb
+++ b/app/serializers/merge_request_poll_cached_widget_entity.rb
@@ -46,18 +46,28 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
end
end
- expose :actual_head_pipeline, as: :pipeline, if: -> (mr, _) {
- Feature.enabled?(:merge_request_cached_pipeline_serializer, mr.project) && presenter(mr).can_read_pipeline?
- } do |merge_request, options|
+ expose :actual_head_pipeline, as: :pipeline, if: -> (mr, _) { presenter(mr).can_read_pipeline? } do |merge_request, options|
MergeRequests::PipelineEntity.represent(merge_request.actual_head_pipeline, options)
end
+ expose :merge_pipeline, if: ->(mr, _) {
+ Feature.enabled?(:merge_request_cached_merge_pipeline_serializer, mr.project, default_enabled: :yaml) &&
+ mr.merged? &&
+ can?(request.current_user, :read_pipeline, mr.target_project)
+ } do |merge_request, options|
+ MergeRequests::PipelineEntity.represent(merge_request.merge_pipeline, options)
+ end
+
# Paths
#
expose :target_branch_commits_path do |merge_request|
presenter(merge_request).target_branch_commits_path
end
+ expose :merge_request_widget_path do |merge_request|
+ widget_project_json_merge_request_path(merge_request.target_project, merge_request, format: :json)
+ end
+
expose :target_branch_tree_path do |merge_request|
presenter(merge_request).target_branch_tree_path
end
@@ -104,6 +114,36 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
presenter(merge_request).api_unapprove_path
end
+ expose :test_reports_path do |merge_request|
+ if merge_request.has_test_reports?
+ test_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
+ end
+ end
+
+ expose :accessibility_report_path do |merge_request|
+ if merge_request.has_accessibility_reports?
+ accessibility_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
+ end
+ end
+
+ expose :codequality_reports_path do |merge_request|
+ if merge_request.has_codequality_reports?
+ codequality_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
+ end
+ end
+
+ expose :terraform_reports_path do |merge_request|
+ if merge_request.has_terraform_reports?
+ terraform_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
+ end
+ end
+
+ expose :exposed_artifacts_path do |merge_request|
+ if merge_request.has_exposed_artifacts?
+ exposed_artifacts_project_merge_request_path(merge_request.project, merge_request, format: :json)
+ end
+ end
+
expose :blob_path do
expose :head_path, if: -> (mr, _) { mr.source_branch_sha } do |merge_request|
project_blob_path(merge_request.project, merge_request.source_branch_sha)
diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb
index 4c34da3fc88..97a81d8170f 100644
--- a/app/serializers/merge_request_poll_widget_entity.rb
+++ b/app/serializers/merge_request_poll_widget_entity.rb
@@ -19,19 +19,22 @@ class MergeRequestPollWidgetEntity < Grape::Entity
# User entities
expose :merge_user, using: UserEntity
- expose :actual_head_pipeline, as: :pipeline, if: -> (mr, _) {
- Feature.disabled?(:merge_request_cached_pipeline_serializer, mr.project) && presenter(mr).can_read_pipeline?
+ expose :merge_pipeline, if: ->(mr, _) {
+ Feature.disabled?(:merge_request_cached_merge_pipeline_serializer, mr.project, default_enabled: :yaml) &&
+ mr.merged? &&
+ can?(request.current_user, :read_pipeline, mr.target_project)
} do |merge_request, options|
- MergeRequests::PipelineEntity.represent(merge_request.actual_head_pipeline, options)
- end
-
- expose :merge_pipeline, if: ->(mr, _) { mr.merged? && can?(request.current_user, :read_pipeline, mr.target_project)} do |merge_request, options|
MergeRequests::PipelineEntity.represent(merge_request.merge_pipeline, options)
end
expose :default_merge_commit_message
- expose :mergeable?, as: :mergeable
+ expose :mergeable do |merge_request, options|
+ next merge_request.mergeable? if Feature.disabled?(:check_mergeability_async_in_widget, merge_request.project, default_enabled: :yaml)
+ next false if options[:async_mergeability_check].present? && merge_request.checking?
+
+ merge_request.mergeable?
+ end
expose :default_merge_commit_message_with_description do |merge_request|
merge_request.default_merge_commit_message(include_description: true)
@@ -73,36 +76,6 @@ class MergeRequestPollWidgetEntity < Grape::Entity
presenter(merge_request).cancel_auto_merge_path
end
- expose :test_reports_path do |merge_request|
- if merge_request.has_test_reports?
- test_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
- end
- end
-
- expose :accessibility_report_path do |merge_request|
- if merge_request.has_accessibility_reports?
- accessibility_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
- end
- end
-
- expose :codequality_reports_path do |merge_request|
- if merge_request.has_codequality_reports?
- codequality_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
- end
- end
-
- expose :terraform_reports_path do |merge_request|
- if merge_request.has_terraform_reports?
- terraform_reports_project_merge_request_path(merge_request.project, merge_request, format: :json)
- end
- end
-
- expose :exposed_artifacts_path do |merge_request|
- if merge_request.has_exposed_artifacts?
- exposed_artifacts_project_merge_request_path(merge_request.project, merge_request, format: :json)
- end
- end
-
expose :create_issue_to_resolve_discussions_path do |merge_request|
presenter(merge_request).create_issue_to_resolve_discussions_path
end
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index 3ed7d9d8914..a168c7a8490 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -36,7 +36,7 @@ class MergeRequestWidgetEntity < Grape::Entity
end
expose :merge_request_widget_path do |merge_request|
- widget_project_json_merge_request_path(merge_request.target_project, merge_request, format: :json)
+ widget_project_json_merge_request_path(merge_request.target_project, merge_request, async_mergeability_check: true, format: :json)
end
expose :merge_request_cached_widget_path do |merge_request|
diff --git a/app/serializers/merge_requests/pipeline_entity.rb b/app/serializers/merge_requests/pipeline_entity.rb
index c7caad0e62b..bdf5cb160b2 100644
--- a/app/serializers/merge_requests/pipeline_entity.rb
+++ b/app/serializers/merge_requests/pipeline_entity.rb
@@ -28,7 +28,7 @@ class MergeRequests::PipelineEntity < Grape::Entity
rel = rel.select { |artifact| can?(request.current_user, :read_job_artifacts, artifact.job) }
end
- BuildArtifactEntity.represent(rel, options)
+ BuildArtifactEntity.represent(rel, options.merge(project: pipeline.project))
end
expose :detailed_status, as: :status, with: DetailedStatusEntity do |pipeline|
@@ -36,6 +36,8 @@ class MergeRequests::PipelineEntity < Grape::Entity
end
expose :stages, using: StageEntity
+
+ expose :finished_at
end
# Coverage isn't always necessary (e.g. when displaying project pipelines in
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
index 9a96778786b..d44958bc0c4 100644
--- a/app/serializers/note_entity.rb
+++ b/app/serializers/note_entity.rb
@@ -36,7 +36,8 @@ class NoteEntity < API::Entities::Note
end
expose :can_resolve_discussion do |note|
- note.discussion.resolvable? && note.discussion.can_resolve?(current_user)
+ discussion = options.fetch(:discussion, nil) || note.discussion
+ discussion.resolvable? && discussion.can_resolve?(current_user)
end
end
diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb
index 4fec543eca8..bb6aa2f78ac 100644
--- a/app/serializers/pipeline_details_entity.rb
+++ b/app/serializers/pipeline_details_entity.rb
@@ -15,7 +15,7 @@ class PipelineDetailsEntity < Ci::PipelineEntity
rel = rel.select { |artifact| can?(request.current_user, :read_job_artifacts, artifact.job) }
end
- BuildArtifactEntity.represent(rel, options)
+ BuildArtifactEntity.represent(rel, options.merge(project: pipeline.project))
end
expose :manual_actions, using: BuildActionEntity
expose :scheduled_actions, using: BuildActionEntity
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index 85887e64a8b..9a2e29a6ee3 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -41,7 +41,6 @@ class PipelineSerializer < BaseSerializer
def preloaded_relations
[
:cancelable_statuses,
- :latest_statuses_ordered_by_stage,
:retryable_builds,
:stages,
:latest_statuses,
diff --git a/app/serializers/runner_entity.rb b/app/serializers/runner_entity.rb
index 97e5b336a35..6d6ba920a3b 100644
--- a/app/serializers/runner_entity.rb
+++ b/app/serializers/runner_entity.rb
@@ -3,7 +3,7 @@
class RunnerEntity < Grape::Entity
include RequestAwareEntity
- expose :id, :description
+ expose :id, :description, :short_sha
expose :edit_path, if: -> (*) { can_edit_runner? } do |runner|
edit_project_runner_path(project, runner)
diff --git a/app/serializers/service_field_entity.rb b/app/serializers/service_field_entity.rb
index 08e08ae187f..960e216906e 100644
--- a/app/serializers/service_field_entity.rb
+++ b/app/serializers/service_field_entity.rb
@@ -2,14 +2,22 @@
class ServiceFieldEntity < Grape::Entity
include RequestAwareEntity
+ include Gitlab::Utils::StrongMemoize
- expose :type, :name, :title, :placeholder, :required, :choices, :help
+ expose :type, :name, :placeholder, :required, :choices
+
+ expose :title do |field|
+ non_empty_password?(field) ? field[:non_empty_password_title] : field[:title]
+ end
+
+ expose :help do |field|
+ non_empty_password?(field) ? field[:non_empty_password_help] : field[:help]
+ end
expose :value do |field|
- # field[:name] is not user input and so can assume is safe
- value = service.public_send(field[:name]) # rubocop:disable GitlabSecurity/PublicSend
+ value = value_for(field)
- if field[:type] == 'password' && value.present?
+ if non_empty_password?(field)
'true'
elsif field[:type] == 'checkbox'
ActiveRecord::Type::Boolean.new.deserialize(value).to_s
@@ -23,4 +31,17 @@ class ServiceFieldEntity < Grape::Entity
def service
request.service
end
+
+ def value_for(field)
+ strong_memoize(:value_for) do
+ # field[:name] is not user input and so can assume is safe
+ service.public_send(field[:name]) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+
+ def non_empty_password?(field)
+ strong_memoize(:non_empty_password) do
+ field[:type] == 'password' && value_for(field).present?
+ end
+ end
end
diff --git a/app/services/application_settings/base_service.rb b/app/services/application_settings/base_service.rb
index ebe067536ca..0929b30b7e9 100644
--- a/app/services/application_settings/base_service.rb
+++ b/app/services/application_settings/base_service.rb
@@ -3,7 +3,9 @@
module ApplicationSettings
class BaseService < ::BaseService
def initialize(application_setting, user, params = {})
- @application_setting, @current_user, @params = application_setting, user, params.dup
+ @application_setting = application_setting
+ @current_user = user
+ @params = params.dup
end
end
end
diff --git a/app/services/authorized_project_update/find_records_due_for_refresh_service.rb b/app/services/authorized_project_update/find_records_due_for_refresh_service.rb
new file mode 100644
index 00000000000..c4b18a26d0e
--- /dev/null
+++ b/app/services/authorized_project_update/find_records_due_for_refresh_service.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+module AuthorizedProjectUpdate
+ # Service for finding the authorized_projects records of a user that needs addition or removal.
+ #
+ # Usage:
+ #
+ # user = User.find_by(username: 'alice')
+ # service = AuthorizedProjectUpdate::FindRecordsDueForRefreshService.new(some_user)
+ # service.execute
+ class FindRecordsDueForRefreshService
+ def initialize(user, source: nil, incorrect_auth_found_callback: nil, missing_auth_found_callback: nil)
+ @user = user
+ @source = source
+ @incorrect_auth_found_callback = incorrect_auth_found_callback
+ @missing_auth_found_callback = missing_auth_found_callback
+ end
+
+ def execute
+ current = current_authorizations_per_project
+ fresh = fresh_access_levels_per_project
+
+ # Projects that have more than one authorizations associated with
+ # the user needs to be deleted.
+ # The correct authorization is added to the ``add`` array in the
+ # next stage.
+ remove = projects_with_duplicates
+ current.except!(*projects_with_duplicates)
+
+ remove |= current.each_with_object([]) do |(project_id, row), array|
+ # rows not in the new list or with a different access level should be
+ # removed.
+ if !fresh[project_id] || fresh[project_id] != row.access_level
+ if incorrect_auth_found_callback
+ incorrect_auth_found_callback.call(project_id, row.access_level)
+ end
+
+ array << row.project_id
+ end
+ end
+
+ add = fresh.each_with_object([]) do |(project_id, level), array|
+ # rows not in the old list or with a different access level should be
+ # added.
+ if !current[project_id] || current[project_id].access_level != level
+ if missing_auth_found_callback
+ missing_auth_found_callback.call(project_id, level)
+ end
+
+ array << [user.id, project_id, level]
+ end
+ end
+
+ [remove, add]
+ end
+
+ def needs_refresh?
+ remove, add = execute
+
+ remove.present? || add.present?
+ end
+
+ def fresh_access_levels_per_project
+ fresh_authorizations.each_with_object({}) do |row, hash|
+ hash[row.project_id] = row.access_level
+ end
+ end
+
+ def current_authorizations_per_project
+ current_authorizations.index_by(&:project_id)
+ end
+
+ def current_authorizations
+ @current_authorizations ||= user.project_authorizations.select(:project_id, :access_level)
+ end
+
+ def fresh_authorizations
+ Gitlab::ProjectAuthorizations.new(user).calculate
+ end
+
+ private
+
+ attr_reader :user, :source, :incorrect_auth_found_callback, :missing_auth_found_callback
+
+ def projects_with_duplicates
+ @projects_with_duplicates ||= current_authorizations
+ .group_by(&:project_id)
+ .select { |project_id, authorizations| authorizations.count > 1 }
+ .keys
+ end
+ end
+end
diff --git a/app/services/base_container_service.rb b/app/services/base_container_service.rb
index 56e4b8c908c..6852237dc25 100644
--- a/app/services/base_container_service.rb
+++ b/app/services/base_container_service.rb
@@ -7,6 +7,8 @@ class BaseContainerService
attr_reader :container, :current_user, :params
def initialize(container:, current_user: nil, params: {})
- @container, @current_user, @params = container, current_user, params.dup
+ @container = container
+ @current_user = current_user
+ @params = params.dup
end
end
diff --git a/app/services/base_service.rb b/app/services/base_service.rb
index b4c4b6980a8..20dfeb67815 100644
--- a/app/services/base_service.rb
+++ b/app/services/base_service.rb
@@ -15,7 +15,9 @@ class BaseService
attr_accessor :project, :current_user, :params
def initialize(project, user = nil, params = {})
- @project, @current_user, @params = project, user, params.dup
+ @project = project
+ @current_user = user
+ @params = params.dup
end
delegate :repository, to: :project
diff --git a/app/services/boards/base_item_move_service.rb b/app/services/boards/base_item_move_service.rb
index bf3e29df54b..28fb1e43043 100644
--- a/app/services/boards/base_item_move_service.rb
+++ b/app/services/boards/base_item_move_service.rb
@@ -22,6 +22,12 @@ module Boards
)
end
+ reposition_ids = move_between_ids(params)
+ if reposition_ids
+ attrs[:move_between_ids] = reposition_ids
+ attrs.merge!(reposition_parent)
+ end
+
attrs
end
@@ -63,10 +69,22 @@ module Boards
if moving_to_list.movable?
moving_from_list.label_id
else
- ::Label.ids_on_board(board.id)
+ board_label_ids
end
Array(label_ids).compact
end
+
+ def board_label_ids
+ ::Label.ids_on_board(board.id)
+ end
+
+ def move_between_ids(move_params)
+ ids = [move_params[:move_after_id], move_params[:move_before_id]]
+ .map(&:to_i)
+ .map { |m| m > 0 ? m : nil }
+
+ ids.any? ? ids : nil
+ end
end
end
diff --git a/app/services/boards/base_service.rb b/app/services/boards/base_service.rb
index 439a5c06223..83bb69b3822 100644
--- a/app/services/boards/base_service.rb
+++ b/app/services/boards/base_service.rb
@@ -6,7 +6,9 @@ module Boards
attr_accessor :parent, :current_user, :params
def initialize(parent, user, params = {})
- @parent, @current_user, @params = parent, user, params.dup
+ @parent = parent
+ @current_user = user
+ @params = params.dup
end
end
end
diff --git a/app/services/boards/destroy_service.rb b/app/services/boards/destroy_service.rb
index 8f3d4b58b7b..0b1cd61b119 100644
--- a/app/services/boards/destroy_service.rb
+++ b/app/services/boards/destroy_service.rb
@@ -3,7 +3,7 @@
module Boards
class DestroyService < Boards::BaseService
def execute(board)
- if parent.boards.size == 1
+ if boards.size == 1
return ServiceResponse.error(message: "The board could not be deleted, because the parent doesn't have any other boards.")
end
@@ -11,5 +11,11 @@ module Boards
ServiceResponse.success
end
+
+ private
+
+ def boards
+ parent.boards
+ end
end
end
diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb
index 99374fa01ae..76ea57968b2 100644
--- a/app/services/boards/issues/move_service.rb
+++ b/app/services/boards/issues/move_service.rb
@@ -3,8 +3,6 @@
module Boards
module Issues
class MoveService < Boards::BaseItemMoveService
- extend ::Gitlab::Utils::Override
-
def execute_multiple(issues)
return execute_multiple_empty_result if issues.empty?
@@ -57,25 +55,8 @@ module Boards
::Issues::UpdateService.new(issue.project, current_user, issue_modification_params).execute(issue)
end
- override :issuable_params
- def issuable_params(issuable)
- attrs = super
-
- move_between_ids = move_between_ids(params)
- if move_between_ids
- attrs[:move_between_ids] = move_between_ids
- attrs[:board_group_id] = board.group&.id
- end
-
- attrs
- end
-
- def move_between_ids(move_params)
- ids = [move_params[:move_after_id], move_params[:move_before_id]]
- .map(&:to_i)
- .map { |m| m > 0 ? m : nil }
-
- ids.any? ? ids : nil
+ def reposition_parent
+ { board_group_id: board.group&.id }
end
end
end
diff --git a/app/services/boards/lists/base_update_service.rb b/app/services/boards/lists/base_update_service.rb
new file mode 100644
index 00000000000..faf58e405fc
--- /dev/null
+++ b/app/services/boards/lists/base_update_service.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Boards
+ module Lists
+ class BaseUpdateService < Boards::BaseService
+ def execute(list)
+ if execute_by_params(list)
+ success(list: list)
+ else
+ error(list.errors.messages, 422)
+ end
+ end
+
+ private
+
+ def execute_by_params(list)
+ update_preferences_result = update_preferences(list) if can_read?(list)
+ update_position_result = update_position(list) if can_admin?(list)
+
+ update_preferences_result || update_position_result
+ end
+
+ def update_preferences(list)
+ return unless preferences?
+
+ list.update_preferences_for(current_user, preferences)
+ end
+
+ def update_position(list)
+ return unless position?
+
+ move_service = Boards::Lists::MoveService.new(parent, current_user, params)
+
+ move_service.execute(list)
+ end
+
+ def preferences
+ { collapsed: Gitlab::Utils.to_boolean(params[:collapsed]) }
+ end
+
+ def preferences?
+ params.has_key?(:collapsed)
+ end
+
+ def position?
+ params.has_key?(:position)
+ end
+
+ def can_read?(list)
+ raise NotImplementedError
+ end
+
+ def can_admin?(list)
+ raise NotImplementedError
+ end
+ end
+ end
+end
diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb
index 3c296cde51e..03d54a8c74c 100644
--- a/app/services/boards/lists/list_service.rb
+++ b/app/services/boards/lists/list_service.rb
@@ -23,12 +23,10 @@ module Boards
end
def hidden_lists_for(board)
- hidden = []
-
- hidden << ::List.list_types[:backlog] if board.hide_backlog_list
- hidden << ::List.list_types[:closed] if board.hide_closed_list
-
- hidden
+ [].tap do |hidden|
+ hidden << ::List.list_types[:backlog] if board.hide_backlog_list?
+ hidden << ::List.list_types[:closed] if board.hide_closed_list?
+ end
end
end
end
diff --git a/app/services/boards/lists/update_service.rb b/app/services/boards/lists/update_service.rb
index e2d9c371ca2..2e1a6592cd9 100644
--- a/app/services/boards/lists/update_service.rb
+++ b/app/services/boards/lists/update_service.rb
@@ -2,50 +2,7 @@
module Boards
module Lists
- class UpdateService < Boards::BaseService
- def execute(list)
- if execute_by_params(list)
- success(list: list)
- else
- error(list.errors.messages, 422)
- end
- end
-
- private
-
- def execute_by_params(list)
- update_preferences_result = update_preferences(list) if can_read?(list)
- update_position_result = update_position(list) if can_admin?(list)
-
- update_preferences_result || update_position_result
- end
-
- def update_preferences(list)
- return unless preferences?
-
- list.update_preferences_for(current_user, preferences)
- end
-
- def update_position(list)
- return unless position?
-
- move_service = Boards::Lists::MoveService.new(parent, current_user, params)
-
- move_service.execute(list)
- end
-
- def preferences
- { collapsed: Gitlab::Utils.to_boolean(params[:collapsed]) }
- end
-
- def preferences?
- params.has_key?(:collapsed)
- end
-
- def position?
- params.has_key?(:position)
- end
-
+ class UpdateService < Boards::Lists::BaseUpdateService
def can_read?(list)
Ability.allowed?(current_user, :read_issue_board_list, parent)
end
diff --git a/app/services/ci/abort_pipelines_service.rb b/app/services/ci/abort_pipelines_service.rb
new file mode 100644
index 00000000000..43734c4dd39
--- /dev/null
+++ b/app/services/ci/abort_pipelines_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Ci
+ class AbortPipelinesService
+ # NOTE: This call fails pipelines in bulk without running callbacks.
+ # Only for pipeline abandonment scenarios (examples: project delete)
+ def execute(pipelines, failure_reason)
+ pipelines.cancelable.each_batch(of: 100) do |pipeline_batch|
+ now = Time.current
+
+ basic_attributes = { status: :failed }
+ all_attributes = basic_attributes.merge(failure_reason: failure_reason, finished_at: now)
+
+ bulk_fail_for(Ci::Stage, pipeline_batch, basic_attributes)
+ bulk_fail_for(CommitStatus, pipeline_batch, all_attributes)
+
+ pipeline_batch.update_all(all_attributes)
+ end
+
+ ServiceResponse.success(message: 'Pipelines stopped')
+ end
+
+ private
+
+ def bulk_fail_for(klass, pipelines, attributes)
+ klass.in_pipelines(pipelines)
+ .cancelable
+ .in_batches(of: 150) # rubocop:disable Cop/InBatches
+ .update_all(attributes)
+ end
+ end
+end
diff --git a/app/services/ci/abort_project_pipelines_service.rb b/app/services/ci/abort_project_pipelines_service.rb
deleted file mode 100644
index 0b2fa9ed3c0..00000000000
--- a/app/services/ci/abort_project_pipelines_service.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class AbortProjectPipelinesService
- # Danger: Cancels in bulk without callbacks
- # Only for pipeline abandonment scenarios (current example: project delete)
- def execute(project)
- return unless Feature.enabled?(:abort_deleted_project_pipelines, default_enabled: :yaml)
-
- pipelines = project.all_pipelines.cancelable
- bulk_abort!(pipelines, status: :canceled)
-
- ServiceResponse.success(message: 'Pipelines canceled')
- end
-
- private
-
- def bulk_abort!(pipelines, status:)
- pipelines.each_batch do |pipeline_batch|
- CommitStatus.in_pipelines(pipeline_batch).in_batches.update_all(status: status) # rubocop: disable Cop/InBatches
- pipeline_batch.update_all(status: status)
- end
- end
- end
-end
diff --git a/app/services/ci/after_requeue_job_service.rb b/app/services/ci/after_requeue_job_service.rb
new file mode 100644
index 00000000000..3858ee9d550
--- /dev/null
+++ b/app/services/ci/after_requeue_job_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Ci
+ class AfterRequeueJobService < ::BaseService
+ def execute(processable)
+ process_subsequent_jobs(processable)
+ reset_ancestor_bridges(processable)
+ end
+
+ private
+
+ def process_subsequent_jobs(processable)
+ processable.pipeline.processables.skipped.after_stage(processable.stage_idx).find_each do |processable|
+ process(processable)
+ end
+ end
+
+ def reset_ancestor_bridges(processable)
+ processable.pipeline.reset_ancestor_bridges!
+ end
+
+ def process(processable)
+ Gitlab::OptimisticLocking.retry_lock(processable, name: 'ci_requeue_job') do |processable|
+ processable.process(current_user)
+ end
+ end
+ end
+end
diff --git a/app/services/ci/cancel_user_pipelines_service.rb b/app/services/ci/cancel_user_pipelines_service.rb
deleted file mode 100644
index 3d3a8032e8e..00000000000
--- a/app/services/ci/cancel_user_pipelines_service.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class CancelUserPipelinesService
- # rubocop: disable CodeReuse/ActiveRecord
- # This is a bug with CodeReuse/ActiveRecord cop
- # https://gitlab.com/gitlab-org/gitlab/issues/32332
- def execute(user)
- # TODO: fix N+1 queries https://gitlab.com/gitlab-org/gitlab/-/issues/300685
- user.pipelines.cancelable.find_each(&:cancel_running)
-
- ServiceResponse.success(message: 'Pipeline canceled')
- rescue ActiveRecord::StaleObjectError
- ServiceResponse.error(message: 'Error canceling pipeline')
- end
- # rubocop: enable CodeReuse/ActiveRecord
- end
-end
diff --git a/app/services/ci/create_job_artifacts_service.rb b/app/services/ci/create_job_artifacts_service.rb
deleted file mode 100644
index f1fdc8e2490..00000000000
--- a/app/services/ci/create_job_artifacts_service.rb
+++ /dev/null
@@ -1,172 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class CreateJobArtifactsService < ::BaseService
- include Gitlab::Utils::UsageData
-
- ArtifactsExistError = Class.new(StandardError)
-
- LSIF_ARTIFACT_TYPE = 'lsif'
- METRICS_REPORT_UPLOAD_EVENT_NAME = 'i_testing_metrics_report_artifact_uploaders'
-
- OBJECT_STORAGE_ERRORS = [
- Errno::EIO,
- Google::Apis::ServerError,
- Signet::RemoteServerError
- ].freeze
-
- def initialize(job)
- @job = job
- @project = job.project
- end
-
- def authorize(artifact_type:, filesize: nil)
- result = validate_requirements(artifact_type: artifact_type, filesize: filesize)
- return result unless result[:status] == :success
-
- headers = JobArtifactUploader.workhorse_authorize(has_length: false, maximum_size: max_size(artifact_type))
-
- if lsif?(artifact_type)
- headers[:ProcessLsif] = true
- track_usage_event('i_source_code_code_intelligence', project.id)
- end
-
- success(headers: headers)
- end
-
- def execute(artifacts_file, params, metadata_file: nil)
- result = validate_requirements(artifact_type: params[:artifact_type], filesize: artifacts_file.size)
- return result unless result[:status] == :success
-
- return success if sha256_matches_existing_artifact?(params[:artifact_type], artifacts_file)
-
- artifact, artifact_metadata = build_artifact(artifacts_file, params, metadata_file)
- result = parse_artifact(artifact)
-
- track_artifact_uploader(artifact)
-
- return result unless result[:status] == :success
-
- persist_artifact(artifact, artifact_metadata, params)
- end
-
- private
-
- attr_reader :job, :project
-
- def validate_requirements(artifact_type:, filesize:)
- return too_large_error if too_large?(artifact_type, filesize)
-
- success
- end
-
- def too_large?(type, size)
- size > max_size(type) if size
- end
-
- def lsif?(type)
- type == LSIF_ARTIFACT_TYPE
- end
-
- def max_size(type)
- Ci::JobArtifact.max_artifact_size(type: type, project: project)
- end
-
- def forbidden_type_error(type)
- error("#{type} artifacts are forbidden", :forbidden)
- end
-
- def too_large_error
- error('file size has reached maximum size limit', :payload_too_large)
- end
-
- def build_artifact(artifacts_file, params, metadata_file)
- expire_in = params['expire_in'] ||
- Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in
-
- artifact = Ci::JobArtifact.new(
- job_id: job.id,
- project: project,
- file: artifacts_file,
- file_type: params[:artifact_type],
- file_format: params[:artifact_format],
- file_sha256: artifacts_file.sha256,
- expire_in: expire_in)
-
- artifact_metadata = if metadata_file
- Ci::JobArtifact.new(
- job_id: job.id,
- project: project,
- file: metadata_file,
- file_type: :metadata,
- file_format: :gzip,
- file_sha256: metadata_file.sha256,
- expire_in: expire_in)
- end
-
- [artifact, artifact_metadata]
- end
-
- def parse_artifact(artifact)
- unless Feature.enabled?(:ci_synchronous_artifact_parsing, project, default_enabled: true)
- return success
- end
-
- case artifact.file_type
- when 'dotenv' then parse_dotenv_artifact(artifact)
- when 'cluster_applications' then parse_cluster_applications_artifact(artifact)
- else success
- end
- end
-
- def persist_artifact(artifact, artifact_metadata, params)
- Ci::JobArtifact.transaction do
- artifact.save!
- artifact_metadata&.save!
-
- # NOTE: The `artifacts_expire_at` column is already deprecated and to be removed in the near future.
- job.update_column(:artifacts_expire_at, artifact.expire_at)
- end
-
- success
- rescue ActiveRecord::RecordNotUnique => error
- track_exception(error, params)
- error('another artifact of the same type already exists', :bad_request)
- rescue *OBJECT_STORAGE_ERRORS => error
- track_exception(error, params)
- error(error.message, :service_unavailable)
- rescue => error
- track_exception(error, params)
- error(error.message, :bad_request)
- end
-
- def sha256_matches_existing_artifact?(artifact_type, artifacts_file)
- existing_artifact = job.job_artifacts.find_by_file_type(artifact_type)
- return false unless existing_artifact
-
- existing_artifact.file_sha256 == artifacts_file.sha256
- end
-
- def track_exception(error, params)
- Gitlab::ErrorTracking.track_exception(error,
- job_id: job.id,
- project_id: job.project_id,
- uploading_type: params[:artifact_type]
- )
- end
-
- def track_artifact_uploader(artifact)
- return unless artifact.file_type == 'metrics'
-
- track_usage_event(METRICS_REPORT_UPLOAD_EVENT_NAME, @job.user_id)
- end
-
- def parse_dotenv_artifact(artifact)
- Ci::ParseDotenvArtifactService.new(project, current_user).execute(artifact)
- end
-
- def parse_cluster_applications_artifact(artifact)
- Clusters::ParseClusterApplicationsArtifactService.new(job, job.user).execute(artifact)
- end
- end
-end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 0fd47e625fd..ca936307acc 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -96,7 +96,8 @@ module Ci
# rubocop: enable Metrics/ParameterLists
def execute!(*args, &block)
- source, params = args[0], Hash(args[1])
+ source = args[0]
+ params = Hash(args[1])
execute(source, **params, &block).tap do |pipeline|
unless pipeline.persisted?
diff --git a/app/services/ci/create_web_ide_terminal_service.rb b/app/services/ci/create_web_ide_terminal_service.rb
index 785d82094b9..3b89a599180 100644
--- a/app/services/ci/create_web_ide_terminal_service.rb
+++ b/app/services/ci/create_web_ide_terminal_service.rb
@@ -58,7 +58,8 @@ module Ci
builds: [terminal_build_seed]
}
- Gitlab::Ci::Pipeline::Seed::Stage.new(pipeline, attributes, [])
+ seed_context = Gitlab::Ci::Pipeline::Seed::Context.new(pipeline)
+ Gitlab::Ci::Pipeline::Seed::Stage.new(seed_context, attributes, [])
end
def terminal_build_seed
diff --git a/app/services/ci/destroy_expired_job_artifacts_service.rb b/app/services/ci/destroy_expired_job_artifacts_service.rb
deleted file mode 100644
index d91cfb3cc82..00000000000
--- a/app/services/ci/destroy_expired_job_artifacts_service.rb
+++ /dev/null
@@ -1,56 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class DestroyExpiredJobArtifactsService
- include ::Gitlab::ExclusiveLeaseHelpers
- include ::Gitlab::LoopHelpers
-
- BATCH_SIZE = 100
- LOOP_TIMEOUT = 5.minutes
- LOOP_LIMIT = 1000
- EXCLUSIVE_LOCK_KEY = 'expired_job_artifacts:destroy:lock'
- LOCK_TIMEOUT = 6.minutes
-
- def initialize
- @removed_artifacts_count = 0
- end
-
- ##
- # Destroy expired job artifacts on GitLab instance
- #
- # This destroy process cannot run for more than 6 minutes. This is for
- # preventing multiple `ExpireBuildArtifactsWorker` CRON jobs run concurrently,
- # which is scheduled every 7 minutes.
- def execute
- in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do
- destroy_job_artifacts_with_slow_iteration(Time.current)
- end
-
- @removed_artifacts_count
- end
-
- private
-
- def destroy_job_artifacts_with_slow_iteration(start_at)
- Ci::JobArtifact.expired_before(start_at).each_batch(of: BATCH_SIZE, column: :expire_at, order: :desc) do |relation, index|
- # For performance reasons, join with ci_pipelines after the batch is queried.
- # See: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47496
- artifacts = relation.unlocked
-
- service_response = destroy_batch_async(artifacts)
- @removed_artifacts_count += service_response[:destroyed_artifacts_count]
-
- break if loop_timeout?(start_at)
- break if index >= LOOP_LIMIT
- end
- end
-
- def destroy_batch_async(artifacts)
- Ci::JobArtifactsDestroyBatchService.new(artifacts).execute
- end
-
- def loop_timeout?(start_at)
- Time.current > start_at + LOOP_TIMEOUT
- end
- end
-end
diff --git a/app/services/ci/disable_user_pipeline_schedules_service.rb b/app/services/ci/disable_user_pipeline_schedules_service.rb
new file mode 100644
index 00000000000..6499fbba0ec
--- /dev/null
+++ b/app/services/ci/disable_user_pipeline_schedules_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Ci
+ class DisableUserPipelineSchedulesService
+ def execute(user)
+ Ci::PipelineSchedule.active.owned_by(user).each_batch do |relation|
+ relation.update_all(active: false)
+ end
+ end
+ end
+end
diff --git a/app/services/ci/drop_pipeline_service.rb b/app/services/ci/drop_pipeline_service.rb
new file mode 100644
index 00000000000..f510943251b
--- /dev/null
+++ b/app/services/ci/drop_pipeline_service.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Ci
+ class DropPipelineService
+ # execute service asynchronously for each cancelable pipeline
+ def execute_async_for_all(pipelines, failure_reason, context_user)
+ pipelines.cancelable.select(:id).find_in_batches do |pipelines_batch|
+ Ci::DropPipelineWorker.bulk_perform_async_with_contexts(
+ pipelines_batch,
+ arguments_proc: -> (pipeline) { [pipeline.id, failure_reason] },
+ context_proc: -> (_) { { user: context_user } }
+ )
+ end
+ end
+
+ def execute(pipeline, failure_reason, retries: 3)
+ Gitlab::OptimisticLocking.retry_lock(pipeline.cancelable_statuses, retries, name: 'ci_pipeline_drop_running') do |cancelables|
+ cancelables.find_in_batches do |batch|
+ preload_associations_for_drop(batch)
+
+ batch.each do |job|
+ job.drop(failure_reason)
+ end
+ end
+ end
+ end
+
+ private
+
+ def preload_associations_for_drop(builds_batch)
+ ActiveRecord::Associations::Preloader.new.preload( # rubocop: disable CodeReuse/ActiveRecord
+ builds_batch,
+ [:project, :pipeline, :metadata, :deployment, :taggings]
+ )
+ end
+ end
+end
diff --git a/app/services/ci/generate_coverage_reports_service.rb b/app/services/ci/generate_coverage_reports_service.rb
index b3aa7b3091b..4e6fbc5462a 100644
--- a/app/services/ci/generate_coverage_reports_service.rb
+++ b/app/services/ci/generate_coverage_reports_service.rb
@@ -15,7 +15,13 @@ module Ci
data: head_pipeline.pipeline_artifacts.find_by_file_type(:code_coverage).present.for_files(merge_request.new_paths)
}
rescue => e
- Gitlab::ErrorTracking.track_exception(e, project_id: project.id)
+ Gitlab::ErrorTracking.track_exception(
+ e,
+ project_id: project.id,
+ base_pipeline_id: base_pipeline&.id,
+ head_pipeline_id: head_pipeline&.id
+ )
+
{
status: :error,
key: key(base_pipeline, head_pipeline),
diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb
new file mode 100644
index 00000000000..65752e56c64
--- /dev/null
+++ b/app/services/ci/job_artifacts/create_service.rb
@@ -0,0 +1,174 @@
+# frozen_string_literal: true
+
+module Ci
+ module JobArtifacts
+ class CreateService < ::BaseService
+ include Gitlab::Utils::UsageData
+
+ ArtifactsExistError = Class.new(StandardError)
+
+ LSIF_ARTIFACT_TYPE = 'lsif'
+ METRICS_REPORT_UPLOAD_EVENT_NAME = 'i_testing_metrics_report_artifact_uploaders'
+
+ OBJECT_STORAGE_ERRORS = [
+ Errno::EIO,
+ Google::Apis::ServerError,
+ Signet::RemoteServerError
+ ].freeze
+
+ def initialize(job)
+ @job = job
+ @project = job.project
+ end
+
+ def authorize(artifact_type:, filesize: nil)
+ result = validate_requirements(artifact_type: artifact_type, filesize: filesize)
+ return result unless result[:status] == :success
+
+ headers = JobArtifactUploader.workhorse_authorize(has_length: false, maximum_size: max_size(artifact_type))
+
+ if lsif?(artifact_type)
+ headers[:ProcessLsif] = true
+ track_usage_event('i_source_code_code_intelligence', project.id)
+ end
+
+ success(headers: headers)
+ end
+
+ def execute(artifacts_file, params, metadata_file: nil)
+ result = validate_requirements(artifact_type: params[:artifact_type], filesize: artifacts_file.size)
+ return result unless result[:status] == :success
+
+ return success if sha256_matches_existing_artifact?(params[:artifact_type], artifacts_file)
+
+ artifact, artifact_metadata = build_artifact(artifacts_file, params, metadata_file)
+ result = parse_artifact(artifact)
+
+ track_artifact_uploader(artifact)
+
+ return result unless result[:status] == :success
+
+ persist_artifact(artifact, artifact_metadata, params)
+ end
+
+ private
+
+ attr_reader :job, :project
+
+ def validate_requirements(artifact_type:, filesize:)
+ return too_large_error if too_large?(artifact_type, filesize)
+
+ success
+ end
+
+ def too_large?(type, size)
+ size > max_size(type) if size
+ end
+
+ def lsif?(type)
+ type == LSIF_ARTIFACT_TYPE
+ end
+
+ def max_size(type)
+ Ci::JobArtifact.max_artifact_size(type: type, project: project)
+ end
+
+ def forbidden_type_error(type)
+ error("#{type} artifacts are forbidden", :forbidden)
+ end
+
+ def too_large_error
+ error('file size has reached maximum size limit', :payload_too_large)
+ end
+
+ def build_artifact(artifacts_file, params, metadata_file)
+ expire_in = params['expire_in'] ||
+ Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in
+
+ artifact = Ci::JobArtifact.new(
+ job_id: job.id,
+ project: project,
+ file: artifacts_file,
+ file_type: params[:artifact_type],
+ file_format: params[:artifact_format],
+ file_sha256: artifacts_file.sha256,
+ expire_in: expire_in)
+
+ artifact_metadata = if metadata_file
+ Ci::JobArtifact.new(
+ job_id: job.id,
+ project: project,
+ file: metadata_file,
+ file_type: :metadata,
+ file_format: :gzip,
+ file_sha256: metadata_file.sha256,
+ expire_in: expire_in)
+ end
+
+ [artifact, artifact_metadata]
+ end
+
+ def parse_artifact(artifact)
+ unless Feature.enabled?(:ci_synchronous_artifact_parsing, project, default_enabled: true)
+ return success
+ end
+
+ case artifact.file_type
+ when 'dotenv' then parse_dotenv_artifact(artifact)
+ when 'cluster_applications' then parse_cluster_applications_artifact(artifact)
+ else success
+ end
+ end
+
+ def persist_artifact(artifact, artifact_metadata, params)
+ Ci::JobArtifact.transaction do
+ artifact.save!
+ artifact_metadata&.save!
+
+ # NOTE: The `artifacts_expire_at` column is already deprecated and to be removed in the near future.
+ job.update_column(:artifacts_expire_at, artifact.expire_at)
+ end
+
+ success
+ rescue ActiveRecord::RecordNotUnique => error
+ track_exception(error, params)
+ error('another artifact of the same type already exists', :bad_request)
+ rescue *OBJECT_STORAGE_ERRORS => error
+ track_exception(error, params)
+ error(error.message, :service_unavailable)
+ rescue => error
+ track_exception(error, params)
+ error(error.message, :bad_request)
+ end
+
+ def sha256_matches_existing_artifact?(artifact_type, artifacts_file)
+ existing_artifact = job.job_artifacts.find_by_file_type(artifact_type)
+ return false unless existing_artifact
+
+ existing_artifact.file_sha256 == artifacts_file.sha256
+ end
+
+ def track_exception(error, params)
+ Gitlab::ErrorTracking.track_exception(error,
+ job_id: job.id,
+ project_id: job.project_id,
+ uploading_type: params[:artifact_type]
+ )
+ end
+
+ def track_artifact_uploader(artifact)
+ return unless artifact.file_type == 'metrics'
+
+ track_usage_event(METRICS_REPORT_UPLOAD_EVENT_NAME, @job.user_id)
+ end
+
+ def parse_dotenv_artifact(artifact)
+ Ci::ParseDotenvArtifactService.new(project, current_user).execute(artifact)
+ end
+
+ def parse_cluster_applications_artifact(artifact)
+ Clusters::ParseClusterApplicationsArtifactService.new(job, job.user).execute(artifact)
+ end
+ end
+ end
+end
diff --git a/app/services/ci/job_artifacts/destroy_all_expired_service.rb b/app/services/ci/job_artifacts/destroy_all_expired_service.rb
new file mode 100644
index 00000000000..3e9cc95d135
--- /dev/null
+++ b/app/services/ci/job_artifacts/destroy_all_expired_service.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Ci
+ module JobArtifacts
+ class DestroyAllExpiredService
+ include ::Gitlab::ExclusiveLeaseHelpers
+ include ::Gitlab::LoopHelpers
+
+ BATCH_SIZE = 100
+ LOOP_TIMEOUT = 5.minutes
+ LOOP_LIMIT = 1000
+ EXCLUSIVE_LOCK_KEY = 'expired_job_artifacts:destroy:lock'
+ LOCK_TIMEOUT = 6.minutes
+
+ def initialize
+ @removed_artifacts_count = 0
+ end
+
+ ##
+ # Destroy expired job artifacts on GitLab instance
+ #
+ # This destroy process cannot run for more than 6 minutes. This is for
+ # preventing multiple `ExpireBuildArtifactsWorker` CRON jobs run concurrently,
+ # which is scheduled every 7 minutes.
+ def execute
+ in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do
+ destroy_job_artifacts_with_slow_iteration(Time.current)
+ end
+
+ @removed_artifacts_count
+ end
+
+ private
+
+ def destroy_job_artifacts_with_slow_iteration(start_at)
+ Ci::JobArtifact.expired_before(start_at).each_batch(of: BATCH_SIZE, column: :expire_at, order: :desc) do |relation, index|
+ # For performance reasons, join with ci_pipelines after the batch is queried.
+ # See: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47496
+ artifacts = relation.unlocked
+
+ service_response = destroy_batch_async(artifacts)
+ @removed_artifacts_count += service_response[:destroyed_artifacts_count]
+
+ break if loop_timeout?(start_at)
+ break if index >= LOOP_LIMIT
+ end
+ end
+
+ def destroy_batch_async(artifacts)
+ Ci::JobArtifacts::DestroyBatchService.new(artifacts).execute
+ end
+
+ def loop_timeout?(start_at)
+ Time.current > start_at + LOOP_TIMEOUT
+ end
+ end
+ end
+end
diff --git a/app/services/ci/job_artifacts/destroy_batch_service.rb b/app/services/ci/job_artifacts/destroy_batch_service.rb
new file mode 100644
index 00000000000..95315dd11ec
--- /dev/null
+++ b/app/services/ci/job_artifacts/destroy_batch_service.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+module Ci
+ module JobArtifacts
+ class DestroyBatchService
+ include BaseServiceUtility
+ include ::Gitlab::Utils::StrongMemoize
+
+ # Danger: Private - Should only be called in Ci Services that pass a batch of job artifacts
+ # Not for use outside of the Ci:: namespace
+
+ # Adds the passed batch of job artifacts to the `ci_deleted_objects` table
+ # for asyncronous destruction of the objects in Object Storage via the `Ci::DeleteObjectsService`
+ # and then deletes the batch of related `ci_job_artifacts` records.
+ # Params:
+ # +job_artifacts+:: A relation of job artifacts to destroy (fewer than MAX_JOB_ARTIFACT_BATCH_SIZE)
+ # +pick_up_at+:: When to pick up for deletion of files
+ # Returns:
+ # +Hash+:: A hash with status and destroyed_artifacts_count keys
+ def initialize(job_artifacts, pick_up_at: nil)
+ @job_artifacts = job_artifacts.with_destroy_preloads.to_a
+ @pick_up_at = pick_up_at
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def execute
+ return success(destroyed_artifacts_count: artifacts_count) if @job_artifacts.empty?
+
+ Ci::DeletedObject.transaction do
+ Ci::DeletedObject.bulk_import(@job_artifacts, @pick_up_at)
+ Ci::JobArtifact.id_in(@job_artifacts.map(&:id)).delete_all
+ destroy_related_records(@job_artifacts)
+ end
+
+ # This is executed outside of the transaction because it depends on Redis
+ update_project_statistics
+ increment_monitoring_statistics(artifacts_count)
+
+ success(destroyed_artifacts_count: artifacts_count)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ # This method is implemented in EE and it must do only database work
+ def destroy_related_records(artifacts); end
+
+ def update_project_statistics
+ 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 increment_monitoring_statistics(size)
+ metrics.increment_destroyed_artifacts(size)
+ end
+
+ def metrics
+ @metrics ||= ::Gitlab::Ci::Artifacts::Metrics.new
+ end
+
+ def artifacts_count
+ strong_memoize(:artifacts_count) do
+ @job_artifacts.count
+ end
+ end
+ end
+ end
+end
+
+Ci::JobArtifacts::DestroyBatchService.prepend_if_ee('EE::Ci::JobArtifacts::DestroyBatchService')
diff --git a/app/services/ci/job_artifacts_destroy_batch_service.rb b/app/services/ci/job_artifacts_destroy_batch_service.rb
deleted file mode 100644
index f8ece27fe86..00000000000
--- a/app/services/ci/job_artifacts_destroy_batch_service.rb
+++ /dev/null
@@ -1,72 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class JobArtifactsDestroyBatchService
- include BaseServiceUtility
- include ::Gitlab::Utils::StrongMemoize
-
- # Danger: Private - Should only be called in Ci Services that pass a batch of job artifacts
- # Not for use outsie of the ci namespace
-
- # Adds the passed batch of job artifacts to the `ci_deleted_objects` table
- # for asyncronous destruction of the objects in Object Storage via the `Ci::DeleteObjectsService`
- # and then deletes the batch of related `ci_job_artifacts` records.
- # Params:
- # +job_artifacts+:: A relation of job artifacts to destroy (fewer than MAX_JOB_ARTIFACT_BATCH_SIZE)
- # +pick_up_at+:: When to pick up for deletion of files
- # Returns:
- # +Hash+:: A hash with status and destroyed_artifacts_count keys
- def initialize(job_artifacts, pick_up_at: nil)
- @job_artifacts = job_artifacts.with_destroy_preloads.to_a
- @pick_up_at = pick_up_at
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def execute
- return success(destroyed_artifacts_count: artifacts_count) if @job_artifacts.empty?
-
- Ci::DeletedObject.transaction do
- Ci::DeletedObject.bulk_import(@job_artifacts, @pick_up_at)
- Ci::JobArtifact.id_in(@job_artifacts.map(&:id)).delete_all
- destroy_related_records(@job_artifacts)
- end
-
- # This is executed outside of the transaction because it depends on Redis
- update_project_statistics
- increment_monitoring_statistics(artifacts_count)
-
- success(destroyed_artifacts_count: artifacts_count)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- private
-
- # This method is implemented in EE and it must do only database work
- def destroy_related_records(artifacts); end
-
- def update_project_statistics
- 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 increment_monitoring_statistics(size)
- metrics.increment_destroyed_artifacts(size)
- end
-
- def metrics
- @metrics ||= ::Gitlab::Ci::Artifacts::Metrics.new
- end
-
- def artifacts_count
- strong_memoize(:artifacts_count) do
- @job_artifacts.count
- end
- end
- end
-end
-
-Ci::JobArtifactsDestroyBatchService.prepend_if_ee('EE::Ci::JobArtifactsDestroyBatchService')
diff --git a/app/services/ci/pipeline_artifacts/destroy_expired_artifacts_service.rb b/app/services/ci/pipeline_artifacts/destroy_all_expired_service.rb
index 0dbabe178da..fed40aef697 100644
--- a/app/services/ci/pipeline_artifacts/destroy_expired_artifacts_service.rb
+++ b/app/services/ci/pipeline_artifacts/destroy_all_expired_service.rb
@@ -2,7 +2,7 @@
module Ci
module PipelineArtifacts
- class DestroyExpiredArtifactsService
+ class DestroyAllExpiredService
include ::Gitlab::LoopHelpers
include ::Gitlab::Utils::StrongMemoize
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
index 35818e2cf3d..883a70c9795 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
@@ -91,17 +91,17 @@ module Ci
def all_statuses_by_id
strong_memoize(:all_statuses_by_id) do
- all_statuses.map do |row|
+ all_statuses.to_h do |row|
[row[:id], row]
- end.to_h
+ end
end
end
def all_statuses_by_name
strong_memoize(:statuses_by_name) do
- all_statuses.map do |row|
+ all_statuses.to_h do |row|
[row[:name], row]
- end.to_h
+ end
end
end
diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb
index dbbaefb2b2f..a5f70d62e13 100644
--- a/app/services/ci/pipeline_trigger_service.rb
+++ b/app/services/ci/pipeline_trigger_service.rb
@@ -6,8 +6,10 @@ module Ci
def execute
if trigger_from_token
+ set_application_context_from_trigger(trigger_from_token)
create_pipeline_from_trigger(trigger_from_token)
elsif job_from_token
+ set_application_context_from_job(job_from_token)
create_pipeline_from_job(job_from_token)
end
@@ -73,11 +75,7 @@ module Ci
end
def variables
- if ::Feature.enabled?(:ci_trigger_payload_into_pipeline, project, default_enabled: :yaml)
- param_variables + [payload_variable]
- else
- param_variables
- end
+ param_variables + [payload_variable]
end
def param_variables
@@ -91,5 +89,20 @@ module Ci
value: params.except(*PAYLOAD_VARIABLE_HIDDEN_PARAMS).to_json,
variable_type: :file }
end
+
+ def set_application_context_from_trigger(trigger)
+ Gitlab::ApplicationContext.push(
+ user: trigger.owner,
+ project: trigger.project
+ )
+ end
+
+ def set_application_context_from_job(job)
+ Gitlab::ApplicationContext.push(
+ user: job.user,
+ project: job.project,
+ runner: job.runner
+ )
+ end
end
end
diff --git a/app/services/ci/play_bridge_service.rb b/app/services/ci/play_bridge_service.rb
index 70c4a8e6136..c5b19a3963a 100644
--- a/app/services/ci/play_bridge_service.rb
+++ b/app/services/ci/play_bridge_service.rb
@@ -8,6 +8,10 @@ module Ci
bridge.tap do |bridge|
bridge.user = current_user
bridge.enqueue!
+
+ next unless ::Feature.enabled?(:ci_fix_pipeline_status_for_dag_needs_manual, project, default_enabled: :yaml)
+
+ AfterRequeueJobService.new(project, current_user).execute(bridge)
end
end
end
diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb
index ebc980a9053..4953b1ea5fc 100644
--- a/app/services/ci/play_build_service.rb
+++ b/app/services/ci/play_build_service.rb
@@ -12,7 +12,13 @@ module Ci
# Try to enqueue the build, otherwise create a duplicate.
#
if build.enqueue
- build.tap { |action| action.update(user: current_user, job_variables_attributes: job_variables_attributes || []) }
+ build.tap do |build|
+ build.update(user: current_user, job_variables_attributes: job_variables_attributes || [])
+
+ next unless ::Feature.enabled?(:ci_fix_pipeline_status_for_dag_needs_manual, project, default_enabled: :yaml)
+
+ AfterRequeueJobService.new(project, current_user).execute(build)
+ end
else
Ci::Build.retry(build, current_user)
end
diff --git a/app/services/ci/process_build_service.rb b/app/services/ci/process_build_service.rb
index 733aa75f255..73cf3308fe7 100644
--- a/app/services/ci/process_build_service.rb
+++ b/app/services/ci/process_build_service.rb
@@ -26,14 +26,6 @@ module Ci
end
def valid_statuses_for_build(build)
- if ::Feature.enabled?(:skip_dag_manual_and_delayed_jobs, build.project, default_enabled: :yaml)
- current_valid_statuses_for_build(build)
- else
- legacy_valid_statuses_for_build(build)
- end
- end
-
- def current_valid_statuses_for_build(build)
case build.when
when 'on_success', 'manual', 'delayed'
build.scheduling_type_dag? ? %w[success] : %w[success skipped]
@@ -45,23 +37,6 @@ module Ci
[]
end
end
-
- def legacy_valid_statuses_for_build(build)
- case build.when
- when 'on_success'
- build.scheduling_type_dag? ? %w[success] : %w[success skipped]
- when 'on_failure'
- %w[failed]
- when 'always'
- %w[success failed skipped]
- when 'manual'
- %w[success skipped]
- when 'delayed'
- %w[success skipped]
- else
- []
- end
- end
end
end
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
index 970652b4da3..6c69df0c616 100644
--- a/app/services/ci/process_pipeline_service.rb
+++ b/app/services/ci/process_pipeline_service.rb
@@ -19,7 +19,7 @@ module Ci
end
def metrics
- @metrics ||= ::Gitlab::Ci::Pipeline::Metrics.new
+ @metrics ||= ::Gitlab::Ci::Pipeline::Metrics
end
private
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index ed9e44d60f1..90341b26fd6 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -10,7 +10,11 @@ module Ci
Result = Struct.new(:build, :build_json, :valid?)
- MAX_QUEUE_DEPTH = 50
+ ##
+ # The queue depth limit number has been determined by observing 95
+ # percentile of effective queue depth on gitlab.com. This is only likely to
+ # affect 5% of the worst case scenarios.
+ MAX_QUEUE_DEPTH = 45
def initialize(runner)
@runner = runner
@@ -20,7 +24,7 @@ module Ci
def execute(params = {})
@metrics.increment_queue_operation(:queue_attempt)
- @metrics.observe_queue_time do
+ @metrics.observe_queue_time(:process, @runner.runner_type) do
process_queue(params)
end
end
@@ -105,22 +109,30 @@ module Ci
builds = builds.queued_before(params[:job_age].seconds.ago)
end
- if Feature.enabled?(:ci_register_job_service_one_by_one, runner)
- build_ids = builds.pluck(:id)
+ if Feature.enabled?(:ci_register_job_service_one_by_one, runner, default_enabled: true)
+ build_ids = retrieve_queue(-> { builds.pluck(:id) })
- @metrics.observe_queue_size(-> { build_ids.size })
+ @metrics.observe_queue_size(-> { build_ids.size }, @runner.runner_type)
build_ids.each do |build_id|
yield Ci::Build.find(build_id)
end
else
- @metrics.observe_queue_size(-> { builds.to_a.size })
+ builds_array = retrieve_queue(-> { builds.to_a })
- builds.each(&blk)
+ @metrics.observe_queue_size(-> { builds_array.size }, @runner.runner_type)
+
+ builds_array.each(&blk)
end
end
# rubocop: enable CodeReuse/ActiveRecord
+ def retrieve_queue(queue_query_proc)
+ @metrics.observe_queue_time(:retrieve, @runner.runner_type) do
+ queue_query_proc.call
+ end
+ end
+
def process_build(build, params)
unless build.pending?
@metrics.increment_queue_operation(:build_not_pending)
@@ -171,7 +183,7 @@ module Ci
def max_queue_depth
@max_queue_depth ||= begin
- if Feature.enabled?(:gitlab_ci_builds_queue_limit, runner, default_enabled: false)
+ if Feature.enabled?(:gitlab_ci_builds_queue_limit, runner, default_enabled: true)
MAX_QUEUE_DEPTH
else
::Gitlab::Database::MAX_INT_VALUE
@@ -266,7 +278,7 @@ module Ci
# Workaround for weird Rails bug, that makes `runner.groups.to_sql` to return `runner_id = NULL`
groups = ::Group.joins(:runner_namespaces).merge(runner.runner_namespaces)
- hierarchy_groups = Gitlab::ObjectHierarchy.new(groups).base_and_descendants
+ hierarchy_groups = Gitlab::ObjectHierarchy.new(groups, options: { use_distinct: Feature.enabled?(:use_distinct_in_register_job_object_hierarchy) }).base_and_descendants
projects = Project.where(namespace_id: hierarchy_groups)
.with_group_runners_enabled
.with_builds_enabled
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index b2c5249a0c7..e3de7f43fda 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -2,8 +2,6 @@
module Ci
class RetryBuildService < ::BaseService
- include Gitlab::OptimisticLocking
-
def self.clone_accessors
%i[pipeline project ref tag options name
allow_failure stage stage_id stage_idx trigger_request
@@ -16,12 +14,10 @@ module Ci
build.ensure_scheduling_type!
reprocess!(build).tap do |new_build|
- mark_subsequent_stages_as_processable(build)
- build.pipeline.reset_ancestor_bridges!
-
Gitlab::OptimisticLocking.retry_lock(new_build, name: 'retry_build', &:enqueue)
+ AfterRequeueJobService.new(project, current_user).execute(build)
- MergeRequests::AddTodoWhenBuildFailsService
+ ::MergeRequests::AddTodoWhenBuildFailsService
.new(project, current_user)
.close(new_build)
end
@@ -33,9 +29,9 @@ module Ci
raise Gitlab::Access::AccessDeniedError
end
- attributes = self.class.clone_accessors.map do |attribute|
+ attributes = self.class.clone_accessors.to_h do |attribute|
[attribute, build.public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend
- end.to_h
+ end
attributes[:user] = current_user
@@ -65,12 +61,6 @@ module Ci
end
build
end
-
- def mark_subsequent_stages_as_processable(build)
- build.pipeline.processables.skipped.after_stage(build.stage_idx).find_each do |skipped|
- retry_optimistic_lock(skipped, name: 'ci_retry_build_mark_subsequent_stages') { |build| build.process(current_user) }
- end
- end
end
end
diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb
index 90ee7b9b3ba..bb8590a769c 100644
--- a/app/services/ci/retry_pipeline_service.rb
+++ b/app/services/ci/retry_pipeline_service.rb
@@ -28,7 +28,7 @@ module Ci
pipeline.reset_ancestor_bridges!
- MergeRequests::AddTodoWhenBuildFailsService
+ ::MergeRequests::AddTodoWhenBuildFailsService
.new(project, current_user)
.close_all(pipeline)
diff --git a/app/services/ci/stop_environments_service.rb b/app/services/ci/stop_environments_service.rb
index b6c5b398cb1..81457130fa0 100644
--- a/app/services/ci/stop_environments_service.rb
+++ b/app/services/ci/stop_environments_service.rb
@@ -35,7 +35,7 @@ module Ci
private
def environments
- @environments ||= EnvironmentsFinder
+ @environments ||= EnvironmentsByDeploymentsFinder
.new(project, current_user, ref: @ref, recently_updated: true)
.execute
end
diff --git a/app/services/ci/test_failure_history_service.rb b/app/services/ci/test_failure_history_service.rb
index 61fda79a4a2..58bbc716ff0 100644
--- a/app/services/ci/test_failure_history_service.rb
+++ b/app/services/ci/test_failure_history_service.rb
@@ -34,7 +34,7 @@ module Ci
# We fetch for up to MAX_TRACKABLE_FAILURES + 1 builds. So if ever we get
# 201 total number of builds with the assumption that each job has at least
- # 1 failed test case, then we have at least 201 failed test cases which exceeds
+ # 1 failed unit test, then we have at least 201 failed unit tests which exceeds
# the MAX_TRACKABLE_FAILURES of 200. If this is the case, let's early exit so we
# don't have to parse each JUnit report of each of the 201 builds.
failed_builds.length <= MAX_TRACKABLE_FAILURES
@@ -51,25 +51,29 @@ module Ci
end
def track_failures
- failed_test_cases = gather_failed_test_cases(failed_builds)
+ failed_unit_tests = gather_failed_unit_tests_from_reports(failed_builds)
- return if failed_test_cases.size > MAX_TRACKABLE_FAILURES
+ return if failed_unit_tests.size > MAX_TRACKABLE_FAILURES
- failed_test_cases.keys.each_slice(100) do |key_hashes|
- Ci::TestCase.transaction do
- ci_test_cases = Ci::TestCase.find_or_create_by_batch(project, key_hashes)
- failures = test_case_failures(ci_test_cases, failed_test_cases)
+ failed_unit_tests.each_slice(100) do |batch|
+ Ci::UnitTest.transaction do
+ unit_test_attrs = ci_unit_test_attrs(batch)
+ ci_unit_tests = Ci::UnitTest.find_or_create_by_batch(project, unit_test_attrs)
- Ci::TestCaseFailure.insert_all(failures)
+ failures = ci_unit_test_failure_attrs(ci_unit_tests, failed_unit_tests)
+ Ci::UnitTestFailure.insert_all(failures)
end
end
end
- def gather_failed_test_cases(failed_builds)
- failed_builds.each_with_object({}) do |build, failed_test_cases|
+ def gather_failed_unit_tests_from_reports(failed_builds)
+ failed_builds.each_with_object({}) do |build, failed_unit_tests|
test_suite = generate_test_suite!(build)
- test_suite.failed.keys.each do |key|
- failed_test_cases[key] = build
+ test_suite.failed.each do |key, unit_test|
+ failed_unit_tests[key] = {
+ build: build, # This will be used in ci_unit_test_failure_attrs
+ unit_test: unit_test # This will be used in ci_unit_test_attrs
+ }
end
end
end
@@ -79,12 +83,24 @@ module Ci
build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new)
end
- def test_case_failures(ci_test_cases, failed_test_cases)
- ci_test_cases.map do |test_case|
- build = failed_test_cases[test_case.key_hash]
+ def ci_unit_test_attrs(batch)
+ batch.map do |item|
+ unit_test = item.last[:unit_test]
{
- test_case_id: test_case.id,
+ key_hash: unit_test.key,
+ name: unit_test.name,
+ suite_name: unit_test.suite_name
+ }
+ end
+ end
+
+ def ci_unit_test_failure_attrs(ci_unit_tests, failed_unit_tests)
+ ci_unit_tests.map do |ci_unit_test|
+ build = failed_unit_tests[ci_unit_test.key_hash][:build]
+
+ {
+ unit_test_id: ci_unit_test.id,
build_id: build.id,
failed_at: build.finished_at
}
diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb
index 6693a58683f..cb2de8b943c 100644
--- a/app/services/clusters/create_service.rb
+++ b/app/services/clusters/create_service.rb
@@ -5,7 +5,8 @@ module Clusters
attr_reader :current_user, :params
def initialize(user = nil, params = {})
- @current_user, @params = user, params.dup
+ @current_user = user
+ @params = params.dup
end
def execute(access_token: nil)
diff --git a/app/services/clusters/destroy_service.rb b/app/services/clusters/destroy_service.rb
index a8de04683fa..371f947add7 100644
--- a/app/services/clusters/destroy_service.rb
+++ b/app/services/clusters/destroy_service.rb
@@ -5,7 +5,8 @@ module Clusters
attr_reader :current_user, :params
def initialize(user = nil, params = {})
- @current_user, @params = user, params.dup
+ @current_user = user
+ @params = params.dup
@response = {}
end
diff --git a/app/services/clusters/integrations/create_service.rb b/app/services/clusters/integrations/create_service.rb
new file mode 100644
index 00000000000..f9e9dd3e457
--- /dev/null
+++ b/app/services/clusters/integrations/create_service.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Integrations
+ class CreateService < BaseContainerService
+ attr_accessor :cluster
+
+ def initialize(container:, cluster:, current_user: nil, params: {})
+ @cluster = cluster
+
+ super(container: container, current_user: current_user, params: params)
+ end
+
+ def execute
+ return ServiceResponse.error(message: 'Unauthorized') unless authorized?
+
+ integration.enabled = params[:enabled]
+ integration.save!
+
+ if integration.enabled?
+ ServiceResponse.success(message: s_('ClusterIntegration|Integration enabled'), payload: { integration: integration })
+ else
+ ServiceResponse.success(message: s_('ClusterIntegration|Integration disabled'), payload: { integration: integration })
+ end
+ end
+
+ private
+
+ def integration
+ case params[:application_type]
+ when 'prometheus'
+ cluster.find_or_build_integration_prometheus
+ else
+ raise ArgumentError, "invalid application_type: #{params[:application_type]}"
+ end
+ end
+
+ def authorized?
+ Ability.allowed?(current_user, :admin_cluster, cluster)
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/update_service.rb b/app/services/clusters/update_service.rb
index ba20826848d..5432d9fbca1 100644
--- a/app/services/clusters/update_service.rb
+++ b/app/services/clusters/update_service.rb
@@ -5,7 +5,8 @@ module Clusters
attr_reader :current_user, :params
def initialize(user = nil, params = {})
- @current_user, @params = user, params.dup
+ @current_user = user
+ @params = params.dup
end
def execute(cluster)
diff --git a/app/services/concerns/integrations/project_test_data.rb b/app/services/concerns/integrations/project_test_data.rb
index 57bcba98b49..5968b90f8fe 100644
--- a/app/services/concerns/integrations/project_test_data.rb
+++ b/app/services/concerns/integrations/project_test_data.rb
@@ -9,7 +9,7 @@ module Integrations
end
def note_events_data
- note = NotesFinder.new(current_user, project: project, target: project).execute.reorder(nil).last # rubocop: disable CodeReuse/ActiveRecord
+ note = NotesFinder.new(current_user, project: project, target: project, sort: 'id_desc').execute.first
return { error: s_('TestHooks|Ensure the project has notes.') } unless note.present?
diff --git a/app/services/concerns/suggestible.rb b/app/services/concerns/suggestible.rb
index 0cba9bf1b8a..82e43c856f8 100644
--- a/app/services/concerns/suggestible.rb
+++ b/app/services/concerns/suggestible.rb
@@ -5,7 +5,7 @@ module Suggestible
include Gitlab::Utils::StrongMemoize
# This translates into limiting suggestion changes to `suggestion:-100+100`.
- MAX_LINES_CONTEXT = 100.freeze
+ MAX_LINES_CONTEXT = 100
def diff_lines
strong_memoize(:diff_lines) do
diff --git a/app/services/deployments/link_merge_requests_service.rb b/app/services/deployments/link_merge_requests_service.rb
index eba5082e6c3..39fbef5dee2 100644
--- a/app/services/deployments/link_merge_requests_service.rb
+++ b/app/services/deployments/link_merge_requests_service.rb
@@ -18,7 +18,22 @@ module Deployments
# app deployments, as this is not useful.
return if deployment.environment.environment_type
- if (prev = deployment.previous_environment_deployment)
+ # This service is triggered by a Sidekiq worker, which only runs when a
+ # deployment is successful. We add an extra check here in case we ever
+ # call this service elsewhere and forget to check the status there.
+ #
+ # The reason we only want to link successful deployments is as follows:
+ # when we link a merge request, we don't link it to future deployments for
+ # the same environment. If we were to link an MR to a failed deploy, we
+ # wouldn't be able to later on link it to a successful deploy (e.g. after
+ # the deploy is retried).
+ #
+ # In addition, showing failed deploys in the UI of a merge request isn't
+ # useful to users, as they can't act upon the information in any
+ # meaningful way (i.e. they can't just retry the deploy themselves).
+ return unless deployment.success?
+
+ if (prev = deployment.previous_deployment)
link_merge_requests_for_range(prev.sha, deployment.sha)
else
# When no previous deployment is found we fall back to linking all merge
@@ -51,8 +66,15 @@ module Deployments
deployment.link_merge_requests(merge_requests)
- picked_merge_requests =
- project.merge_requests.by_cherry_pick_sha(slice)
+ # The cherry picked commits are tracked via `notes.commit_id`
+ # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22209
+ #
+ # NOTE: cross-joining `merge_requests` table and `notes` table could
+ # result in very poor performance because PG planner often uses an
+ # inappropriate index.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/321032.
+ mr_ids = project.notes.cherry_picked_merge_requests(slice)
+ picked_merge_requests = project.merge_requests.id_in(mr_ids)
deployment.link_merge_requests(picked_merge_requests)
end
diff --git a/app/services/draft_notes/base_service.rb b/app/services/draft_notes/base_service.rb
index 95c291ea800..66f9e04ef24 100644
--- a/app/services/draft_notes/base_service.rb
+++ b/app/services/draft_notes/base_service.rb
@@ -5,7 +5,9 @@ module DraftNotes
attr_accessor :merge_request, :current_user, :params
def initialize(merge_request, current_user, params = nil)
- @merge_request, @current_user, @params = merge_request, current_user, params.dup
+ @merge_request = merge_request
+ @current_user = current_user
+ @params = params.dup
end
def merge_request_activity_counter
diff --git a/app/services/git/wiki_push_service.rb b/app/services/git/wiki_push_service.rb
index 0905b2d98df..82958abfe6e 100644
--- a/app/services/git/wiki_push_service.rb
+++ b/app/services/git/wiki_push_service.rb
@@ -8,7 +8,9 @@ module Git
attr_reader :wiki
def initialize(wiki, current_user, params)
- @wiki, @current_user, @params = wiki, current_user, params.dup
+ @wiki = wiki
+ @current_user = current_user
+ @params = params.dup
end
def execute
diff --git a/app/services/git/wiki_push_service/change.rb b/app/services/git/wiki_push_service/change.rb
index 3d1d0fe8c4e..9109a7f9d58 100644
--- a/app/services/git/wiki_push_service/change.rb
+++ b/app/services/git/wiki_push_service/change.rb
@@ -9,7 +9,9 @@ module Git
# @param [Hash] change - must have keys `:oldrev` and `:newrev`
# @param [Gitlab::Git::RawDiffChange] raw_change
def initialize(wiki, change, raw_change)
- @wiki, @raw_change, @change = wiki, raw_change, change
+ @wiki = wiki
+ @raw_change = raw_change
+ @change = change
end
def page
diff --git a/app/services/groups/base_service.rb b/app/services/groups/base_service.rb
index 019cd047ae9..06136aff50e 100644
--- a/app/services/groups/base_service.rb
+++ b/app/services/groups/base_service.rb
@@ -5,11 +5,25 @@ module Groups
attr_accessor :group, :current_user, :params
def initialize(group, user, params = {})
- @group, @current_user, @params = group, user, params.dup
+ @group = group
+ @current_user = user
+ @params = params.dup
end
private
+ def handle_namespace_settings
+ settings_params = params.slice(*::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS)
+
+ return if settings_params.empty?
+
+ ::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS.each do |nsp|
+ params.delete(nsp)
+ end
+
+ ::NamespaceSettings::UpdateService.new(current_user, group, settings_params).execute
+ end
+
def remove_unallowed_params
# overridden in EE
end
diff --git a/app/services/groups/count_service.rb b/app/services/groups/count_service.rb
new file mode 100644
index 00000000000..2a15ae3bc57
--- /dev/null
+++ b/app/services/groups/count_service.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Groups
+ class CountService < BaseCountService
+ include Gitlab::Utils::StrongMemoize
+
+ VERSION = 1
+ CACHED_COUNT_THRESHOLD = 1000
+ EXPIRATION_TIME = 24.hours
+
+ attr_reader :group, :user
+
+ def initialize(group, user = nil)
+ @group = group
+ @user = user
+ end
+
+ def count
+ cached_count = Rails.cache.read(cache_key)
+ return cached_count unless cached_count.blank?
+
+ refreshed_count = uncached_count
+ update_cache_for_key(cache_key) { refreshed_count } if refreshed_count > CACHED_COUNT_THRESHOLD
+ refreshed_count
+ end
+
+ def cache_key
+ ['groups', "#{issuable_key}_count_service", VERSION, group.id, cache_key_name]
+ end
+
+ private
+
+ def relation_for_count
+ raise NotImplementedError
+ end
+
+ def cache_options
+ super.merge({ expires_in: EXPIRATION_TIME })
+ end
+
+ def cache_key_name
+ raise NotImplementedError, 'cache_key_name must be implemented and return a String'
+ end
+
+ def issuable_key
+ raise NotImplementedError, 'issuable_key must be implemented and return a String'
+ end
+ end
+end
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index 3ead2323588..9ddb8ae7695 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -3,7 +3,8 @@
module Groups
class CreateService < Groups::BaseService
def initialize(user, params = {})
- @current_user, @params = user, params.dup
+ @current_user = user
+ @params = params.dup
@chat_team = @params.delete(:create_chat_team)
end
@@ -11,7 +12,10 @@ module Groups
remove_unallowed_params
set_visibility_level
- @group = Group.new(params)
+ @group = Group.new(params.except(*::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS))
+
+ @group.build_namespace_settings
+ handle_namespace_settings
after_build_hook(@group, params)
@@ -33,7 +37,6 @@ module Groups
Group.transaction do
if @group.save
@group.add_owner(current_user)
- @group.create_namespace_settings unless @group.namespace_settings
Service.create_from_active_default_integrations(@group, :group_id)
OnboardingProgress.onboard(@group)
end
diff --git a/app/services/groups/group_links/create_service.rb b/app/services/groups/group_links/create_service.rb
index 57c746c3841..0a60140d037 100644
--- a/app/services/groups/group_links/create_service.rb
+++ b/app/services/groups/group_links/create_service.rb
@@ -2,7 +2,7 @@
module Groups
module GroupLinks
- class CreateService < BaseService
+ class CreateService < Groups::BaseService
def execute(shared_group)
unless group && shared_group &&
can?(current_user, :admin_group_member, shared_group) &&
diff --git a/app/services/groups/merge_requests_count_service.rb b/app/services/groups/merge_requests_count_service.rb
new file mode 100644
index 00000000000..bb49efe571a
--- /dev/null
+++ b/app/services/groups/merge_requests_count_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Groups
+ # Service class for counting and caching the number of open merge requests of a group.
+ class MergeRequestsCountService < Groups::CountService
+ private
+
+ def cache_key_name
+ 'open_merge_requests_count'
+ end
+
+ def relation_for_count
+ MergeRequestsFinder
+ .new(user, group_id: group.id, state: 'opened', non_archived: true, include_subgroups: true)
+ .execute
+ end
+
+ def issuable_key
+ 'open_merge_requests'
+ end
+ end
+end
diff --git a/app/services/groups/nested_create_service.rb b/app/services/groups/nested_create_service.rb
index a51ac9aa593..35d45aaf0cc 100644
--- a/app/services/groups/nested_create_service.rb
+++ b/app/services/groups/nested_create_service.rb
@@ -5,7 +5,8 @@ module Groups
attr_reader :group_path, :visibility_level
def initialize(user, params)
- @current_user, @params = user, params.dup
+ @current_user = user
+ @params = params.dup
@group_path = @params.delete(:group_path)
@visibility_level = @params.delete(:visibility_level) ||
Gitlab::CurrentSettings.current_application_settings.default_group_visibility
diff --git a/app/services/groups/open_issues_count_service.rb b/app/services/groups/open_issues_count_service.rb
index db1ca09212a..ef787a04315 100644
--- a/app/services/groups/open_issues_count_service.rb
+++ b/app/services/groups/open_issues_count_service.rb
@@ -2,47 +2,12 @@
module Groups
# Service class for counting and caching the number of open issues of a group.
- class OpenIssuesCountService < BaseCountService
- include Gitlab::Utils::StrongMemoize
-
- VERSION = 1
+ class OpenIssuesCountService < Groups::CountService
PUBLIC_COUNT_KEY = 'group_public_open_issues_count'
TOTAL_COUNT_KEY = 'group_total_open_issues_count'
- CACHED_COUNT_THRESHOLD = 1000
- EXPIRATION_TIME = 24.hours
-
- attr_reader :group, :user
-
- def initialize(group, user = nil)
- @group = group
- @user = user
- end
-
- # Reads count value from cache and return it if present.
- # If empty or expired, #uncached_count will calculate the issues count for the group and
- # compare it with the threshold. If it is greater, it will be written to the cache and returned.
- # If below, it will be returned without being cached.
- # This results in only caching large counts and calculating the rest with every call to maintain
- # accuracy.
- def count
- cached_count = Rails.cache.read(cache_key)
- return cached_count unless cached_count.blank?
-
- refreshed_count = uncached_count
- update_cache_for_key(cache_key) { refreshed_count } if refreshed_count > CACHED_COUNT_THRESHOLD
- refreshed_count
- end
-
- def cache_key(key = nil)
- ['groups', 'open_issues_count_service', VERSION, group.id, cache_key_name]
- end
private
- def cache_options
- super.merge({ expires_in: EXPIRATION_TIME })
- end
-
def cache_key_name
public_only? ? PUBLIC_COUNT_KEY : TOTAL_COUNT_KEY
end
@@ -60,5 +25,9 @@ module Groups
def relation_for_count
IssuesFinder.new(user, group_id: group.id, state: 'opened', non_archived: true, include_subgroups: true, public_only: public_only?).execute
end
+
+ def issuable_key
+ 'open_issues'
+ end
end
end
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index 84385f5da25..ff369d01efc 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -46,18 +46,6 @@ module Groups
private
- def handle_namespace_settings
- settings_params = params.slice(*::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS)
-
- return if settings_params.empty?
-
- ::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS.each do |nsp|
- params.delete(nsp)
- end
-
- ::NamespaceSettings::UpdateService.new(current_user, group, settings_params).execute
- end
-
def valid_path_change_with_npm_packages?
return true unless group.packages_feature_enabled?
return true if params[:path].blank?
diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb
index d3d543edcd7..8bcbb92cd0e 100644
--- a/app/services/issuable/bulk_update_service.rb
+++ b/app/services/issuable/bulk_update_service.rb
@@ -7,7 +7,9 @@ module Issuable
attr_accessor :parent, :current_user, :params
def initialize(parent, user = nil, params = {})
- @parent, @current_user, @params = parent, user, params.dup
+ @parent = parent
+ @current_user = user
+ @params = params.dup
end
def execute(type)
@@ -15,7 +17,7 @@ module Issuable
set_update_params(type)
items = update_issuables(type, ids)
- response_success(payload: { count: items.count })
+ response_success(payload: { count: items.size })
rescue ArgumentError => e
response_error(e.message, 422)
end
@@ -59,10 +61,17 @@ module Issuable
def find_issuables(parent, model_class, ids)
if parent.is_a?(Project)
- model_class.id_in(ids).of_projects(parent)
+ projects = parent
elsif parent.is_a?(Group)
- model_class.id_in(ids).of_projects(parent.all_projects)
+ projects = parent.all_projects
+ else
+ return
end
+
+ model_class
+ .id_in(ids)
+ .of_projects(projects)
+ .includes_for_bulk_update
end
def response_success(message: nil, payload: nil)
diff --git a/app/services/issuable/destroy_service.rb b/app/services/issuable/destroy_service.rb
index 4c64655a622..d5aa84d8d6c 100644
--- a/app/services/issuable/destroy_service.rb
+++ b/app/services/issuable/destroy_service.rb
@@ -3,12 +3,36 @@
module Issuable
class DestroyService < IssuableBaseService
def execute(issuable)
- TodoService.new.destroy_target(issuable) do |issuable|
- if issuable.destroy
- issuable.update_project_counter_caches
- issuable.assignees.each(&:invalidate_cache_counts)
- end
+ if issuable.destroy
+ after_destroy(issuable)
+ end
+ end
+
+ private
+
+ def after_destroy(issuable)
+ delete_todos(issuable)
+ issuable.update_project_counter_caches
+ issuable.assignees.each(&:invalidate_cache_counts)
+ end
+
+ def group_for(issuable)
+ issuable.resource_parent.group
+ end
+
+ def delete_todos(issuable)
+ actor = group_for(issuable)
+
+ if Feature.enabled?(:destroy_issuable_todos_async, actor, default_enabled: :yaml)
+ TodosDestroyer::DestroyedIssuableWorker
+ .perform_async(issuable.id, issuable.class.name)
+ else
+ TodosDestroyer::DestroyedIssuableWorker
+ .new
+ .perform(issuable.id, issuable.class.name)
end
end
end
end
+
+Issuable::DestroyService.prepend_if_ee('EE::Issuable::DestroyService')
diff --git a/app/services/issuable/process_assignees.rb b/app/services/issuable/process_assignees.rb
index c9c6b0bed85..1ef6d3d9c42 100644
--- a/app/services/issuable/process_assignees.rb
+++ b/app/services/issuable/process_assignees.rb
@@ -14,12 +14,13 @@ module Issuable
end
def execute
- if assignee_ids.blank?
- updated_new_assignees = new_assignee_ids
+ updated_new_assignees = new_assignee_ids
+
+ if add_assignee_ids.blank? && remove_assignee_ids.blank?
+ updated_new_assignees = assignee_ids if assignee_ids
+ else
updated_new_assignees |= add_assignee_ids if add_assignee_ids
updated_new_assignees -= remove_assignee_ids if remove_assignee_ids
- else
- updated_new_assignees = assignee_ids
end
updated_new_assignees.uniq
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 094b31b4ad6..add53bc6267 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -29,32 +29,48 @@ class IssuableBaseService < BaseService
params.delete(:label_ids)
params.delete(:assignee_ids)
params.delete(:assignee_id)
+ params.delete(:add_assignee_ids)
+ params.delete(:remove_assignee_ids)
params.delete(:due_date)
params.delete(:canonical_issue_id)
params.delete(:project)
params.delete(:discussion_locked)
end
- filter_assignee(issuable)
+ filter_assignees(issuable)
filter_milestone
filter_labels
end
- def filter_assignee(issuable)
- return if params[:assignee_ids].blank?
+ def filter_assignees(issuable)
+ filter_assignees_with_key(issuable, :assignee_ids, :assignees)
+ filter_assignees_with_key(issuable, :add_assignee_ids, :add_assignees)
+ filter_assignees_with_key(issuable, :remove_assignee_ids, :remove_assignees)
+ end
+
+ def filter_assignees_with_key(issuable, id_key, key)
+ if params[key] && params[id_key].blank?
+ params[id_key] = params[key].map(&:id)
+ end
+
+ return if params[id_key].blank?
+
+ filter_assignees_using_checks(issuable, id_key)
+ end
+ def filter_assignees_using_checks(issuable, id_key)
unless issuable.allows_multiple_assignees?
- params[:assignee_ids] = params[:assignee_ids].first(1)
+ params[id_key] = params[id_key].first(1)
end
- assignee_ids = params[:assignee_ids].select { |assignee_id| user_can_read?(issuable, assignee_id) }
+ assignee_ids = params[id_key].select { |assignee_id| user_can_read?(issuable, assignee_id) }
- if params[:assignee_ids].map(&:to_s) == [IssuableFinder::Params::NONE]
- params[:assignee_ids] = []
+ if params[id_key].map(&:to_s) == [IssuableFinder::Params::NONE]
+ params[id_key] = []
elsif assignee_ids.any?
- params[:assignee_ids] = assignee_ids
+ params[id_key] = assignee_ids
else
- params.delete(:assignee_ids)
+ params.delete(id_key)
end
end
@@ -116,6 +132,15 @@ class IssuableBaseService < BaseService
new_label_ids.uniq
end
+ def process_assignee_ids(attributes, existing_assignee_ids: nil, extra_assignee_ids: [])
+ process = Issuable::ProcessAssignees.new(assignee_ids: attributes.delete(:assignee_ids),
+ add_assignee_ids: attributes.delete(:add_assignee_ids),
+ remove_assignee_ids: attributes.delete(:remove_assignee_ids),
+ existing_assignee_ids: existing_assignee_ids,
+ extra_assignee_ids: extra_assignee_ids)
+ process.execute
+ end
+
def handle_quick_actions(issuable)
merge_quick_actions_into_params!(issuable)
end
@@ -145,6 +170,10 @@ class IssuableBaseService < BaseService
params[:author] ||= current_user
params[:label_ids] = process_label_ids(params, extra_label_ids: issuable.label_ids.to_a)
+ if issuable.respond_to?(:assignee_ids)
+ params[:assignee_ids] = process_assignee_ids(params, extra_assignee_ids: issuable.assignee_ids.to_a)
+ end
+
issuable.assign_attributes(params)
before_create(issuable)
@@ -191,6 +220,7 @@ class IssuableBaseService < BaseService
old_associations = associations_before_update(issuable)
assign_requested_labels(issuable)
+ assign_requested_assignees(issuable)
if issuable.changed? || params.present?
issuable.assign_attributes(params)
@@ -354,6 +384,16 @@ class IssuableBaseService < BaseService
issuable.touch
end
+ def assign_requested_assignees(issuable)
+ return if issuable.is_a?(Epic)
+
+ assignee_ids = process_assignee_ids(params, existing_assignee_ids: issuable.assignee_ids)
+ if ids_changing?(issuable.assignee_ids, assignee_ids)
+ params[:assignee_ids] = assignee_ids
+ issuable.touch
+ end
+ end
+
# Arrays of ids are used, but we should really use sets of ids, so
# let's have an helper to properly check if some ids are changing
def ids_changing?(old_array, new_array)
@@ -384,6 +424,20 @@ class IssuableBaseService < BaseService
associations
end
+ def handle_move_between_ids(issuable_position)
+ return unless params[:move_between_ids]
+
+ after_id, before_id = params.delete(:move_between_ids)
+ positioning_scope_id = params.delete(positioning_scope_key)
+
+ issuable_before = issuable_for_positioning(before_id, positioning_scope_id)
+ issuable_after = issuable_for_positioning(after_id, positioning_scope_id)
+
+ raise ActiveRecord::RecordNotFound unless issuable_before || issuable_after
+
+ issuable_position.move_between(issuable_before, issuable_after)
+ end
+
def has_changes?(issuable, old_labels: [], old_assignees: [], old_reviewers: [])
valid_attrs = [:title, :description, :assignee_ids, :reviewer_ids, :milestone_id, :target_branch]
@@ -429,6 +483,8 @@ class IssuableBaseService < BaseService
# we need to check this because milestone from milestone_id param is displayed on "new" page
# where private project milestone could leak without this check
def ensure_milestone_available(issuable)
+ return unless issuable.supports_milestone? && issuable.milestone_id.present?
+
issuable.milestone_id = nil unless issuable.milestone_available?
end
diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb
index f148c503dcf..cbb81f1f521 100644
--- a/app/services/issuable_links/create_service.rb
+++ b/app/services/issuable_links/create_service.rb
@@ -7,7 +7,9 @@ module IssuableLinks
attr_reader :issuable, :current_user, :params
def initialize(issuable, user, params)
- @issuable, @current_user, @params = issuable, user, params.dup
+ @issuable = issuable
+ @current_user = user
+ @params = params.dup
end
def execute
@@ -107,11 +109,11 @@ module IssuableLinks
end
def issuables_assigned_message
- 'Issue(s) already assigned'
+ _("Issue(s) already assigned")
end
def issuables_not_found_message
- 'No Issue found for given params'
+ _("No matching issue found. Make sure that you are adding a valid issue URL.")
end
end
end
diff --git a/app/services/issuable_links/destroy_service.rb b/app/services/issuable_links/destroy_service.rb
index 57e1314e0da..28035bbb291 100644
--- a/app/services/issuable_links/destroy_service.rb
+++ b/app/services/issuable_links/destroy_service.rb
@@ -15,14 +15,18 @@ module IssuableLinks
return error(not_found_message, 404) unless permission_to_remove_relation?
remove_relation
- create_notes
- track_event
+ after_destroy
success(message: 'Relation was removed')
end
private
+ def after_destroy
+ create_notes
+ track_event
+ end
+
def remove_relation
link.destroy!
end
diff --git a/app/services/issuable_links/list_service.rb b/app/services/issuable_links/list_service.rb
index 10a2da7eb03..fe9678dcc32 100644
--- a/app/services/issuable_links/list_service.rb
+++ b/app/services/issuable_links/list_service.rb
@@ -7,7 +7,8 @@ module IssuableLinks
attr_reader :issuable, :current_user
def initialize(issuable, user)
- @issuable, @current_user = issuable, user
+ @issuable = issuable
+ @current_user = user
end
def execute
diff --git a/app/services/issue_rebalancing_service.rb b/app/services/issue_rebalancing_service.rb
index db5c5ddfb84..f9c3388204f 100644
--- a/app/services/issue_rebalancing_service.rb
+++ b/app/services/issue_rebalancing_service.rb
@@ -62,7 +62,7 @@ class IssueRebalancingService
def run_update_query(values, query_name)
Issue.connection.exec_query(<<~SQL, query_name)
- WITH cte(cte_id, new_pos) AS (
+ WITH cte(cte_id, new_pos) AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
SELECT *
FROM (VALUES #{values}) as t (id, pos)
)
diff --git a/app/services/issues/after_create_service.rb b/app/services/issues/after_create_service.rb
new file mode 100644
index 00000000000..0c6ec65f0e2
--- /dev/null
+++ b/app/services/issues/after_create_service.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Issues
+ class AfterCreateService < Issues::BaseService
+ def execute(issue)
+ todo_service.new_issue(issue, current_user)
+ delete_milestone_total_issue_counter_cache(issue.milestone)
+ track_incident_action(current_user, issue, :incident_created)
+ end
+ end
+end
+
+Issues::AfterCreateService.prepend_ee_mod
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index 25f319da03b..87615d1b4f2 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -52,7 +52,7 @@ module Issues
end
def execute_hooks(issue, action = 'open', old_associations: {})
- issue_data = hook_data(issue, action, old_associations: old_associations)
+ issue_data = Gitlab::Lazy.new { hook_data(issue, action, old_associations: old_associations) }
hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks
issue.project.execute_hooks(issue_data, hooks_scope)
issue.project.execute_services(issue_data, hooks_scope)
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index 3fdc66ed84e..68660b35bee 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -6,7 +6,7 @@ module Issues
def execute(skip_system_notes: false)
@request = params.delete(:request)
- @spam_params = Spam::SpamActionService.filter_spam_params!(params)
+ @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
@issue = BuildService.new(project, current_user, params).execute
@@ -32,13 +32,11 @@ module Issues
end
end
+ # Add new items to Issues::AfterCreateService if they can be performed in Sidekiq
def after_create(issue)
add_incident_label(issue)
- todo_service.new_issue(issue, current_user)
user_agent_detail_service.create
resolve_discussions_with_issue(issue)
- delete_milestone_total_issue_counter_cache(issue.milestone)
- track_incident_action(current_user, issue, :incident_created)
super
end
@@ -77,4 +75,4 @@ module Issues
end
end
-Issues::CreateService.prepend_if_ee('EE::Issues::CreateService')
+Issues::CreateService.prepend_ee_mod
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index 2906bdf62a7..702527d80a7 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -8,7 +8,7 @@ module Issues
handle_move_between_ids(issue)
@request = params.delete(:request)
- @spam_params = Spam::SpamActionService.filter_spam_params!(params)
+ @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
change_issue_duplicate(issue)
move_issue_to_new_project(issue) || clone_issue(issue) || update_task_event(issue) || update(issue)
@@ -96,19 +96,15 @@ module Issues
end
def handle_move_between_ids(issue)
- return unless params[:move_between_ids]
-
- after_id, before_id = params.delete(:move_between_ids)
- board_group_id = params.delete(:board_group_id)
-
- issue_before = get_issue_if_allowed(before_id, board_group_id)
- issue_after = get_issue_if_allowed(after_id, board_group_id)
- raise ActiveRecord::RecordNotFound unless issue_before || issue_after
+ super
- issue.move_between(issue_before, issue_after)
rebalance_if_needed(issue)
end
+ def positioning_scope_key
+ :board_group_id
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def change_issue_duplicate(issue)
canonical_issue_id = params.delete(:canonical_issue_id)
@@ -185,7 +181,7 @@ module Issues
end
# rubocop: disable CodeReuse/ActiveRecord
- def get_issue_if_allowed(id, board_group_id = nil)
+ def issuable_for_positioning(id, board_group_id = nil)
return unless id
issue =
diff --git a/app/services/jira_connect_subscriptions/base_service.rb b/app/services/jira_connect_subscriptions/base_service.rb
index 0e5bb91660e..042169acb6f 100644
--- a/app/services/jira_connect_subscriptions/base_service.rb
+++ b/app/services/jira_connect_subscriptions/base_service.rb
@@ -5,7 +5,9 @@ module JiraConnectSubscriptions
attr_accessor :jira_connect_installation, :current_user, :params
def initialize(jira_connect_installation, user = nil, params = {})
- @jira_connect_installation, @current_user, @params = jira_connect_installation, user, params.dup
+ @jira_connect_installation = jira_connect_installation
+ @current_user = user
+ @params = params.dup
end
end
end
diff --git a/app/services/keys/base_service.rb b/app/services/keys/base_service.rb
index 113e22b01ce..9b238e2f176 100644
--- a/app/services/keys/base_service.rb
+++ b/app/services/keys/base_service.rb
@@ -5,7 +5,8 @@ module Keys
attr_accessor :user, :params
def initialize(user, params = {})
- @user, @params = user, params
+ @user = user
+ @params = params
@ip_address = @params.delete(:ip_address)
end
diff --git a/app/services/keys/create_service.rb b/app/services/keys/create_service.rb
index c256de7b35d..c1c3ef8792f 100644
--- a/app/services/keys/create_service.rb
+++ b/app/services/keys/create_service.rb
@@ -5,7 +5,8 @@ module Keys
attr_accessor :current_user
def initialize(current_user, params = {})
- @current_user, @params = current_user, params
+ @current_user = current_user
+ @params = params
@ip_address = @params.delete(:ip_address)
@user = params.delete(:user) || current_user
end
diff --git a/app/services/keys/expiry_notification_service.rb b/app/services/keys/expiry_notification_service.rb
new file mode 100644
index 00000000000..b486f77ced2
--- /dev/null
+++ b/app/services/keys/expiry_notification_service.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Keys
+ class ExpiryNotificationService < ::Keys::BaseService
+ attr_accessor :keys, :expiring_soon
+
+ def initialize(user, params)
+ @keys = params[:keys]
+ @expiring_soon = params[:expiring_soon]
+
+ super
+ end
+
+ def execute
+ return unless allowed?
+
+ if expiring_soon
+ trigger_expiring_soon_notification
+ else
+ trigger_expired_notification
+ end
+ end
+
+ private
+
+ def allowed?
+ user.can?(:receive_notifications)
+ end
+
+ def trigger_expiring_soon_notification
+ notification_service.ssh_key_expiring_soon(user, keys.map(&:fingerprint))
+
+ keys.update_all(before_expiry_notification_delivered_at: Time.current.utc)
+ end
+
+ def trigger_expired_notification
+ notification_service.ssh_key_expired(user, keys.map(&:fingerprint))
+
+ keys.update_all(expiry_notification_delivered_at: Time.current.utc)
+ end
+ end
+end
diff --git a/app/services/mattermost/create_team_service.rb b/app/services/mattermost/create_team_service.rb
index afcd6439a14..2cbcaaad5e1 100644
--- a/app/services/mattermost/create_team_service.rb
+++ b/app/services/mattermost/create_team_service.rb
@@ -3,7 +3,8 @@
module Mattermost
class CreateTeamService < ::BaseService
def initialize(group, current_user)
- @group, @current_user = group, current_user
+ @group = group
+ @current_user = current_user
end
def execute
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index cffccda1a44..953cf7f5bf6 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -2,67 +2,98 @@
module Members
class CreateService < Members::BaseService
- include Gitlab::Utils::StrongMemoize
+ BlankInvitesError = Class.new(StandardError)
+ TooManyInvitesError = Class.new(StandardError)
- DEFAULT_LIMIT = 100
+ DEFAULT_INVITE_LIMIT = 100
- def execute(source)
- return error(s_('AddMember|No users specified.')) if user_ids.blank?
+ def initialize(*args)
+ super
- return error(s_("AddMember|Too many users specified (limit is %{user_limit})") % { user_limit: user_limit }) if
- user_limit && user_ids.size > user_limit
+ @errors = []
+ @invites = invites_from_params&.split(',')&.uniq&.flatten
+ @source = params[:source]
+ end
+
+ def execute
+ validate_invites!
+
+ add_members
+ enqueue_onboarding_progress_action
+ result
+ rescue BlankInvitesError, TooManyInvitesError => e
+ error(e.message)
+ end
+
+ private
+
+ attr_reader :source, :errors, :invites, :member_created_namespace_id
+
+ def invites_from_params
+ params[:user_ids]
+ end
+
+ def validate_invites!
+ raise BlankInvitesError, blank_invites_message if invites.blank?
+
+ return unless user_limit && invites.size > user_limit
+
+ raise TooManyInvitesError,
+ format(s_("AddMember|Too many users specified (limit is %{user_limit})"), user_limit: user_limit)
+ end
+
+ def blank_invites_message
+ s_('AddMember|No users specified.')
+ end
+ def add_members
members = source.add_users(
- user_ids,
+ invites,
params[:access_level],
expires_at: params[:expires_at],
current_user: current_user
)
- errors = []
-
- members.each do |member|
- if member.invalid?
- current_error =
- # Invited users may not have an associated user
- if member.user.present?
- "#{member.user.username}: "
- else
- ""
- end
-
- current_error += member.errors.full_messages.to_sentence
- errors << current_error
- else
- after_execute(member: member)
- end
- end
-
- enqueue_onboarding_progress_action(source) if members.size > errors.size
-
- return success unless errors.any?
+ members.each { |member| process_result(member) }
+ end
- error(errors.to_sentence)
+ def process_result(member)
+ if member.invalid?
+ add_error_for_member(member)
+ else
+ after_execute(member: member)
+ @member_created_namespace_id ||= member.namespace_id
+ end
end
- private
+ def add_error_for_member(member)
+ prefix = "#{member.user.username}: " if member.user.present?
- def user_ids
- strong_memoize(:user_ids) do
- ids = params[:user_ids] || ''
- ids.split(',').uniq.flatten
- end
+ errors << "#{prefix}#{member.errors.full_messages.to_sentence}"
end
def user_limit
- limit = params.fetch(:limit, DEFAULT_LIMIT)
+ limit = params.fetch(:limit, DEFAULT_INVITE_LIMIT)
limit && limit < 0 ? nil : limit
end
- def enqueue_onboarding_progress_action(source)
- namespace_id = source.is_a?(Project) ? source.namespace_id : source.id
- Namespaces::OnboardingUserAddedWorker.perform_async(namespace_id)
+ def enqueue_onboarding_progress_action
+ return unless member_created_namespace_id
+
+ Namespaces::OnboardingUserAddedWorker.perform_async(member_created_namespace_id)
+ end
+
+ def result
+ if errors.any?
+ error(formatted_errors)
+ else
+ success
+ end
+ end
+
+ def formatted_errors
+ errors.to_sentence
end
end
end
diff --git a/app/services/members/invite_service.rb b/app/services/members/invite_service.rb
index 169500d08f0..48010f9c8e7 100644
--- a/app/services/members/invite_service.rb
+++ b/app/services/members/invite_service.rb
@@ -1,98 +1,46 @@
# frozen_string_literal: true
module Members
- class InviteService < Members::BaseService
- BlankEmailsError = Class.new(StandardError)
- TooManyEmailsError = Class.new(StandardError)
+ class InviteService < Members::CreateService
+ extend ::Gitlab::Utils::Override
def initialize(*args)
super
@errors = {}
- @emails = params[:email]&.split(',')&.uniq&.flatten
- end
-
- def execute(source)
- validate_emails!
-
- @source = source
- emails.each(&method(:process_email))
- result
- rescue BlankEmailsError, TooManyEmailsError => e
- error(e.message)
end
private
- attr_reader :source, :errors, :emails
-
- def validate_emails!
- raise BlankEmailsError, s_('AddMember|Email cannot be blank') if emails.blank?
-
- if user_limit && emails.size > user_limit
- raise TooManyEmailsError, s_("AddMember|Too many users specified (limit is %{user_limit})") % { user_limit: user_limit }
- end
- end
-
- def user_limit
- limit = params.fetch(:limit, Members::CreateService::DEFAULT_LIMIT)
-
- limit < 0 ? nil : limit
- end
-
- def process_email(email)
- return if existing_member?(email)
- return if existing_invite?(email)
- return if existing_request?(email)
-
- add_member(email)
- end
-
- def existing_member?(email)
- existing_member = source.members.with_user_by_email(email).exists?
-
- if existing_member
- errors[email] = s_("AddMember|Already a member of %{source_name}") % { source_name: source.name }
- return true
- end
+ alias_method :formatted_errors, :errors
- false
+ def invites_from_params
+ params[:email]
end
- def existing_invite?(email)
- existing_invite = source.members.search_invite_email(email).exists?
+ def validate_invites!
+ super
- if existing_invite
- errors[email] = s_("AddMember|Member already invited to %{source_name}") % { source_name: source.name }
- return true
- end
+ # we need the below due to add_users hitting Member#parse_users_list and ignoring invalid emails
+ # ideally we wouldn't need this, but we can't really change the add_users method
+ valid, invalid = invites.partition { |email| Member.valid_email?(email) }
+ @invites = valid
- false
+ invalid.each { |email| errors[email] = s_('AddMember|Invite email is invalid') }
end
- def existing_request?(email)
- existing_request = source.requesters.with_user_by_email(email).exists?
-
- if existing_request
- errors[email] = s_("AddMember|Member cannot be invited because they already requested to join %{source_name}") % { source_name: source.name }
- return true
- end
-
- false
+ override :blank_invites_message
+ def blank_invites_message
+ s_('AddMember|Emails cannot be blank')
end
- def add_member(email)
- new_member = source.add_user(email, params[:access_level], current_user: current_user, expires_at: params[:expires_at])
-
- errors[email] = new_member.errors.full_messages.to_sentence if new_member.invalid?
+ override :add_error_for_member
+ def add_error_for_member(member)
+ errors[invite_email(member)] = member.errors.full_messages.to_sentence
end
- def result
- if errors.any?
- error(errors)
- else
- success
- end
+ def invite_email(member)
+ member.invite_email || member.user.email
end
end
end
diff --git a/app/services/merge_requests/add_context_service.rb b/app/services/merge_requests/add_context_service.rb
index b693f8509a2..77b00f645c9 100644
--- a/app/services/merge_requests/add_context_service.rb
+++ b/app/services/merge_requests/add_context_service.rb
@@ -49,11 +49,9 @@ module MergeRequests
def duplicates
existing_oids = merge_request.merge_request_context_commits.map { |commit| commit.sha.to_s }
- duplicate_oids = existing_oids.select do |existing_oid|
+ existing_oids.select do |existing_oid|
commit_ids.select { |commit_id| existing_oid.start_with?(commit_id) }.count > 0
end
-
- duplicate_oids
end
def build_context_commit_rows(merge_request_id, commits)
diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb
index b22afe8a20d..ed9747a8c99 100644
--- a/app/services/merge_requests/after_create_service.rb
+++ b/app/services/merge_requests/after_create_service.rb
@@ -24,6 +24,18 @@ module MergeRequests
merge_request.create_cross_references!(current_user)
OnboardingProgressService.new(merge_request.target_project.namespace).execute(action: :merge_request_created)
+
+ todo_service.new_merge_request(merge_request, current_user)
+ merge_request.cache_merge_request_closes_issues!(current_user)
+
+ Gitlab::UsageDataCounters::MergeRequestCounter.count(:create)
+ link_lfs_objects(merge_request)
+
+ delete_milestone_total_merge_requests_counter_cache(merge_request.milestone)
+ end
+
+ def link_lfs_objects(merge_request)
+ LinkLfsObjectsService.new(merge_request.target_project).execute(merge_request)
end
end
end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 317cd11a69d..3a3765355d8 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -143,8 +143,12 @@ module MergeRequests
merge_request, merge_request.project, current_user, old_reviewers)
end
- def create_pipeline_for(merge_request, user)
- MergeRequests::CreatePipelineService.new(project, user).execute(merge_request)
+ def create_pipeline_for(merge_request, user, async: false)
+ if async
+ MergeRequests::CreatePipelineWorker.perform_async(project.id, user.id, merge_request.id)
+ else
+ MergeRequests::CreatePipelineService.new(project, user).execute(merge_request)
+ end
end
def abort_auto_merge(merge_request, reason)
@@ -164,7 +168,7 @@ module MergeRequests
def pipeline_merge_requests(pipeline)
pipeline.all_merge_requests.opened.each do |merge_request|
- next unless pipeline == merge_request.head_pipeline
+ next unless pipeline.id == merge_request.head_pipeline_id
yield merge_request
end
@@ -195,6 +199,12 @@ module MergeRequests
merge_request.update(merge_error: message) if save_message_on_model
end
+
+ def delete_milestone_total_merge_requests_counter_cache(milestone)
+ return unless milestone
+
+ Milestones::MergeRequestsCountService.new(milestone).delete_cache
+ end
end
end
diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb
index e4d3c91d13e..ecc55eae5de 100644
--- a/app/services/merge_requests/build_service.rb
+++ b/app/services/merge_requests/build_service.rb
@@ -16,17 +16,7 @@ module MergeRequests
merge_request.source_project = find_source_project
merge_request.target_project = find_target_project
- # Force remove the source branch?
- merge_request.merge_params['force_remove_source_branch'] = force_remove_source_branch
-
- # Only assign merge requests params that are allowed
- self.params = assign_allowed_merge_params(merge_request, params)
-
- # Filter out params that are either not allowed or invalid
- filter_params(merge_request)
-
- # Filter out :add_label_ids and :remove_label_ids params
- filter_label_id_params
+ process_params
merge_request.compare_commits = []
set_merge_request_target_branch
@@ -70,21 +60,41 @@ module MergeRequests
end
end
- def filter_label_id_params
+ def filter_id_params
# merge_request.assign_attributes(...) below is a Rails
# method that only work if all the params it is passed have
# corresponding fields in the database. As there are no fields
- # in the database for :add_label_ids and :remove_label_ids, we
+ # in the database for :add_label_ids, :remove_label_ids,
+ # :add_assignee_ids and :remove_assignee_ids, we
# need to remove them from the params before the call to
# merge_request.assign_attributes(...)
#
- # IssuableBaseService#process_label_ids takes care
+ # IssuableBaseService#process_label_ids and
+ # IssuableBaseService#process_assignee_ids take care
# of the removal.
params[:label_ids] = process_label_ids(params, extra_label_ids: merge_request.label_ids.to_a)
+ params[:assignee_ids] = process_assignee_ids(params, extra_assignee_ids: merge_request.assignee_ids.to_a)
+
merge_request.assign_attributes(params.to_h.compact)
end
+ def process_params
+ # Force remove the source branch?
+ merge_request.merge_params['force_remove_source_branch'] = force_remove_source_branch
+
+ # Only assign merge requests params that are allowed
+ self.params = assign_allowed_merge_params(merge_request, params)
+
+ # Filter out params that are either not allowed or invalid
+ filter_params(merge_request)
+
+ # Filter out the following from params:
+ # - :add_label_ids and :remove_label_ids
+ # - :add_assignee_ids and :remove_assignee_ids
+ filter_id_params
+ end
+
def find_source_project
source_project = project_from_params(:source_project)
return source_project if source_project.present? && can?(current_user, :create_merge_request_from, source_project)
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index ac84a13f437..8186472ec65 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -14,16 +14,12 @@ module MergeRequests
end
def after_create(issuable)
+ issuable.mark_as_preparing
+
# Add new items to MergeRequests::AfterCreateService if they can
# be performed in Sidekiq
NewMergeRequestWorker.perform_async(issuable.id, current_user.id)
- todo_service.new_merge_request(issuable, current_user)
- issuable.cache_merge_request_closes_issues!(current_user)
-
- Gitlab::UsageDataCounters::MergeRequestCounter.count(:create)
- link_lfs_objects(issuable)
-
super
end
@@ -54,10 +50,6 @@ module MergeRequests
raise Gitlab::Access::AccessDeniedError
end
end
-
- def link_lfs_objects(issuable)
- LinkLfsObjectsService.new(issuable.target_project).execute(issuable)
- end
end
end
diff --git a/app/services/merge_requests/handle_assignees_change_service.rb b/app/services/merge_requests/handle_assignees_change_service.rb
new file mode 100644
index 00000000000..77ff0791eb4
--- /dev/null
+++ b/app/services/merge_requests/handle_assignees_change_service.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class HandleAssigneesChangeService < MergeRequests::BaseService
+ def async_execute(merge_request, old_assignees, options = {})
+ if Feature.enabled?(:async_handle_merge_request_assignees_change, merge_request.target_project, default_enabled: :yaml)
+ MergeRequests::HandleAssigneesChangeWorker
+ .perform_async(
+ merge_request.id,
+ current_user.id,
+ old_assignees.map(&:id),
+ options
+ )
+ else
+ execute(merge_request, old_assignees, options)
+ end
+ end
+
+ def execute(merge_request, old_assignees, options = {})
+ create_assignee_note(merge_request, old_assignees)
+ notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignees.to_a)
+ todo_service.reassigned_assignable(merge_request, current_user, old_assignees)
+
+ new_assignees = merge_request.assignees - old_assignees
+ merge_request_activity_counter.track_users_assigned_to_mr(users: new_assignees)
+ merge_request_activity_counter.track_assignees_changed_action(user: current_user)
+
+ execute_assignees_hooks(merge_request, old_assignees) if options[:execute_hooks]
+ end
+
+ private
+
+ def execute_assignees_hooks(merge_request, old_assignees)
+ execute_hooks(
+ merge_request,
+ 'update',
+ old_associations: { assignees: old_assignees }
+ )
+ end
+ end
+end
+
+MergeRequests::HandleAssigneesChangeService.prepend_if_ee('EE::MergeRequests::HandleAssigneesChangeService')
diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb
index c0115e94903..e07e0c985b4 100644
--- a/app/services/merge_requests/merge_to_ref_service.rb
+++ b/app/services/merge_requests/merge_to_ref_service.rb
@@ -66,7 +66,13 @@ module MergeRequests
end
def commit
- repository.merge_to_ref(current_user, source, merge_request, target_ref, commit_message, first_parent_ref, allow_conflicts)
+ repository.merge_to_ref(current_user,
+ source_sha: source,
+ branch: merge_request.target_branch,
+ target_ref: target_ref,
+ message: commit_message,
+ first_parent_ref: first_parent_ref,
+ allow_conflicts: allow_conflicts)
rescue Gitlab::Git::PreReceiveError, Gitlab::Git::CommandError => error
raise MergeError, error.message
end
diff --git a/app/services/merge_requests/migrate_external_diffs_service.rb b/app/services/merge_requests/migrate_external_diffs_service.rb
index 89b1e594c95..b1d2cd5d1c7 100644
--- a/app/services/merge_requests/migrate_external_diffs_service.rb
+++ b/app/services/merge_requests/migrate_external_diffs_service.rb
@@ -2,7 +2,7 @@
module MergeRequests
class MigrateExternalDiffsService < ::BaseService
- MAX_JOBS = 1000.freeze
+ MAX_JOBS = 1000
attr_reader :diff
diff --git a/app/services/merge_requests/push_options_handler_service.rb b/app/services/merge_requests/push_options_handler_service.rb
index 821558b8d6f..05ec87c7d60 100644
--- a/app/services/merge_requests/push_options_handler_service.rb
+++ b/app/services/merge_requests/push_options_handler_service.rb
@@ -129,7 +129,9 @@ module MergeRequests
target_branch: push_options[:target],
force_remove_source_branch: push_options[:remove_source_branch],
label: push_options[:label],
- unlabel: push_options[:unlabel]
+ unlabel: push_options[:unlabel],
+ assign: push_options[:assign],
+ unassign: push_options[:unassign]
}
params.compact!
@@ -137,6 +139,9 @@ module MergeRequests
params[:add_labels] = params.delete(:label).keys if params.has_key?(:label)
params[:remove_labels] = params.delete(:unlabel).keys if params.has_key?(:unlabel)
+ params[:add_assignee_ids] = params.delete(:assign).keys if params.has_key?(:assign)
+ params[:remove_assignee_ids] = params.delete(:unassign).keys if params.has_key?(:unassign)
+
params
end
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index 0fb16597aff..e04c5168cef 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -162,9 +162,12 @@ module MergeRequests
end
def refresh_pipelines_on_merge_requests(merge_request)
- create_pipeline_for(merge_request, current_user)
-
- UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
+ if Feature.enabled?(:code_review_async_pipeline_creation, project, default_enabled: :yaml)
+ create_pipeline_for(merge_request, current_user, async: true)
+ else
+ create_pipeline_for(merge_request, current_user, async: false)
+ UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
+ end
end
def abort_auto_merges(merge_request)
diff --git a/app/services/merge_requests/resolve_todos_service.rb b/app/services/merge_requests/resolve_todos_service.rb
new file mode 100644
index 00000000000..0010b596eee
--- /dev/null
+++ b/app/services/merge_requests/resolve_todos_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class ResolveTodosService
+ include BaseServiceUtility
+
+ def initialize(merge_request, user)
+ @merge_request = merge_request
+ @user = user
+ end
+
+ def async_execute
+ if Feature.enabled?(:resolve_merge_request_todos_async, merge_request.target_project, default_enabled: :yaml)
+ MergeRequests::ResolveTodosWorker.perform_async(merge_request.id, user.id)
+ else
+ execute
+ end
+ end
+
+ def execute
+ todo_service.resolve_todos_for_target(merge_request, user)
+ end
+
+ private
+
+ attr_reader :merge_request, :user
+ end
+end
diff --git a/app/services/merge_requests/retarget_chain_service.rb b/app/services/merge_requests/retarget_chain_service.rb
index f24d67243c9..e8101e447d2 100644
--- a/app/services/merge_requests/retarget_chain_service.rb
+++ b/app/services/merge_requests/retarget_chain_service.rb
@@ -17,7 +17,7 @@ module MergeRequests
.opened
.by_target_branch(merge_request.source_branch)
.preload_source_project
- .at_most(MAX_RETARGET_MERGE_REQUESTS)
+ .limit(MAX_RETARGET_MERGE_REQUESTS)
other_merge_requests.find_each do |other_merge_request|
# Update only MRs on projects that we have access to
diff --git a/app/services/merge_requests/update_assignees_service.rb b/app/services/merge_requests/update_assignees_service.rb
new file mode 100644
index 00000000000..b339a644e8c
--- /dev/null
+++ b/app/services/merge_requests/update_assignees_service.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class UpdateAssigneesService < UpdateService
+ # a stripped down service that only does what it must to update the
+ # assignees, and knows that it does not have to check for other updates.
+ # This saves a lot of queries for irrelevant things that cannot possibly
+ # change in the execution of this service.
+ def execute(merge_request)
+ return merge_request unless current_user&.can?(:update_merge_request, merge_request)
+
+ old_assignees = merge_request.assignees
+ old_ids = old_assignees.map(&:id)
+ new_ids = new_assignee_ids(merge_request)
+ return merge_request if new_ids.size != update_attrs[:assignee_ids].size
+ return merge_request if old_ids.to_set == new_ids.to_set # no-change
+
+ attrs = update_attrs.merge(assignee_ids: new_ids)
+ merge_request.update!(**attrs)
+
+ # Defer the more expensive operations (handle_assignee_changes) to the background
+ MergeRequests::HandleAssigneesChangeService
+ .new(project, current_user)
+ .async_execute(merge_request, old_assignees, execute_hooks: true)
+
+ merge_request
+ end
+
+ private
+
+ def new_assignee_ids(merge_request)
+ # prime the cache - prevent N+1 lookup during authorization loop.
+ merge_request.project.team.max_member_access_for_user_ids(update_attrs[:assignee_ids])
+ User.id_in(update_attrs[:assignee_ids]).map do |user|
+ if user.can?(:read_merge_request, merge_request)
+ user.id
+ else
+ merge_request.errors.add(
+ :assignees,
+ "Cannot assign #{user.to_reference} to #{merge_request.to_reference}"
+ )
+ nil
+ end
+ end.compact
+ end
+
+ def assignee_ids
+ params.fetch(:assignee_ids).first(1)
+ end
+
+ def params
+ ps = super
+
+ # allow either assignee_id or assignee_ids, preferring assignee_id if passed.
+ { assignee_ids: ps.key?(:assignee_id) ? Array.wrap(ps[:assignee_id]) : ps[:assignee_ids] }
+ end
+
+ def update_attrs
+ @attrs ||= { updated_at: Time.current, updated_by: current_user, assignee_ids: assignee_ids }
+ end
+ end
+end
+
+MergeRequests::UpdateAssigneesService.prepend_if_ee('EE::MergeRequests::UpdateAssigneesService')
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index f5e14797f7e..8995c5f2411 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -11,18 +11,7 @@ module MergeRequests
end
def execute(merge_request)
- # We don't allow change of source/target projects and source branch
- # after merge request was created
- params.delete(:source_project_id)
- params.delete(:target_project_id)
- params.delete(:source_branch)
-
- if merge_request.closed_or_merged_without_fork?
- params.delete(:target_branch)
- params.delete(:force_remove_source_branch)
- end
-
- update_task_event(merge_request) || update(merge_request)
+ update_merge_request_with_specialized_service(merge_request) || general_fallback(merge_request)
end
def handle_changes(merge_request, options)
@@ -86,6 +75,21 @@ module MergeRequests
attr_reader :target_branch_was_deleted
+ def general_fallback(merge_request)
+ # We don't allow change of source/target projects and source branch
+ # after merge request was created
+ params.delete(:source_project_id)
+ params.delete(:target_project_id)
+ params.delete(:source_branch)
+
+ if merge_request.closed_or_merged_without_fork?
+ params.delete(:target_branch)
+ params.delete(:force_remove_source_branch)
+ end
+
+ update_task_event(merge_request) || update(merge_request)
+ end
+
def track_title_and_desc_edits(changed_fields)
tracked_fields = %w(title description)
@@ -147,7 +151,11 @@ module MergeRequests
def resolve_todos(merge_request, old_labels, old_assignees, old_reviewers)
return unless has_changes?(merge_request, old_labels: old_labels, old_assignees: old_assignees, old_reviewers: old_reviewers)
- todo_service.resolve_todos_for_target(merge_request, current_user)
+ service_user = current_user
+
+ merge_request.run_after_commit_or_now do
+ ::MergeRequests::ResolveTodosService.new(merge_request, service_user).async_execute
+ end
end
def handle_target_branch_change(merge_request)
@@ -200,21 +208,22 @@ module MergeRequests
merge_request_activity_counter.track_milestone_changed_action(user: current_user)
+ previous_milestone = Milestone.find_by_id(merge_request.previous_changes['milestone_id'].first)
+ delete_milestone_total_merge_requests_counter_cache(previous_milestone)
+
if merge_request.milestone.nil?
notification_service.async.removed_milestone_merge_request(merge_request, current_user)
else
notification_service.async.changed_milestone_merge_request(merge_request, merge_request.milestone, current_user)
+
+ delete_milestone_total_merge_requests_counter_cache(merge_request.milestone)
end
end
def handle_assignees_change(merge_request, old_assignees)
- create_assignee_note(merge_request, old_assignees)
- notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignees)
- todo_service.reassigned_assignable(merge_request, current_user, old_assignees)
-
- new_assignees = merge_request.assignees - old_assignees
- merge_request_activity_counter.track_users_assigned_to_mr(users: new_assignees)
- merge_request_activity_counter.track_assignees_changed_action(user: current_user)
+ MergeRequests::HandleAssigneesChangeService
+ .new(project, current_user)
+ .async_execute(merge_request, old_assignees)
end
def handle_reviewers_change(merge_request, old_reviewers)
@@ -267,6 +276,34 @@ module MergeRequests
def quick_action_options
{ merge_request_diff_head_sha: params.delete(:merge_request_diff_head_sha) }
end
+
+ def update_merge_request_with_specialized_service(merge_request)
+ return unless params.delete(:use_specialized_service)
+
+ # If we're attempting to modify only a single attribute, look up whether
+ # we have a specialized, targeted service we should use instead. We may
+ # in the future extend this to include specialized services that operate
+ # on multiple attributes, but for now limit to only single attribute
+ # updates.
+ #
+ return unless params.one?
+
+ attempt_specialized_update_services(merge_request, params.each_key.first.to_sym)
+ end
+
+ def attempt_specialized_update_services(merge_request, attribute)
+ case attribute
+ when :assignee_ids
+ assignees_service.execute(merge_request)
+ else
+ nil
+ end
+ end
+
+ def assignees_service
+ @assignees_service ||= ::MergeRequests::UpdateAssigneesService
+ .new(project, current_user, params)
+ end
end
end
diff --git a/app/services/metrics/dashboard/annotations/create_service.rb b/app/services/metrics/dashboard/annotations/create_service.rb
index c04f4c56b51..54f4e96378c 100644
--- a/app/services/metrics/dashboard/annotations/create_service.rb
+++ b/app/services/metrics/dashboard/annotations/create_service.rb
@@ -13,7 +13,8 @@ module Metrics
:create
def initialize(user, params)
- @user, @params = user, params
+ @user = user
+ @params = params
end
def execute
diff --git a/app/services/metrics/dashboard/annotations/delete_service.rb b/app/services/metrics/dashboard/annotations/delete_service.rb
index c6a6c4f5fbf..3efe6924a9b 100644
--- a/app/services/metrics/dashboard/annotations/delete_service.rb
+++ b/app/services/metrics/dashboard/annotations/delete_service.rb
@@ -11,7 +11,8 @@ module Metrics
:delete
def initialize(user, annotation)
- @user, @annotation = user, annotation
+ @user = user
+ @annotation = annotation
end
def execute
diff --git a/app/services/metrics/dashboard/grafana_metric_embed_service.rb b/app/services/metrics/dashboard/grafana_metric_embed_service.rb
index b8c5c17c738..6069d236e82 100644
--- a/app/services/metrics/dashboard/grafana_metric_embed_service.rb
+++ b/app/services/metrics/dashboard/grafana_metric_embed_service.rb
@@ -122,7 +122,8 @@ module Metrics
# Identifies the uid of the dashboard based on url format
class GrafanaUidParser
def initialize(grafana_url, project)
- @grafana_url, @project = grafana_url, project
+ @grafana_url = grafana_url
+ @project = project
end
def parse
@@ -145,7 +146,8 @@ module Metrics
# If no panel is specified, defaults to the first valid panel.
class DatasourceNameParser
def initialize(grafana_url, grafana_dashboard)
- @grafana_url, @grafana_dashboard = grafana_url, grafana_dashboard
+ @grafana_url = grafana_url
+ @grafana_dashboard = grafana_dashboard
end
def parse
diff --git a/app/services/metrics/dashboard/panel_preview_service.rb b/app/services/metrics/dashboard/panel_preview_service.rb
index 5b24d817fb6..02dd908e229 100644
--- a/app/services/metrics/dashboard/panel_preview_service.rb
+++ b/app/services/metrics/dashboard/panel_preview_service.rb
@@ -22,7 +22,9 @@ module Metrics
].freeze
def initialize(project, panel_yaml, environment)
- @project, @panel_yaml, @environment = project, panel_yaml, environment
+ @project = project
+ @panel_yaml = panel_yaml
+ @environment = environment
end
def execute
diff --git a/app/services/metrics/users_starred_dashboards/create_service.rb b/app/services/metrics/users_starred_dashboards/create_service.rb
index 7784ed4eb4e..9642df87861 100644
--- a/app/services/metrics/users_starred_dashboards/create_service.rb
+++ b/app/services/metrics/users_starred_dashboards/create_service.rb
@@ -11,7 +11,9 @@ module Metrics
:create
def initialize(user, project, dashboard_path)
- @user, @project, @dashboard_path = user, project, dashboard_path
+ @user = user
+ @project = project
+ @dashboard_path = dashboard_path
end
def execute
diff --git a/app/services/metrics/users_starred_dashboards/delete_service.rb b/app/services/metrics/users_starred_dashboards/delete_service.rb
index 579715bd49f..229c0e8cfc0 100644
--- a/app/services/metrics/users_starred_dashboards/delete_service.rb
+++ b/app/services/metrics/users_starred_dashboards/delete_service.rb
@@ -5,7 +5,9 @@ module Metrics
module UsersStarredDashboards
class DeleteService < ::BaseService
def initialize(user, project, dashboard_path = nil)
- @user, @project, @dashboard_path = user, project, dashboard_path
+ @user = user
+ @project = project
+ @dashboard_path = dashboard_path
end
def execute
diff --git a/app/services/milestones/base_service.rb b/app/services/milestones/base_service.rb
index f30194c0bfe..0d7d855bf5e 100644
--- a/app/services/milestones/base_service.rb
+++ b/app/services/milestones/base_service.rb
@@ -6,7 +6,9 @@ module Milestones
attr_accessor :parent, :current_user, :params
def initialize(parent, user, params = {})
- @parent, @current_user, @params = parent, user, params.dup
+ @parent = parent
+ @current_user = user
+ @params = params.dup
super
end
end
diff --git a/app/services/milestones/find_or_create_service.rb b/app/services/milestones/find_or_create_service.rb
index 881011e5106..b467ff98f54 100644
--- a/app/services/milestones/find_or_create_service.rb
+++ b/app/services/milestones/find_or_create_service.rb
@@ -5,7 +5,9 @@ module Milestones
attr_accessor :project, :current_user, :params
def initialize(project, user, params = {})
- @project, @current_user, @params = project, user, params.dup
+ @project = project
+ @current_user = user
+ @params = params.dup
end
def execute
diff --git a/app/services/milestones/merge_requests_count_service.rb b/app/services/milestones/merge_requests_count_service.rb
new file mode 100644
index 00000000000..be9ce3af44d
--- /dev/null
+++ b/app/services/milestones/merge_requests_count_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Milestones
+ class MergeRequestsCountService < BaseCountService
+ def initialize(milestone)
+ @milestone = milestone
+ end
+
+ def cache_key
+ "milestone_merge_requests_count_#{@milestone.milestoneish_id}"
+ end
+
+ def relation_for_count
+ @milestone.merge_requests
+ end
+ end
+end
diff --git a/app/services/milestones/transfer_service.rb b/app/services/milestones/transfer_service.rb
index 18d7e41adc7..b9bd259ca8b 100644
--- a/app/services/milestones/transfer_service.rb
+++ b/app/services/milestones/transfer_service.rb
@@ -24,6 +24,9 @@ module Milestones
update_issues_milestone(milestone, new_milestone)
update_merge_requests_milestone(milestone.id, new_milestone&.id)
+
+ delete_milestone_counts_caches(milestone)
+ delete_milestone_counts_caches(new_milestone)
end
end
end
@@ -71,9 +74,6 @@ module Milestones
def update_issues_milestone(old_milestone, new_milestone)
Issue.where(project: project, milestone_id: old_milestone.id)
.update_all(milestone_id: new_milestone&.id)
-
- delete_milestone_issues_caches(old_milestone)
- delete_milestone_issues_caches(new_milestone)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -84,11 +84,12 @@ module Milestones
end
# rubocop: enable CodeReuse/ActiveRecord
- def delete_milestone_issues_caches(milestone)
+ def delete_milestone_counts_caches(milestone)
return unless milestone
Milestones::IssuesCountService.new(milestone).delete_cache
Milestones::ClosedIssuesCountService.new(milestone).delete_cache
+ Milestones::MergeRequestsCountService.new(milestone).delete_cache
end
end
end
diff --git a/app/services/namespace_settings/update_service.rb b/app/services/namespace_settings/update_service.rb
index 3c9b7b637ac..c6c04b63690 100644
--- a/app/services/namespace_settings/update_service.rb
+++ b/app/services/namespace_settings/update_service.rb
@@ -13,12 +13,25 @@ module NamespaceSettings
end
def execute
+ validate_resource_access_token_creation_allowed_param
+
if group.namespace_settings
group.namespace_settings.attributes = settings_params
else
group.build_namespace_settings(settings_params)
end
end
+
+ private
+
+ def validate_resource_access_token_creation_allowed_param
+ return if settings_params[:resource_access_token_creation_allowed].nil?
+
+ unless can?(current_user, :admin_group, group)
+ settings_params.delete(:resource_access_token_creation_allowed)
+ group.namespace_settings.errors.add(:resource_access_token_creation_allowed, _('can only be changed by a group admin.'))
+ end
+ end
end
end
diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb
index f009f5d8538..eb81253bc08 100644
--- a/app/services/namespaces/in_product_marketing_emails_service.rb
+++ b/app/services/namespaces/in_product_marketing_emails_service.rb
@@ -23,10 +23,12 @@ module Namespaces
def initialize(track, interval)
@track = track
@interval = interval
- @sent_email_user_ids = []
+ @in_product_marketing_email_records = []
end
def execute
+ raise ArgumentError, "Track #{track} not defined" unless TRACKS.key?(track)
+
groups_for_track.each_batch do |groups|
groups.each do |group|
send_email_for_group(group)
@@ -36,16 +38,23 @@ module Namespaces
private
- attr_reader :track, :interval, :sent_email_user_ids
+ attr_reader :track, :interval, :in_product_marketing_email_records
def send_email_for_group(group)
- experiment_enabled_for_group = experiment_enabled_for_group?(group)
- experiment_add_group(group, experiment_enabled_for_group)
- return unless experiment_enabled_for_group
+ if Gitlab.com?
+ experiment_enabled_for_group = experiment_enabled_for_group?(group)
+ experiment_add_group(group, experiment_enabled_for_group)
+ return unless experiment_enabled_for_group
+ end
users_for_group(group).each do |user|
- send_email(user, group) if can_perform_action?(user, group)
+ if can_perform_action?(user, group)
+ send_email(user, group)
+ track_sent_email(user, track, series)
+ end
end
+
+ save_tracked_emails!
end
def experiment_enabled_for_group?(group)
@@ -70,8 +79,9 @@ module Namespaces
end
def users_for_group(group)
- group.users.where(email_opted_in: true)
- .where.not(id: sent_email_user_ids)
+ group.users
+ .where(email_opted_in: true)
+ .merge(Users::InProductMarketingEmail.without_track_and_series(track, series))
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -85,14 +95,11 @@ module Namespaces
user.can?(:start_trial, group)
when :team
user.can?(:admin_group_member, group)
- else
- raise NotImplementedError, "No ability defined for track #{track}"
end
end
def send_email(user, group)
NotificationService.new.in_product_marketing(user.id, group.id, track, series)
- sent_email_user_ids << user.id
end
def completed_actions
@@ -101,7 +108,8 @@ module Namespaces
end
def range
- (interval + 1).days.ago.beginning_of_day..(interval + 1).days.ago.end_of_day
+ date = (interval + 1).days.ago
+ date.beginning_of_day..date.end_of_day
end
def incomplete_action
@@ -111,5 +119,20 @@ module Namespaces
def series
INTERVAL_DAYS.index(interval)
end
+
+ def save_tracked_emails!
+ Users::InProductMarketingEmail.bulk_insert!(in_product_marketing_email_records)
+ @in_product_marketing_email_records = []
+ end
+
+ def track_sent_email(user, track, series)
+ in_product_marketing_email_records << Users::InProductMarketingEmail.new(
+ user: user,
+ track: track,
+ series: series,
+ created_at: Time.zone.now,
+ updated_at: Time.zone.now
+ )
+ end
end
end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 488c847dcbb..e63099a0820 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -75,16 +75,9 @@ module Notes
increment_usage_counter(note)
track_event(note, current_user)
- if Feature.enabled?(:notes_create_service_tracking, project)
- Gitlab::Tracking.event('Notes::CreateService', 'execute', **tracking_data_for(note))
- end
-
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?
- track_note_creation_usage_for_merge_requests(note) if note.for_merge_request?
end
def do_commands(note, update_params, message, only_commands)
@@ -111,6 +104,16 @@ module Notes
}
end
+ def track_event(note, user)
+ track_note_creation_usage_for_issues(note) if note.for_issue?
+ track_note_creation_usage_for_merge_requests(note) if note.for_merge_request?
+ track_usage_event(:incident_management_incident_comment, user.id) if note.for_issue? && note.noteable.incident?
+
+ if Feature.enabled?(:notes_create_service_tracking, project)
+ Gitlab::Tracking.event('Notes::CreateService', 'execute', **tracking_data_for(note))
+ end
+ end
+
def tracking_data_for(note)
label = Gitlab.ee? && note.author == User.visual_review_bot ? 'anonymous_visual_review_note' : 'note'
@@ -120,12 +123,6 @@ module Notes
}
end
- def track_event(note, user)
- return unless note.noteable.is_a?(Issue) && note.noteable.incident?
-
- 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
@@ -135,3 +132,5 @@ module Notes
end
end
end
+
+Notes::CreateService.prepend_if_ee('EE::Notes::CreateService')
diff --git a/app/services/notification_recipients/builder/base.rb b/app/services/notification_recipients/builder/base.rb
index 81e6750a4ca..b41b969ad7c 100644
--- a/app/services/notification_recipients/builder/base.rb
+++ b/app/services/notification_recipients/builder/base.rb
@@ -100,6 +100,8 @@ module NotificationRecipients
# Get project/group users with CUSTOM notification level
# rubocop: disable CodeReuse/ActiveRecord
def add_custom_notifications
+ return new_add_custom_notifications if Feature.enabled?(:notification_setting_recipient_refactor, project)
+
user_ids = []
# Users with a notification setting on group or project
@@ -115,6 +117,48 @@ module NotificationRecipients
add_recipients(user_scope.where(id: user_ids), :custom, nil)
end
+
+ def new_add_custom_notifications
+ notification_by_sources = related_notification_settings_sources(:custom)
+
+ return if notification_by_sources.blank?
+
+ user_ids = NotificationSetting.from_union(notification_by_sources).select(:user_id)
+
+ add_recipients(user_scope.where(id: user_ids), :custom, nil)
+ end
+
+ def related_notification_settings_sources(level)
+ sources = [project, group].compact
+
+ sources.map do |source|
+ source
+ .notification_settings
+ .where(source_or_global_setting_by_level_query(level)).select(:user_id)
+ end
+ end
+
+ def global_setting_by_level_query(level)
+ table = NotificationSetting.arel_table
+ aliased_table = table.alias
+
+ table
+ .project('true')
+ .from(aliased_table)
+ .where(
+ aliased_table[:user_id].eq(table[:user_id])
+ .and(aliased_table[:source_id].eq(nil))
+ .and(aliased_table[:source_type].eq(nil))
+ .and(aliased_table[:level].eq(level))
+ ).exists
+ end
+
+ def source_or_global_setting_by_level_query(level)
+ table = NotificationSetting.arel_table
+ table.grouping(
+ table[:level].eq(:global).and(global_setting_by_level_query(level))
+ ).or(table[:level].eq(level))
+ end
# rubocop: enable CodeReuse/ActiveRecord
def add_project_watchers
diff --git a/app/services/notification_recipients/builder/request_review.rb b/app/services/notification_recipients/builder/request_review.rb
index 911d89c6a8e..8dd0c5d1587 100644
--- a/app/services/notification_recipients/builder/request_review.rb
+++ b/app/services/notification_recipients/builder/request_review.rb
@@ -6,7 +6,9 @@ module NotificationRecipients
attr_reader :merge_request, :current_user, :reviewer
def initialize(merge_request, current_user, reviewer)
- @merge_request, @current_user, @reviewer = merge_request, current_user, reviewer
+ @merge_request = merge_request
+ @current_user = current_user
+ @reviewer = reviewer
end
def target
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index fc2eb1dc4e4..6f1f3309ad9 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -79,6 +79,20 @@ class NotificationService
mailer.access_token_expired_email(user).deliver_later
end
+ # Notify the user when at least one of their ssh key has expired today
+ def ssh_key_expired(user, fingerprints)
+ return unless user.can?(:receive_notifications)
+
+ mailer.ssh_key_expired_email(user, fingerprints).deliver_later
+ end
+
+ # Notify the user when at least one of their ssh key is expiring soon
+ def ssh_key_expiring_soon(user, fingerprints)
+ return unless user.can?(:receive_notifications)
+
+ mailer.ssh_key_expiring_soon_email(user, fingerprints).deliver_later
+ end
+
# Notify a user when a previously unknown IP or device is used to
# sign in to their account
def unknown_sign_in(user, ip, time)
@@ -857,7 +871,7 @@ class NotificationService
end
def warn_skipping_notifications(user, object)
- Gitlab::AppLogger.warn(message: "Skipping sending notifications", user: user.id, klass: object.class, object_id: object.id)
+ Gitlab::AppLogger.warn(message: "Skipping sending notifications", user: user.id, klass: object.class.to_s, object_id: object.id)
end
end
diff --git a/app/services/packages/composer/composer_json_service.rb b/app/services/packages/composer/composer_json_service.rb
index 98aabd84d3d..f346b654c59 100644
--- a/app/services/packages/composer/composer_json_service.rb
+++ b/app/services/packages/composer/composer_json_service.rb
@@ -6,7 +6,8 @@ module Packages
InvalidJson = Class.new(StandardError)
def initialize(project, target)
- @project, @target = project, target
+ @project = project
+ @target = target
end
def execute
diff --git a/app/services/packages/composer/version_parser_service.rb b/app/services/packages/composer/version_parser_service.rb
index 811cac0b3b7..36275d1b680 100644
--- a/app/services/packages/composer/version_parser_service.rb
+++ b/app/services/packages/composer/version_parser_service.rb
@@ -4,7 +4,8 @@ module Packages
module Composer
class VersionParserService
def initialize(tag_name: nil, branch_name: nil)
- @tag_name, @branch_name = tag_name, branch_name
+ @tag_name = tag_name
+ @branch_name = branch_name
end
def execute
diff --git a/app/services/packages/debian/create_distribution_service.rb b/app/services/packages/debian/create_distribution_service.rb
index c6df033e3c1..f947d2e4293 100644
--- a/app/services/packages/debian/create_distribution_service.rb
+++ b/app/services/packages/debian/create_distribution_service.rb
@@ -4,7 +4,8 @@ module Packages
module Debian
class CreateDistributionService
def initialize(container, user, params)
- @container, @params = container, params
+ @container = container
+ @params = params
@params[:creator] = user
@components = params.delete(:components) || ['main']
diff --git a/app/services/packages/debian/extract_changes_metadata_service.rb b/app/services/packages/debian/extract_changes_metadata_service.rb
new file mode 100644
index 00000000000..eb5baa7e53f
--- /dev/null
+++ b/app/services/packages/debian/extract_changes_metadata_service.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+module Packages
+ module Debian
+ class ExtractChangesMetadataService
+ include Gitlab::Utils::StrongMemoize
+
+ ExtractionError = Class.new(StandardError)
+
+ def initialize(package_file)
+ @package_file = package_file
+ @entries = {}
+ end
+
+ def execute
+ {
+ file_type: file_type,
+ architecture: metadata[:architecture],
+ fields: fields,
+ files: files
+ }
+ rescue ActiveModel::ValidationError => e
+ raise ExtractionError.new(e.message)
+ end
+
+ private
+
+ def metadata
+ strong_memoize(:metadata) do
+ ::Packages::Debian::ExtractMetadataService.new(@package_file).execute
+ end
+ end
+
+ def file_type
+ metadata[:file_type]
+ end
+
+ def fields
+ metadata[:fields]
+ end
+
+ def files
+ strong_memoize(:files) do
+ raise ExtractionError.new("is not a changes file") unless file_type == :changes
+ raise ExtractionError.new("Files field is missing") if fields['Files'].blank?
+ raise ExtractionError.new("Checksums-Sha1 field is missing") if fields['Checksums-Sha1'].blank?
+ raise ExtractionError.new("Checksums-Sha256 field is missing") if fields['Checksums-Sha256'].blank?
+
+ init_entries_from_files
+ entries_from_checksums_sha1
+ entries_from_checksums_sha256
+ entries_from_package_files
+
+ @entries
+ end
+ end
+
+ def init_entries_from_files
+ each_lines_for('Files') do |line|
+ md5sum, size, section, priority, filename = line.split
+ entry = FileEntry.new(
+ filename: filename,
+ size: size.to_i,
+ md5sum: md5sum,
+ section: section,
+ priority: priority)
+
+ @entries[filename] = entry
+ end
+ end
+
+ def entries_from_checksums_sha1
+ each_lines_for('Checksums-Sha1') do |line|
+ sha1sum, size, filename = line.split
+ entry = @entries[filename]
+ raise ExtractionError.new("#{filename} is listed in Checksums-Sha1 but not in Files") unless entry
+ raise ExtractionError.new("Size for #{filename} in Files and Checksums-Sha1 differ") unless entry.size == size.to_i
+
+ entry.sha1sum = sha1sum
+ end
+ end
+
+ def entries_from_checksums_sha256
+ each_lines_for('Checksums-Sha256') do |line|
+ sha256sum, size, filename = line.split
+ entry = @entries[filename]
+ raise ExtractionError.new("#{filename} is listed in Checksums-Sha256 but not in Files") unless entry
+ raise ExtractionError.new("Size for #{filename} in Files and Checksums-Sha256 differ") unless entry.size == size.to_i
+
+ entry.sha256sum = sha256sum
+ end
+ end
+
+ def each_lines_for(field)
+ fields[field].split("\n").each do |line|
+ next if line.blank?
+
+ yield(line)
+ end
+ end
+
+ def entries_from_package_files
+ @entries.each do |filename, entry|
+ entry.package_file = ::Packages::PackageFileFinder.new(@package_file.package, filename).execute!
+ entry.validate!
+ rescue ActiveRecord::RecordNotFound
+ raise ExtractionError.new("#{filename} is listed in Files but was not uploaded")
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/debian/extract_metadata_service.rb b/app/services/packages/debian/extract_metadata_service.rb
index fd5832bc0ba..015f472c7c9 100644
--- a/app/services/packages/debian/extract_metadata_service.rb
+++ b/app/services/packages/debian/extract_metadata_service.rb
@@ -58,21 +58,22 @@ module Packages
file_type == :dsc || file_type == :buildinfo || file_type == :changes
end
- def extracted_fields
- if file_type_debian?
- package_file.file.use_file do |file_path|
- ::Packages::Debian::ExtractDebMetadataService.new(file_path).execute
- end
- elsif file_type_meta?
- package_file.file.use_file do |file_path|
- ::Packages::Debian::ParseDebian822Service.new(File.read(file_path)).execute.each_value.first
+ def fields
+ strong_memoize(:fields) do
+ if file_type_debian?
+ package_file.file.use_file do |file_path|
+ ::Packages::Debian::ExtractDebMetadataService.new(file_path).execute
+ end
+ elsif file_type_meta?
+ package_file.file.use_file do |file_path|
+ ::Packages::Debian::ParseDebian822Service.new(File.read(file_path)).execute.each_value.first
+ end
end
end
end
def extract_metadata
- fields = extracted_fields
- architecture = fields.delete(:Architecture) if file_type_debian?
+ architecture = fields['Architecture'] if file_type_debian?
{
file_type: file_type,
diff --git a/app/services/packages/debian/parse_debian822_service.rb b/app/services/packages/debian/parse_debian822_service.rb
index 665929d2324..8be5fdf3b66 100644
--- a/app/services/packages/debian/parse_debian822_service.rb
+++ b/app/services/packages/debian/parse_debian822_service.rb
@@ -26,7 +26,7 @@ module Packages
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
+ field = match[:field]
raise InvalidDebian822Error, "Duplicate field '#{field}' in section '#{section_name}'" if section.include?(field)
diff --git a/app/services/packages/debian/process_changes_service.rb b/app/services/packages/debian/process_changes_service.rb
new file mode 100644
index 00000000000..881ad2c46f4
--- /dev/null
+++ b/app/services/packages/debian/process_changes_service.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+module Packages
+ module Debian
+ class ProcessChangesService
+ include ExclusiveLeaseGuard
+ include Gitlab::Utils::StrongMemoize
+
+ # used by ExclusiveLeaseGuard
+ DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze
+
+ def initialize(package_file, creator)
+ @package_file = package_file
+ @creator = creator
+ end
+
+ def execute
+ try_obtain_lease do
+ # return if changes file has already been processed
+ break if package_file.debian_file_metadatum&.changes?
+
+ validate!
+
+ package_file.transaction do
+ update_files_metadata
+ update_changes_metadata
+ end
+ end
+ end
+
+ private
+
+ attr_reader :package_file, :creator
+
+ def validate!
+ raise ArgumentError, 'invalid package file' unless package_file.debian_file_metadatum
+ raise ArgumentError, 'invalid package file' unless package_file.debian_file_metadatum.unknown?
+ raise ArgumentError, 'invalid package file' unless metadata[:file_type] == :changes
+ end
+
+ def update_files_metadata
+ files.each do |filename, entry|
+ entry.package_file.package = package
+
+ file_metadata = ::Packages::Debian::ExtractMetadataService.new(entry.package_file).execute
+
+ entry.package_file.debian_file_metadatum.update!(
+ file_type: file_metadata[:file_type],
+ component: files[filename].component,
+ architecture: file_metadata[:architecture],
+ fields: file_metadata[:fields]
+ )
+ entry.package_file.save!
+ end
+ end
+
+ def update_changes_metadata
+ package_file.update!(package: package)
+ package_file.debian_file_metadatum.update!(
+ file_type: metadata[:file_type],
+ fields: metadata[:fields]
+ )
+ end
+
+ def metadata
+ strong_memoize(:metadata) do
+ ::Packages::Debian::ExtractChangesMetadataService.new(package_file).execute
+ end
+ end
+
+ def files
+ metadata[:files]
+ end
+
+ def project
+ package_file.package.project
+ end
+
+ def package
+ strong_memoize(:package) do
+ params = {
+ 'name': metadata[:fields]['Source'],
+ 'version': metadata[:fields]['Version'],
+ 'distribution_name': metadata[:fields]['Distribution']
+ }
+ response = Packages::Debian::FindOrCreatePackageService.new(project, creator, params).execute
+ response.payload[:package]
+ end
+ end
+
+ # used by ExclusiveLeaseGuard
+ def lease_key
+ "packages:debian:process_changes_service:package_file:#{package_file.id}"
+ end
+
+ # used by ExclusiveLeaseGuard
+ def lease_timeout
+ DEFAULT_LEASE_TIMEOUT
+ end
+ end
+ end
+end
diff --git a/app/services/packages/debian/update_distribution_service.rb b/app/services/packages/debian/update_distribution_service.rb
index 5bb59b854e9..95face912d5 100644
--- a/app/services/packages/debian/update_distribution_service.rb
+++ b/app/services/packages/debian/update_distribution_service.rb
@@ -4,7 +4,8 @@ module Packages
module Debian
class UpdateDistributionService
def initialize(distribution, params)
- @distribution, @params = distribution, params
+ @distribution = distribution
+ @params = params
@components = params.delete(:components)
diff --git a/app/services/packages/go/create_package_service.rb b/app/services/packages/go/create_package_service.rb
new file mode 100644
index 00000000000..4e8b8ef8d6b
--- /dev/null
+++ b/app/services/packages/go/create_package_service.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module Packages
+ module Go
+ class CreatePackageService < BaseService
+ GoZipSizeError = Class.new(StandardError)
+
+ attr_accessor :version
+
+ def initialize(project, user = nil, version:)
+ super(project, user)
+
+ @version = version
+ end
+
+ def execute
+ # check for existing package to avoid SQL errors due to the index
+ package = ::Packages::Go::PackageFinder.new(version.mod.project, version.mod.name, version.name).execute
+ return package if package
+
+ # this can be expensive, so do it outside the transaction
+ files = {}
+ files[:mod] = prepare_file(version, :mod, version.gomod)
+ files[:zip] = prepare_file(version, :zip, version.archive.string)
+
+ ActiveRecord::Base.transaction do
+ # create new package and files
+ package = create_package
+ files.each { |type, (file, digests)| create_file(package, type, file, digests) }
+ package
+ end
+ end
+
+ private
+
+ def prepare_file(version, type, content)
+ file = CarrierWaveStringFile.new(content)
+ raise GoZipSizeError, "#{version.mod.name}@#{version.name}.#{type} exceeds size limit" if file.size > project.actual_limits.golang_max_file_size
+
+ digests = {
+ md5: Digest::MD5.hexdigest(content),
+ sha1: Digest::SHA1.hexdigest(content),
+ sha256: Digest::SHA256.hexdigest(content)
+ }
+
+ [file, digests]
+ end
+
+ def create_package
+ version.mod.project.packages.create!(
+ name: version.mod.name,
+ version: version.name,
+ package_type: :golang,
+ created_at: version.commit.committed_date
+ )
+ end
+
+ def create_file(package, type, file, digests)
+ CreatePackageFileService.new(package,
+ file: file,
+ size: file.size,
+ file_name: "#{version.name}.#{type}",
+ file_md5: digests[:md5],
+ file_sha1: digests[:sha1],
+ file_sha256: digests[:sha256]
+ ).execute
+ end
+ end
+ end
+end
diff --git a/app/services/packages/go/sync_packages_service.rb b/app/services/packages/go/sync_packages_service.rb
new file mode 100644
index 00000000000..c35d3600388
--- /dev/null
+++ b/app/services/packages/go/sync_packages_service.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Packages
+ module Go
+ class SyncPackagesService < BaseService
+ include Gitlab::Golang
+
+ def initialize(project, ref, path = '')
+ super(project)
+
+ @ref = ref
+ @path = path
+
+ raise ArgumentError, 'project is required' unless project
+ raise ArgumentError, 'ref is required' unless ref
+ raise ArgumentError, "ref #{ref} not found" unless project.repository.find_tag(ref) || project.repository.find_branch(ref)
+ end
+
+ def execute_async
+ Packages::Go::SyncPackagesWorker.perform_async(project.id, @ref, @path)
+ end
+ 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 401e52f7e51..a6cffa3038c 100644
--- a/app/services/packages/maven/find_or_create_package_service.rb
+++ b/app/services/packages/maven/find_or_create_package_service.rb
@@ -33,7 +33,8 @@ module Packages
#
# The first upload has to create the proper package (the one with the version set).
if params[:file_name] == Packages::Maven::Metadata.filename && !params[:path]&.ends_with?(SNAPSHOT_TERM)
- package_name, version = params[:path], nil
+ package_name = params[:path]
+ version = nil
else
package_name, _, version = params[:path].rpartition('/')
end
diff --git a/app/services/packages/maven/metadata/sync_service.rb b/app/services/packages/maven/metadata/sync_service.rb
index a6534aa706d..48e157d4930 100644
--- a/app/services/packages/maven/metadata/sync_service.rb
+++ b/app/services/packages/maven/metadata/sync_service.rb
@@ -13,16 +13,20 @@ module Packages
def execute
return error('Blank package name') unless package_name
return error('Not allowed') unless Ability.allowed?(current_user, :destroy_package, project)
- return error('Non existing versionless package') unless versionless_package_for_versions
- return error('Non existing metadata file for versions') unless metadata_package_file_for_versions
+ result = success('Non existing versionless package(s). Nothing to do.')
+
+ # update versionless package for plugins if it exists
if metadata_package_file_for_plugins
result = update_plugins_xml
return result if result.error?
end
- update_versions_xml
+ # update versionless_package for versions if it exists
+ return update_versions_xml if metadata_package_file_for_versions
+
+ result
end
private
@@ -79,6 +83,9 @@ module Packages
def metadata_package_file_for_plugins
strong_memoize(:metadata_package_file_for_plugins) do
+ pkg_name = package_name_for_plugins
+ next unless pkg_name
+
metadata_package_file_for(versionless_package_named(package_name_for_plugins))
end
end
@@ -106,6 +113,8 @@ module Packages
end
def package_name_for_plugins
+ return unless versionless_package_for_versions
+
group = versionless_package_for_versions.maven_metadatum.app_group
group.tr('.', '/')
end
diff --git a/app/services/packages/nuget/create_dependency_service.rb b/app/services/packages/nuget/create_dependency_service.rb
index 19143fe3778..62ab485c0fc 100644
--- a/app/services/packages/nuget/create_dependency_service.rb
+++ b/app/services/packages/nuget/create_dependency_service.rb
@@ -54,9 +54,9 @@ module Packages
end
def dependencies_for_create_dependency_service
- names_and_versions = @dependencies.map do |dependency|
+ names_and_versions = @dependencies.to_h do |dependency|
[dependency[:name], version_or_empty_string(dependency[:version])]
- end.to_h
+ end
{ 'dependencies' => names_and_versions }
end
diff --git a/app/services/packages/rubygems/create_dependencies_service.rb b/app/services/packages/rubygems/create_dependencies_service.rb
new file mode 100644
index 00000000000..dea429148cf
--- /dev/null
+++ b/app/services/packages/rubygems/create_dependencies_service.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Packages
+ module Rubygems
+ class CreateDependenciesService
+ include BulkInsertSafe
+
+ def initialize(package, gemspec)
+ @package = package
+ @gemspec = gemspec
+ end
+
+ def execute
+ set_dependencies
+ end
+
+ private
+
+ attr_reader :package, :gemspec
+
+ def set_dependencies
+ Packages::Dependency.transaction do
+ dependency_type_rows = gemspec.dependencies.map do |dependency|
+ dependency = Packages::Dependency.safe_find_or_create_by!(
+ name: dependency.name,
+ version_pattern: dependency.requirement.to_s
+ )
+
+ {
+ dependency_id: dependency.id,
+ package_id: package.id,
+ dependency_type: :dependencies
+ }
+ end
+
+ package.dependency_links.upsert_all(
+ dependency_type_rows,
+ unique_by: %i[package_id dependency_id dependency_type]
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/rubygems/create_gemspec_service.rb b/app/services/packages/rubygems/create_gemspec_service.rb
new file mode 100644
index 00000000000..22533264480
--- /dev/null
+++ b/app/services/packages/rubygems/create_gemspec_service.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Packages
+ module Rubygems
+ class CreateGemspecService
+ def initialize(package, gemspec)
+ @package = package
+ @gemspec = gemspec
+ end
+
+ def execute
+ write_gemspec_to_file
+ end
+
+ private
+
+ attr_reader :package, :gemspec
+
+ def write_gemspec_to_file
+ file = Tempfile.new
+
+ begin
+ content = gemspec.to_ruby
+ file.write(content)
+ file.flush
+
+ package.package_files.create!(
+ file: file,
+ size: file.size,
+ file_name: "#{gemspec.name}.gemspec",
+ file_sha1: Digest::SHA1.hexdigest(content),
+ file_md5: Digest::MD5.hexdigest(content),
+ file_sha256: Digest::SHA256.hexdigest(content)
+ )
+ ensure
+ file.close
+ file.unlink
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/packages/rubygems/metadata_extraction_service.rb b/app/services/packages/rubygems/metadata_extraction_service.rb
new file mode 100644
index 00000000000..b3bac1854d7
--- /dev/null
+++ b/app/services/packages/rubygems/metadata_extraction_service.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Packages
+ module Rubygems
+ class MetadataExtractionService
+ def initialize(package, gemspec)
+ @package = package
+ @gemspec = gemspec
+ end
+
+ def execute
+ write_metadata
+ end
+
+ private
+
+ attr_reader :package, :gemspec
+
+ # rubocop:disable Metrics/AbcSize
+ # rubocop:disable Metrics/PerceivedComplexity
+ # rubocop:disable Metrics/CyclomaticComplexity
+ def write_metadata
+ metadatum.update!(
+ authors: gemspec&.authors,
+ files: gemspec&.files&.to_json,
+ summary: gemspec&.summary,
+ description: gemspec&.description,
+ email: gemspec&.email,
+ homepage: gemspec&.homepage,
+ licenses: gemspec&.licenses&.to_json,
+ metadata: gemspec&.metadata&.to_json,
+ author: gemspec&.author,
+ bindir: gemspec&.bindir,
+ executables: gemspec&.executables&.to_json,
+ extensions: gemspec&.extensions&.to_json,
+ extra_rdoc_files: gemspec&.extra_rdoc_files&.to_json,
+ platform: gemspec&.platform,
+ post_install_message: gemspec&.post_install_message,
+ rdoc_options: gemspec&.rdoc_options&.to_json,
+ require_paths: gemspec&.require_paths&.to_json,
+ required_ruby_version: gemspec&.required_ruby_version&.to_s,
+ required_rubygems_version: gemspec&.required_rubygems_version&.to_s,
+ requirements: gemspec&.requirements&.to_json,
+ rubygems_version: gemspec&.rubygems_version
+ )
+ end
+ # rubocop:enable Metrics/AbcSize
+ # rubocop:enable Metrics/PerceivedComplexity
+ # rubocop:enable Metrics/CyclomaticComplexity
+
+ def metadatum
+ Packages::Rubygems::Metadatum.safe_find_or_create_by!(package: package)
+ end
+ end
+ end
+end
diff --git a/app/services/packages/rubygems/process_gem_service.rb b/app/services/packages/rubygems/process_gem_service.rb
new file mode 100644
index 00000000000..59bf2a1ec28
--- /dev/null
+++ b/app/services/packages/rubygems/process_gem_service.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'rubygems/package'
+
+module Packages
+ module Rubygems
+ class ProcessGemService
+ include Gitlab::Utils::StrongMemoize
+ include ExclusiveLeaseGuard
+
+ ExtractionError = Class.new(StandardError)
+ DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze
+
+ def initialize(package_file)
+ @package_file = package_file
+ end
+
+ def execute
+ return success if process_gem
+
+ error('Gem was not processed')
+ end
+
+ private
+
+ attr_reader :package_file
+
+ def process_gem
+ return false unless package_file
+
+ try_obtain_lease do
+ package.transaction do
+ rename_package_and_set_version
+ rename_package_file
+ ::Packages::Rubygems::MetadataExtractionService.new(package, gemspec).execute
+ ::Packages::Rubygems::CreateGemspecService.new(package, gemspec).execute
+ ::Packages::Rubygems::CreateDependenciesService.new(package, gemspec).execute
+ cleanup_temp_package
+ end
+ end
+
+ true
+ end
+
+ def rename_package_and_set_version
+ package.update!(
+ name: gemspec.name,
+ version: gemspec.version,
+ status: :default
+ )
+ end
+
+ def rename_package_file
+ # Updating file_name updates the path where the file is stored.
+ # We must pass the file again so that CarrierWave can handle the update
+ package_file.update!(
+ file_name: "#{gemspec.name}-#{gemspec.version}.gem",
+ file: package_file.file,
+ package_id: package.id
+ )
+ end
+
+ def cleanup_temp_package
+ temp_package.destroy if package.id != temp_package.id
+ end
+
+ def gemspec
+ strong_memoize(:gemspec) do
+ gem.spec
+ end
+ end
+
+ def success
+ ServiceResponse.success(payload: { package: package })
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def temp_package
+ strong_memoize(:temp_package) do
+ package_file.package
+ end
+ end
+
+ def package
+ strong_memoize(:package) do
+ # if package with name/version already exists, use that package
+ package = temp_package.project
+ .packages
+ .rubygems
+ .with_name(gemspec.name)
+ .with_version(gemspec.version.to_s)
+ .last
+ package || temp_package
+ end
+ end
+
+ def gem
+ # use_file will set an exclusive lease on the file for as long as
+ # the resulting gem object is being used. This means we are not
+ # able to rename the package_file while also using the gem object.
+ # We need to use a separate AR object to create the gem file to allow
+ # `package_file` to be free for update so we re-find the file here.
+ Packages::PackageFile.find(package_file.id).file.use_file do |file_path|
+ Gem::Package.new(File.open(file_path))
+ end
+ rescue
+ raise ExtractionError.new('Unable to read gem file')
+ end
+
+ # used by ExclusiveLeaseGuard
+ def lease_key
+ "packages:rubygems:process_gem_service:package:#{package.id}"
+ end
+
+ # used by ExclusiveLeaseGuard
+ def lease_timeout
+ DEFAULT_LEASE_TIMEOUT
+ end
+ end
+ end
+end
diff --git a/app/services/pages/delete_service.rb b/app/services/pages/delete_service.rb
index 3dc9254718e..c4009dcc4ec 100644
--- a/app/services/pages/delete_service.rb
+++ b/app/services/pages/delete_service.rb
@@ -9,7 +9,7 @@ module Pages
DestroyPagesDeploymentsWorker.perform_async(project.id)
# TODO: remove this call https://gitlab.com/gitlab-org/gitlab/-/issues/320775
- PagesRemoveWorker.perform_async(project.id) if Feature.enabled?(:pages_update_legacy_storage, default_enabled: true)
+ PagesRemoveWorker.perform_async(project.id) if ::Settings.pages.local_store.enabled
end
end
end
diff --git a/app/services/pages/migrate_from_legacy_storage_service.rb b/app/services/pages/migrate_from_legacy_storage_service.rb
index 9b36b3f11b4..b6aa08bba01 100644
--- a/app/services/pages/migrate_from_legacy_storage_service.rb
+++ b/app/services/pages/migrate_from_legacy_storage_service.rb
@@ -2,36 +2,45 @@
module Pages
class MigrateFromLegacyStorageService
- def initialize(logger, migration_threads:, batch_size:, ignore_invalid_entries:)
+ def initialize(logger, ignore_invalid_entries:, mark_projects_as_not_deployed:)
@logger = logger
- @migration_threads = migration_threads
- @batch_size = batch_size
@ignore_invalid_entries = ignore_invalid_entries
+ @mark_projects_as_not_deployed = mark_projects_as_not_deployed
@migrated = 0
@errored = 0
@counters_lock = Mutex.new
end
- def execute
+ def execute_with_threads(threads:, batch_size:)
@queue = SizedQueue.new(1)
- threads = start_migration_threads
+ migration_threads = start_migration_threads(threads)
- ProjectPagesMetadatum.only_on_legacy_storage.each_batch(of: @batch_size) do |batch|
+ ProjectPagesMetadatum.only_on_legacy_storage.each_batch(of: batch_size) do |batch|
@queue.push(batch)
end
@queue.close
- @logger.info("Waiting for threads to finish...")
- threads.each(&:join)
+ @logger.info(message: "Pages legacy storage migration: Waiting for threads to finish...")
+ migration_threads.each(&:join)
{ migrated: @migrated, errored: @errored }
end
- def start_migration_threads
- Array.new(@migration_threads) do
+ def execute_for_batch(project_ids)
+ batch = ProjectPagesMetadatum.only_on_legacy_storage.where(project_id: project_ids) # rubocop: disable CodeReuse/ActiveRecord
+
+ process_batch(batch)
+
+ { migrated: @migrated, errored: @errored }
+ end
+
+ private
+
+ def start_migration_threads(count)
+ Array.new(count) do
Thread.new do
while batch = @queue.pop
Rails.application.executor.wrap do
@@ -49,30 +58,32 @@ module Pages
migrate_project(project)
end
- @logger.info("#{@migrated} projects are migrated successfully, #{@errored} projects failed to be migrated")
+ @logger.info(message: "Pages legacy storage migration: batch processed", migrated: @migrated, errored: @errored)
rescue => e
# This method should never raise exception otherwise all threads might be killed
# and this will result in queue starving (and deadlock)
Gitlab::ErrorTracking.track_exception(e)
- @logger.error("failed processing a batch: #{e.message}")
+ @logger.error(message: "Pages legacy storage migration: failed processing a batch: #{e.message}")
end
def migrate_project(project)
result = nil
time = Benchmark.realtime do
- result = ::Pages::MigrateLegacyStorageToDeploymentService.new(project, ignore_invalid_entries: @ignore_invalid_entries).execute
+ result = ::Pages::MigrateLegacyStorageToDeploymentService.new(project,
+ ignore_invalid_entries: @ignore_invalid_entries,
+ mark_projects_as_not_deployed: @mark_projects_as_not_deployed).execute
end
if result[:status] == :success
- @logger.info("project_id: #{project.id} #{project.pages_path} has been migrated in #{time.round(2)} seconds")
+ @logger.info(message: "Pages legacy storage migration: project migrated: #{result[:message]}", project_id: project.id, pages_path: project.pages_path, duration: time.round(2))
@counters_lock.synchronize { @migrated += 1 }
else
- @logger.error("project_id: #{project.id} #{project.pages_path} failed to be migrated in #{time.round(2)} seconds: #{result[:message]}")
+ @logger.error(message: "Pages legacy storage migration: project failed to be migrated: #{result[:message]}", project_id: project.id, pages_path: project.pages_path, duration: time.round(2))
@counters_lock.synchronize { @errored += 1 }
end
rescue => e
@counters_lock.synchronize { @errored += 1 }
- @logger.error("project_id: #{project&.id} #{project&.pages_path} failed to be migrated: #{e.message}")
+ @logger.error(message: "Pages legacy storage migration: project failed to be migrated: #{result[:message]}", project_id: project&.id, pages_path: project&.pages_path)
Gitlab::ErrorTracking.track_exception(e, project_id: project&.id)
end
end
diff --git a/app/services/pages/migrate_legacy_storage_to_deployment_service.rb b/app/services/pages/migrate_legacy_storage_to_deployment_service.rb
index 63410b9fe4a..95c7107eb62 100644
--- a/app/services/pages/migrate_legacy_storage_to_deployment_service.rb
+++ b/app/services/pages/migrate_legacy_storage_to_deployment_service.rb
@@ -9,9 +9,10 @@ module Pages
attr_reader :project
- def initialize(project, ignore_invalid_entries: false)
+ def initialize(project, ignore_invalid_entries: false, mark_projects_as_not_deployed: false)
@project = project
@ignore_invalid_entries = ignore_invalid_entries
+ @mark_projects_as_not_deployed = mark_projects_as_not_deployed
end
def execute
@@ -30,16 +31,20 @@ module Pages
zip_result = ::Pages::ZipDirectoryService.new(project.pages_path, ignore_invalid_entries: @ignore_invalid_entries).execute
if zip_result[:status] == :error
- if !project.pages_metadatum&.reload&.pages_deployment &&
- Feature.enabled?(:pages_migration_mark_as_not_deployed, project)
- project.mark_pages_as_not_deployed
- end
-
return error("Can't create zip archive: #{zip_result[:message]}")
end
archive_path = zip_result[:archive_path]
+ unless archive_path
+ return error("Archive not created. Missing public directory in #{@project.pages_path}") unless @mark_projects_as_not_deployed
+
+ project.set_first_pages_deployment!(nil)
+
+ return success(
+ message: "Archive not created. Missing public directory in #{project.pages_path}? Marked project as not deployed")
+ end
+
deployment = nil
File.open(archive_path) do |file|
deployment = project.pages_deployments.create!(
diff --git a/app/services/pages/zip_directory_service.rb b/app/services/pages/zip_directory_service.rb
index ae08d40ee37..6cb79452e1b 100644
--- a/app/services/pages/zip_directory_service.rb
+++ b/app/services/pages/zip_directory_service.rb
@@ -18,9 +18,7 @@ module Pages
end
def execute
- unless resolve_public_dir
- return error("Can not find valid public dir in #{@input_dir}")
- end
+ return success unless resolve_public_dir
output_file = File.join(real_dir, "@migrated.zip") # '@' to avoid any name collision with groups or projects
diff --git a/app/services/pod_logs/kubernetes_service.rb b/app/services/pod_logs/kubernetes_service.rb
index 03b84f98973..28b1a179635 100644
--- a/app/services/pod_logs/kubernetes_service.rb
+++ b/app/services/pod_logs/kubernetes_service.rb
@@ -2,7 +2,7 @@
module PodLogs
class KubernetesService < PodLogs::BaseService
- LOGS_LIMIT = 500.freeze
+ LOGS_LIMIT = 500
REPLACEMENT_CHAR = "\u{FFFD}"
EncodingHelperError = Class.new(StandardError)
diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb
index 84d9db5435b..3dc8fd8929a 100644
--- a/app/services/post_receive_service.rb
+++ b/app/services/post_receive_service.rb
@@ -48,7 +48,7 @@ class PostReceiveService
end
def process_mr_push_options(push_options, changes)
- Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/61359')
+ Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/28494')
return unless repository
unless repository.repo_type.project?
diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb
index 2ba64b73699..a5ee7173bdf 100644
--- a/app/services/projects/alerting/notify_service.rb
+++ b/app/services/projects/alerting/notify_service.rb
@@ -36,7 +36,7 @@ module Projects
override :alert_source
def alert_source
- alert.monitoring_tool || integration&.name || 'Generic Alert Endpoint'
+ super || integration&.name || 'Generic Alert Endpoint'
end
def active_integration?
diff --git a/app/services/projects/branches_by_mode_service.rb b/app/services/projects/branches_by_mode_service.rb
index fb66bfa073b..dbdcef066f4 100644
--- a/app/services/projects/branches_by_mode_service.rb
+++ b/app/services/projects/branches_by_mode_service.rb
@@ -71,7 +71,8 @@ class Projects::BranchesByModeService
# And increase it whenever we go to the next page
previous_offset = params[:offset].to_i
- previous_path, next_path = nil, nil
+ previous_path = nil
+ next_path = nil
return [branches, previous_path, next_path] if branches.blank?
diff --git a/app/services/projects/create_from_template_service.rb b/app/services/projects/create_from_template_service.rb
index 45b52a1861c..3c66ff709c9 100644
--- a/app/services/projects/create_from_template_service.rb
+++ b/app/services/projects/create_from_template_service.rb
@@ -7,7 +7,8 @@ module Projects
attr_reader :template_name
def initialize(user, params)
- @current_user, @params = user, params.to_h.dup
+ @current_user = user
+ @params = params.to_h.dup
@template_name = @params.delete(:template_name).presence
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index e3b1fd5f4c0..5fb0bda912e 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -5,11 +5,12 @@ module Projects
include ValidatesClassificationLabel
def initialize(user, params)
- @current_user, @params = user, params.dup
- @skip_wiki = @params.delete(:skip_wiki)
+ @current_user = user
+ @params = params.dup
+ @skip_wiki = @params.delete(:skip_wiki)
@initialize_with_readme = Gitlab::Utils.to_boolean(@params.delete(:initialize_with_readme))
- @import_data = @params.delete(:import_data)
- @relations_block = @params.delete(:relations_block)
+ @import_data = @params.delete(:import_data)
+ @relations_block = @params.delete(:relations_block)
end
def execute
@@ -110,7 +111,12 @@ module Projects
setup_authorizations
current_user.invalidate_personal_projects_count
- create_prometheus_service
+
+ if Feature.enabled?(:projects_post_creation_worker, current_user, default_enabled: :yaml)
+ Projects::PostCreationWorker.perform_async(@project.id)
+ else
+ create_prometheus_service
+ end
create_readme if @initialize_with_readme
end
@@ -193,6 +199,7 @@ module Projects
@project
end
+ # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/326665
def create_prometheus_service
service = @project.find_or_initialize_service(::PrometheusService.to_param)
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 6840c395a76..4ba48f74273 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -27,7 +27,9 @@ module Projects
# Git data (e.g. a list of branch names).
flush_caches(project)
- ::Ci::AbortProjectPipelinesService.new.execute(project)
+ if Feature.enabled?(:abort_deleted_project_pipelines, default_enabled: :yaml)
+ ::Ci::AbortPipelinesService.new.execute(project.all_pipelines, :project_deleted)
+ end
Projects::UnlinkForkService.new(project, current_user).execute
diff --git a/app/services/projects/download_service.rb b/app/services/projects/download_service.rb
index 9810db84605..72cb3997045 100644
--- a/app/services/projects/download_service.rb
+++ b/app/services/projects/download_service.rb
@@ -7,7 +7,8 @@ module Projects
].freeze
def initialize(project, url)
- @project, @url = project, url
+ @project = project
+ @url = url
end
def execute
diff --git a/app/services/projects/gitlab_projects_import_service.rb b/app/services/projects/gitlab_projects_import_service.rb
index 27cce15f97d..38f0e2f7c1a 100644
--- a/app/services/projects/gitlab_projects_import_service.rb
+++ b/app/services/projects/gitlab_projects_import_service.rb
@@ -11,7 +11,9 @@ module Projects
attr_reader :current_user, :params
def initialize(user, import_params, override_params = nil)
- @current_user, @params, @override_params = user, import_params.dup, override_params
+ @current_user = user
+ @params = import_params.dup
+ @override_params = override_params
end
def execute
diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb
index 01539d58545..b63903c6c61 100644
--- a/app/services/projects/update_pages_configuration_service.rb
+++ b/app/services/projects/update_pages_configuration_service.rb
@@ -11,7 +11,7 @@ module Projects
end
def execute
- return success unless Feature.enabled?(:pages_update_legacy_storage, default_enabled: true)
+ return success unless ::Settings.pages.local_store.enabled
# If the pages were never deployed, we can't write out the config, as the
# directory would not exist.
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index 2b59fdd539d..6fa42b293c5 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -23,7 +23,8 @@ module Projects
attr_reader :build
def initialize(project, build)
- @project, @build = project, build
+ @project = project
+ @build = build
end
def execute
@@ -31,9 +32,9 @@ module Projects
# Create status notifying the deployment of pages
@status = create_status
+ @status.update_older_statuses_retried! if Feature.enabled?(:ci_fix_commit_status_retried, project, default_enabled: :yaml)
@status.enqueue!
@status.run!
- @status.update_older_statuses_retried! if Feature.enabled?(:ci_fix_commit_status_retried, project, default_enabled: :yaml)
raise InvalidStateError, 'missing pages artifacts' unless build.artifacts?
raise InvalidStateError, 'build SHA is outdated for this ref' unless latest?
@@ -83,7 +84,9 @@ module Projects
def deploy_to_legacy_storage(artifacts_path)
# path today used by one project can later be used by another
# so we can't really scope this feature flag by project or group
- return unless Feature.enabled?(:pages_update_legacy_storage, default_enabled: true)
+ return unless ::Settings.pages.local_store.enabled
+
+ return if Feature.enabled?(:skip_pages_deploy_to_legacy_storage, project, default_enabled: :yaml)
# Create temporary directory in which we will extract the artifacts
make_secure_tmp_dir(tmp_path) do |tmp_path|
@@ -250,13 +253,17 @@ module Projects
def make_secure_tmp_dir(tmp_path)
FileUtils.mkdir_p(tmp_path)
- path = Dir.mktmpdir(nil, tmp_path)
+ path = Dir.mktmpdir(tmp_dir_prefix, tmp_path)
begin
yield(path)
ensure
FileUtils.remove_entry_secure(path)
end
end
+
+ def tmp_dir_prefix
+ "project-#{project.id}-build-#{build.id}-"
+ end
end
end
diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb
index 6115db54829..8832a1bc027 100644
--- a/app/services/projects/update_remote_mirror_service.rb
+++ b/app/services/projects/update_remote_mirror_service.rb
@@ -9,8 +9,10 @@ module Projects
def execute(remote_mirror, tries)
return success unless remote_mirror.enabled?
+ # Blocked URLs are a hard failure, no need to attempt to retry
if Gitlab::UrlBlocker.blocked_url?(normalized_url(remote_mirror.url))
- return error("The remote mirror URL is invalid.")
+ hard_retry_or_fail(remote_mirror, _('The remote mirror URL is invalid.'), tries)
+ return error(remote_mirror.last_error)
end
update_mirror(remote_mirror)
@@ -19,11 +21,11 @@ module Projects
rescue Gitlab::Git::CommandError => e
# This happens if one of the gitaly calls above fail, for example when
# branches have diverged, or the pre-receive hook fails.
- retry_or_fail(remote_mirror, e.message, tries)
+ hard_retry_or_fail(remote_mirror, e.message, tries)
error(e.message)
rescue => e
- remote_mirror.mark_as_failed!(e.message)
+ remote_mirror.hard_fail!(e.message)
raise e
end
@@ -70,15 +72,15 @@ module Projects
).execute
end
- def retry_or_fail(mirror, message, tries)
+ def hard_retry_or_fail(mirror, message, tries)
if tries < MAX_TRIES
- mirror.mark_for_retry!(message)
+ mirror.hard_retry!(message)
else
# It's not likely we'll be able to recover from this ourselves, so we'll
# notify the users of the problem, and don't trigger any sidekiq retries
# Instead, we'll wait for the next change to try the push again, or until
# a user manually retries.
- mirror.mark_as_failed!(message)
+ mirror.hard_fail!(message)
end
end
end
diff --git a/app/services/prometheus/create_default_alerts_service.rb b/app/services/prometheus/create_default_alerts_service.rb
index 53baf6a650e..4ae2743cc28 100644
--- a/app/services/prometheus/create_default_alerts_service.rb
+++ b/app/services/prometheus/create_default_alerts_service.rb
@@ -84,7 +84,7 @@ module Prometheus
def environment
strong_memoize(:environment) do
- EnvironmentsFinder.new(project, nil, name: 'production').find.first ||
+ EnvironmentsFinder.new(project, nil, name: 'production').execute.first ||
project.environments.first
end
end
diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb
index c1bafd03b48..33635796771 100644
--- a/app/services/prometheus/proxy_service.rb
+++ b/app/services/prometheus/proxy_service.rb
@@ -44,8 +44,8 @@ module Prometheus
def self.from_cache(proxyable_class_name, proxyable_id, method, path, params)
proxyable_class = begin
proxyable_class_name.constantize
- rescue NameError
- nil
+ rescue NameError
+ nil
end
return unless proxyable_class
diff --git a/app/services/prometheus/proxy_variable_substitution_service.rb b/app/services/prometheus/proxy_variable_substitution_service.rb
index 820b551c30a..846dfeb33ce 100644
--- a/app/services/prometheus/proxy_variable_substitution_service.rb
+++ b/app/services/prometheus/proxy_variable_substitution_service.rb
@@ -41,7 +41,8 @@ module Prometheus
# }
# })
def initialize(environment, params = {})
- @environment, @params = environment, params.deep_dup
+ @environment = environment
+ @params = params.deep_dup
end
# @return - params [Hash<Symbol,Any>] Returns a Hash containing a params key which is
diff --git a/app/services/releases/base_service.rb b/app/services/releases/base_service.rb
index d0e1577bd8d..de7c97b3518 100644
--- a/app/services/releases/base_service.rb
+++ b/app/services/releases/base_service.rb
@@ -8,7 +8,9 @@ module Releases
attr_accessor :project, :current_user, :params
def initialize(project, user = nil, params = {})
- @project, @current_user, @params = project, user, params.dup
+ @project = project
+ @current_user = user
+ @params = params.dup
end
def tag_name
diff --git a/app/services/repositories/changelog_service.rb b/app/services/repositories/changelog_service.rb
index 3981e91e7f3..0122bfb154d 100644
--- a/app/services/repositories/changelog_service.rb
+++ b/app/services/repositories/changelog_service.rb
@@ -61,14 +61,14 @@ module Repositories
# rubocop: enable Metrics/ParameterLists
def execute
- from = start_of_commit_range
+ config = Gitlab::Changelog::Config.from_git(@project)
+ from = start_of_commit_range(config)
# For every entry we want to only include the merge request that
# originally introduced the commit, which is the oldest merge request that
# contains the commit. We fetch there merge requests in batches, reducing
# the number of SQL queries needed to get this data.
mrs_finder = MergeRequests::OldestPerCommitFinder.new(@project)
- config = Gitlab::Changelog::Config.from_git(@project)
release = Gitlab::Changelog::Release
.new(version: @version, date: @date, config: config)
@@ -98,10 +98,12 @@ module Repositories
.commit(release: release, file: @file, branch: @branch, message: @message)
end
- def start_of_commit_range
+ def start_of_commit_range(config)
return @from if @from
- if (prev_tag = PreviousTagFinder.new(@project).execute(@version))
+ finder = ChangelogTagFinder.new(@project, regex: config.tag_regex)
+
+ if (prev_tag = finder.execute(@version))
return prev_tag.target_commit.id
end
diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb
index 36858f33b49..620dfff91e2 100644
--- a/app/services/resource_access_tokens/create_service.rb
+++ b/app/services/resource_access_tokens/create_service.rb
@@ -39,7 +39,7 @@ module ResourceAccessTokens
attr_reader :resource_type, :resource
def has_permission_to_create?
- %w(project group).include?(resource_type) && can?(current_user, :admin_resource_access_tokens, resource)
+ %w(project group).include?(resource_type) && can?(current_user, :create_resource_access_tokens, resource)
end
def create_user
diff --git a/app/services/resource_access_tokens/revoke_service.rb b/app/services/resource_access_tokens/revoke_service.rb
index 59402701ddc..0924ca3bac4 100644
--- a/app/services/resource_access_tokens/revoke_service.rb
+++ b/app/services/resource_access_tokens/revoke_service.rb
@@ -14,7 +14,7 @@ module ResourceAccessTokens
end
def execute
- return error("#{current_user.name} cannot delete #{bot_user.name}") unless can_destroy_bot_member?
+ return error("#{current_user.name} cannot delete #{bot_user.name}") unless can_destroy_token?
return error("Failed to find bot user") unless find_member
access_token.revoke!
@@ -37,14 +37,8 @@ module ResourceAccessTokens
DeleteUserWorker.perform_async(current_user.id, bot_user.id, skip_authorization: true)
end
- def can_destroy_bot_member?
- if resource.is_a?(Project)
- can?(current_user, :admin_project_member, @resource)
- elsif resource.is_a?(Group)
- can?(current_user, :admin_group_member, @resource)
- else
- false
- end
+ def can_destroy_token?
+ %w(project group).include?(resource.class.name.downcase) && can?(current_user, :destroy_resource_access_tokens, resource)
end
def find_member
diff --git a/app/services/resource_events/base_synthetic_notes_builder_service.rb b/app/services/resource_events/base_synthetic_notes_builder_service.rb
index a2d78ec67c3..5939b9d2f9c 100644
--- a/app/services/resource_events/base_synthetic_notes_builder_service.rb
+++ b/app/services/resource_events/base_synthetic_notes_builder_service.rb
@@ -25,9 +25,7 @@ module ResourceEvents
def apply_common_filters(events)
events = apply_last_fetched_at(events)
- events = apply_fetch_until(events)
-
- events
+ apply_fetch_until(events)
end
def apply_last_fetched_at(events)
diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb
index ddf3b05ac10..89eb90e9360 100644
--- a/app/services/resource_events/change_labels_service.rb
+++ b/app/services/resource_events/change_labels_service.rb
@@ -5,7 +5,8 @@ module ResourceEvents
attr_reader :resource, :user
def initialize(resource, user)
- @resource, @user = resource, user
+ @resource = resource
+ @user = user
end
def execute(added_labels: [], removed_labels: [])
diff --git a/app/services/resource_events/change_state_service.rb b/app/services/resource_events/change_state_service.rb
index c5120ba82e1..d68b86a1513 100644
--- a/app/services/resource_events/change_state_service.rb
+++ b/app/services/resource_events/change_state_service.rb
@@ -5,7 +5,8 @@ module ResourceEvents
attr_reader :resource, :user
def initialize(user:, resource:)
- @user, @resource = user, resource
+ @user = user
+ @resource = resource
end
def execute(params)
diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb
index 9038650adb7..055034d87a1 100644
--- a/app/services/search/global_service.rb
+++ b/app/services/search/global_service.rb
@@ -9,7 +9,8 @@ module Search
attr_accessor :current_user, :params
def initialize(user, params)
- @current_user, @params = user, params.dup
+ @current_user = user
+ @params = params.dup
end
def execute
diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb
index e5fc5a7a438..4227dfe2fac 100644
--- a/app/services/search/project_service.rb
+++ b/app/services/search/project_service.rb
@@ -9,7 +9,9 @@ module Search
attr_accessor :project, :current_user, :params
def initialize(project, user, params)
- @project, @current_user, @params = project, user, params.dup
+ @project = project
+ @current_user = user
+ @params = params.dup
end
def execute
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index 802bfd813dc..c95b459cd2a 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -6,7 +6,7 @@ module Snippets
# NOTE: disable_spam_action_service can be removed when the ':snippet_spam' feature flag is removed.
disable_spam_action_service = params.delete(:disable_spam_action_service) == true
@request = params.delete(:request)
- @spam_params = Spam::SpamActionService.filter_spam_params!(params)
+ @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
@snippet = build_from_params
diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb
index 5b427817a02..aedb6a4819d 100644
--- a/app/services/snippets/update_service.rb
+++ b/app/services/snippets/update_service.rb
@@ -10,7 +10,7 @@ module Snippets
# NOTE: disable_spam_action_service can be removed when the ':snippet_spam' feature flag is removed.
disable_spam_action_service = params.delete(:disable_spam_action_service) == true
@request = params.delete(:request)
- @spam_params = Spam::SpamActionService.filter_spam_params!(params)
+ @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
return invalid_params_error(snippet) unless valid_params?
diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb
index 185b9e39070..2220198583c 100644
--- a/app/services/spam/spam_action_service.rb
+++ b/app/services/spam/spam_action_service.rb
@@ -11,22 +11,30 @@ module Spam
# Takes a hash of parameters from an incoming request to modify a model (via a controller,
# service, or GraphQL mutation). The parameters will either be camelCase (if they are
# received directly via controller params) or underscore_case (if they have come from
- # a GraphQL mutation which has converted them to underscore)
+ # a GraphQL mutation which has converted them to underscore), or in the
+ # headers when using the header based flow.
#
# Deletes the parameters which are related to spam and captcha processing, and returns
# them in a SpamParams parameters object. See:
# https://refactoring.com/catalog/introduceParameterObject.html
- def self.filter_spam_params!(params)
+ def self.filter_spam_params!(params, request)
# NOTE: The 'captcha_response' field can be expanded to multiple fields when we move to future
# alternative captcha implementations such as FriendlyCaptcha. See
# https://gitlab.com/gitlab-org/gitlab/-/issues/273480
- captcha_response = params.delete(:captcha_response) || params.delete(:captchaResponse)
+ headers = request&.headers || {}
+ api = params.delete(:api)
+ captcha_response = read_parameter(:captcha_response, params, headers)
+ spam_log_id = read_parameter(:spam_log_id, params, headers)&.to_i
- SpamParams.new(
- api: params.delete(:api),
- captcha_response: captcha_response,
- spam_log_id: params.delete(:spam_log_id) || params.delete(:spamLogId)
- )
+ SpamParams.new(api: api, captcha_response: captcha_response, spam_log_id: spam_log_id)
+ end
+
+ def self.read_parameter(name, params, headers)
+ [
+ params.delete(name),
+ params.delete(name.to_s.camelize(:lower).to_sym),
+ headers["X-GitLab-#{name.to_s.titlecase(keep_id_suffix: true).tr(' ', '-')}"]
+ ].compact.first
end
attr_accessor :target, :request, :options
@@ -40,6 +48,7 @@ module Spam
@options = {}
end
+ # rubocop:disable Metrics/AbcSize
def execute(spam_params:)
if request
options[:ip_address] = request.env['action_dispatch.remote_ip'].to_s
@@ -58,19 +67,20 @@ module Spam
)
if recaptcha_verified
- # If it's a request which is already verified through captcha,
+ # If it's a request which is already verified through CAPTCHA,
# update the spam log accordingly.
SpamLog.verify_recaptcha!(user_id: user.id, id: spam_params.spam_log_id)
- ServiceResponse.success(message: "Captcha was successfully verified")
+ ServiceResponse.success(message: "CAPTCHA successfully verified")
else
return ServiceResponse.success(message: 'Skipped spam check because user was allowlisted') if allowlisted?(user)
return ServiceResponse.success(message: 'Skipped spam check because request was not present') unless request
return ServiceResponse.success(message: 'Skipped spam check because it was not required') unless check_for_spam?
perform_spam_service_check(spam_params.api)
- ServiceResponse.success(message: "Spam check performed, check #{target.class.name} spammable model for any errors or captcha requirement")
+ ServiceResponse.success(message: "Spam check performed. Check #{target.class.name} spammable model for any errors or CAPTCHA requirement")
end
end
+ # rubocop:enable Metrics/AbcSize
delegate :check_for_spam?, to: :target
diff --git a/app/services/spam/spam_params.rb b/app/services/spam/spam_params.rb
index fef5355c7f3..3420748822d 100644
--- a/app/services/spam/spam_params.rb
+++ b/app/services/spam/spam_params.rb
@@ -23,10 +23,10 @@ module Spam
end
def ==(other)
- other.class == self.class &&
- other.api == self.api &&
- other.captcha_response == self.captcha_response &&
- other.spam_log_id == self.spam_log_id
+ other.class <= self.class &&
+ other.api == api &&
+ other.captcha_response == captcha_response &&
+ other.spam_log_id == spam_log_id
end
end
end
diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb
index 8ab1193b04f..d628b1ea7c7 100644
--- a/app/services/submit_usage_ping_service.rb
+++ b/app/services/submit_usage_ping_service.rb
@@ -35,7 +35,13 @@ class SubmitUsagePingService
raise SubmissionError.new("Unsuccessful response code: #{response.code}") unless response.success?
- raw_usage_data.update_sent_at! if raw_usage_data
+ version_usage_data_id = response.dig('conv_index', 'usage_data_id') || response.dig('dev_ops_score', 'usage_data_id')
+
+ unless version_usage_data_id.is_a?(Integer) && version_usage_data_id > 0
+ raise SubmissionError.new("Invalid usage_data_id in response: #{version_usage_data_id}")
+ end
+
+ raw_usage_data.update_version_metadata!(usage_data_id: version_usage_data_id)
store_metrics(response)
end
diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb
index d854b95cb93..53e810035c5 100644
--- a/app/services/system_hooks_service.rb
+++ b/app/services/system_hooks_service.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class SystemHooksService
- BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES = [GroupMember, Group, ProjectMember].freeze
+ BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES = [GroupMember, Group, ProjectMember, User].freeze
def execute_hooks_for(model, event)
data = build_event_data(model, event)
@@ -47,15 +47,6 @@ class SystemHooksService
if event == :rename || event == :transfer
data[:old_path_with_namespace] = model.old_path_with_namespace
end
- when User
- data.merge!(user_data(model))
-
- case event
- when :rename
- data[:old_username] = model.username_before_last_save
- when :failed_login
- data[:state] = model.state
- end
end
data
@@ -79,15 +70,6 @@ class SystemHooksService
}
end
- def user_data(model)
- {
- name: model.name,
- email: model.email,
- user_id: model.id,
- username: model.username
- }
- end
-
def builder_driven_event_data_available?(model)
model.class.in?(BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES)
end
@@ -100,10 +82,10 @@ class SystemHooksService
Gitlab::HookData::GroupBuilder
when ProjectMember
Gitlab::HookData::ProjectMemberBuilder
+ when User
+ Gitlab::HookData::UserBuilder
end
builder_class.new(model).build(event)
end
end
-
-SystemHooksService.prepend_if_ee('EE::SystemHooksService')
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 082ed93eca2..4377bd8554b 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -7,7 +7,7 @@
module SystemNoteService
extend self
- # Called when commits are added to a Merge Request
+ # Called when commits are added to a merge request
#
# noteable - Noteable object
# project - Project owning noteable
diff --git a/app/services/system_notes/alert_management_service.rb b/app/services/system_notes/alert_management_service.rb
index 27ddf2e36f1..70cdd5c6434 100644
--- a/app/services/system_notes/alert_management_service.rb
+++ b/app/services/system_notes/alert_management_service.rb
@@ -73,7 +73,7 @@ module SystemNotes
#
# Returns the created Note object
def log_resolving_alert(monitoring_tool)
- body = "logged a resolving alert from **#{monitoring_tool}**"
+ body = "logged a recovery alert from **#{monitoring_tool}**"
create_note(NoteSummary.new(noteable, project, User.alert_bot, body, action: 'new_alert_added'))
end
diff --git a/app/services/system_notes/commit_service.rb b/app/services/system_notes/commit_service.rb
index 11119956e0f..c89998f77c7 100644
--- a/app/services/system_notes/commit_service.rb
+++ b/app/services/system_notes/commit_service.rb
@@ -2,7 +2,7 @@
module SystemNotes
class CommitService < ::SystemNotes::BaseService
- # Called when commits are added to a Merge Request
+ # Called when commits are added to a merge request
#
# new_commits - Array of Commits added since last push
# existing_commits - Array of Commits added in a previous push
diff --git a/app/services/task_list_toggle_service.rb b/app/services/task_list_toggle_service.rb
index f6602a35033..32cfa198ce8 100644
--- a/app/services/task_list_toggle_service.rb
+++ b/app/services/task_list_toggle_service.rb
@@ -9,9 +9,11 @@ class TaskListToggleService
attr_reader :updated_markdown, :updated_markdown_html
def initialize(markdown, markdown_html, line_source:, line_number:, toggle_as_checked:)
- @markdown, @markdown_html = markdown, markdown_html
- @line_source, @line_number = line_source, line_number
- @toggle_as_checked = toggle_as_checked
+ @markdown = markdown
+ @markdown_html = markdown_html
+ @line_source = line_source
+ @line_number = line_number
+ @toggle_as_checked = toggle_as_checked
@updated_markdown, @updated_markdown_html = nil
end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index dea116c8546..e473a6dc594 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -47,7 +47,7 @@ class TodoService
yield target
- todo_users.each(&:update_todos_count_cache)
+ Users::UpdateTodoCountCacheService.new(todo_users).execute if todo_users.present?
end
# When we reassign an assignable object (issuable, alert) we should:
@@ -177,7 +177,7 @@ class TodoService
def resolve_todos_for_target(target, current_user)
attributes = attributes_for_target(target)
- resolve_todos(pending_todos(current_user, attributes), current_user)
+ resolve_todos(pending_todos([current_user], attributes), current_user)
end
def resolve_todos(todos, current_user, resolution: :done, resolved_by_action: :system_done)
@@ -220,16 +220,23 @@ class TodoService
private
def create_todos(users, attributes)
- Array(users).map do |user|
- next if pending_todos(user, attributes).exists? && Feature.disabled?(:multiple_todos, user)
+ users = Array(users)
+ return if users.empty?
+
+ users_with_pending_todos = pending_todos(users, attributes).pluck_user_id
+ users.reject! { |user| users_with_pending_todos.include?(user.id) && Feature.disabled?(:multiple_todos, user) }
+
+ todos = users.map do |user|
issue_type = attributes.delete(:issue_type)
track_todo_creation(user, issue_type)
- todo = Todo.create(attributes.merge(user_id: user.id))
- user.update_todos_count_cache
- todo
+ Todo.create(attributes.merge(user_id: user.id))
end
+
+ Users::UpdateTodoCountCacheService.new(users).execute
+
+ todos
end
def new_issuable(issuable, author)
@@ -353,8 +360,8 @@ class TodoService
end
end
- def pending_todos(user, criteria = {})
- PendingTodosFinder.new(user, criteria).execute
+ def pending_todos(users, criteria = {})
+ PendingTodosFinder.new(users, criteria).execute
end
def track_todo_creation(user, issue_type)
diff --git a/app/services/todos/destroy/base_service.rb b/app/services/todos/destroy/base_service.rb
index 7378f10e7c4..4e971246185 100644
--- a/app/services/todos/destroy/base_service.rb
+++ b/app/services/todos/destroy/base_service.rb
@@ -13,7 +13,7 @@ module Todos
# rubocop: disable CodeReuse/ActiveRecord
def without_authorized(items)
- items.where('todos.user_id NOT IN (?)', authorized_users)
+ items.where.not('todos.user_id' => authorized_users)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/todos/destroy/destroyed_issuable_service.rb b/app/services/todos/destroy/destroyed_issuable_service.rb
new file mode 100644
index 00000000000..db12965224b
--- /dev/null
+++ b/app/services/todos/destroy/destroyed_issuable_service.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Todos
+ module Destroy
+ class DestroyedIssuableService
+ BATCH_SIZE = 100
+
+ def initialize(target_id, target_type)
+ @target_id = target_id
+ @target_type = target_type
+ end
+
+ def execute
+ inner_query = Todo.select(:id).for_target(target_id).for_type(target_type).limit(BATCH_SIZE)
+
+ delete_query = <<~SQL
+ DELETE FROM "#{Todo.table_name}"
+ WHERE id IN (#{inner_query.to_sql})
+ RETURNING user_id
+ SQL
+
+ loop do
+ result = ActiveRecord::Base.connection.execute(delete_query)
+
+ break if result.cmd_tuples == 0
+
+ user_ids = result.map { |row| row['user_id'] }.uniq
+
+ invalidate_todos_cache_counts(user_ids)
+ end
+ end
+
+ private
+
+ attr_reader :target_id, :target_type
+
+ def invalidate_todos_cache_counts(user_ids)
+ user_ids.each do |id|
+ # Only build a user instance since we only need its ID for
+ # `User#invalidate_todos_cache_counts` to work.
+ User.new(id: id).invalidate_todos_cache_counts
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/todos/destroy/entity_leave_service.rb b/app/services/todos/destroy/entity_leave_service.rb
index 7cfedc2233a..6d4fc3865ac 100644
--- a/app/services/todos/destroy/entity_leave_service.rb
+++ b/app/services/todos/destroy/entity_leave_service.rb
@@ -65,8 +65,10 @@ module Todos
end
def remove_group_todos
+ return unless entity.is_a?(Namespace)
+
Todo
- .for_group(non_authorized_groups)
+ .for_group(non_authorized_non_public_groups)
.for_user(user)
.delete_all
end
@@ -102,12 +104,19 @@ module Todos
GroupsFinder.new(user, min_access_level: Gitlab::Access::REPORTER).execute.select(:id)
end
- def non_authorized_groups
+ # since the entity is a private group, we can assume all subgroups are also
+ # private. We can therefore limit GroupsFinder with `all_available: false`.
+ # Otherwise it tries to include all public groups. This generates an expensive
+ # SQL queries: https://gitlab.com/gitlab-org/gitlab/-/issues/325133
+ # rubocop: disable CodeReuse/ActiveRecord
+ def non_authorized_non_public_groups
return [] unless entity.is_a?(Namespace)
+ return [] unless entity.private?
entity.self_and_descendants.select(:id)
- .id_not_in(GroupsFinder.new(user).execute.select(:id))
+ .id_not_in(GroupsFinder.new(user, all_available: false).execute.select(:id).reorder(nil))
end
+ # rubocop: enable CodeReuse/ActiveRecord
def non_authorized_reporter_groups
entity.self_and_descendants.select(:id)
diff --git a/app/services/todos/destroy/private_features_service.rb b/app/services/todos/destroy/private_features_service.rb
index bd49519d694..44c3ff231f8 100644
--- a/app/services/todos/destroy/private_features_service.rb
+++ b/app/services/todos/destroy/private_features_service.rb
@@ -36,7 +36,7 @@ module Todos
items = Todo.where(project_id: project_id)
items = items.where(user_id: user_id) if user_id
- items.where('user_id NOT IN (?)', authorized_users)
+ items.where.not(user_id: authorized_users)
.where(target_type: target_types)
.delete_all
end
diff --git a/app/services/two_factor/base_service.rb b/app/services/two_factor/base_service.rb
index 7d3f63f3442..0957d7ebabd 100644
--- a/app/services/two_factor/base_service.rb
+++ b/app/services/two_factor/base_service.rb
@@ -7,7 +7,8 @@ module TwoFactor
attr_reader :current_user, :params, :user
def initialize(current_user, params = {})
- @current_user, @params = current_user, params
+ @current_user = current_user
+ @params = params
@user = params.delete(:user)
end
end
diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb
index ba6ead41836..39d1ffa4d6b 100644
--- a/app/services/upload_service.rb
+++ b/app/services/upload_service.rb
@@ -1,8 +1,14 @@
# frozen_string_literal: true
class UploadService
+ # Temporarily introduced for upload API: https://gitlab.com/gitlab-org/gitlab/-/issues/325788
+ attr_accessor :override_max_attachment_size
+
def initialize(model, file, uploader_class = FileUploader, **uploader_context)
- @model, @file, @uploader_class, @uploader_context = model, file, uploader_class, uploader_context
+ @model = model
+ @file = file
+ @uploader_class = uploader_class
+ @uploader_context = uploader_context
end
def execute
@@ -19,6 +25,6 @@ class UploadService
attr_reader :model, :file, :uploader_class, :uploader_context
def max_attachment_size
- Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i
+ override_max_attachment_size || Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i
end
end
diff --git a/app/services/user_agent_detail_service.rb b/app/services/user_agent_detail_service.rb
index 5cb42e879a0..9302c86d3e6 100644
--- a/app/services/user_agent_detail_service.rb
+++ b/app/services/user_agent_detail_service.rb
@@ -4,7 +4,8 @@ class UserAgentDetailService
attr_accessor :spammable, :request
def initialize(spammable, request)
- @spammable, @request = spammable, request
+ @spammable = spammable
+ @request = request
end
def create
diff --git a/app/services/user_preferences/update_service.rb b/app/services/user_preferences/update_service.rb
new file mode 100644
index 00000000000..a1ee35d4580
--- /dev/null
+++ b/app/services/user_preferences/update_service.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module UserPreferences
+ class UpdateService < BaseService
+ def initialize(user, params = {})
+ @preferences = user.user_preference
+ @params = params.to_h.dup.with_indifferent_access
+ end
+
+ def execute
+ if @preferences.update(@params)
+ ServiceResponse.success(
+ message: 'Preference was updated',
+ payload: { preferences: @preferences })
+ else
+ ServiceResponse.error(message: 'Could not update preference')
+ end
+ end
+ end
+end
diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb
index 85855f45e33..64844a3f002 100644
--- a/app/services/users/activity_service.rb
+++ b/app/services/users/activity_service.rb
@@ -37,3 +37,5 @@ module Users
end
end
end
+
+Users::ActivityService.prepend_ee_mod
diff --git a/app/services/users/batch_status_cleaner_service.rb b/app/services/users/batch_status_cleaner_service.rb
index ea6142f13cc..533794f8d60 100644
--- a/app/services/users/batch_status_cleaner_service.rb
+++ b/app/services/users/batch_status_cleaner_service.rb
@@ -2,7 +2,7 @@
module Users
class BatchStatusCleanerService
- BATCH_SIZE = 100.freeze
+ BATCH_SIZE = 100
# Cleanup BATCH_SIZE user_statuses records
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb
index 070713929e4..d28ff45bfdf 100644
--- a/app/services/users/refresh_authorized_projects_service.rb
+++ b/app/services/users/refresh_authorized_projects_service.rb
@@ -51,38 +51,12 @@ module Users
# This method returns the updated User object.
def execute_without_lease
- current = current_authorizations_per_project
- fresh = fresh_access_levels_per_project
-
- # Delete projects that have more than one authorizations associated with
- # the user. The correct authorization is added to the ``add`` array in the
- # next stage.
- remove = projects_with_duplicates
- current.except!(*projects_with_duplicates)
-
- remove |= current.each_with_object([]) do |(project_id, row), array|
- # rows not in the new list or with a different access level should be
- # removed.
- if !fresh[project_id] || fresh[project_id] != row.access_level
- if incorrect_auth_found_callback
- incorrect_auth_found_callback.call(project_id, row.access_level)
- end
-
- array << row.project_id
- end
- end
-
- add = fresh.each_with_object([]) do |(project_id, level), array|
- # rows not in the old list or with a different access level should be
- # added.
- if !current[project_id] || current[project_id].access_level != level
- if missing_auth_found_callback
- missing_auth_found_callback.call(project_id, level)
- end
-
- array << [user.id, project_id, level]
- end
- end
+ remove, add = AuthorizedProjectUpdate::FindRecordsDueForRefreshService.new(
+ user,
+ source: source,
+ incorrect_auth_found_callback: incorrect_auth_found_callback,
+ missing_auth_found_callback: missing_auth_found_callback
+ ).execute
update_authorizations(remove, add)
end
@@ -104,6 +78,10 @@ module Users
user.reset
end
+ private
+
+ attr_reader :incorrect_auth_found_callback, :missing_auth_found_callback
+
def log_refresh_details(remove, add)
Gitlab::AppJsonLogger.info(event: 'authorized_projects_refresh',
user_id: user.id,
@@ -115,34 +93,5 @@ module Users
'authorized_projects_refresh.rows_deleted_slice': remove.first(5),
'authorized_projects_refresh.rows_added_slice': add.first(5))
end
-
- def fresh_access_levels_per_project
- fresh_authorizations.each_with_object({}) do |row, hash|
- hash[row.project_id] = row.access_level
- end
- end
-
- def current_authorizations_per_project
- current_authorizations.index_by(&:project_id)
- end
-
- def current_authorizations
- @current_authorizations ||= user.project_authorizations.select(:project_id, :access_level)
- end
-
- def fresh_authorizations
- Gitlab::ProjectAuthorizations.new(user).calculate
- end
-
- private
-
- attr_reader :incorrect_auth_found_callback, :missing_auth_found_callback
-
- def projects_with_duplicates
- @projects_with_duplicates ||= current_authorizations
- .group_by(&:project_id)
- .select { |project_id, authorizations| authorizations.count > 1 }
- .keys
- end
end
end
diff --git a/app/services/users/respond_to_terms_service.rb b/app/services/users/respond_to_terms_service.rb
index 254480304f9..7cdfef1489b 100644
--- a/app/services/users/respond_to_terms_service.rb
+++ b/app/services/users/respond_to_terms_service.rb
@@ -3,7 +3,8 @@
module Users
class RespondToTermsService
def initialize(user, term)
- @user, @term = user, term
+ @user = user
+ @term = term
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/services/users/set_status_service.rb b/app/services/users/set_status_service.rb
index a907937070f..2b4be8c833b 100644
--- a/app/services/users/set_status_service.rb
+++ b/app/services/users/set_status_service.rb
@@ -7,7 +7,8 @@ module Users
attr_reader :current_user, :target_user, :params
def initialize(current_user, params)
- @current_user, @params = current_user, params.dup
+ @current_user = current_user
+ @params = params.dup
@target_user = params.delete(:user) || current_user
end
diff --git a/app/services/users/update_canonical_email_service.rb b/app/services/users/update_canonical_email_service.rb
index 1400fd58eb4..e75452f60fd 100644
--- a/app/services/users/update_canonical_email_service.rb
+++ b/app/services/users/update_canonical_email_service.rb
@@ -7,7 +7,7 @@ module Users
INCLUDED_DOMAINS_PATTERN = [/gmail.com/].freeze
def initialize(user:)
- raise ArgumentError.new("Please provide a user") unless user&.is_a?(User)
+ raise ArgumentError.new("Please provide a user") unless user.is_a?(User)
@user = user
end
diff --git a/app/services/users/update_todo_count_cache_service.rb b/app/services/users/update_todo_count_cache_service.rb
new file mode 100644
index 00000000000..03ab66bd64a
--- /dev/null
+++ b/app/services/users/update_todo_count_cache_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Users
+ class UpdateTodoCountCacheService < BaseService
+ QUERY_BATCH_SIZE = 10
+
+ attr_reader :users
+
+ # users - An array of User objects
+ def initialize(users)
+ @users = users
+ end
+
+ def execute
+ users.each_slice(QUERY_BATCH_SIZE) do |users_batch|
+ todo_counts = Todo.for_user(users_batch).count_grouped_by_user_id_and_state
+
+ users_batch.each do |user|
+ update_count_cache(user, todo_counts, :done)
+ update_count_cache(user, todo_counts, :pending)
+ end
+ end
+ end
+
+ private
+
+ def update_count_cache(user, todo_counts, state)
+ count = todo_counts.fetch([user.id, state.to_s], 0)
+ expiration_time = user.count_cache_validity_period
+
+ Rails.cache.write(['users', user.id, "todos_#{state}_count"], count, expires_in: expiration_time)
+ end
+ end
+end
diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb
index d4c74ce277f..ea71930062c 100644
--- a/app/uploaders/object_storage.rb
+++ b/app/uploaders/object_storage.rb
@@ -187,6 +187,7 @@ module ObjectStorage
hash[:TempPath] = workhorse_local_upload_path
end
+ hash[:FeatureFlagExtractBase] = Feature.enabled?(:workhorse_extract_filename_base, default_enabled: :yaml)
hash[:MaximumSize] = maximum_size if maximum_size.present?
end
end
diff --git a/app/validators/json_schema_validator.rb b/app/validators/json_schema_validator.rb
index fee4a00cec5..8dc6265f471 100644
--- a/app/validators/json_schema_validator.rb
+++ b/app/validators/json_schema_validator.rb
@@ -12,12 +12,14 @@
class JsonSchemaValidator < ActiveModel::EachValidator
FILENAME_ALLOWED = /\A[a-z0-9_-]*\Z/.freeze
FilenameError = Class.new(StandardError)
- JSON_VALIDATOR_MAX_DRAFT_VERSION = 4
+ BASE_DIRECTORY = %w(app validators json_schemas).freeze
def initialize(options)
raise ArgumentError, "Expected 'filename' as an argument" unless options[:filename]
raise FilenameError, "Must be a valid 'filename'" unless options[:filename].match?(FILENAME_ALLOWED)
+ @base_directory = options.delete(:base_directory) || BASE_DIRECTORY
+
super(options)
end
@@ -29,19 +31,27 @@ class JsonSchemaValidator < ActiveModel::EachValidator
private
+ attr_reader :base_directory
+
def valid_schema?(value)
- if draft_version > JSON_VALIDATOR_MAX_DRAFT_VERSION
- JSONSchemer.schema(Pathname.new(schema_path)).valid?(value)
- else
- JSON::Validator.validate(schema_path, value)
- end
+ validator.valid?(value)
+ end
+
+ def validator
+ @validator ||= JSONSchemer.schema(Pathname.new(schema_path))
end
def schema_path
- Rails.root.join('app', 'validators', 'json_schemas', "#{options[:filename]}.json").to_s
+ @schema_path ||= Rails.root.join(*base_directory, filename_with_extension).to_s
+ end
+
+ def filename_with_extension
+ "#{options[:filename]}.json"
end
def draft_version
options[:draft] || JSON_VALIDATOR_MAX_DRAFT_VERSION
end
end
+
+JsonSchemaValidator.prepend_ee_mod
diff --git a/app/validators/json_schemas/application_setting_kroki_formats.json b/app/validators/json_schemas/application_setting_kroki_formats.json
index 460dc74069f..4dfa710abea 100644
--- a/app/validators/json_schemas/application_setting_kroki_formats.json
+++ b/app/validators/json_schemas/application_setting_kroki_formats.json
@@ -1,4 +1,5 @@
{
+ "$schema": "http://json-schema.org/draft-07/schema#",
"description": "Kroki formats",
"type": "object",
"properties": {
diff --git a/app/validators/json_schemas/build_metadata_secrets.json b/app/validators/json_schemas/build_metadata_secrets.json
index e745a266777..799e7ab1642 100644
--- a/app/validators/json_schemas/build_metadata_secrets.json
+++ b/app/validators/json_schemas/build_metadata_secrets.json
@@ -1,4 +1,5 @@
{
+ "$schema": "http://json-schema.org/draft-07/schema#",
"description": "CI builds metadata secrets",
"type": "object",
"patternProperties": {
diff --git a/app/validators/json_schemas/build_report_result_data.json b/app/validators/json_schemas/build_report_result_data.json
index 0fb4fd6d0b7..0a12c9c39a7 100644
--- a/app/validators/json_schemas/build_report_result_data.json
+++ b/app/validators/json_schemas/build_report_result_data.json
@@ -1,4 +1,5 @@
{
+ "$schema": "http://json-schema.org/draft-07/schema#",
"description": "Build report result data",
"type": "object",
"properties": {
diff --git a/app/validators/json_schemas/build_report_result_data_tests.json b/app/validators/json_schemas/build_report_result_data_tests.json
index b38559e727f..610070fde5f 100644
--- a/app/validators/json_schemas/build_report_result_data_tests.json
+++ b/app/validators/json_schemas/build_report_result_data_tests.json
@@ -1,4 +1,5 @@
{
+ "$schema": "http://json-schema.org/draft-07/schema#",
"description": "Build report result data tests",
"type": "object",
"properties": {
diff --git a/app/validators/json_schemas/codeclimate.json b/app/validators/json_schemas/codeclimate.json
index 56056c62c4e..dc43eab6290 100644
--- a/app/validators/json_schemas/codeclimate.json
+++ b/app/validators/json_schemas/codeclimate.json
@@ -1,4 +1,5 @@
{
+ "$schema": "http://json-schema.org/draft-07/schema#",
"description": "Codequality used by codeclimate parser",
"type": "object",
"required": ["description", "fingerprint", "severity", "location"],
diff --git a/app/validators/json_schemas/daily_build_group_report_result_data.json b/app/validators/json_schemas/daily_build_group_report_result_data.json
index 2524ac63050..2b073506375 100644
--- a/app/validators/json_schemas/daily_build_group_report_result_data.json
+++ b/app/validators/json_schemas/daily_build_group_report_result_data.json
@@ -1,4 +1,5 @@
{
+ "$schema": "http://json-schema.org/draft-07/schema#",
"description": "Daily build group report result data",
"type": "object",
"properties": {
diff --git a/app/validators/json_schemas/debian_fields.json b/app/validators/json_schemas/debian_fields.json
index b9f6ad2b31d..ae1a2726ea2 100644
--- a/app/validators/json_schemas/debian_fields.json
+++ b/app/validators/json_schemas/debian_fields.json
@@ -1,4 +1,5 @@
{
+ "$schema": "http://json-schema.org/draft-07/schema#",
"description": "Debian fields",
"type": "object",
"patternProperties": {
diff --git a/app/validators/json_schemas/git_trailers.json b/app/validators/json_schemas/git_trailers.json
index 18ac97226a7..384eb280765 100644
--- a/app/validators/json_schemas/git_trailers.json
+++ b/app/validators/json_schemas/git_trailers.json
@@ -1,4 +1,5 @@
{
+ "$schema": "http://json-schema.org/draft-07/schema#",
"description": "Git trailer key/value pairs",
"type": "object",
"patternProperties": {
diff --git a/app/validators/json_schemas/http_integration_payload_attribute_mapping.json b/app/validators/json_schemas/http_integration_payload_attribute_mapping.json
index a194daf5e45..7aebc959169 100644
--- a/app/validators/json_schemas/http_integration_payload_attribute_mapping.json
+++ b/app/validators/json_schemas/http_integration_payload_attribute_mapping.json
@@ -1,4 +1,5 @@
{
+ "$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"patternProperties": {
".*": {
diff --git a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
index 08442565931..dc4880946b2 100644
--- a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
+++ b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json
@@ -1,4 +1,5 @@
{
+ "$schema": "http://json-schema.org/draft-07/schema#",
"global": [
{
"field" : "SECURE_ANALYZERS_PREFIX",
@@ -50,36 +51,36 @@
],
"analyzers": [
{
- "name": "brakeman",
- "label": "Brakeman",
+ "name": "bandit",
+ "label": "Bandit",
"enabled" : true,
- "description": "Ruby on Rails",
+ "description": "Python",
"variables": [
{
- "field" : "SAST_BRAKEMAN_LEVEL",
- "label" : "Brakeman confidence level.",
+ "field" : "SAST_BANDIT_EXCLUDED_PATHS",
+ "label" : "Paths to exclude from scan",
"type": "string",
- "default_value": "1",
+ "default_value": "",
"value": "",
"size": "SMALL",
- "description": "Ignore Brakeman vulnerabilities under given confidence level. Integer, 1=Low, 2=Medium, 3=High."
+ "description": "Comma-separated list of paths to exclude from scan. Uses Python’s 'fnmatch' syntax; For example: '*/tests/*, */venv/*'"
}
]
},
{
- "name": "bandit",
- "label": "Bandit",
+ "name": "brakeman",
+ "label": "Brakeman",
"enabled" : true,
- "description": "Python",
+ "description": "Ruby on Rails",
"variables": [
{
- "field" : "SAST_BANDIT_EXCLUDED_PATHS",
- "label" : "Paths to exclude from scan.",
+ "field" : "SAST_BRAKEMAN_LEVEL",
+ "label" : "Brakeman confidence level",
"type": "string",
- "default_value": "",
+ "default_value": "1",
"value": "",
"size": "SMALL",
- "description": "Comma-separated list of paths to exclude from scan. Uses Python’s 'fnmatch' syntax; For example: '*/tests/*, */venv/*'"
+ "description": "Ignore Brakeman vulnerabilities under given confidence level. Integer, 1=Low, 2=Medium, 3=High."
}
]
},
@@ -109,7 +110,7 @@
},
{
"name": "kubesec",
- "label": "kubesec",
+ "label": "Kubesec",
"enabled" : true,
"description": "Kubernetes manifests, Helm Charts",
"variables": []
@@ -123,7 +124,7 @@
},
{
"name": "gosec",
- "label": "Golang Security Checker",
+ "label": "Gosec",
"enabled" : true,
"description": "Go",
"variables": [
diff --git a/app/validators/json_schemas/security_scan_info.json b/app/validators/json_schemas/security_scan_info.json
deleted file mode 100644
index c8932c1870d..00000000000
--- a/app/validators/json_schemas/security_scan_info.json
+++ /dev/null
@@ -1,28 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-07/schema#",
- "type": "object",
- "title": "Security::Scan#info schema",
- "description": "The schema validates the content of the Security::Scan#info attribute",
- "additionalProperties": false,
- "properties": {
- "errors": {
- "type": "array",
- "items": {
- "type": "object",
- "additionalProperties": false,
- "properties": {
- "type": {
- "type": "string"
- },
- "message": {
- "type": "string"
- }
- },
- "required": [
- "type",
- "message"
- ]
- }
- }
- }
-}
diff --git a/app/validators/json_schemas/vulnerability_finding_details.json b/app/validators/json_schemas/vulnerability_finding_details.json
deleted file mode 100644
index 2ba1fc9e9db..00000000000
--- a/app/validators/json_schemas/vulnerability_finding_details.json
+++ /dev/null
@@ -1,294 +0,0 @@
-{
- "type": "object",
- "description": "The schema for vulnerability finding details",
- "additionalProperties": false,
- "patternProperties": {
- "^.*$": {
- "allOf": [
- { "$ref": "#/definitions/named_field" },
- { "$ref": "#/definitions/detail_type" }
- ]
- }
- },
- "definitions": {
- "detail_type": {
- "oneOf": [
- { "$ref": "#/definitions/named_list" },
- { "$ref": "#/definitions/list" },
- { "$ref": "#/definitions/table" },
- { "$ref": "#/definitions/text" },
- { "$ref": "#/definitions/url" },
- { "$ref": "#/definitions/code" },
- { "$ref": "#/definitions/value" },
- { "$ref": "#/definitions/diff" },
- { "$ref": "#/definitions/markdown" },
- { "$ref": "#/definitions/commit" },
- { "$ref": "#/definitions/file_location" },
- { "$ref": "#/definitions/module_location" }
- ]
- },
- "text_value": {
- "type": "string"
- },
- "named_field": {
- "type": "object",
- "required": [
- "name"
- ],
- "properties": {
- "name": {
- "$ref": "#/definitions/text_value",
- "minLength": 1
- },
- "description": {
- "$ref": "#/definitions/text_value"
- }
- }
- },
- "named_list": {
- "type": "object",
- "description": "An object with named and typed fields",
- "required": [
- "type",
- "items"
- ],
- "properties": {
- "type": {
- "const": "named-list"
- },
- "items": {
- "type": "object",
- "patternProperties": {
- "^.*$": {
- "allOf": [
- {
- "$ref": "#/definitions/named_field"
- },
- {
- "$ref": "#/definitions/detail_type"
- }
- ]
- }
- }
- }
- }
- },
- "list": {
- "type": "object",
- "description": "A list of typed fields",
- "required": [
- "type",
- "items"
- ],
- "properties": {
- "type": {
- "const": "list"
- },
- "items": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/detail_type"
- }
- }
- }
- },
- "table": {
- "type": "object",
- "description": "A table of typed fields",
- "required": [
- "type",
- "rows"
- ],
- "properties": {
- "type": {
- "const": "table"
- },
- "header": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/detail_type"
- }
- },
- "rows": {
- "type": "array",
- "items": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/detail_type"
- }
- }
- }
- }
- },
- "text": {
- "type": "object",
- "description": "Raw text",
- "required": [
- "type",
- "value"
- ],
- "properties": {
- "type": {
- "const": "text"
- },
- "value": {
- "$ref": "#/definitions/text_value"
- }
- }
- },
- "url": {
- "type": "object",
- "description": "A single URL",
- "required": [
- "type",
- "href"
- ],
- "properties": {
- "type": {
- "const": "url"
- },
- "text": {
- "$ref": "#/definitions/text_value"
- },
- "href": {
- "type": "string",
- "minLength": 1,
- "examples": ["http://mysite.com"]
- }
- }
- },
- "code": {
- "type": "object",
- "description": "A codeblock",
- "required": [
- "type",
- "value"
- ],
- "properties": {
- "type": {
- "const": "code"
- },
- "value": {
- "type": "string"
- },
- "lang": {
- "type": "string",
- "description": "A programming language"
- }
- }
- },
- "value": {
- "type": "object",
- "description": "A field that can store a range of types of value",
- "required": ["type", "value"],
- "properties": {
- "type": { "const": "value" },
- "value": {
- "type": ["number", "string", "boolean"]
- }
- }
- },
- "diff": {
- "type": "object",
- "description": "A diff",
- "required": [
- "type",
- "before",
- "after"
- ],
- "properties": {
- "type": {
- "const": "diff"
- },
- "before": {
- "type": "string"
- },
- "after": {
- "type": "string"
- }
- }
- },
- "markdown": {
- "type": "object",
- "description": "GitLab flavoured markdown, see https://docs.gitlab.com/ee/user/markdown.html",
- "required": [
- "type",
- "value"
- ],
- "properties": {
- "type": {
- "const": "markdown"
- },
- "value": {
- "$ref": "#/definitions/text_value",
- "examples": ["Here is markdown `inline code` #1 [test](gitlab.com)\n\n![GitLab Logo](https://about.gitlab.com/images/press/logo/preview/gitlab-logo-white-preview.png)"]
- }
- }
- },
- "commit": {
- "type": "object",
- "description": "A commit/tag/branch within the GitLab project",
- "required": [
- "type",
- "value"
- ],
- "properties": {
- "type": {
- "const": "commit"
- },
- "value": {
- "type": "string",
- "description": "The commit SHA",
- "minLength": 1
- }
- }
- },
- "file_location": {
- "type": "object",
- "description": "A location within a file in the project",
- "required": [
- "type",
- "file_name",
- "line_start"
- ],
- "properties": {
- "type": {
- "const": "file-location"
- },
- "file_name": {
- "type": "string",
- "minLength": 1
- },
- "line_start": {
- "type": "integer"
- },
- "line_end": {
- "type": "integer"
- }
- }
- },
- "module_location": {
- "type": "object",
- "description": "A location within a binary module of the form module+relative_offset",
- "required": [
- "type",
- "module_name",
- "offset"
- ],
- "properties": {
- "type": {
- "const": "module-location"
- },
- "module_name": {
- "type": "string",
- "minLength": 1,
- "examples": ["compiled_binary"]
- },
- "offset": {
- "type": "integer",
- "examples": [100]
- }
- }
- }
- }
-}
diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml
index 3e1a76f31e1..dbfc7bf1046 100644
--- a/app/views/admin/abuse_reports/_abuse_report.html.haml
+++ b/app/views/admin/abuse_reports/_abuse_report.html.haml
@@ -25,10 +25,10 @@
%td
- if user
= link_to _('Remove user & report'), admin_abuse_report_path(abuse_report, remove_user: true),
- data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name } }, remote: true, method: :delete, class: "gl-button btn btn-sm btn-block btn-danger js-remove-tr"
+ data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name } }, remote: true, method: :delete, class: "gl-button btn btn-block btn-danger js-remove-tr"
- if user && !user.blocked?
- = link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "gl-button btn btn-sm btn-block"
+ = link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "gl-button btn btn-default btn-block"
- else
- .btn.btn-sm.disabled.btn-block
+ .gl-button.btn.btn-default.disabled.btn-block
= _('Already blocked')
- = link_to _('Remove report'), [:admin, abuse_report], remote: true, method: :delete, class: "gl-button btn btn-sm btn-block btn-close js-remove-tr"
+ = link_to _('Remove report'), [:admin, abuse_report], remote: true, method: :delete, class: "gl-button btn btn-default btn-block btn-close js-remove-tr"
diff --git a/app/views/admin/abuse_reports/index.html.haml b/app/views/admin/abuse_reports/index.html.haml
index daa766429e0..8b1bbbc17c7 100644
--- a/app/views/admin/abuse_reports/index.html.haml
+++ b/app/views/admin/abuse_reports/index.html.haml
@@ -19,13 +19,14 @@
%table.table.responsive-table
%thead.d-none.d-md-table-header-group
%tr
- %th User
- %th Reported by
- %th.wide Message
- %th Action
+ %th= _('User')
+ %th= _('Reported by')
+ %th.wide= _('Message')
+ %th= _('Action')
= render @abuse_reports
= paginate @abuse_reports, theme: 'gitlab'
- else
.empty-state
.text-center
- %h4 There are no abuse reports! #{emoji_icon('tada')}
+ %h4= _("There are no abuse reports!")
+ %h3= emoji_icon('tada')
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
index 2e5cf156a65..1e2c9f821d2 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -1,4 +1,4 @@
-= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-account-settings'), html: { class: 'fieldset-form' } do |f|
+= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-account-settings'), html: { class: 'fieldset-form', id: 'account-settings' } do |f|
= form_errors(@application_setting)
%fieldset
diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml
index c5ae5c579ad..7286fffcaf6 100644
--- a/app/views/admin/application_settings/_diff_limits.html.haml
+++ b/app/views/admin/application_settings/_diff_limits.html.haml
@@ -1,4 +1,4 @@
-= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-merge-request-settings'), html: { class: 'fieldset-form' } do |f|
+= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-merge-request-settings'), html: { class: 'fieldset-form', id: 'merge-request-settings' } do |f|
= form_errors(@application_setting)
%fieldset
diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml
index 1ddf927ed13..c44bad132bd 100644
--- a/app/views/admin/application_settings/_eks.html.haml
+++ b/app/views/admin/application_settings/_eks.html.haml
@@ -9,7 +9,7 @@
= _('Amazon EKS integration allows you to provision EKS clusters from GitLab.')
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-eks-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-eks-settings'), html: { class: 'fieldset-form', id: 'eks-settings' } do |f|
= form_errors(@application_setting)
%fieldset
diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
index 97e09476e78..abd182027b1 100644
--- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml
+++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
@@ -8,7 +8,7 @@
= _('External Classification Policy Authorization')
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-external-auth-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-external-auth-settings'), html: { class: 'fieldset-form', id: 'external-auth-settings' } do |f|
= form_errors(@application_setting)
%fieldset
diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml
index 48b0c6be0a8..6d335e2db16 100644
--- a/app/views/admin/application_settings/_gitpod.html.haml
+++ b/app/views/admin/application_settings/_gitpod.html.haml
@@ -12,7 +12,7 @@
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-gitpod-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-gitpod-settings'), html: { class: 'fieldset-form', id: 'gitpod-settings' } do |f|
= form_errors(@application_setting)
%fieldset
diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml
index 18d71a90e34..e584aaf9880 100644
--- a/app/views/admin/application_settings/_ip_limits.html.haml
+++ b/app/views/admin/application_settings/_ip_limits.html.haml
@@ -8,14 +8,14 @@
.form-check
= f.check_box :throttle_unauthenticated_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_unauthenticated_checkbox' }
= f.label :throttle_unauthenticated_enabled, class: 'form-check-label label-bold' do
- Enable unauthenticated request rate limit
+ = _("Enable unauthenticated request rate limit")
%span.form-text.text-muted
- Helps reduce request volume (e.g. from crawlers or abusive bots)
+ = _("Helps reduce request volume (e.g. from crawlers or abusive bots)")
.form-group
- = f.label :throttle_unauthenticated_requests_per_period, 'Max unauthenticated requests per period per IP', class: 'label-bold'
+ = f.label :throttle_unauthenticated_requests_per_period, _('Max unauthenticated requests per period per IP'), class: 'label-bold'
= f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control gl-form-input'
.form-group
- = f.label :throttle_unauthenticated_period_in_seconds, 'Unauthenticated rate limit period in seconds', class: 'label-bold'
+ = f.label :throttle_unauthenticated_period_in_seconds, _('Unauthenticated rate limit period in seconds'), class: 'label-bold'
= f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control gl-form-input'
%hr
%h5
@@ -24,14 +24,14 @@
.form-check
= f.check_box :throttle_authenticated_api_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_authenticated_api_checkbox' }
= f.label :throttle_authenticated_api_enabled, class: 'form-check-label label-bold' do
- Enable authenticated API request rate limit
+ = _("Enable authenticated API request rate limit")
%span.form-text.text-muted
- Helps reduce request volume (e.g. from crawlers or abusive bots)
+ = _("Helps reduce request volume (e.g. from crawlers or abusive bots)")
.form-group
- = f.label :throttle_authenticated_api_requests_per_period, 'Max authenticated API requests per period per user', class: 'label-bold'
+ = f.label :throttle_authenticated_api_requests_per_period, _('Max authenticated API requests per period per user'), class: 'label-bold'
= f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control gl-form-input'
.form-group
- = f.label :throttle_authenticated_api_period_in_seconds, 'Authenticated API rate limit period in seconds', class: 'label-bold'
+ = f.label :throttle_authenticated_api_period_in_seconds, _('Authenticated API rate limit period in seconds'), class: 'label-bold'
= f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control gl-form-input'
%hr
%h5
@@ -44,10 +44,10 @@
%span.form-text.text-muted
Helps reduce request volume (e.g. from crawlers or abusive bots)
.form-group
- = f.label :throttle_authenticated_web_requests_per_period, 'Max authenticated web requests per period per user', class: 'label-bold'
+ = f.label :throttle_authenticated_web_requests_per_period, _('Max authenticated web requests per period per user'), class: 'label-bold'
= f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control gl-form-input'
.form-group
- = f.label :throttle_authenticated_web_period_in_seconds, 'Authenticated web rate limit period in seconds', class: 'label-bold'
+ = f.label :throttle_authenticated_web_period_in_seconds, _('Authenticated web rate limit period in seconds'), class: 'label-bold'
= f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control gl-form-input'
%hr
%h5
@@ -57,4 +57,4 @@
= _('A plain-text response to show to clients that hit the rate limit.')
= f.text_area :rate_limiting_response_text, placeholder: ::Gitlab::Throttle::DEFAULT_RATE_LIMITING_RESPONSE_TEXT, class: 'form-control gl-form-input', rows: 5
- = f.submit 'Save changes', class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_kroki.html.haml b/app/views/admin/application_settings/_kroki.html.haml
index fc51942ed1f..b9da2047453 100644
--- a/app/views/admin/application_settings/_kroki.html.haml
+++ b/app/views/admin/application_settings/_kroki.html.haml
@@ -8,7 +8,7 @@
%p
= _('Allow rendering of diagrams in AsciiDoc and Markdown documents using %{link}.').html_safe % { link: link_to('Kroki', 'https://kroki.io', target: '_blank') }
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-kroki-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-kroki-settings'), html: { class: 'fieldset-form', id: 'kroki-settings' } do |f|
= form_errors(@application_setting) if expanded
%fieldset
diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml
index 8f52e8b8461..5d6443825b7 100644
--- a/app/views/admin/application_settings/_pages.html.haml
+++ b/app/views/admin/application_settings/_pages.html.haml
@@ -3,7 +3,7 @@
%fieldset
.form-group
- = f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'label-bold'
+ = f.label :max_pages_size, _('Maximum size of pages (MB)'), class: 'label-bold'
= f.number_field :max_pages_size, class: 'form-control gl-form-input'
.form-text.text-muted
= _("0 for unlimited")
diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml
index 5ee68e8fd16..50fc11ec7f3 100644
--- a/app/views/admin/application_settings/_performance.html.haml
+++ b/app/views/admin/application_settings/_performance.html.haml
@@ -6,7 +6,7 @@
.form-check
= f.check_box :authorized_keys_enabled, class: 'form-check-input'
= f.label :authorized_keys_enabled, class: 'form-check-label' do
- Write to "authorized_keys" file
+ = _('Write to "authorized_keys" file')
.form-text.text-muted
By default, we write to the "authorized_keys" file to support Git
over SSH without additional configuration. GitLab can be optimized
@@ -31,4 +31,4 @@
.form-text.text-muted
= _('Number of changes (branches or tags) in a single push to determine whether individual push events or bulk push event will be created. Bulk push event will be created if it surpasses that value.')
- = f.submit 'Save changes', class: "gl-button btn btn-confirm"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml
index 8f2bdd109cb..f603dcab407 100644
--- a/app/views/admin/application_settings/_performance_bar.html.haml
+++ b/app/views/admin/application_settings/_performance_bar.html.haml
@@ -6,9 +6,9 @@
.form-check
= f.check_box :performance_bar_enabled, class: 'form-check-input', data: { qa_selector: 'enable_performance_bar_checkbox'}
= f.label :performance_bar_enabled, class: 'form-check-label' do
- Enable access to the Performance Bar
+ = _("Enable access to the Performance Bar")
.form-group
- = f.label :performance_bar_allowed_group_path, 'Allowed group', class: 'label-bold'
+ = f.label :performance_bar_allowed_group_path, _('Allowed group'), class: 'label-bold'
= f.text_field :performance_bar_allowed_group_path, class: 'form-control gl-form-input', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path
- = f.submit 'Save changes', class: 'gl-button btn btn-confirm qa-save-changes-button'
+ = f.submit _('Save changes'), class: 'gl-button btn btn-confirm qa-save-changes-button'
diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml
index e6e9bbf3ee0..d57ae94b084 100644
--- a/app/views/admin/application_settings/_plantuml.html.haml
+++ b/app/views/admin/application_settings/_plantuml.html.haml
@@ -8,7 +8,7 @@
%p
= _('Allow rendering of PlantUML diagrams in Asciidoc documents.')
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-plantuml-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-plantuml-settings'), html: { class: 'fieldset-form', id: 'plantuml-settings' } do |f|
= form_errors(@application_setting) if expanded
%fieldset
diff --git a/app/views/admin/application_settings/_realtime.html.haml b/app/views/admin/application_settings/_realtime.html.haml
index bee120d2f78..545c27d2a7e 100644
--- a/app/views/admin/application_settings/_realtime.html.haml
+++ b/app/views/admin/application_settings/_realtime.html.haml
@@ -3,15 +3,10 @@
%fieldset
.form-group
- = f.label :polling_interval_multiplier, 'Polling interval multiplier', class: 'label-bold'
+ = f.label :polling_interval_multiplier, _('Polling interval multiplier'), class: 'label-bold'
= f.text_field :polling_interval_multiplier, class: 'form-control gl-form-input'
.form-text.text-muted
- Change this value to influence how frequently the GitLab UI polls for updates.
- If you set the value to 2 all polling intervals are multiplied
- by 2, which means that polling happens half as frequently.
- The multiplier can also have a decimal value.
- The default value (1) is a reasonable choice for the majority of GitLab
- installations. Set to 0 to completely disable polling.
+ = _("Change this value to influence how frequently the GitLab UI polls for updates. If you set the value to 2 all polling intervals are multiplied by 2, which means that polling happens half as frequently. The multiplier can also have a decimal value. The default value (1) is a reasonable choice for the majority of GitLab installations. Set to 0 to completely disable polling.")
= link_to sprite_icon('question-o'), help_page_path('administration/polling')
- = f.submit 'Save changes', class: "gl-button btn btn-confirm"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml
index fc03a6dd10c..78d4e8c8cc3 100644
--- a/app/views/admin/application_settings/_registry.html.haml
+++ b/app/views/admin/application_settings/_registry.html.haml
@@ -3,7 +3,7 @@
%fieldset
.form-group
- = f.label :container_registry_token_expire_delay, 'Authorization token duration (minutes)', class: 'label-bold'
+ = f.label :container_registry_token_expire_delay, _('Authorization token duration (minutes)'), class: 'label-bold'
= f.number_field :container_registry_token_expire_delay, class: 'form-control gl-form-input'
.form-group
.form-check
@@ -31,4 +31,4 @@
.form-text.text-muted
= _("The maximum number of tags that a single worker accepts for cleanup. If the number of tags goes above this limit, the list of tags to delete is truncated to this number. To remove this limit, set it to 0.")
- = f.submit 'Save changes', class: "gl-button btn btn-confirm"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml
index ee0281b6e33..edf6853a1aa 100644
--- a/app/views/admin/application_settings/_repository_check.html.haml
+++ b/app/views/admin/application_settings/_repository_check.html.haml
@@ -3,56 +3,51 @@
%fieldset
.sub-section
- %h4 Repository checks
+ %h4= _("Repository checks")
.form-group
.form-check
= f.check_box :repository_checks_enabled, class: 'form-check-input'
= f.label :repository_checks_enabled, class: 'form-check-label' do
- Enable Repository Checks
+ = _("Enable Repository Checks")
.form-text.text-muted
- GitLab will periodically run
- %a{ href: 'https://git-scm.com/docs/git-fsck', target: 'blank' } 'git fsck'
- in all project and wiki repositories to look for silent disk corruption issues.
+ - link_to_git_fsck = link_to('git fsck', 'https://git-scm.com/docs/git-fsck', target: '_blank')
+ = _("GitLab will periodically run %{link_to_git_fsck} in all project and wiki repositories to look for silent disk corruption issues.").html_safe % { link_to_git_fsck: link_to_git_fsck }
.form-group
.form-text.text-muted
- If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database.
+ = _("If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database.")
- clear_repository_checks_link = _('Clear all repository checks')
- clear_repository_checks_message = _('This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?')
= link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message }, method: :put, class: "gl-button btn btn-sm btn-danger"
.sub-section
- %h4 Housekeeping
+ %h4= _("Housekeeping")
.form-group
.form-check
= f.check_box :housekeeping_enabled, class: 'form-check-input'
= f.label :housekeeping_enabled, class: 'form-check-label' do
- Enable automatic repository housekeeping (git repack, git gc)
+ = _("Enable automatic repository housekeeping (git repack, git gc)")
.form-text.text-muted
- If you keep automatic housekeeping disabled for a long time Git
- repository access on your GitLab server will become slower and your
- repositories will use more disk space. We recommend to always leave
- this enabled.
+ = _("If you keep automatic housekeeping disabled for a long time Git repository access on your GitLab server will become slower and your repositories will use more disk space. We recommend to always leave this enabled.")
.form-check
= f.check_box :housekeeping_bitmaps_enabled, class: 'form-check-input'
= f.label :housekeeping_bitmaps_enabled, class: 'form-check-label' do
- Enable Git pack file bitmap creation
+ = _("Enable Git pack file bitmap creation")
.form-text.text-muted
- Creating pack file bitmaps makes housekeeping take a little longer but
- bitmaps should accelerate 'git clone' performance.
+ = _("Creating pack file bitmaps makes housekeeping take a little longer but bitmaps should accelerate 'git clone' performance.")
.form-group
= f.label :housekeeping_incremental_repack_period, 'Incremental repack period', class: 'label-bold'
= f.number_field :housekeeping_incremental_repack_period, class: 'form-control gl-form-input'
.form-text.text-muted
- Number of Git pushes after which an incremental 'git repack' is run.
+ = _("Number of Git pushes after which an incremental 'git repack' is run.")
.form-group
= f.label :housekeeping_full_repack_period, 'Full repack period', class: 'label-bold'
= f.number_field :housekeeping_full_repack_period, class: 'form-control gl-form-input'
.form-text.text-muted
- Number of Git pushes after which a full 'git repack' is run.
+ = _("Number of Git pushes after which a full 'git repack' is run.")
.form-group
- = f.label :housekeeping_gc_period, 'Git GC period', class: 'label-bold'
+ = f.label :housekeeping_gc_period, _('Git GC period'), class: 'label-bold'
= f.number_field :housekeeping_gc_period, class: 'form-control gl-form-input'
.form-text.text-muted
- Number of Git pushes after which 'git gc' is run.
+ = _("Number of Git pushes after which 'git gc' is run.")
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml
index 54bd5cf4072..62d6c973efe 100644
--- a/app/views/admin/application_settings/_signin.html.haml
+++ b/app/views/admin/application_settings/_signin.html.haml
@@ -1,4 +1,4 @@
-= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-signin-settings'), html: { class: 'fieldset-form' } do |f|
+= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-signin-settings'), html: { class: 'fieldset-form', id: 'signin-settings' } do |f|
= form_errors(@application_setting)
%fieldset
@@ -32,6 +32,15 @@
= f.label :require_two_factor_authentication, class: 'form-check-label' do
Require all users to set up Two-factor authentication
.form-group
+ = f.label :admin_mode, _('Admin Mode'), class: 'label-bold'
+ = sprite_icon('lock', css_class: 'gl-icon')
+ .form-check
+ = f.check_box :admin_mode, class: 'form-check-input'
+ = f.label :admin_mode, class: 'form-check-label' do
+ = _('Require additional authentication for administrative tasks')
+ .form-text.text-muted
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/sign_in_restrictions', anchor: 'admin-mode')
+ .form-group
= f.label :unknown_sign_in, _('Email notification for unknown sign-ins'), class: 'label-bold'
.form-check
= f.check_box :notify_on_unknown_sign_in, class: 'form-check-input'
diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml
index 272eba67b1b..a5b47159239 100644
--- a/app/views/admin/application_settings/_signup.html.haml
+++ b/app/views/admin/application_settings/_signup.html.haml
@@ -1,80 +1,20 @@
-= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-signup-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting)
+= form_errors(@application_setting)
- %fieldset
- .form-group
- .form-check
- = f.check_box :signup_enabled, class: 'form-check-input', data: { qa_selector: 'signup_enabled_checkbox' }
- = f.label :signup_enabled, class: 'form-check-label' do
- 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)}" }
- .form-group
- .form-check
- = f.check_box :require_admin_approval_after_user_signup, class: 'form-check-input', data: { qa_selector: 'require_admin_approval_after_user_signup_checkbox' }
- = 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 gl-form-input', rows: 4, min: ApplicationSetting::DEFAULT_MINIMUM_PASSWORD_LENGTH, max: Devise.password_length.max
- - password_policy_guidelines_link = link_to _('Password Policy Guidelines'), 'https://about.gitlab.com/handbook/security/#gitlab-password-policy-guidelines', target: '_blank', rel: 'noopener noreferrer nofollow'
- .form-text.text-muted
- = _("See GitLab's %{password_policy_guidelines}").html_safe % { password_policy_guidelines: password_policy_guidelines_link }
- .form-group
- = f.label :domain_allowlist, _('Allowed domains for sign-ups'), class: 'label-bold'
- = f.text_area :domain_allowlist_raw, placeholder: 'domain.com', class: 'form-control gl-form-input', 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_denylist_enabled, _('Domain denylist'), class: 'label-bold'
- .form-check
- = 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 :denylist_type, :file, false, class: 'form-check-input'
- = label_tag :denylist_type_file, class: 'form-check-label' do
- .option-title
- Upload denylist file
- .form-check
- = 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 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 gl-form-input', 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.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 gl-form-input', 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'
- .form-check
- = f.check_box :email_restrictions_enabled, class: 'form-check-input'
- = f.label :email_restrictions_enabled, class: 'form-check-label' do
- = _('Enable email restrictions for sign ups')
- .form-group
- = f.label :email_restrictions, _('Email restrictions for sign-ups'), class: 'label-bold'
- = f.text_area :email_restrictions, class: 'form-control gl-form-input', rows: 4
- .form-text.text-muted
- - supported_syntax_link_url = 'https://github.com/google/re2/wiki/Syntax'
- - supported_syntax_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: supported_syntax_link_url }
- = _('Restricts sign-ups for email addresses that match the given regex. See the %{supported_syntax_link_start}supported syntax%{supported_syntax_link_end} for more information.').html_safe % { supported_syntax_link_start: supported_syntax_link_start, supported_syntax_link_end: '</a>'.html_safe }
-
- .form-group
- = f.label :after_sign_up_text, class: 'label-bold'
- = f.text_area :after_sign_up_text, class: 'form-control gl-form-input', rows: 4
- .form-text.text-muted Markdown enabled
- = f.submit 'Save changes', class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
+#js-signup-form{ data: { host: new_user_session_url(host: Gitlab.config.gitlab.host),
+ settings_path: general_admin_application_settings_path(anchor: 'js-signup-settings'),
+ signup_enabled: @application_setting[:signup_enabled].to_s,
+ require_admin_approval_after_user_signup: @application_setting[:require_admin_approval_after_user_signup].to_s,
+ send_user_confirmation_email: @application_setting[:send_user_confirmation_email].to_s,
+ minimum_password_length: @application_setting[:minimum_password_length],
+ minimum_password_length_min: ApplicationSetting::DEFAULT_MINIMUM_PASSWORD_LENGTH,
+ minimum_password_length_max: Devise.password_length.max,
+ minimum_password_length_help_link: 'https://about.gitlab.com/handbook/security/#gitlab-password-policy-guidelines',
+ domain_allowlist_raw: @application_setting.domain_allowlist_raw,
+ new_user_signups_cap: @application_setting[:new_user_signups_cap].to_s,
+ domain_denylist_enabled: @application_setting[:domain_denylist_enabled].to_s,
+ denylist_type_raw_selected: (@application_setting.domain_denylist.present? || @application_setting.domain_denylist.blank?).to_s,
+ domain_denylist_raw: @application_setting.domain_denylist_raw,
+ email_restrictions_enabled: @application_setting[:email_restrictions_enabled].to_s,
+ supported_syntax_link_url: 'https://github.com/google/re2/wiki/Syntax',
+ email_restrictions: @application_setting.email_restrictions,
+ after_sign_up_text: @application_setting[:after_sign_up_text] } }
diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml
index e6ac2a4db34..5daf220d81c 100644
--- a/app/views/admin/application_settings/_snowplow.html.haml
+++ b/app/views/admin/application_settings/_snowplow.html.haml
@@ -8,7 +8,7 @@
%p
= _('Configure the %{link} integration.').html_safe % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank') }
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form', id: 'snowplow-settings' } do |f|
= form_errors(@application_setting) if expanded
%fieldset
diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml
index af25577f058..d87ded09a2b 100644
--- a/app/views/admin/application_settings/_sourcegraph.html.haml
+++ b/app/views/admin/application_settings/_sourcegraph.html.haml
@@ -16,7 +16,7 @@
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-sourcegraph-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-sourcegraph-settings'), html: { class: 'fieldset-form', id: 'sourcegraph-settings' } do |f|
= form_errors(@application_setting)
%fieldset
diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml
index 487ce25a4da..482466c4b3b 100644
--- a/app/views/admin/application_settings/_terminal.html.haml
+++ b/app/views/admin/application_settings/_terminal.html.haml
@@ -1,4 +1,4 @@
-= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-terminal-settings'), html: { class: 'fieldset-form' } do |f|
+= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-terminal-settings'), html: { class: 'fieldset-form', id: 'terminal-settings' } do |f|
= form_errors(@application_setting)
%fieldset
diff --git a/app/views/admin/application_settings/_terms.html.haml b/app/views/admin/application_settings/_terms.html.haml
index 8cc4169b383..fe260812ad9 100644
--- a/app/views/admin/application_settings/_terms.html.haml
+++ b/app/views/admin/application_settings/_terms.html.haml
@@ -1,4 +1,4 @@
-= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-terms-settings'), html: { class: 'fieldset-form' } do |f|
+= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-terms-settings'), html: { class: 'fieldset-form', id: 'terms-settings' } do |f|
= form_errors(@application_setting)
%fieldset
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 970c9c6b003..5df2454ed2e 100644
--- a/app/views/admin/application_settings/_third_party_offers.html.haml
+++ b/app/views/admin/application_settings/_third_party_offers.html.haml
@@ -8,7 +8,7 @@
%p
= _('Control the display of third party offers.')
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form', id: 'third-party-offers-settings' } do |f|
= form_errors(@application_setting) if expanded
%fieldset
diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml
index 00306e1ba06..a2d61bd010f 100644
--- a/app/views/admin/application_settings/_usage.html.haml
+++ b/app/views/admin/application_settings/_usage.html.haml
@@ -27,7 +27,7 @@
- usage_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: usage_ping_path }
%p.mb-2= s_('%{usage_ping_link_start}Learn more%{usage_ping_link_end} about what information is shared with GitLab Inc.').html_safe % { usage_ping_link_start: usage_ping_link_start, usage_ping_link_end: '</a>'.html_safe }
- %button.btn.js-payload-preview-trigger{ type: 'button', data: { payload_selector: ".#{payload_class}" } }
+ %button.gl-button.btn.btn-default.js-payload-preview-trigger{ type: 'button', data: { payload_selector: ".#{payload_class}" } }
.spinner.js-spinner.d-none
.js-text.d-inline= _('Preview payload')
%pre.usage-data.js-syntax-highlight.code.highlight.mt-2.d-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml
index e51a41d5254..4bf47c3d60d 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -1,4 +1,4 @@
-= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-visibility-settings'), html: { class: 'fieldset-form' } do |f|
+= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-visibility-settings'), html: { class: 'fieldset-form', id: 'visibility-settings' } do |f|
= form_errors(@application_setting)
%fieldset
diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml
index aa4cebdb603..919f501d2ee 100644
--- a/app/views/admin/application_settings/ci/_header.html.haml
+++ b/app/views/admin/application_settings/ci/_header.html.haml
@@ -8,7 +8,7 @@
%p
= _('Variables store information, like passwords and secret keys, that you can use in job scripts. All projects on the instance can use these variables.')
- = link_to s_('Learn more.'), help_page_path('ci/variables/README', anchor: 'instance-level-cicd-variables'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to s_('Learn more.'), help_page_path('ci/variables/README', anchor: 'instance-cicd-variables'), target: '_blank', rel: 'noopener noreferrer'
%p
= _('Variables can be:')
%ul
@@ -16,4 +16,4 @@
= html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
%li
= html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to _('Learn more.'), help_page_path('ci/variables/README', anchor: 'masked-variable-requirements'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('ci/variables/README', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml
index 485fb71d111..d38b4cba40a 100644
--- a/app/views/admin/application_settings/ci_cd.html.haml
+++ b/app/views/admin/application_settings/ci_cd.html.haml
@@ -2,16 +2,15 @@
- page_title _("CI/CD")
- @content_class = "limit-container-width" unless fluid_layout
-- if ::Gitlab::Ci::Features.instance_variables_ui_enabled?
- %section.settings.no-animate#js-ci-cd-variables{ class: ('expanded' if expanded_by_default?) }
- .settings-header
- = render 'admin/application_settings/ci/header', expanded: expanded_by_default?
- .settings-content
- - if ci_variable_protected_by_default?
- %p.settings-message.text-center
- - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protect-a-custom-variable') }
- = s_('Environment variables on this GitLab instance are configured to be %{link_start}protected%{link_end} by default.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- #js-instance-variables{ data: { endpoint: admin_ci_variables_path, group: 'true', maskable_regex: ci_variable_maskable_regex, protected_by_default: ci_variable_protected_by_default?.to_s} }
+%section.settings.no-animate#js-ci-cd-variables{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ = render 'admin/application_settings/ci/header', expanded: expanded_by_default?
+ .settings-content
+ - if ci_variable_protected_by_default?
+ %p.settings-message.text-center
+ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protect-a-cicd-variable') }
+ = s_('Environment variables on this GitLab instance are configured to be %{link_start}protected%{link_end} by default.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ #js-instance-variables{ data: { endpoint: admin_ci_variables_path, group: 'true', maskable_regex: ci_variable_maskable_regex, protected_by_default: ci_variable_protected_by_default?.to_s} }
%section.settings.as-ci-cd.no-animate#js-ci-cd-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index 3a14b4fbc7b..86226a9de2f 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -90,7 +90,7 @@
%p
= _('Manage Web IDE features')
.settings-content
- = form_for @application_setting, url: general_admin_application_settings_path(anchor: "#js-web-ide-settings"), html: { class: 'fieldset-form' } do |f|
+ = form_for @application_setting, url: general_admin_application_settings_path(anchor: "#js-web-ide-settings"), html: { class: 'fieldset-form', id: 'web-ide-settings' } do |f|
= form_errors(@application_setting)
%fieldset
diff --git a/app/views/admin/application_settings/integrations.html.haml b/app/views/admin/application_settings/integrations.html.haml
index 949908b09a7..93bc054754e 100644
--- a/app/views/admin/application_settings/integrations.html.haml
+++ b/app/views/admin/application_settings/integrations.html.haml
@@ -13,8 +13,8 @@
.gl-alert-actions
= link_to s_('AdminSettings|Go to General Settings'), general_admin_application_settings_path, class: 'btn gl-alert-action btn-info gl-button'
-%h4= s_('AdminSettings|Apply integration settings to all Projects')
-%p
- = s_('AdminSettings|Integrations configured here will automatically apply to all projects on this instance.')
- = link_to _('Learn more'), integrations_help_page_path, target: '_blank', rel: 'noopener noreferrer'
+%h3= s_('Integrations|Project integration management')
+
+- integrations_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: integrations_help_page_path }
+%p= s_('Integrations|As a GitLab administrator, you can set default configuration parameters for a given integration that all projects can inherit and use. When you set these parameters, your changes update the integration for all projects that are not already using custom settings. Learn more about %{integrations_link_start}Project integration management%{link_end}.').html_safe % { integrations_link_start: integrations_link_start, link_end: '</a>'.html_safe }
= render 'shared/integrations/index', integrations: @integrations
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index 21908c08690..9a4bb9b0a48 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -1,5 +1,5 @@
.broadcast-message.broadcast-banner-message.gl-alert-warning.js-broadcast-banner-message-preview.gl-mt-3{ style: broadcast_message_style(@broadcast_message), class: ('gl-display-none' unless @broadcast_message.banner? ) }
- = sprite_icon('bullhorn', css_class:'vertical-align-text-top')
+ = sprite_icon('bullhorn', css_class: 'vertical-align-text-top')
.js-broadcast-message-preview
- if @broadcast_message.message.present?
= render_broadcast_message(@broadcast_message)
@@ -7,7 +7,7 @@
Your message here
.d-flex.justify-content-center
.broadcast-message.broadcast-notification-message.preview.js-broadcast-notification-message-preview.mt-2{ class: ('hidden' unless @broadcast_message.notification? ) }
- = sprite_icon('bullhorn', css_class:'vertical-align-text-top')
+ = sprite_icon('bullhorn', css_class: 'vertical-align-text-top')
.js-broadcast-message-preview
- if @broadcast_message.message.present?
= render_broadcast_message(@broadcast_message)
diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml
index a14d342bc14..9dce33bf037 100644
--- a/app/views/admin/broadcast_messages/index.html.haml
+++ b/app/views/admin/broadcast_messages/index.html.haml
@@ -2,10 +2,9 @@
- page_title _("Broadcast Messages")
%h3.page-title
- Broadcast Messages
+ = _('Broadcast Messages')
%p.light
- Broadcast messages are displayed for every user and can be used to notify
- users about scheduled maintenance, recent upgrades and more.
+ = _('Broadcast messages are displayed for every user and can be used to notify users about scheduled maintenance, recent upgrades and more.')
= render 'form'
@@ -15,12 +14,12 @@
%table.table.table-responsive
%thead
%tr
- %th Status
- %th Preview
- %th Starts
- %th Ends
- %th Target Path
- %th Type
+ %th= _('Status')
+ %th= _('Preview')
+ %th= _('Starts')
+ %th= _('Ends')
+ %th= _(' Target Path')
+ %th= _(' Type')
%th &nbsp;
%tbody
- @broadcast_messages.each do |message|
@@ -38,7 +37,7 @@
%td
= message.broadcast_type.capitalize
%td.gl-white-space-nowrap.gl-display-flex
- = link_to sprite_icon('pencil-square', css_class: 'gl-icon'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn btn-icon gl-button'
- = link_to sprite_icon('remove', css_class: 'gl-icon'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-icon gl-button btn-danger ml-2'
+ = link_to sprite_icon('pencil-square', css_class: 'gl-icon'), edit_admin_broadcast_message_path(message), title: _('Edit'), class: 'btn btn-icon gl-button'
+ = link_to sprite_icon('remove', css_class: 'gl-icon'), admin_broadcast_message_path(message), method: :delete, remote: true, title: _('Remove'), class: 'js-remove-tr btn btn-icon gl-button btn-danger ml-2'
= paginate @broadcast_messages, theme: 'gitlab'
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index f16158d5656..e34808665bb 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -101,7 +101,7 @@
enabled: Gitlab.config.registry.enabled,
doc_href: help_page_path('user/packages/container_registry/index'))
- = feature_entry(_('Gitlab Pages'),
+ = feature_entry(_('GitLab Pages'),
enabled: Gitlab.config.pages.enabled,
doc_href: help_instance_configuration_url)
@@ -119,8 +119,7 @@
%p
= link_to _('GitLab'), general_admin_application_settings_path
%span.float-right
- = Gitlab::VERSION
- = "(#{Gitlab.revision})"
+ = link_to_version
%p
= _('GitLab Shell')
%span.float-right
@@ -165,27 +164,30 @@
.gl-card-body
%h4= s_('AdminArea|Latest projects')
- @projects.each do |project|
- %p
- = link_to project.full_name, admin_project_path(project), class: 'str-truncated-60'
- %span.light.float-right
+ .gl-display-flex.gl-py-3
+ .gl-mr-auto.gl-overflow-hidden.gl-text-overflow-ellipsis
+ = link_to project.full_name, admin_project_path(project)
+ %span.gl-white-space-nowrap.gl-text-right
#{time_ago_with_tooltip(project.created_at)}
.col-md-4.gl-mb-6
.gl-card
.gl-card-body
%h4= s_('AdminArea|Latest users')
- @users.each do |user|
- %p
- = link_to [:admin, user], class: 'str-truncated-60' do
- = user.name
- %span.light.float-right
+ .gl-display-flex.gl-py-3
+ .gl-mr-auto.gl-overflow-hidden.gl-text-overflow-ellipsis
+ = link_to [:admin, user] do
+ = user.name
+ %span.gl-white-space-nowrap.gl-text-right
#{time_ago_with_tooltip(user.created_at)}
.col-md-4.gl-mb-6
.gl-card
.gl-card-body
%h4= s_('AdminArea|Latest groups')
- @groups.each do |group|
- %p
- = link_to [:admin, group], class: 'str-truncated-60' do
- = group.full_name
- %span.light.float-right
+ .gl-display-flex.gl-py-3
+ .gl-mr-auto.gl-overflow-hidden.gl-text-overflow-ellipsis
+ = link_to [:admin, group] do
+ = group.full_name
+ %span.gl-white-space-nowrap.gl-text-right
#{time_ago_with_tooltip(group.created_at)}
diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml
index dc49db6557b..0eaf7b60b25 100644
--- a/app/views/admin/deploy_keys/new.html.haml
+++ b/app/views/admin/deploy_keys/new.html.haml
@@ -1,5 +1,5 @@
- page_title _('New Deploy Key')
-%h3.page-title New public deploy key
+%h3.page-title= _('New public deploy key')
%hr
%div
diff --git a/app/views/admin/dev_ops_report/_callout.html.haml b/app/views/admin/dev_ops_report/_callout.html.haml
index 7507f433af8..f313865478d 100644
--- a/app/views/admin/dev_ops_report/_callout.html.haml
+++ b/app/views/admin/dev_ops_report/_callout.html.haml
@@ -1,7 +1,7 @@
.gl-mt-3
.user-callout{ data: { uid: 'dev_ops_report_intro_callout_dismissed' } }
.bordered-box.landing.content-block
- %button.btn.btn-default.close.js-close-callout{ type: 'button',
+ %button.gl-button.btn.btn-default-tertiary.close.js-close-callout{ type: 'button',
'aria-label' => _('Dismiss DevOps Report introduction') }
= sprite_icon('close', size: 16, css_class: 'dismiss-icon')
.user-callout-copy
diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index 15306ab7878..84a9b988d22 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -3,6 +3,8 @@
= render 'shared/group_form', f: f
= render 'shared/group_form_description', f: f
+ = render 'shared/admin/admin_note_form', f: f
+
= render_if_exists 'shared/old_repository_size_limit_setting', form: f, type: :group
= render_if_exists 'admin/namespace_plan', f: f
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index f8c490dd948..5f8ec5086bd 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -62,6 +62,7 @@
= link_to sprite_icon('question-o'), help_page_path('topics/git/lfs/index')
= render_if_exists 'namespaces/shared_runner_status', namespace: @group
+ = render_if_exists 'namespaces/additional_minutes_status', namespace: @group
= render 'shared/custom_attributes', custom_attributes: @group.custom_attributes
@@ -103,6 +104,8 @@
%span.monospace= project.full_path + '.git'
.col-md-6
+ = render 'shared/admin/admin_note'
+
- if can?(current_user, :admin_group_member, @group)
.card
.card-header
@@ -124,7 +127,7 @@
.card
.card-header
= html_escape(_("%{group_name} group members")) % { group_name: "<strong>#{html_escape(@group.name)}</strong>".html_safe }
- %span.badge.badge-pill= @group.members.size
+ %span.badge.badge-pill= @group.users_count
.float-right
= link_to group_group_members_path(@group), class: 'btn btn-default gl-button btn-sm' do
= sprite_icon('pencil-square', css_class: 'gl-icon')
diff --git a/app/views/admin/hook_logs/_index.html.haml b/app/views/admin/hook_logs/_index.html.haml
index 5e70e80cff7..61af7535c1e 100644
--- a/app/views/admin/hook_logs/_index.html.haml
+++ b/app/views/admin/hook_logs/_index.html.haml
@@ -1,18 +1,18 @@
.row.gl-mt-3.gl-mb-3
.col-lg-3
%h4.gl-mt-0
- Recent Deliveries
- %p When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong.
+ = _('Recent Deliveries')
+ %p= _('When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong.')
.col-lg-9
- if hook_logs.any?
%table.table
%thead
%tr
- %th Status
- %th Trigger
- %th URL
- %th Elapsed time
- %th Request time
+ %th= _('Status')
+ %th= _('Trigger')
+ %th= _('URL')
+ %th= _('Elapsed time')
+ %th= _('Request time')
%th
- hook_logs.each do |hook_log|
%tr
@@ -28,10 +28,10 @@
%td.light
= time_ago_with_tooltip(hook_log.created_at)
%td
- = link_to 'View details', admin_hook_hook_log_path(hook, hook_log)
+ = link_to _('View details'), admin_hook_hook_log_path(hook, hook_log)
= paginate hook_logs, theme: 'gitlab'
- else
.settings-message.text-center
- You don't have any webhooks deliveries
+ = _("You don't have any webhooks deliveries")
diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml
index b31b9bdab0a..a357c3d9d34 100644
--- a/app/views/admin/labels/_label.html.haml
+++ b/app/views/admin/labels/_label.html.haml
@@ -1,7 +1,7 @@
%li.label-list-item{ id: dom_id(label) }
= render "shared/label_row", label: label.present(issuable_subject: nil)
.label-actions-list
- = link_to edit_admin_label_path(label), class: 'btn gl-button btn-transparent label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
+ = link_to edit_admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
= sprite_icon('pencil')
- = link_to admin_label_path(label), class: 'btn gl-button btn-transparent remove-row label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: "Delete this label? Are you sure?" }, aria_label: _('Delete'), method: :delete, remote: true do
+ = link_to admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary hover-red js-remove-row label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: "Delete this label? Are you sure?" }, aria_label: _('Delete'), method: :delete, remote: true do
= sprite_icon('remove')
diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml
index 6861a802a63..6007d891aad 100644
--- a/app/views/admin/labels/index.html.haml
+++ b/app/views/admin/labels/index.html.haml
@@ -7,7 +7,7 @@
= _('Labels')
%hr
-.labels.labels-container.admin-labels
+.labels.labels-container.admin-labels.gl-bg-gray-10.gl-border-solid.gl-border-1.gl-border-gray-100
- if @labels.present?
%ul.manage-labels-list
= render @labels
diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml
index c2e40413a14..7e505729213 100644
--- a/app/views/admin/projects/_projects.html.haml
+++ b/app/views/admin/projects/_projects.html.haml
@@ -4,7 +4,7 @@
- @projects.each_with_index do |project|
%li.project-row{ class: ('no-description' if project.description.blank?) }
.controls
- = link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn gl-button btn-default"
+ = link_to _('Edit'), edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn gl-button btn-default"
%button.delete-project-button.gl-button.btn.btn-danger{ data: { delete_project_url: admin_project_path(project), project_name: project.name } }
= s_('AdminProjects|Delete')
@@ -31,6 +31,6 @@
= paginate @projects, theme: 'gitlab'
- else
- .nothing-here-block No projects found
+ .nothing-here-block= _('No projects found')
#delete-project-modal
diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml
index 50f3c94bcb3..79d77790b02 100644
--- a/app/views/admin/projects/index.html.haml
+++ b/app/views/admin/projects/index.html.haml
@@ -18,20 +18,20 @@
.search-holder
= render 'shared/projects/search_form', autofocus: true, admin_view: true
.dropdown
- - toggle_text = 'Namespace'
+ - toggle_text = _('Namespace')
- if params[:namespace_id].present?
= hidden_field_tag :namespace_id, params[:namespace_id]
- namespace = Namespace.find(params[:namespace_id])
- toggle_text = "#{namespace.kind}: #{namespace.full_path}"
= dropdown_toggle(toggle_text, { toggle: 'dropdown', is_filter: 'true' }, { toggle_class: 'js-namespace-select large' })
.dropdown-menu.dropdown-select.dropdown-menu-right
- = dropdown_title('Namespaces')
- = dropdown_filter("Search for Namespace")
+ = dropdown_title(_('Namespaces'))
+ = dropdown_filter(_("Search for Namespace"))
= dropdown_content
= dropdown_loading
= render 'shared/projects/dropdown'
= link_to new_project_path, class: 'gl-button btn btn-confirm' do
- New Project
- = button_tag "Search", class: "gl-button btn btn-confirm btn-search hide"
+ = _('New Project')
+ = button_tag _("Search"), class: "gl-button btn btn-confirm btn-search hide"
= render 'projects'
diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml
index 0d819dc5b47..c3e4626c14e 100644
--- a/app/views/admin/runners/_runner.html.haml
+++ b/app/views/admin/runners/_runner.html.haml
@@ -1,27 +1,28 @@
+-# Note: This file should stay aligned with:
+-# `app/views/groups/runners/_runner.html.haml`
+
.gl-responsive-table-row{ id: dom_id(runner) }
.table-section.section-10.section-wrap
.table-mobile-header{ role: 'rowheader' }= _('Type')
.table-mobile-content
- if runner.instance_type?
- %span.badge.badge-success shared
+ %span.badge.badge-pill.gl-badge.sm.badge-success= _("shared")
- elsif runner.group_type?
- %span.badge.badge-success group
+ %span.badge.badge-pill.gl-badge.sm.badge-success= _("group")
- else
- %span.badge.badge-info specific
+ %span.badge.badge-pill.gl-badge.sm.badge-info= _("specific")
- if runner.locked?
- %span.badge.badge-warning locked
+ %span.badge.badge-pill.gl-badge.sm.badge-warning= _("locked")
- unless runner.active?
- %span.badge.badge-danger paused
+ %span.badge.badge-pill.gl-badge.sm.badge-danger= _("paused")
- .table-section.section-10
- .table-mobile-header{ role: 'rowheader' }= _('Runner token')
+ .table-section.section-30
+ .table-mobile-header{ role: 'rowheader' }= s_('Runners|Runner')
.table-mobile-content
- = link_to runner.short_sha, admin_runner_path(runner)
-
- .table-section.section-20
- .table-mobile-header{ role: 'rowheader' }= _('Description')
- .table-mobile-content.str-truncated.has-tooltip{ title: runner.description }
- = runner.description
+ = link_to("##{runner.id} (#{runner.short_sha})", admin_runner_path(runner))
+ .gl-text-truncate
+ %span{ title: runner.description, data: { toggle: 'tooltip', container: 'body' } }
+ = runner.description
.table-section.section-10
.table-mobile-header{ role: 'rowheader' }= _('Version')
@@ -65,15 +66,15 @@
.table-section.table-button-footer.section-10
.btn-group.table-action-buttons
.btn-group
- = link_to admin_runner_path(runner), class: 'gl-button btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do
- = sprite_icon('pencil')
+ = link_to admin_runner_path(runner), class: 'gl-button btn btn-default btn-icon has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do
+ = sprite_icon('pencil', css_class: 'gl-icon')
.btn-group
- if runner.active?
- = link_to [:pause, :admin, runner], method: :post, class: 'gl-button btn btn-default btn-svg has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
- = sprite_icon('pause')
+ = link_to [:pause, :admin, runner], method: :post, class: 'gl-button btn btn-default btn-icon has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
+ = sprite_icon('pause', css_class: 'gl-icon')
- else
- = link_to [:resume, :admin, runner], method: :post, class: 'gl-button btn btn-default btn-svg has-tooltip gl-px-3', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do
- = sprite_icon('play')
+ = link_to [:resume, :admin, runner], method: :post, class: 'gl-button btn btn-default btn-icon has-tooltip gl-px-3', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do
+ = sprite_icon('play', css_class: 'gl-icon')
.btn-group
- = link_to [:admin, runner], method: :delete, class: 'gl-button btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
- = sprite_icon('close')
+ = link_to [:admin, runner], method: :delete, class: 'gl-button btn btn-danger btn-icon has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
+ = sprite_icon('close', css_class: 'gl-icon')
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index 8e62dae6c4d..a38615d9b1b 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -50,7 +50,7 @@
.filtered-search-box
= dropdown_tag(_('Recent searches'),
options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
- toggle_class: 'btn filtered-search-history-dropdown-toggle-button',
+ toggle_class: 'gl-button btn btn-default filtered-search-history-dropdown-toggle-button',
dropdown_class: 'filtered-search-history-dropdown',
content_class: 'filtered-search-history-dropdown-content' }) do
.js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } }
@@ -72,7 +72,7 @@
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
{{ title }}
%span.btn-helptext
{{ help }}
@@ -100,12 +100,12 @@
#js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
- %button.btn.btn-link
+ %button.gl-button.btn.btn-link
= _('No Tag')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.btn.btn-link.js-data-value
+ %button.gl-button.btn.btn-link.js-data-value
%span.dropdown-light-content
{{name}}
@@ -118,12 +118,11 @@
= _('Runners currently online: %{active_runners_count}') % { active_runners_count: @active_runners_count }
- if @runners.any?
- .runners-content.content-list
+ .content-list{ data: { testid: 'runners-table' } }
.table-holder
.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-10{ role: 'rowheader' }= _('Type/State')
- .table-section.section-10{ role: 'rowheader' }= _('Runner token')
- .table-section.section-20{ role: 'rowheader' }= _('Description')
+ .table-section.section-30{ role: 'rowheader' }= s_('Runners|Runner')
.table-section.section-10{ role: 'rowheader' }= _('Version')
.table-section.section-10{ role: 'rowheader' }= _('IP Address')
.table-section.section-5{ role: 'rowheader' }= _('Projects')
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index aca50de3852..705716c09b7 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -1,34 +1,17 @@
- add_page_specific_style 'page_bundles/ci_status'
-= content_for :title do
- %h3.project-title
- Runner ##{@runner.id}
- .float-right
- - if @runner.instance_type?
- %span.runner-state.runner-state-shared
- Shared
- - else
- %span.runner-state.runner-state-specific
- Specific
-
- page_title @runner.short_sha
-- add_to_breadcrumbs _("Runners"), admin_runners_path
-- breadcrumb_title "##{@runner.id}"
+- add_to_breadcrumbs _('Runners'), admin_runners_path
+- breadcrumb_title page_title
-- if @runner.instance_type?
- .bs-callout.bs-callout-success
- %h4= _('This runner processes jobs for all unassigned projects.')
- %p
- = _('If you want a runner to build only specific projects, restrict the project in the table below. After you restrict a runner to a project, you cannot change it back to a shared runner.')
-- elsif @runner.group_type?
- .bs-callout.bs-callout-success
- %h4= _('This runner processes jobs for all projects in its group and subgroups.')
+- if Feature.enabled?(:runner_detailed_view_vue_ui, current_user, default_enabled: :yaml)
+ #js-runner-detail{ data: {runner_id: @runner.id} }
- else
- .bs-callout.bs-callout-info
- %h4= _('This runner processes jobs for assigned projects only.')
- %p
- = _('You cannot make this a shared runner.')
-%hr
+ %h2.page-title
+ = s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id })
+ = render 'shared/runners/runner_type_badge', runner: @runner
+
+= render 'shared/runners/runner_type_alert', runner: @runner
.gl-mb-6
= render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner), in_gitlab_com_admin_context: Gitlab.com?
@@ -37,7 +20,7 @@
.col-md-6
%h4= _('Restrict projects for this runner')
- if @runner.projects.any?
- %table.table.assigned-projects
+ %table.table{ data: { testid: 'assigned-projects' } }
%thead
%tr
%th= _('Assigned projects')
@@ -54,7 +37,7 @@
.gl-alert-actions
= link_to s_('Disable'), admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn gl-alert-action btn-info btn-md gl-button'
- %table.table.unassigned-projects
+ %table.table{ data: { testid: 'unassigned-projects' } }
%thead
%tr
%th= _('Project')
diff --git a/app/views/admin/serverless/domains/index.html.haml b/app/views/admin/serverless/domains/index.html.haml
index bd3c6bc6e04..c2b6baed4de 100644
--- a/app/views/admin/serverless/domains/index.html.haml
+++ b/app/views/admin/serverless/domains/index.html.haml
@@ -10,7 +10,7 @@
.settings-header
%h4
= _('Serverless domain')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.gl-button.btn.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= _('Set an instance-wide domain that will be available to all clusters when installing Knative.')
diff --git a/app/views/admin/services/_form.html.haml b/app/views/admin/services/_form.html.haml
index c17ab5e08a7..4d9fa6d3d57 100644
--- a/app/views/admin/services/_form.html.haml
+++ b/app/views/admin/services/_form.html.haml
@@ -1,7 +1,9 @@
+= render "service_templates_deprecated_alert"
+
%h3.page-title
= @service.title
-%p #{@service.description} template.
+%p= @service.description
= form_for :service, url: admin_application_settings_service_path, method: :put, html: { class: 'fieldset-form js-integration-settings-form' } do |form|
= render 'shared/service_settings', form: form, integration: @service
diff --git a/app/views/admin/services/_service_templates_deprecated_alert.html.haml b/app/views/admin/services/_service_templates_deprecated_alert.html.haml
new file mode 100644
index 00000000000..0cc44099049
--- /dev/null
+++ b/app/views/admin/services/_service_templates_deprecated_alert.html.haml
@@ -0,0 +1,8 @@
+- doc_link_start = "<a href=\"#{integrations_help_page_path}\" target='_blank' rel='noopener noreferrer'>".html_safe
+- settings_link_start = "<a href=\"#{integrations_admin_application_settings_path}\">".html_safe
+
+.gl-alert.gl-alert-danger.gl-mt-5{ role: 'alert' }
+ = sprite_icon('error', css_class: 'gl-alert-icon gl-alert-icon-no-title')
+ %h4.gl-alert-title= s_('AdminSettings|Service templates are deprecated and will be removed in GitLab 14.0.')
+ .gl-alert-body
+ = html_escape_once(s_("AdminSettings|You can't add new templates. To migrate or remove a Service template, create a new integration at %{settings_link_start}Settings &gt; Integrations%{link_end}. Learn more about %{doc_link_start}Project integration management%{link_end}.")).html_safe % { settings_link_start: settings_link_start, doc_link_start: doc_link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/admin/services/index.html.haml b/app/views/admin/services/index.html.haml
index 3517beac976..91706452402 100644
--- a/app/views/admin/services/index.html.haml
+++ b/app/views/admin/services/index.html.haml
@@ -1,23 +1,13 @@
- page_title _("Service Templates")
- @content_class = 'limit-container-width' unless fluid_layout
-- if show_service_templates_deprecated?
- .gl-alert.gl-alert-tip.js-service-templates-deprecated.gl-mt-5{ role: 'alert', data: { feature_id: UserCalloutsHelper::SERVICE_TEMPLATES_DEPRECATED, dismiss_endpoint: user_callouts_path } }
- = sprite_icon('bulb', css_class: 'gl-alert-icon gl-alert-icon-no-title')
- %button.js-close.gl-alert-dismiss{ type: 'button', aria: { label: _('Dismiss') } }
- = sprite_icon('close')
- %h4.gl-alert-title= s_('AdminSettings|Service Templates will soon be deprecated.')
- .gl-alert-body
- = s_('AdminSettings|Try using the latest version of Integrations instead.')
- .gl-alert-actions
- = link_to _('Go to Integrations'), integrations_admin_application_settings_path, class: 'btn btn-info gl-alert-action gl-button'
- = link_to _('Learn more'), help_page_path('user/admin_area/settings/project_integration_management'), class: 'btn btn-default gl-alert-action btn-secondary gl-button', target: '_blank', rel: 'noopener noreferrer'
+= render "service_templates_deprecated_alert"
-%h3.page-title Service templates
-%p.light= s_('AdminSettings|Service template allows you to set default values for integrations')
+- if @activated_services.any?
+ %h3.page-title Service templates
+ %p= s_('AdminSettings|Service template allows you to set default values for integrations')
-.table-holder
- %table.table
+ %table.table.b-table.gl-table
%colgroup
%col
%col
@@ -26,10 +16,10 @@
%thead
%tr
%th
- %th Service
- %th Description
- %th Last edit
- - @services.each do |service|
+ %th= _('Service')
+ %th= _('Description')
+ %th= _('Last edit')
+ - @activated_services.each do |service|
- if service.type.in?(@existing_instance_types)
%tr
%td
diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml
index 2e7114ddab4..2bfe905fb9d 100644
--- a/app/views/admin/spam_logs/_spam_log.html.haml
+++ b/app/views/admin/spam_logs/_spam_log.html.haml
@@ -6,9 +6,9 @@
- if user
= link_to user.name, [:admin, user]
.light.small
- Joined #{time_ago_with_tooltip(user.created_at)}
+ = _('Joined %{user_created_time}').html_safe % { user_created_time: time_ago_with_tooltip(user.created_at) }
- else
- (removed)
+ = _('(removed)')
%td
= spam_log.source_ip
%td
@@ -23,17 +23,17 @@
= truncate(spam_log.description, length: 100)
%td
- if user
- = link_to 'Remove user', admin_spam_log_path(spam_log, remove_user: true),
- data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "gl-button btn btn-sm btn-danger"
+ = link_to _('Remove user'), admin_spam_log_path(spam_log, remove_user: true),
+ data: { confirm: _("USER %{user_name} WILL BE REMOVED! Are you sure?") % { user_name: user.name } }, method: :delete, class: "gl-button btn btn-sm btn-danger"
%td
- if spam_log.submitted_as_ham?
- .btn.btn-sm.disabled
- Submitted as ham
+ .gl-button.btn.btn-default.btn-sm.disabled.gl-mb-3
+ = _("Submitted as ham")
- else
- = link_to 'Submit as ham', mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'gl-button btn btn-sm btn-warning'
+ = link_to _('Submit as ham'), mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'gl-button btn btn-default btn-sm gl-mb-3'
- if user && !user.blocked?
- = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "gl-button btn btn-sm"
+ = link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "gl-button btn btn-default btn-sm gl-mb-3"
- else
- .btn.btn-sm.disabled
+ .gl-button.btn.btn-default.btn-sm.disabled.gl-mb-3
Already blocked
- = link_to 'Remove log', [:admin, spam_log], remote: true, method: :delete, class: "gl-button btn btn-sm btn-close js-remove-tr"
+ = link_to _('Remove log'), [:admin, spam_log], remote: true, method: :delete, class: "gl-button btn btn-default btn-sm btn-close js-remove-tr"
diff --git a/app/views/admin/users/_admin_notes.html.haml b/app/views/admin/users/_admin_notes.html.haml
index a20b2fbffc4..7c3220e2cee 100644
--- a/app/views/admin/users/_admin_notes.html.haml
+++ b/app/views/admin/users/_admin_notes.html.haml
@@ -2,6 +2,6 @@
%legend= _('Admin notes')
.form-group.row
.col-sm-2.col-form-label
- = f.label :note, s_('AdminNote|Note')
+ = f.label :note, s_('Admin|Note')
.col-sm-10
= f.text_area :note, class: 'form-control gl-form-input gl-form-textarea'
diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml
index 8a4a1a54c58..ade3581e5b9 100644
--- a/app/views/admin/users/_head.html.haml
+++ b/app/views/admin/users/_head.html.haml
@@ -20,20 +20,20 @@
.float-right
- if impersonation_enabled? && @user != current_user && @user.can?(:log_in)
- = link_to 'Impersonate', impersonate_admin_user_path(@user), method: :post, class: "btn btn-info gl-button btn-grouped", data: { qa_selector: 'impersonate_user_link' }
+ = link_to _('Impersonate'), impersonate_admin_user_path(@user), method: :post, class: "btn btn-info gl-button btn-grouped", data: { qa_selector: 'impersonate_user_link' }
= link_to edit_admin_user_path(@user), class: "btn btn-default gl-button btn-grouped" do
- = sprite_icon('pencil-square', css_class: 'gl-icon')
+ = sprite_icon('pencil-square', css_class: 'gl-icon gl-button-icon')
= _('Edit')
%hr
%ul.nav-links.nav.nav-tabs
= nav_link(path: 'users#show') do
- = link_to "Account", admin_user_path(@user)
+ = link_to _("Account"), admin_user_path(@user)
= nav_link(path: 'users#projects') do
- = link_to "Groups and projects", projects_admin_user_path(@user)
+ = link_to _("Groups and projects"), projects_admin_user_path(@user)
= nav_link(path: 'users#keys') do
- = link_to "SSH keys", keys_admin_user_path(@user)
+ = link_to _("SSH keys"), keys_admin_user_path(@user)
= nav_link(controller: :identities) do
- = link_to "Identities", admin_user_identities_path(@user)
+ = link_to _("Identities"), admin_user_identities_path(@user)
= nav_link(controller: :impersonation_tokens) do
- = link_to "Impersonation Tokens", admin_user_impersonation_tokens_path(@user)
+ = link_to _("Impersonation Tokens"), admin_user_impersonation_tokens_path(@user)
.gl-mb-3
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index 224a3cea28d..f2920579057 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -39,18 +39,18 @@
= link_to s_('AdminUsers|Approve'), approve_admin_user_path(user), method: :put
= link_to s_('AdminUsers|Reject'), reject_admin_user_path(user), method: :delete
- else
- %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_unblock_data(user) }
+ %button.gl-button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_unblock_data(user) }
= s_('AdminUsers|Unblock')
- else
- %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_block_data(user, user_block_effects) }
+ %button.gl-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
- %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_deactivation_data(user, user_deactivation_effects) }
+ %button.gl-button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_deactivation_data(user, user_deactivation_effects) }
= s_('AdminUsers|Deactivate')
- elsif user.deactivated?
%li
- %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_activation_data(user) }
+ %button.gl-button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_activation_data(user) }
= s_('AdminUsers|Activate')
- if user.access_locked?
%li
@@ -59,13 +59,13 @@
%li.divider
- if user.can_be_removed?
%li
- %button.js-delete-user-modal-button.btn.btn-default-tertiary.text-danger{ data: { 'gl-modal-action': 'delete',
+ %button.js-delete-user-modal-button.gl-button.btn.btn-danger-tertiary{ data: { 'gl-modal-action': 'delete',
delete_user_url: admin_user_path(user),
block_user_url: block_admin_user_path(user),
username: sanitize_name(user.name) } }
= s_('AdminUsers|Delete user')
%li
- %button.js-delete-user-modal-button.btn.btn-default-tertiary.text-danger{ data: { 'gl-modal-action': 'delete-with-contributions',
+ %button.js-delete-user-modal-button.gl-button.btn.btn-danger-tertiary{ data: { 'gl-modal-action': 'delete-with-contributions',
delete_user_url: admin_user_path(user, hard_delete: true),
block_user_url: block_admin_user_path(user),
username: sanitize_name(user.name) } }
diff --git a/app/views/authentication/_authenticate.html.haml b/app/views/authentication/_authenticate.html.haml
index 2d8948ae9aa..5a2ae3f44c2 100644
--- a/app/views/authentication/_authenticate.html.haml
+++ b/app/views/authentication/_authenticate.html.haml
@@ -1,5 +1,5 @@
#js-authenticate-token-2fa
-%a.btn.btn-block.btn-info#js-login-2fa-device{ href: '#' }= _("Sign in via 2FA code")
+%a.gl-button.btn.btn-block.btn-confirm#js-login-2fa-device{ href: '#' }= _("Sign in via 2FA code")
%script#js-authenticate-token-2fa-in-progress{ type: "text/template" }
%p= _("Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.")
@@ -7,7 +7,7 @@
%script#js-authenticate-token-2fa-error{ type: "text/template" }
%div
%p <%= error_message %> (<%= error_name %>)
- %a.btn.gl-button.btn-block.btn-warning#js-token-2fa-try-again= _("Try again?")
+ %a.btn.btn-default.gl-button.btn-block#js-token-2fa-try-again= _("Try again?")
%script#js-authenticate-token-2fa-authenticated{ type: "text/template" }
%div
diff --git a/app/views/authentication/_register.html.haml b/app/views/authentication/_register.html.haml
index d250cddf0f8..678fd3c8e8c 100644
--- a/app/views/authentication/_register.html.haml
+++ b/app/views/authentication/_register.html.haml
@@ -21,7 +21,7 @@
%div
%p
%span <%= error_message %> (<%= error_name %>)
- %a.btn.gl-button.btn-warning#js-token-2fa-try-again= _("Try again?")
+ %a.btn.btn-default.gl-button#js-token-2fa-try-again= _("Try again?")
%script#js-register-token-2fa-registered{ type: "text/template" }
.row.gl-mb-3
@@ -33,4 +33,4 @@
= text_field_tag 'device_registration[name]', nil, class: 'form-control', placeholder: _("Pick a name")
.col-md-3
= hidden_field_tag 'device_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
- = submit_tag _("Register device"), class: "btn btn-success"
+ = submit_tag _("Register device"), class: "gl-button btn btn-confirm"
diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml
index a063fe54c99..3b91bcdd990 100644
--- a/app/views/award_emoji/_awards_block.html.haml
+++ b/app/views/award_emoji/_awards_block.html.haml
@@ -1,19 +1,26 @@
-- grouped_emojis = awardable.grouped_awards(with_thumbs: inline)
-.awards.js-awards-block{ class: ("hidden" if !inline && grouped_emojis.empty?), data: { award_url: toggle_award_url(awardable) } }
- - awards_sort(grouped_emojis).each do |emoji, awards|
- %button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button",
- class: [(award_state_class(awardable, awards, current_user))],
- data: { title: award_user_list(awards, current_user) } }
- = emoji_icon(emoji)
- %span.award-control-text.js-counter
- = awards.count
+- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
- - if can?(current_user, :award_emoji, awardable)
- .award-menu-holder.js-award-holder
- %button.btn.award-control.has-tooltip.js-add-award{ type: 'button',
- 'aria-label': _('Add reaction'),
- data: { title: _('Add reaction') } }
- %span{ class: "award-control-icon award-control-icon-neutral" }= sprite_icon('slight-smile')
- %span{ class: "award-control-icon award-control-icon-positive" }= sprite_icon('smiley')
- %span{ class: "award-control-icon award-control-icon-super-positive" }= sprite_icon('smile')
- = yield
+- if api_awards_path
+ .gl-display-flex.gl-flex-wrap
+ #js-vue-awards-block{ data: { path: api_awards_path, can_award_emoji: can?(current_user, :award_emoji, awardable).to_s } }
+ = yield
+- else
+ - grouped_emojis = awardable.grouped_awards(with_thumbs: inline)
+ .awards.js-awards-block{ class: ("hidden" if !inline && grouped_emojis.empty?), data: { award_url: toggle_award_url(awardable) } }
+ - awards_sort(grouped_emojis).each do |emoji, awards|
+ %button.gl-button.btn.btn-default.award-control.js-emoji-btn.has-tooltip{ type: "button",
+ class: [award_state_class(awardable, awards, current_user)],
+ data: { title: award_user_list(awards, current_user) } }
+ = emoji_icon(emoji)
+ %span.award-control-text.js-counter
+ = awards.count
+
+ - if can?(current_user, :award_emoji, awardable)
+ .award-menu-holder.js-award-holder
+ %button.gl-button.btn.btn-default.award-control.has-tooltip.js-add-award{ type: 'button',
+ 'aria-label': _('Add reaction'),
+ data: { title: _('Add reaction') } }
+ %span{ class: "award-control-icon award-control-icon-neutral" }= sprite_icon('slight-smile')
+ %span{ class: "award-control-icon award-control-icon-positive" }= sprite_icon('smiley')
+ %span{ class: "award-control-icon award-control-icon-super-positive" }= sprite_icon('smile')
+ = yield
diff --git a/app/views/ci/group_variables/_index.html.haml b/app/views/ci/group_variables/_index.html.haml
index a74dbe793a6..eb49a9a0261 100644
--- a/app/views/ci/group_variables/_index.html.haml
+++ b/app/views/ci/group_variables/_index.html.haml
@@ -8,5 +8,7 @@
%td.gl-text-truncate
= variable.key
%td.gl-text-truncate
+ = variable.environment_scope
+ %td.gl-text-truncate
%a.group-origin-link{ href: group_settings_ci_cd_path(variable.group) }
= variable.group.name
diff --git a/app/views/ci/group_variables/_variable_header.html.haml b/app/views/ci/group_variables/_variable_header.html.haml
index ec512ab37e7..75a432e7f7c 100644
--- a/app/views/ci/group_variables/_variable_header.html.haml
+++ b/app/views/ci/group_variables/_variable_header.html.haml
@@ -2,4 +2,6 @@
%th
= s_('Key')
%th
+ = s_('Environments')
+ %th
= s_('Group')
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 6c2e4c69d83..03a3c9b0de8 100644
--- a/app/views/ci/runner/_how_to_setup_runner.html.haml
+++ b/app/views/ci/runner/_how_to_setup_runner.html.haml
@@ -22,4 +22,4 @@
method: :put, class: 'gl-button btn btn-default',
data: { confirm: _("Are you sure you want to reset the registration token?") }
-#js-install-runner{ data: { project_path: project_path, group_path: group_path } }
+#js-install-runner
diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml
deleted file mode 100644
index 5e9b02b5fe2..00000000000
--- a/app/views/ci/status/_dropdown_graph_badge.html.haml
+++ /dev/null
@@ -1,20 +0,0 @@
--# Renders the content of each li in the dropdown
-
-- subject = local_assigns.fetch(:subject)
-- status = subject.detailed_status(current_user)
-- klass = "ci-status-icon ci-status-icon-#{status.group}"
-- tooltip = "#{subject.name} - #{status.status_tooltip}"
-
-- if status.has_details?
- = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item d-flex', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
- %span{ class: klass }= sprite_icon(status.icon)
- %span.gl-text-truncate.mw-70p.gl-pl-2= subject.name
-
-- else
- .menu-item.mini-pipeline-graph-dropdown-item.d-flex{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } }
- %span{ class: klass }= sprite_icon(status.icon)
- %span.gl-text-truncate.mw-70p.gl-pl-2= subject.name
-
-- if status.has_action?
- = link_to status.action_path, class: "gl-button ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do
- = sprite_icon(status.action_icon, css_class: "icon-action-#{status.action_icon}")
diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml
index fd4b546e150..5eded970bf0 100644
--- a/app/views/ci/variables/_content.html.haml
+++ b/app/views/ci/variables/_content.html.haml
@@ -7,4 +7,4 @@
= html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
%li
= html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to _('Learn more.'), help_page_path('ci/variables/README', anchor: 'masked-variable-requirements'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('ci/variables/README', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index fc0e3488e57..f5d28adfa66 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -2,7 +2,7 @@
- if ci_variable_protected_by_default?
%p.settings-message.text-center
- - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protect-a-custom-variable') }
+ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protect-a-cicd-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 }
- is_group = !@group.nil?
@@ -16,8 +16,8 @@
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'),
+ protected_environment_variables_link: help_page_path('ci/variables/README', anchor: 'protect-a-cicd-variable'),
+ masked_environment_variables_link: help_page_path('ci/variables/README', anchor: 'mask-a-cicd-variable'),
} }
- if !@group && @project.group
diff --git a/app/views/ci/variables/_url_query_variable_row.html.haml b/app/views/ci/variables/_url_query_variable_row.html.haml
index 4c6eeb17c07..9c34daf88bd 100644
--- a/app/views/ci/variables/_url_query_variable_row.html.haml
+++ b/app/views/ci/variables/_url_query_variable_row.html.haml
@@ -24,5 +24,5 @@
name: value_input_name,
placeholder: s_('CiVariables|Input variable value') }
= value
- %button.btn.btn-svg.btn-item-remove.js-row-remove-button.ci-variable-row-remove-button.table-section{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') }
+ %button.gl-button.btn.btn-default.btn-icon.btn-item-remove.js-row-remove-button.ci-variable-row-remove-button.table-section{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') }
= sprite_icon('close')
diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml
index 193ec8abf04..856d03ba258 100644
--- a/app/views/ci/variables/_variable_row.html.haml
+++ b/app/views/ci/variables/_variable_row.html.haml
@@ -25,21 +25,21 @@
%input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name }
%select.js-ci-variable-input-variable-type.ci-variable-body-item.form-control.select-control.custom-select.table-section.section-15{ name: variable_type_input_name }
= options_for_select(ci_variable_type_options, variable_type)
- %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control.table-section.section-15{ type: "text",
+ %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control.gl-form-input.table-section.section-15{ type: "text",
name: key_input_name,
value: key,
placeholder: s_('CiVariables|Input variable key') }
.ci-variable-body-item.gl-show-field-errors.table-section.section-15.border-top-0.p-0
.form-control.js-secret-value-placeholder.qa-ci-variable-input-value.overflow-hidden{ class: ('hide' unless id) }
= '*' * 17
- %textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control{ class: ('hide' if id),
+ %textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control.gl-form-input{ class: ('hide' if id),
rows: 1,
name: value_input_name,
placeholder: s_('CiVariables|Input variable value') }
= value
%p.masking-validation-error.gl-field-error.hide
= s_("CiVariables|Cannot use Masked Variable with current value")
- = link_to sprite_icon('question-o'), help_page_path('ci/variables/README', anchor: 'mask-a-custom-variable'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to sprite_icon('question-o'), help_page_path('ci/variables/README', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
- unless only_key_value
.ci-variable-body-item.ci-variable-protected-item.table-section.section-20.mr-0.border-top-0
.gl-mr-3
@@ -60,5 +60,5 @@
value: is_masked,
data: { default: is_masked_default.to_s } }
= render_if_exists 'ci/variables/environment_scope', form_field: form_field, variable: variable
- %button.btn.btn-svg.js-row-remove-button.ci-variable-row-remove-button.table-section{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') }
+ %button.gl-button.btn.btn-default.btn-icon.js-row-remove-button.ci-variable-row-remove-button.table-section{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') }
= sprite_icon('close')
diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
index 8c23fc7c590..5df368ef3af 100644
--- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
+++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
@@ -5,5 +5,5 @@
= sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
%h4.gl-alert-title= s_('ClusterIntegration|Did you know?')
%p.gl-alert-body= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link }
- %a.gl-button.btn-info{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' }
+ %a.gl-button.btn-confirm.text-decoration-none{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' }
= s_("ClusterIntegration|Apply for credit")
diff --git a/app/views/clusters/clusters/_gitlab_integration_form.html.haml b/app/views/clusters/clusters/_gitlab_integration_form.html.haml
index 87af74a398f..b6d6dcdd7a9 100644
--- a/app/views/clusters/clusters/_gitlab_integration_form.html.haml
+++ b/app/views/clusters/clusters/_gitlab_integration_form.html.haml
@@ -1,3 +1,3 @@
-= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'js-cluster-integration-form' } do |field|
+= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'js-cluster-details-form' } do |field|
= form_errors(@cluster)
- #js-cluster-integration-form{ data: js_cluster_form_data(@cluster, can?(current_user, :update_cluster, @cluster)) }
+ #js-cluster-details-form{ data: js_cluster_form_data(@cluster, can?(current_user, :update_cluster, @cluster)) }
diff --git a/app/views/clusters/clusters/_health.html.haml b/app/views/clusters/clusters/_health.html.haml
index 5400bd7f201..025f52d8771 100644
--- a/app/views/clusters/clusters/_health.html.haml
+++ b/app/views/clusters/clusters/_health.html.haml
@@ -3,4 +3,4 @@
#prometheus-graphs{ data: @cluster.health_data(clusterable) }
- else
- %p.settings-message.text-center= s_("ClusterIntegration|In order to view the health of your cluster, you must first install Prometheus in the Applications tab.")
+ %p.settings-message.text-center= s_("ClusterIntegration|In order to view the health of your cluster, you must first enable Prometheus in the Integrations tab.")
diff --git a/app/views/clusters/clusters/_integrations.html.haml b/app/views/clusters/clusters/_integrations.html.haml
new file mode 100644
index 00000000000..d718e3ecb26
--- /dev/null
+++ b/app/views/clusters/clusters/_integrations.html.haml
@@ -0,0 +1,19 @@
+.settings.expanded.border-0.m-0
+ %p
+ = s_('ClusterIntegration|Integrations enable you to integrate your cluster as part of your GitLab workflow.')
+ = link_to _('Learn more'), help_page_path('user/clusters/integrations.md'), target: '_blank'
+ .settings-content#advanced-settings-section
+ - if can?(current_user, :admin_cluster, @cluster)
+ .sub-section.form-group
+ = form_for @prometheus_integration, url: @cluster.integrations_path, as: :integration, method: :post, html: { class: 'js-cluster-integrations-form' } do |form|
+ = form.hidden_field :application_type
+ .form-group
+ .gl-form-checkbox.custom-control.custom-checkbox
+ = form.check_box :enabled, { class: 'custom-control-input'}
+ = form.label :enabled, s_('ClusterIntegration|Enable Prometheus integration'), class: 'custom-control-label'
+ .gl-form-group
+ .form-text.text-gl-muted
+ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path("user/clusters/integrations", anchor: "prometheus-cluster-integration") }
+ - link_end = '</a>'.html_safe
+ = html_escape(s_('ClusterIntegration|Before you enable this integration, follow the %{link_start}documented process%{link_end}.')) % { link_start: link_start, link_end: link_end }
+ = form.submit _('Save changes'), class: 'btn gl-button btn-success'
diff --git a/app/views/clusters/clusters/_integrations_tab.html.haml b/app/views/clusters/clusters/_integrations_tab.html.haml
new file mode 100644
index 00000000000..77b8b6ca3e6
--- /dev/null
+++ b/app/views/clusters/clusters/_integrations_tab.html.haml
@@ -0,0 +1,6 @@
+- tab_name = 'integrations'
+- active = params[:tab] == tab_name
+
+%li.nav-item{ role: 'presentation' }
+ %a#cluster-apps-tab.nav-link{ class: active_when(active), href: clusterable.cluster_path(@cluster.id, params: {tab: tab_name}) }
+ %span= _('Integrations')
diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml
index 4407b27df1e..93e8b1241a8 100644
--- a/app/views/clusters/clusters/aws/_new.html.haml
+++ b/app/views/clusters/clusters/aws/_new.html.haml
@@ -12,6 +12,6 @@
'role-arn' => @aws_role.role_arn,
'instance-types' => @instance_types,
'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'),
+ 'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'new-eks-cluster'),
+ 'create-role-arn-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'new-eks-cluster'),
'external-link-icon' => sprite_icon('external-link') } }
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index cb464eeafbb..01ba7c06154 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -59,6 +59,7 @@
= render_if_exists 'clusters/clusters/environments_tab'
= render 'clusters/clusters/health_tab'
= render 'applications_tab'
+ = render 'integrations_tab'
= render 'advanced_settings_tab'
.tab-content.py-3
diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml
index 57c0801074b..90a49e4bbe3 100644
--- a/app/views/dashboard/_projects_head.html.haml
+++ b/app/views/dashboard/_projects_head.html.haml
@@ -18,11 +18,11 @@
= nav_link(page: [dashboard_projects_path, root_path]) do
= link_to dashboard_projects_path, class: 'shortcuts-activity', data: {placement: 'right'} do
= _("Your projects")
- %span.badge.badge-pill= limited_counter_with_delimiter(@total_user_projects_count)
+ %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(@total_user_projects_count)
= nav_link(page: starred_dashboard_projects_path) do
= link_to starred_dashboard_projects_path, data: {placement: 'right'} do
= _("Starred projects")
- %span.badge.badge-pill= limited_counter_with_delimiter(@total_starred_projects_count)
+ %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(@total_starred_projects_count)
= nav_link(page: [explore_root_path, trending_explore_projects_path, starred_explore_projects_path, explore_projects_path]) do
= link_to explore_root_path, data: {placement: 'right'} do
= _("Explore projects")
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index d47df24b1b9..ae557b73620 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -1,11 +1,11 @@
- @hide_top_links = true
-- page_title _("Merge Requests")
+- page_title _("Merge requests")
- @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username)
= render_dashboard_ultimate_trial(current_user)
.page-title-holder.d-flex.align-items-start.flex-column.flex-sm-row.align-items-sm-center
- %h1.page-title= _('Merge Requests')
+ %h1.page-title= _('Merge requests')
- if current_user
.page-title-controls.ml-0.mb-3.ml-sm-auto.mb-sm-0
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index 1f4bd06aea4..c24d386c412 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -3,7 +3,7 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity")
-- if show_customize_homepage_banner?(@customize_homepage)
+- if show_customize_homepage_banner?
= content_for :customize_homepage_banner do
.gl-display-none.gl-md-display-block{ class: "gl-pt-6! gl-pb-2! #{(container_class unless @no_container)} #{@content_class}" }
.js-customize-homepage-banner{ data: { svg_path: image_path('illustrations/monitoring/getting_started.svg'),
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index d78059b6aed..a0016417f0c 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -42,12 +42,12 @@
- if params[:group_id].present?
= hidden_field_tag(:group_id, params[:group_id])
= dropdown_tag(group_dropdown_label(params[:group_id], 'Group'), options: { toggle_class: 'js-group-search js-filter-submit', title: 'Filter by group', filter: true, filterInput: 'input#group-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit',
- placeholder: 'Search groups', data: { data: todo_group_options, default_label: 'Group', display: 'static' } })
+ placeholder: 'Search groups', data: { default_label: 'Group', display: 'static' } })
.filter-item.inline
- if params[:project_id].present?
= hidden_field_tag(:project_id, params[:project_id])
= dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit',
- placeholder: 'Search projects', data: { data: todo_projects_options, default_label: 'Project', display: 'static' } })
+ placeholder: 'Search projects', data: { default_label: 'Project', display: 'static' } })
.filter-item.inline
- if params[:author_id].present?
= hidden_field_tag(:author_id, params[:author_id])
diff --git a/app/views/devise/mailer/_confirmation_instructions_account.html.haml b/app/views/devise/mailer/_confirmation_instructions_account.html.haml
index 27ef586d90f..9d469ff6e7b 100644
--- a/app/views/devise/mailer/_confirmation_instructions_account.html.haml
+++ b/app/views/devise/mailer/_confirmation_instructions_account.html.haml
@@ -2,15 +2,15 @@
- if @resource.unconfirmed_email.present? || !@resource.created_recently?
#content
= email_default_heading(@resource.unconfirmed_email || @resource.email)
- %p Click the link below to confirm your email address.
+ %p= _('Click the link below to confirm your email address.')
#cta
- = link_to 'Confirm your email address', confirmation_link
+ = link_to _('Confirm your email address'), confirmation_link
- else
#content
- if Gitlab.com?
- = email_default_heading('Thanks for signing up to GitLab!')
+ = email_default_heading(_('Thanks for signing up to GitLab!'))
- else
- = email_default_heading("Welcome, #{@resource.name}!")
- %p To get started, click the link below to confirm your account.
+ = email_default_heading(_("Welcome, %{name}!") % { name: @resource.name })
+ %p= _("To get started, click the link below to confirm your account.")
#cta
- = link_to 'Confirm your account', confirmation_link
+ = link_to _('Confirm your account'), confirmation_link
diff --git a/app/views/devise/mailer/_confirmation_instructions_account.text.erb b/app/views/devise/mailer/_confirmation_instructions_account.text.erb
index 5bccb68bbe2..e6da78e3a3d 100644
--- a/app/views/devise/mailer/_confirmation_instructions_account.text.erb
+++ b/app/views/devise/mailer/_confirmation_instructions_account.text.erb
@@ -1,13 +1,13 @@
<% if @resource.unconfirmed_email.present? || !@resource.created_recently? %>
<%= @resource.unconfirmed_email || @resource.email %>,
-Use the link below to confirm your email address.
+<%= _('Use the link below to confirm your email address.') %>
<% else %>
<% if Gitlab.com? %>
-Thanks for signing up to GitLab!
+<%= _('Thanks for signing up to GitLab!') %>
<% else %>
-Welcome, <%= @resource.name %>!
+<%= _("Welcome, %{name}!") % { name: @resource.name } %>
<% end %>
-To get started, use the link below to confirm your account.
-<% end %>
+<%= _('To get started, use the link below to confirm your account.') %>
+<% end %>
<%= confirmation_url(@resource, confirmation_token: @token) %>
diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb
index b91498ccfae..ab46aaaca1a 100644
--- a/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb
+++ b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb
@@ -1,7 +1,7 @@
-<%= @resource.user.name %>, confirm your email address now!
+<%= _(" %{name}, confirm your email address now! ") % { name: @resource.user.name } %>
-Use the link below to confirm your email address (<%= @resource.email %>)
+<%= _("Use the link below to confirm your email address (%{email})") % { email: @resource.email } %>
<%= confirmation_url(@resource, confirmation_token: @token) %>
-If this email was added in error, you can remove it here: <%= profile_emails_url %>
+<%= _("If this email was added in error, you can remove it here: %{profile_emails_url}") % { profile_emails_url: profile_emails_url } %>
diff --git a/app/views/devise/mailer/password_change.html.haml b/app/views/devise/mailer/password_change.html.haml
index 5ec515285f2..5c0219ea3ad 100644
--- a/app/views/devise/mailer/password_change.html.haml
+++ b/app/views/devise/mailer/password_change.html.haml
@@ -1,8 +1,5 @@
-= email_default_heading("Hello, #{@resource.name}!")
+= email_default_heading(_("Hello, %{name}!") % { name: @resource.name })
%p
- The password for your GitLab account on
- #{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url)}
- has successfully been changed.
+ = _('The password for your GitLab account on %{link_to_gitlab} has successfully been changed.').html_safe % { link_to_gitlab: link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url) }
%p
- If you did not initiate this change, please contact your administrator
- immediately.
+ = _('If you did not initiate this change, please contact your administrator immediately.')
diff --git a/app/views/devise/mailer/password_change.text.erb b/app/views/devise/mailer/password_change.text.erb
index 95923d9f8de..6a8128186f5 100644
--- a/app/views/devise/mailer/password_change.text.erb
+++ b/app/views/devise/mailer/password_change.text.erb
@@ -1,7 +1,5 @@
-Hello, <%= @resource.name %>!
+<%= _('Hello, %{name}!') % { name: @resource.name } %>
-The password for your GitLab account on <%= Gitlab.config.gitlab.url %>
-has successfully been changed.
+<%= _('The password for your GitLab account on %{gitlab_url} has successfully been changed.') % { gitlab_url: Gitlab.config.gitlab.url } %>
-If you did not initiate this change, please contact your administrator
-immediately.
+<%= _('If you did not initiate this change, please contact your administrator immediately.') %>
diff --git a/app/views/devise/mailer/unlock_instructions.html.haml b/app/views/devise/mailer/unlock_instructions.html.haml
index 8ddfd3ea74a..0c05ee4a6cd 100644
--- a/app/views/devise/mailer/unlock_instructions.html.haml
+++ b/app/views/devise/mailer/unlock_instructions.html.haml
@@ -1,8 +1,6 @@
#content
- = email_default_heading("Hello, #{@resource.name}!")
+ = email_default_heading(_("Hello, %{name}!") % { name: @resource.name })
%p
- Your GitLab account has been locked due to an excessive amount of unsuccessful
- sign in attempts. Your account will automatically unlock in #{distance_of_time_in_words(Devise.unlock_in)}
- or you may click the link below to unlock now.
+ = _("Your GitLab account has been locked due to an excessive amount of unsuccessful sign in attempts. Your account will automatically unlock in %{duration} or you may click the link below to unlock now.") % { duration: distance_of_time_in_words(Devise.unlock_in) }
#cta
- = link_to('Unlock account', unlock_url(@resource, unlock_token: @token))
+ = link_to(_('Unlock account'), unlock_url(@resource, unlock_token: @token))
diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml
index 7876aed2c0a..10c04423589 100644
--- a/app/views/devise/passwords/edit.html.haml
+++ b/app/views/devise/passwords/edit.html.haml
@@ -1,4 +1,4 @@
-= render 'devise/shared/tab_single', tab_title:'Change your password'
+= render 'devise/shared/tab_single', tab_title: _('Change your password')
.login-box
.login-body
= form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors' }) do |f|
@@ -6,16 +6,16 @@
= render "devise/shared/error_messages", resource: resource
= f.hidden_field :reset_password_token
.form-group
- = f.label 'New password', for: "user_password"
- = f.password_field :password, class: "form-control gl-form-input top", required: true, title: 'This field is required', data: { qa_selector: 'password_field'}
+ = f.label _('New password'), for: "user_password"
+ = f.password_field :password, class: "form-control gl-form-input top", required: true, title: _('This field is required.'), data: { qa_selector: 'password_field'}
.form-group
- = f.label 'Confirm new password', for: "user_password_confirmation"
- = f.password_field :password_confirmation, class: "form-control gl-form-input bottom", title: 'This field is required', data: { qa_selector: 'password_confirmation_field' }, required: true
+ = f.label _('Confirm new password'), for: "user_password_confirmation"
+ = f.password_field :password_confirmation, class: "form-control gl-form-input bottom", title: _('This field is required.'), data: { qa_selector: 'password_confirmation_field' }, required: true
.clearfix
- = f.submit "Change your password", class: "gl-button btn btn-confirm", data: { qa_selector: 'change_password_button' }
+ = f.submit _("Change your password"), class: "gl-button btn btn-confirm", data: { qa_selector: 'change_password_button' }
.clearfix.prepend-top-20
%p
- %span.light Didn't receive a confirmation email?
- = link_to "Request a new one", new_confirmation_path(:user)
+ %span.light= _("Didn't receive a confirmation email?")
+ = link_to _("Request a new one"), new_confirmation_path(:user)
= render 'devise/shared/sign_in_link'
diff --git a/app/views/devise/passwords/new.html.haml b/app/views/devise/passwords/new.html.haml
index c4672a5b25e..ef876779ad6 100644
--- a/app/views/devise/passwords/new.html.haml
+++ b/app/views/devise/passwords/new.html.haml
@@ -5,9 +5,9 @@
= render "devise/shared/error_messages", resource: resource
.form-group
= f.label :email
- = f.email_field :email, class: "form-control gl-form-input", required: true, value: params[:user_email], autofocus: true, title: 'Please provide a valid email address.'
+ = f.email_field :email, class: "form-control gl-form-input", required: true, value: params[:user_email], autofocus: true, title: _('Please provide a valid email address.')
.clearfix
- = f.submit "Reset password", class: "gl-button btn-confirm btn"
+ = f.submit _("Reset password"), class: "gl-button btn-confirm btn"
.clearfix.prepend-top-20
= render 'devise/shared/sign_in_link'
diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb
index 5a1388ac7a1..eeaefb8c6ac 100644
--- a/app/views/devise/registrations/edit.html.erb
+++ b/app/views/devise/registrations/edit.html.erb
@@ -9,20 +9,20 @@
<div><%= f.label :name %><br />
<%= f.text_field :name %></div>
- <div><%= f.label :password %> <i>(leave blank if you don't want to change it)</i><br />
+ <div><%= f.label :password %> <i><%= _("(leave blank if you don't want to change it)") %></i><br />
<%= f.password_field :password %></div>
<div><%= f.label :password_confirmation %><br />
<%= f.password_field :password_confirmation %></div>
- <div><%= f.label :current_password %> <i>(we need your current password to confirm your changes)</i><br />
+ <div><%= f.label :current_password %> <i><%= _("(we need your current password to confirm your changes)") %></i><br />
<%= f.password_field :current_password %></div>
-<div><%= f.submit "Update", class: "input_button" %></div>
+<div><%= f.submit _("Update"), class: "input_button" %></div>
<% end %>
-<h3>Cancel your account</h3>
+<h3><%= _('Cancel your account') %></h3>
-<p>Unhappy? <%= link_to "Cancel your account", registration_path(resource_name), data: { confirm: "Are you sure?" }, method: :delete %>.</p>
+<p><%= _('Unhappy?') %> <%= link_to _("Cancel your account"), registration_path(resource_name), data: { confirm: _("Are you sure?") }, method: :delete %>.</p>
-<%= link_to "Back", :back %>
+<%= link_to _("Back"), :back %>
diff --git a/app/views/devise/sessions/_new_crowd.html.haml b/app/views/devise/sessions/_new_crowd.html.haml
index 161e23d700e..769268748f4 100644
--- a/app/views/devise/sessions/_new_crowd.html.haml
+++ b/app/views/devise/sessions/_new_crowd.html.haml
@@ -1,13 +1,13 @@
= form_tag(omniauth_authorize_path(:user, :crowd), id: 'new_crowd_user', class: 'gl-show-field-errors') do
.form-group
- = label_tag :username, 'Username or email'
- = text_field_tag :username, nil, { class: "form-control top", title: "This field is required", autofocus: "autofocus", required: true }
+ = label_tag :username, _('Username or email')
+ = text_field_tag :username, nil, { class: "form-control top", title: _("This field is required."), autofocus: "autofocus", required: true }
.form-group
= label_tag :password
- = password_field_tag :password, nil, { class: "form-control bottom", title: "This field is required.", required: true }
+ = password_field_tag :password, nil, { class: "form-control bottom", title: _("This field is required."), required: true }
- if devise_mapping.rememberable?
.remember-me
%label{ for: "remember_me" }
= check_box_tag :remember_me, '1', false, id: 'remember_me'
- %span Remember me
- = submit_tag "Sign in", class: "gl-button btn-confirm btn"
+ %span= _('Remember me')
+ = submit_tag _("Sign in"), class: "gl-button btn-confirm btn"
diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml
index 19fcabb1a2e..f599a652b71 100644
--- a/app/views/devise/sessions/_new_ldap.html.haml
+++ b/app/views/devise/sessions/_new_ldap.html.haml
@@ -5,15 +5,15 @@
= form_tag(omniauth_callback_path(:user, server['provider_name']), id: 'new_ldap_user', class: "gl-show-field-errors") do
.form-group
= label_tag :username, "#{server['label']} Username"
- = text_field_tag :username, nil, { class: "form-control gl-form-input top", title: "This field is required.", autofocus: "autofocus", data: { qa_selector: 'username_field' }, required: true }
+ = text_field_tag :username, nil, { class: "form-control gl-form-input top", title: _("This field is required."), autofocus: "autofocus", data: { qa_selector: 'username_field' }, required: true }
.form-group
= label_tag :password
- = password_field_tag :password, nil, { class: "form-control gl-form-input bottom", title: "This field is required.", data: { qa_selector: 'password_field' }, required: true }
+ = password_field_tag :password, nil, { class: "form-control gl-form-input bottom", title: _("This field is required."), data: { qa_selector: 'password_field' }, required: true }
- if !hide_remember_me && devise_mapping.rememberable?
.remember-me
%label{ for: "remember_me" }
= check_box_tag :remember_me, '1', false, id: 'remember_me'
- %span Remember me
+ %span= _('Remember me')
.submit-container.move-submit-down
= submit_tag submit_message, class: "gl-button btn btn-confirm", 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 cce0a3b926e..74f3e3e7e34 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -13,7 +13,7 @@
-# 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.
+ = _('No authentication methods configured.')
- if allow_signup?
%p.gl-mt-3
diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml
index 404484cfb93..29bcb3c158b 100644
--- a/app/views/devise/sessions/two_factor.html.haml
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -1,5 +1,5 @@
%div
- = render 'devise/shared/tab_single', tab_title: 'Two-Factor Authentication'
+ = render 'devise/shared/tab_single', tab_title: _('Two-Factor Authentication')
.login-box
.login-body
- if @user.two_factor_otp_enabled?
@@ -7,10 +7,10 @@
- resource_params = params[resource_name].presence || params
= f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0)
%div
- = f.label 'Two-Factor Authentication code', name: :otp_attempt
- = f.text_field :otp_attempt, class: 'form-control gl-form-input', 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.
+ = f.label _('Two-Factor Authentication code'), name: :otp_attempt
+ = f.text_field :otp_attempt, class: 'form-control gl-form-input', 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: "gl-button btn btn-confirm", data: { qa_selector: 'verify_code_button' }
+ = f.submit _("Verify code"), class: "gl-button btn btn-confirm", 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/_email_opted_in.html.haml b/app/views/devise/shared/_email_opted_in.html.haml
new file mode 100644
index 00000000000..6896ef21536
--- /dev/null
+++ b/app/views/devise/shared/_email_opted_in.html.haml
@@ -0,0 +1,7 @@
+- is_hidden = local_assigns.fetch(:hidden, Gitlab.dev_env_or_com?)
+
+.gl-mb-3.js-email-opt-in{ class: is_hidden ? 'hidden' : '' }
+ .gl-font-weight-bold.gl-mb-3
+ = _('Email updates (optional)')
+ = f.check_box :email_opted_in
+ = f.label :email_opted_in, _("I'd like to receive updates about GitLab via email"), class: 'gl-font-weight-normal'
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index 3ec859551ca..8b54b735205 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -2,7 +2,7 @@
.omniauth-container.gl-mt-5
%label.label-bold.d-block
- Sign in with
+ = _('Sign in with')
- providers = enabled_button_based_providers
.d-flex.justify-content-between.flex-wrap
- providers.each do |provider|
@@ -17,4 +17,4 @@
%label
= check_box_tag :remember_me, nil, false, class: 'remember-me-checkbox'
%span
- Remember me
+ = _('Remember me')
diff --git a/app/views/devise/shared/_sign_in_link.html.haml b/app/views/devise/shared/_sign_in_link.html.haml
index 9a7d8a0a160..0a48c342502 100644
--- a/app/views/devise/shared/_sign_in_link.html.haml
+++ b/app/views/devise/shared/_sign_in_link.html.haml
@@ -1,4 +1,6 @@
%p.text-center
%span.light
- Already have login and password?
- = link_to "Sign in", new_session_path(:user, redirect_to_referer: 'yes')
+ = _('Already have login and password?')
+ - path_params = { redirect_to_referer: 'yes' }
+ - path_params[:invite_email] = @invite_email if @invite_email.present?
+ = link_to _('Sign in'), new_session_path(:user, path_params)
diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml
index 27057d023b1..0ef4a30d820 100644
--- a/app/views/devise/shared/_tabs_ldap.html.haml
+++ b/app/views/devise/shared/_tabs_ldap.html.haml
@@ -4,7 +4,7 @@
%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', role: '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
@@ -17,4 +17,4 @@
= 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' }, role: '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 398a4fa0c5e..abaf169afd5 100644
--- a/app/views/devise/unlocks/new.html.haml
+++ b/app/views/devise/unlocks/new.html.haml
@@ -1,4 +1,4 @@
-= render 'devise/shared/tab_single', tab_title: 'Resend unlock instructions'
+= render 'devise/shared/tab_single', tab_title: _('Resend unlock instructions')
.login-box
.login-body
= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f|
@@ -6,9 +6,9 @@
= render "devise/shared/error_messages", resource: resource
.form-group.gl-mb-6
= f.label :email
- = f.email_field :email, class: 'form-control', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', title: 'Please provide a valid email address.'
+ = 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: 'gl-button btn btn-confirm'
+ = f.submit _('Resend unlock instructions'), class: 'gl-button btn btn-confirm'
.clearfix.prepend-top-20
= render 'devise/shared/sign_in_link'
diff --git a/app/views/doorkeeper/applications/edit.html.haml b/app/views/doorkeeper/applications/edit.html.haml
index aad4200f240..99e6a5eca19 100644
--- a/app/views/doorkeeper/applications/edit.html.haml
+++ b/app/views/doorkeeper/applications/edit.html.haml
@@ -1,4 +1,5 @@
- page_title _("Edit"), @application.name, _("Applications")
- @content_class = "limit-container-width" unless fluid_layout
+
%h3.page-title= _('Edit application')
-= render 'form', application: @application
+= render 'shared/doorkeeper/applications/form', url: doorkeeper_submit_path(@application)
diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml
index 827a839234f..e17448fbeaf 100644
--- a/app/views/doorkeeper/applications/index.html.haml
+++ b/app/views/doorkeeper/applications/index.html.haml
@@ -1,85 +1,8 @@
- page_title _("Applications")
-- @content_class = "limit-container-width" unless fluid_layout
-.row.gl-mt-3
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = page_title
- %p
- - if user_oauth_applications?
- = _("Manage applications that can use GitLab as an OAuth provider, and applications that you've authorized to use your account.")
- - else
- = _("Manage applications that you've authorized to use your account.")
- .col-lg-8
- - if user_oauth_applications?
- %h5.gl-mt-0
- = _('Add new application')
- = render 'form', application: @application
- %hr
- - else
- .bs-callout.bs-callout-disabled
- = _('Adding new applications is disabled in your GitLab instance. Please contact your GitLab administrator to get the permission')
- - if user_oauth_applications?
- .oauth-applications
- %h5
- = _("Your applications (%{size})") % { size: @applications.size }
- - if @applications.any?
- .table-responsive
- %table.table
- %thead
- %tr
- %th= _('Name')
- %th= _('Callback URL')
- %th= _('Clients')
- %th.last-heading
- %tbody
- - @applications.each do |application|
- %tr{ id: "application_#{application.id}" }
- %td= link_to application.name, oauth_application_path(application)
- %td
- - application.redirect_uri.split.each do |uri|
- %div= uri
- %td= application.access_tokens.count
- %td.gl-display-flex
- = link_to edit_oauth_application_path(application), class: "gl-button btn btn-default gl-mr-2" do
- %span.sr-only
- = _('Edit')
- = sprite_icon('pencil')
- = render 'delete_form', application: application, small: true
- - else
- .settings-message.text-center
- = _("You don't have any applications")
- .oauth-authorized-applications.prepend-top-20.gl-mb-3
- - if user_oauth_applications?
- %h5
- = _("Authorized applications (%{size})") % { size: @authorized_apps.size + @authorized_anonymous_tokens.size }
-
- - if @authorized_tokens.any?
- .table-responsive
- %table.table.table-striped
- %thead
- %tr
- %th= _('Name')
- %th= _('Authorized At')
- %th= _('Scope')
- %th
- %tbody
- - @authorized_apps.each do |app|
- - token = app.authorized_tokens.order('created_at desc').first # rubocop: disable CodeReuse/ActiveRecord
- %tr{ id: "application_#{app.id}" }
- %td= app.name
- %td= token.created_at
- %td= token.scopes
- %td= render 'doorkeeper/authorized_applications/delete_form', application: app
- - @authorized_anonymous_tokens.each do |token|
- %tr
- %td
- = _('Anonymous')
- .form-text.text-muted
- %em= _("Authorization was granted by entering your username and password in the application.")
- %td= token.created_at
- %td= token.scopes
- %td= render 'doorkeeper/authorized_applications/delete_form', token: token
- - else
- .settings-message.text-center
- = _("You don't have any authorized applications")
+= render 'shared/doorkeeper/applications/index',
+ oauth_applications_enabled: user_oauth_applications?,
+ oauth_authorized_applications_enabled: true,
+ form_url: doorkeeper_submit_path(@application),
+ application_url: ->(application) { oauth_application_path(application) },
+ edit_application_url: ->(application) { edit_oauth_application_path(application) }
diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml
index 046d44bc47f..75521d42f7e 100644
--- a/app/views/doorkeeper/applications/show.html.haml
+++ b/app/views/doorkeeper/applications/show.html.haml
@@ -6,42 +6,4 @@
%h3.page-title
= _("Application: %{name}") % { name: @application.name }
-.table-holder.oauth-application-show
- %table.table
- %tr
- %td
- = _('Application ID')
- %td
- .clipboard-group
- .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: "gl-button btn btn-default")
- %tr
- %td
- = _('Secret')
- %td
- .clipboard-group
- .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: "gl-button btn btn-default")
- %tr
- %td
- = _('Callback URL')
- %td
- - @application.redirect_uri.split.each do |uri|
- %div
- %span.monospace= uri
-
- %tr
- %td
- = _('Confidential')
- %td
- = @application.confidential? ? _('Yes') : _('No')
-
- = render "shared/tokens/scopes_list", token: @application
-
-.form-actions
- = link_to _('Edit'), edit_oauth_application_path(@application), class: 'gl-button btn btn-confirm wide float-left'
- = render 'delete_form', application: @application, submit_btn_css: 'gl-button btn btn-danger gl-ml-3'
+= render 'shared/doorkeeper/applications/show', edit_path: edit_oauth_application_path(@application), delete_path: oauth_application_path(@application)
diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml
index 7ea10296d97..5e93b1d89eb 100644
--- a/app/views/doorkeeper/authorizations/new.html.haml
+++ b/app/views/doorkeeper/authorizations/new.html.haml
@@ -1,6 +1,6 @@
%main{ :role => "main" }
- .modal-no-backdrop.modal-doorkeepr-auth
- .modal-content
+ .modal-dialog.modal-doorkeepr-auth
+ .modal-content.gl-shadow-none
.modal-header
%h3.page-title
- link_to_client = link_to(@pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer')
diff --git a/app/views/errors/_footer.html.haml b/app/views/errors/_footer.html.haml
index bb9edc54b4b..62bac62c70c 100644
--- a/app/views/errors/_footer.html.haml
+++ b/app/views/errors/_footer.html.haml
@@ -4,7 +4,8 @@
= link_to s_('Nav|Home'), root_path
%li
- if current_user
- = link_to s_('Nav|Sign out and sign in with a different account'), destroy_user_session_path, method: :post
+ = link_to s_('Nav|Sign out and sign in with a different account'), '#', id: 'sign_out_link'
+ %form{ action: destroy_user_session_path, method: :post, id: 'sign_out_form' }
- else
= link_to s_('Nav|Sign In / Register'), new_session_path(:user, redirect_to_referer: 'yes')
%li
diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml
index 2fa595503e5..d08c3d5ba41 100644
--- a/app/views/events/event/_note.html.haml
+++ b/app/views/events/event/_note.html.haml
@@ -22,7 +22,7 @@
- if note.attachment.url
- if note.attachment.image?
= link_to note.attachment.url, target: '_blank' do
- = image_tag note.attachment.url, class: 'note-image-attach'
+ = image_tag note.attachment.url, class: 'note-image-attach col-lg-4'
- else
= link_to note.attachment.url, target: '_blank', class: 'note-file-attach' do
= sprite_icon("paperclip")
diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml
index 97dd606855b..62d6ab36578 100644
--- a/app/views/events/event/_push.html.haml
+++ b/app/views/events/event/_push.html.haml
@@ -18,7 +18,7 @@
- if event.push_with_commits?
.event-body
- %ul.content-list.event_commits
+ %ul.content-list.event-commits
= render "events/commit", project: project, event: event
- create_mr = event.new_ref? && create_mr_button?(from: project.default_branch, to: event.ref_name, source_project: project, target_project: project) && event.authored_by?(current_user)
@@ -46,4 +46,4 @@
- elsif create_mr
%li.commits-stat
= link_to create_mr_path do
- Create Merge Request
+ Create merge request
diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml
index 769455dc951..b1a40bfc96b 100644
--- a/app/views/groups/_activities.html.haml
+++ b/app/views/groups/_activities.html.haml
@@ -1,7 +1,7 @@
.nav-block.activities
= render 'shared/event_filter', show_group_events: @group.supports_events?
.controls
- = link_to group_path(@group, rss_url_options), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' , title: 'Subscribe' do
+ = link_to group_path(@group, rss_url_options), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' , title: _('Subscribe') do
= sprite_icon('rss', css_class: 'qa-rss-icon gl-icon')
.content_list
diff --git a/app/views/groups/_create_chat_team.html.haml b/app/views/groups/_create_chat_team.html.haml
index f141b646e69..8f50d499605 100644
--- a/app/views/groups/_create_chat_team.html.haml
+++ b/app/views/groups/_create_chat_team.html.haml
@@ -3,7 +3,7 @@
= f.label :create_chat_team do
%span.gl-display-flex
= custom_icon('icon_mattermost')
- %span.gl-ml-2 Mattermost
+ %span.gl-ml-2= _('Mattermost')
.col-sm-12
.form-check.js-toggle-container
.js-toggle-button.form-check-input= f.check_box(:create_chat_team, { checked: false }, true, false)
@@ -11,7 +11,7 @@
= _('Create a Mattermost team for this group')
%br
%small.light.js-toggle-content
- Mattermost URL:
+ = _('Mattermost URL:')
= Settings.mattermost.host
%span> /
%span{ "data-bind-out" => "create_chat_team" }
diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml
index 393ab8013e7..0c3eff85f16 100644
--- a/app/views/groups/_group_admin_settings.html.haml
+++ b/app/views/groups/_group_admin_settings.html.haml
@@ -1,15 +1,15 @@
.form-group.row
.col-sm-2.col-form-label.pt-0
- = f.label :lfs_enabled, 'Large File Storage'
+ = f.label :lfs_enabled, _('Large File Storage')
.col-sm-10
.form-check
= f.check_box :lfs_enabled, checked: @group.lfs_enabled?, class: 'form-check-input'
= f.label :lfs_enabled, class: 'form-check-label' do
%strong
- Allow projects within this group to use Git LFS
+ = _('Allow projects within this group to use Git LFS')
= link_to sprite_icon('question-o'), help_page_path('topics/git/lfs/index')
%br/
- %span This setting can be overridden in each project.
+ %span= _('This setting can be overridden in each project.')
.form-group.row
.col-sm-2.col-form-label
= f.label s_('ProjectCreationLevel|Allowed to create projects')
@@ -24,16 +24,16 @@
.form-group.row
.col-sm-2.col-form-label.pt-0
- = f.label :require_two_factor_authentication, 'Two-factor authentication'
+ = f.label :require_two_factor_authentication, _('Two-factor authentication')
.col-sm-10
.form-check
= f.check_box :require_two_factor_authentication, class: 'form-check-input'
= f.label :require_two_factor_authentication, class: 'form-check-label' do
%strong
- Require all users in this group to set up Two-factor authentication
+ = _("Require all users in this group to setup Two-factor authentication")
= link_to sprite_icon('question-o'), help_page_path('security/two_factor_authentication', anchor: 'enforcing-2fa-for-all-users-in-a-group')
.form-group.row
.offset-sm-2.col-sm-10
.form-check
= f.text_field :two_factor_grace_period, class: 'form-control'
- .form-text.text-muted Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication
+ .form-text.text-muted= _("Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication")
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index 2df5a6740b0..624d0a21b81 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -37,5 +37,5 @@
.home-panel-description
.home-panel-description-markdown.read-more-container{ itemprop: 'description' }
= markdown_field(@group, :description)
- %button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" }
+ %button.gl-button.btn.btn-link.js-read-more-trigger.d-lg-none{ type: "button" }
= _("Read more")
diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml
index 14a3b0ece95..fd0a7af30ed 100644
--- a/app/views/groups/_new_group_fields.html.haml
+++ b/app/views/groups/_new_group_fields.html.haml
@@ -16,6 +16,11 @@
.row
.col-sm-4
= render_if_exists 'shared/groups/invite_members'
+
+- if captcha_required?
+ .row.recaptcha
+ .col-sm-4
+ = recaptcha_tags
.row
.form-actions.col-sm-12
= f.submit _('Create group'), class: "btn gl-button btn-confirm"
diff --git a/app/views/groups/activity.html.haml b/app/views/groups/activity.html.haml
index bc75fada937..6ba6dab96ae 100644
--- a/app/views/groups/activity.html.haml
+++ b/app/views/groups/activity.html.haml
@@ -1,5 +1,5 @@
= content_for :meta_tags do
- = auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
+ = auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: _("%{group_name} activity") % { group_name: @group.name })
- page_title _("Activity")
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index d1c4e1a7deb..6e355d31204 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -3,8 +3,9 @@
- @content_class = "limit-container-width" unless fluid_layout
- expanded = expanded_by_default?
+= render 'shared/namespaces/cascading_settings/lock_popovers'
-%section.settings.gs-general.no-animate#js-general-settings{ class: ('expanded') }
+%section.settings.gs-general.no-animate.expanded#js-general-settings
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' }
= _('Naming, visibility')
@@ -45,7 +46,7 @@
= render_if_exists 'groups/custom_project_templates_setting'
= render_if_exists 'groups/templates_setting', expanded: expanded
-%section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded) }
+%section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded), data: { qa_selector: 'advanced_settings_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' }
= _('Advanced')
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index da00879ecf9..106a7832cc7 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -41,25 +41,25 @@
= link_to '#tab-members', class: ['nav-link', ('active' unless invited_active)], data: { toggle: 'tab' } do
%span
= _('Members')
- %span.badge.badge-pill= @members.total_count
+ %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @members.total_count
- if @group.shared_with_group_links.any?
%li.nav-item
= link_to '#tab-groups', class: ['nav-link'] , data: { toggle: 'tab', qa_selector: 'groups_list_tab' } do
%span
= _('Groups')
- %span.badge.badge-pill= @group.shared_with_group_links.count
+ %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @group.shared_with_group_links.count
- if show_invited_members
%li.nav-item
= link_to '#tab-invited-members', class: ['nav-link', ('active' if invited_active)], data: { toggle: 'tab' } do
%span
= _('Invited')
- %span.badge.badge-pill= @invited_members.total_count
+ %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @invited_members.total_count
- if show_access_requests
%li.nav-item
= link_to '#tab-access-requests', class: 'nav-link', data: { toggle: 'tab' } do
%span
= _('Access requests')
- %span.badge.badge-pill= @requesters.count
+ %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @requesters.count
.tab-content
#tab-members.tab-pane{ class: ('active' unless invited_active) }
.js-group-members-list{ data: group_members_list_data_attributes(@group, @members) }
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index ef7e3efdc68..ae4b0807fc5 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -5,32 +5,29 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues")
-- if group_issues_count(state: 'all') == 0
- = render 'shared/empty_states/issues', project_select_button: true
-- else
- .top-area
- = render 'shared/issuable/nav', type: :issues
- .nav-controls
- = render 'shared/issuable/feed_buttons'
+.top-area
+ = render 'shared/issuable/nav', type: :issues
+ .nav-controls
+ = render 'shared/issuable/feed_buttons'
- - if @can_bulk_update
- = render_if_exists 'shared/issuable/bulk_update_button', type: :issues
+ - if @can_bulk_update
+ = render_if_exists 'shared/issuable/bulk_update_button', type: :issues
- = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues, with_feature_enabled: 'issues', with_shared: false, include_projects_in_subgroups: true
+ = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues, with_feature_enabled: 'issues', with_shared: false, include_projects_in_subgroups: true
- = render 'shared/issuable/search_bar', type: :issues
+= render 'shared/issuable/search_bar', type: :issues
- - if @can_bulk_update
- = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
+- if @can_bulk_update
+ = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
- - if Feature.enabled?(:vue_issuables_list, @group)
- - if use_startup_call?
- - add_page_startup_api_call(api_v4_groups_issues_path(id: @group.id, params: startup_call_params))
- .js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)),
- 'can-bulk-edit': @can_bulk_update.to_json,
- 'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') },
- 'sort-key': @sort,
- type: 'issues',
- 'scoped-labels-available': scoped_labels_available?(@group).to_json } }
- - else
- = render 'shared/issues'
+- if Feature.enabled?(:vue_issuables_list, @group) && @issues.to_a.any?
+ - if use_startup_call?
+ - add_page_startup_api_call(api_v4_groups_issues_path(id: @group.id, params: startup_call_params))
+ .js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)),
+ 'can-bulk-edit': @can_bulk_update.to_json,
+ 'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') },
+ 'sort-key': @sort,
+ type: 'issues',
+ 'scoped-labels-available': scoped_labels_available?(@group).to_json } }
+- else
+ = render 'shared/issues', project_select_button: true
diff --git a/app/views/groups/labels/edit.html.haml b/app/views/groups/labels/edit.html.haml
index fbab4f8a250..d9b8f99ea0c 100644
--- a/app/views/groups/labels/edit.html.haml
+++ b/app/views/groups/labels/edit.html.haml
@@ -3,7 +3,7 @@
- page_title _("Edit"), @label.name, _("Labels")
%h3.page-title
- Edit Label
+ = _('Edit Label')
%hr
= render 'shared/labels/form', url: group_label_path(@group, @label), back_path: @previous_labels_path
diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml
index 804d2da2c4b..c480123dad1 100644
--- a/app/views/groups/labels/index.html.haml
+++ b/app/views/groups/labels/index.html.haml
@@ -8,7 +8,7 @@
#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
+ .labels-container.gl-mt-2.gl-bg-gray-10.gl-border-solid.gl-border-1.gl-border-gray-100
- if @labels.any?
.text-muted
= _('Labels can be applied to %{features}. Group labels are available for any project within the group.') % { features: issuable_types.to_sentence }
diff --git a/app/views/groups/labels/new.html.haml b/app/views/groups/labels/new.html.haml
index bb0b8d2b94d..75b4ad5c795 100644
--- a/app/views/groups/labels/new.html.haml
+++ b/app/views/groups/labels/new.html.haml
@@ -3,7 +3,7 @@
- page_title _("New Label")
%h3.page-title
- New Label
+ = _('New Label')
%hr
= render 'shared/labels/form', url: group_labels_path, back_path: @previous_labels_path
diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml
index 15e777f5c36..15864e18f7c 100644
--- a/app/views/groups/merge_requests.html.haml
+++ b/app/views/groups/merge_requests.html.haml
@@ -1,8 +1,8 @@
- @can_bulk_update = can?(current_user, :admin_merge_request, @group) && @group.feature_available?(:group_bulk_edit)
-- page_title _("Merge Requests")
+- page_title _("Merge requests")
-- if group_merge_requests_count(state: 'all') == 0
+- if @merge_requests&.size == 0
= render 'shared/empty_states/merge_requests', project_select_button: true
- else
.top-area
diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml
index 52060e2be16..d4d8a7a57ef 100644
--- a/app/views/groups/milestones/_form.html.haml
+++ b/app/views/groups/milestones/_form.html.haml
@@ -4,23 +4,23 @@
.col-md-6
.form-group.row
.col-form-label.col-sm-2
- = f.label :title, "Title"
+ = f.label :title, _("Title")
.col-sm-10
= f.text_field :title, maxlength: 255, class: "form-control", data: { qa_selector: "milestone_title_field" }, required: true, autofocus: true
.form-group.row.milestone-description
.col-form-label.col-sm-2
- = f.label :description, "Description"
+ = f.label :description, _("Description")
.col-sm-10
= render layout: 'shared/md_preview', locals: { url: group_preview_markdown_path } do
- = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', qa_selector: 'milestone_description_field', placeholder: 'Write milestone description...', supports_autocomplete: false
+ = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', qa_selector: 'milestone_description_field', placeholder: _('Write milestone description...'), supports_autocomplete: false
.clearfix
.error-alert
= render "shared/milestones/form_dates", f: f
.form-actions
- if @milestone.new_record?
- = f.submit 'Create milestone', class: "btn-confirm gl-button btn", data: { qa_selector: "create_milestone_button" }
- = link_to "Cancel", group_milestones_path(@group), class: "btn gl-button btn-cancel"
+ = f.submit _('Create milestone'), class: "btn-confirm 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-confirm gl-button btn"
- = link_to "Cancel", group_milestone_path(@group, @milestone), class: "btn gl-button btn-cancel"
+ = f.submit _('Update milestone'), class: "btn-confirm gl-button btn"
+ = link_to _("Cancel"), group_milestone_path(@group, @milestone), class: "btn gl-button btn-cancel"
diff --git a/app/views/groups/milestones/edit.html.haml b/app/views/groups/milestones/edit.html.haml
index c703d5f7f93..187c2d24b56 100644
--- a/app/views/groups/milestones/edit.html.haml
+++ b/app/views/groups/milestones/edit.html.haml
@@ -4,7 +4,7 @@
- render "header_title"
%h3.page-title
- Edit Milestone
+ = _('Edit Milestone')
%hr
= render "form"
diff --git a/app/views/groups/runners/_group_runners.html.haml b/app/views/groups/runners/_group_runners.html.haml
index f60cdc9f8da..910b36770f1 100644
--- a/app/views/groups/runners/_group_runners.html.haml
+++ b/app/views/groups/runners/_group_runners.html.haml
@@ -19,5 +19,5 @@
type: 'group',
reset_token_url: reset_registration_token_group_settings_ci_cd_path,
project_path: '',
- group_path: @group.path }
+ group_path: @group.full_path }
%br
diff --git a/app/views/groups/runners/_index.html.haml b/app/views/groups/runners/_index.html.haml
index 7cbc709ecf8..187588f5f11 100644
--- a/app/views/groups/runners/_index.html.haml
+++ b/app/views/groups/runners/_index.html.haml
@@ -19,7 +19,7 @@
.filtered-search-box
= dropdown_tag(_('Recent searches'),
options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
- toggle_class: 'btn filtered-search-history-dropdown-toggle-button',
+ toggle_class: 'gl-button btn btn-default filtered-search-history-dropdown-toggle-button',
dropdown_class: 'filtered-search-history-dropdown',
content_class: 'filtered-search-history-dropdown-content' }) do
.js-filtered-search-history-dropdown{ data: { full_path: group_settings_ci_cd_path } }
@@ -31,7 +31,7 @@
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
- = button_tag class: 'btn btn-link' do
+ = button_tag class: 'gl-button btn btn-link' do
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
@@ -41,7 +41,7 @@
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
- = button_tag class: 'btn btn-link' do
+ = button_tag class: 'gl-button btn btn-link' do
{{ title }}
%span.btn-helptext
{{ help }}
@@ -49,7 +49,7 @@
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_STATUSES.each do |status|
%li.filter-dropdown-item{ data: { value: status } }
- = button_tag class: 'btn btn-link' do
+ = button_tag class: 'gl-button btn btn-link' do
= status.titleize
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
@@ -57,18 +57,18 @@
- Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
- next if runner_type == 'instance_type'
%li.filter-dropdown-item{ data: { value: runner_type } }
- = button_tag class: 'btn btn-link' do
+ = button_tag class: 'gl-button btn btn-link' do
= runner_type.titleize
#js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
- = button_tag class: 'btn btn-link' do
+ = button_tag class: 'gl-button btn btn-link' do
= _('No Tag')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- = button_tag class: 'btn btn-link js-data-value' do
+ = button_tag class: 'gl-button btn btn-link js-data-value' do
%span.dropdown-light-content
{{name}}
@@ -82,12 +82,11 @@
- if @group_runners.any?
- .runners-content.content-list
+ .content-list{ data: { testid: 'runners-table' } }
.table-holder
.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-10{ role: 'rowheader' }= _('Type/State')
- .table-section.section-10{ role: 'rowheader' }= _('Runner token')
- .table-section.section-20{ role: 'rowheader' }= _('Description')
+ .table-section.section-30{ role: 'rowheader' }= s_('Runners|Runner')
.table-section.section-10{ role: 'rowheader' }= _('Version')
.table-section.section-10{ role: 'rowheader' }= _('IP Address')
.table-section.section-5{ role: 'rowheader' }= _('Projects')
diff --git a/app/views/groups/runners/_runner.html.haml b/app/views/groups/runners/_runner.html.haml
index 80739395713..89e32c0999c 100644
--- a/app/views/groups/runners/_runner.html.haml
+++ b/app/views/groups/runners/_runner.html.haml
@@ -1,29 +1,30 @@
+-# Note: This file should stay aligned with:
+-# `app/views/admin/runners/_runner.html.haml`
+
.gl-responsive-table-row{ id: dom_id(runner) }
.table-section.section-10.section-wrap
.table-mobile-header{ role: 'rowheader' }= _('Type')
.table-mobile-content
- if runner.group_type?
- %span.badge.badge-success
+ %span.badge.badge-pill.gl-badge.sm.badge-success
= _('group')
- else
- %span.badge.badge-info
+ %span.badge.badge-pill.gl-badge.sm.badge-info
= _('specific')
- if runner.locked?
- %span.badge.badge-warning
+ %span.badge.badge-pill.gl-badge.sm.badge-warning
= _('locked')
- unless runner.active?
- %span.badge.badge-danger
+ %span.badge.badge-pill.gl-badge.sm.badge-danger
= _('paused')
- .table-section.section-10
- .table-mobile-header{ role: 'rowheader' }= _('Runner token')
+ .table-section.section-30
+ .table-mobile-header{ role: 'rowheader' }= s_('Runners|Runner')
.table-mobile-content
- = link_to runner.short_sha, group_runner_path(@group, runner)
-
- .table-section.section-20
- .table-mobile-header{ role: 'rowheader' }= _('Description')
- .table-mobile-content.str-truncated.has-tooltip{ title: runner.description }
- = runner.description
+ = link_to("##{runner.id} (#{runner.short_sha})", group_runner_path(@group, runner))
+ .gl-text-truncate
+ %span{ title: runner.description, data: { toggle: 'tooltip', container: 'body' } }
+ = runner.description
.table-section.section-10
.table-mobile-header{ role: 'rowheader' }= _('Version')
@@ -67,21 +68,21 @@
.table-section.table-button-footer.section-10
.btn-group.table-action-buttons
.btn-group
- = link_to edit_group_runner_path(@group, runner), class: 'btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do
- = sprite_icon('pencil')
+ = link_to edit_group_runner_path(@group, runner), class: 'gl-button btn btn-default btn-icon has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do
+ = sprite_icon('pencil', css_class: 'gl-icon')
.btn-group
- if runner.active?
- = link_to pause_group_runner_path(@group, runner), method: :post, class: 'btn btn-default has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
- = sprite_icon('pause')
+ = link_to pause_group_runner_path(@group, runner), method: :post, class: 'gl-button btn btn-default btn-icon has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
+ = sprite_icon('pause', css_class: 'gl-icon')
- else
- = link_to resume_group_runner_path(@group, runner), method: :post, class: 'btn btn-default has-tooltip', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do
- = sprite_icon('play')
+ = link_to resume_group_runner_path(@group, runner), method: :post, class: 'gl-button btn btn-default btn-icon has-tooltip', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do
+ = sprite_icon('play', css_class: 'gl-icon')
- if runner.belongs_to_more_than_one_project?
- delete_runner_tooltip = _('Multi-project Runners cannot be removed')
.btn-group.has-tooltip{ data: { container: 'body', placement: 'top' }, title: delete_runner_tooltip }
- .btn.btn-danger{ 'aria-label' => delete_runner_tooltip, disabled: 'disabled' }
- = sprite_icon('close')
+ .gl-button.btn.btn-danger.btn-icon{ 'aria-label' => delete_runner_tooltip, disabled: 'disabled' }
+ = sprite_icon('close', css_class: 'gl-icon')
- else
.btn-group
- = link_to group_runner_path(@group, runner), method: :delete, class: 'btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
- = sprite_icon('close')
+ = link_to group_runner_path(@group, runner), method: :delete, class: 'gl-button btn btn-danger btn-icon has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
+ = sprite_icon('close', css_class: 'gl-icon')
diff --git a/app/views/groups/runners/edit.html.haml b/app/views/groups/runners/edit.html.haml
index fcd096eeaa0..3794c345aa6 100644
--- a/app/views/groups/runners/edit.html.haml
+++ b/app/views/groups/runners/edit.html.haml
@@ -1,6 +1,9 @@
-- page_title _('Edit'), "#{@runner.description} ##{@runner.id}", 'Runners'
+- page_title _('Edit'), "#{@runner.description} ##{@runner.id}", _('Runners')
-%h4 Runner ##{@runner.id}
+%h2.page-title
+ = s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id })
+ = render 'shared/runners/runner_type_badge', runner: @runner
-%hr
- = render 'shared/runners/form', runner: @runner, runner_form_url: group_runner_path(@group, @runner)
+= render 'shared/runners/runner_type_alert', runner: @runner
+
+= render 'shared/runners/form', runner: @runner, runner_form_url: group_runner_path(@group, @runner)
diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml
index fddb83114f3..d7a145924de 100644
--- a/app/views/groups/settings/_advanced.html.haml
+++ b/app/views/groups/settings/_advanced.html.haml
@@ -28,17 +28,17 @@
%h4.warning-title= s_('GroupSettings|Transfer group')
= form_for @group, url: transfer_group_path(@group), method: :put, html: { class: 'js-group-transfer-form' } do |f|
.form-group
- = dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: 'Search groups', data: { data: parent_group_options(@group) } })
+ = dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: 'Search groups', data: { data: parent_group_options(@group), qa_selector: 'select_group_dropdown' } })
= hidden_field_tag 'new_parent_group_id'
%ul
- - side_effects_link_start = '<a href="https://docs.gitlab.com/ee/user/project/index.html#redirects-when-changing-repository-paths" target="_blank">'
- - warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended %{side_effects_link_start}side effects%{side_effects_link_end}.") % { side_effects_link_start: side_effects_link_start, side_effects_link_end:'</a>' }
+ - side_effects_link_start = '<a href="https://docs.gitlab.com/ee/user/project/index.html#redirects-when-changing-repository-paths" target="_blank">'.html_safe
+ - warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended %{side_effects_link_start}side effects%{side_effects_link_end}.") % { side_effects_link_start: side_effects_link_start, side_effects_link_end: '</a>'.html_safe }
%li= warning_text.html_safe
%li= s_('GroupSettings|You can only transfer the group to a group you manage.')
%li= s_('GroupSettings|You will need to update your local repositories to point to the new location.')
%li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.")
- = f.submit s_('GroupSettings|Transfer group'), class: 'btn gl-button btn-warning'
+ = f.submit s_('GroupSettings|Transfer group'), class: 'btn gl-button btn-warning', data: { qa_selector: "transfer_group_button" }
= render 'groups/settings/remove', group: @group
= render_if_exists 'groups/settings/restore', group: @group
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index f5cd7dde6a4..7a2d5c91af6 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -1,5 +1,3 @@
-- enable_search_settings locals: { container_class: 'gl-my-5' }
-
= form_for @group, html: { multipart: true, class: 'gl-show-field-errors js-general-settings-form' }, authenticity_token: true do |f|
%input{ type: 'hidden', name: 'update_section', value: 'js-general-settings' }
= form_errors(@group)
@@ -28,7 +26,7 @@
= render 'shared/choose_avatar_button', f: f
- if @group.avatar?
%hr
- = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link'
+ = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'gl-button btn btn-danger-secondary'
= render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
= f.submit _('Save changes'), class: 'btn gl-button btn-confirm mt-4 js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' }
diff --git a/app/views/groups/settings/_permanent_deletion.html.haml b/app/views/groups/settings/_permanent_deletion.html.haml
index 8bd47fbea44..125a20060ed 100644
--- a/app/views/groups/settings/_permanent_deletion.html.haml
+++ b/app/views/groups/settings/_permanent_deletion.html.haml
@@ -5,4 +5,5 @@
= _('Removing this group also removes all child projects, including archived projects, and their resources.')
%br
%strong= _('Removed group can not be restored!')
- = button_to _('Remove group'), '#', class: 'btn gl-button btn-danger js-confirm-danger', data: { 'confirm-danger-message' => remove_group_message(group) }
+
+ = render 'groups/settings/remove_button', group: group
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index dc20e796846..fcfe70bd694 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -8,28 +8,29 @@
= render 'shared/allow_request_access', form: f
.form-group.gl-mb-3
- .form-check
- = f.check_box :share_with_group_lock, disabled: !can_change_share_with_group_lock?(@group), class: 'form-check-input'
- = f.label :share_with_group_lock, class: 'form-check-label' do
- %span.d-block
+ .gl-form-checkbox.custom-control.custom-checkbox
+ = f.check_box :share_with_group_lock, disabled: !can_change_share_with_group_lock?(@group), class: 'custom-control-input'
+ = f.label :share_with_group_lock, class: 'custom-control-label' do
+ %span
- group_link = link_to @group.name, group_path(@group)
= s_('GroupSettings|Prevent sharing a project within %{group} with other groups').html_safe % { group: group_link }
- %span.js-descr.text-muted= share_with_group_lock_help_text(@group)
+ %p.js-descr.help-text= share_with_group_lock_help_text(@group)
.form-group.gl-mb-3
- .form-check
- = f.check_box :emails_disabled, checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group), class: 'form-check-input'
- = f.label :emails_disabled, class: 'form-check-label' do
- %span.d-block= s_('GroupSettings|Disable email notifications')
- %span.text-muted= s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.')
+ .gl-form-checkbox.custom-control.custom-checkbox
+ = f.check_box :emails_disabled, checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group), class: 'custom-control-input'
+ = f.label :emails_disabled, class: 'custom-control-label' do
+ %span= s_('GroupSettings|Disable email notifications')
+ %p.help-text= s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.')
.form-group.gl-mb-3
- .form-check
- = f.check_box :mentions_disabled, checked: @group.mentions_disabled?, class: 'form-check-input'
- = f.label :mentions_disabled, class: 'form-check-label' do
- %span.d-block= s_('GroupSettings|Disable group mentions')
- %span.text-muted= s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.')
+ .gl-form-checkbox.custom-control.custom-checkbox
+ = f.check_box :mentions_disabled, checked: @group.mentions_disabled?, class: 'custom-control-input'
+ = f.label :mentions_disabled, class: 'custom-control-label' do
+ %span= s_('GroupSettings|Disable group mentions')
+ %p.help-text= s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.')
+ = render 'groups/settings/project_access_token_creation', f: f, group: @group
= render_if_exists 'groups/settings/delayed_project_removal', f: f, group: @group
= render_if_exists 'groups/settings/ip_restriction', f: f, group: @group
= render_if_exists 'groups/settings/allowed_email_domain', f: f, group: @group
diff --git a/app/views/groups/settings/_project_access_token_creation.html.haml b/app/views/groups/settings/_project_access_token_creation.html.haml
new file mode 100644
index 00000000000..8be17c6cc30
--- /dev/null
+++ b/app/views/groups/settings/_project_access_token_creation.html.haml
@@ -0,0 +1,10 @@
+- return unless render_setting_to_allow_project_access_token_creation?(group)
+
+.form-group.gl-mb-3
+ .gl-form-checkbox.custom-control.custom-checkbox
+ = f.check_box :resource_access_token_creation_allowed, checked: group.namespace_settings.resource_access_token_creation_allowed?, class: 'custom-control-input', data: { qa_selector: 'resource_access_token_creation_allowed_checkbox' }
+ = f.label :resource_access_token_creation_allowed, class: 'custom-control-label' do
+ %span= s_('GroupSettings|Allow project access token creation')
+ - project_access_tokens_link = help_page_path('user/project/settings/project_access_tokens')
+ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: project_access_tokens_link }
+ %p.help-text= s_('GroupSettings|Users can create %{link_start}project access tokens%{link_end} for projects in this group.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/groups/settings/_remove_button.html.haml b/app/views/groups/settings/_remove_button.html.haml
new file mode 100644
index 00000000000..a04dba68b92
--- /dev/null
+++ b/app/views/groups/settings/_remove_button.html.haml
@@ -0,0 +1,7 @@
+- if group.paid?
+ .gl-alert.gl-alert-info.gl-mb-5{ data: { testid: 'group-has-linked-subscription-alert' } }
+ = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body
+ = html_escape(_("This group can't be removed because it is linked to a subscription. To remove this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe }
+
+= button_to _('Remove group'), '#', class: ['btn gl-button btn-danger js-confirm-danger', ('disabled' if group.paid?)], data: { 'confirm-danger-message' => remove_group_message(group), 'testid' => 'remove-group-button' }
diff --git a/app/views/groups/settings/applications/edit.html.haml b/app/views/groups/settings/applications/edit.html.haml
new file mode 100644
index 00000000000..cba4892eef9
--- /dev/null
+++ b/app/views/groups/settings/applications/edit.html.haml
@@ -0,0 +1,5 @@
+- page_title _("Edit"), @application.name, _("Group applications")
+- @content_class = "limit-container-width" unless fluid_layout
+
+%h3.page-title= _('Edit group application')
+= render 'shared/doorkeeper/applications/form', url: group_settings_application_path(@group, @application)
diff --git a/app/views/groups/settings/applications/index.html.haml b/app/views/groups/settings/applications/index.html.haml
new file mode 100644
index 00000000000..96f834bd271
--- /dev/null
+++ b/app/views/groups/settings/applications/index.html.haml
@@ -0,0 +1,8 @@
+- page_title _("Group applications")
+
+= render 'shared/doorkeeper/applications/index',
+ oauth_applications_enabled: user_oauth_applications?,
+ oauth_authorized_applications_enabled: false,
+ form_url: group_settings_applications_path(@group),
+ application_url: ->(application) { group_settings_application_path(@group, application) },
+ edit_application_url: ->(application) { edit_group_settings_application_path(@group, application) }
diff --git a/app/views/groups/settings/applications/show.html.haml b/app/views/groups/settings/applications/show.html.haml
new file mode 100644
index 00000000000..fce3602349b
--- /dev/null
+++ b/app/views/groups/settings/applications/show.html.haml
@@ -0,0 +1,9 @@
+- add_to_breadcrumbs _("Group applications"), group_settings_applications_path(@group)
+- breadcrumb_title @application.name
+- page_title @application.name, _("Group applications")
+- @content_class = "limit-container-width" unless fluid_layout
+
+%h3.page-title
+ = _("Group application: %{name}") % { name: @application.name }
+
+= render 'shared/doorkeeper/applications/show', edit_path: edit_group_settings_application_path(@group, @application), delete_path: group_settings_application_path(@group, @application)
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index 574750d5f57..3c6514b95b8 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -4,8 +4,6 @@
- expanded = expanded_by_default?
- general_expanded = @group.errors.empty? ? expanded : true
-- enable_search_settings locals: { container_class: 'gl-my-5' }
-
-# Given we only have one field in this form which is also admin-only,
-# we don't want to show an empty section to non-admin users,
- if can?(current_user, :update_max_artifacts_size, @group)
diff --git a/app/views/groups/settings/integrations/index.html.haml b/app/views/groups/settings/integrations/index.html.haml
index f62eb17d236..92b545cad0a 100644
--- a/app/views/groups/settings/integrations/index.html.haml
+++ b/app/views/groups/settings/integrations/index.html.haml
@@ -2,8 +2,8 @@
- page_title _('Integrations')
- @content_class = 'limit-container-width' unless fluid_layout
-%h4= s_('GroupSettings|Apply integration settings to all Projects')
-%p
- = s_('GroupSettings|Integrations configured here will automatically apply to all projects in this group.')
- = link_to _('Learn more'), integrations_help_page_path, target: '_blank', rel: 'noopener noreferrer'
+%h3= s_('Integrations|Project integration management')
+
+- integrations_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: integrations_help_page_path }
+%p= s_('Integrations|As a GitLab administrator, you can set default configuration parameters for a given integration that all projects can inherit and use. When you set these parameters, your changes update the integration for all projects that are not already using custom settings. Learn more about %{integrations_link_start}Project integration management%{link_end}.').html_safe % { integrations_link_start: integrations_link_start, link_end: '</a>'.html_safe }
= render 'shared/integrations/index', integrations: @integrations
diff --git a/app/views/groups/settings/packages_and_registries/index.html.haml b/app/views/groups/settings/packages_and_registries/index.html.haml
index 21eef20a987..1a12ad4902b 100644
--- a/app/views/groups/settings/packages_and_registries/index.html.haml
+++ b/app/views/groups/settings/packages_and_registries/index.html.haml
@@ -2,6 +2,4 @@
- page_title _('Packages & Registries')
- @content_class = 'limit-container-width' unless fluid_layout
-- enable_search_settings locals: { container_class: 'gl-my-5' }
-
%section#js-packages-and-registries-settings{ data: { default_expanded: expanded_by_default?.to_s, group_path: @group.full_path } }
diff --git a/app/views/groups/settings/repository/show.html.haml b/app/views/groups/settings/repository/show.html.haml
index b15d36c631a..a5819320405 100644
--- a/app/views/groups/settings/repository/show.html.haml
+++ b/app/views/groups/settings/repository/show.html.haml
@@ -1,8 +1,6 @@
- breadcrumb_title _('Repository Settings')
- page_title _('Repository')
-- enable_search_settings locals: { container_class: 'gl-my-5' }
-
- 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
diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml
index 03f8539293b..a56eaaf685f 100644
--- a/app/views/help/index.html.haml
+++ b/app/views/help/index.html.haml
@@ -13,19 +13,20 @@
- unless Gitlab::CurrentSettings.help_page_hide_commercial_content?
%p.slead
- GitLab is open source software to collaborate on code.
+ = _('GitLab is open source software to collaborate on code.')
%br
- Manage git repositories with fine-grained access controls that keep your code secure.
+ = _('Manage git repositories with fine-grained access controls that keep your code secure.')
%br
- Perform code reviews and enhance collaboration with merge requests.
+ = _('Perform code reviews and enhance collaboration with merge requests.')
%br
- Each project can also have an issue tracker and a wiki.
+ = _('Each project can also have an issue tracker and a wiki.')
%br
- Used by more than 100,000 organizations, GitLab is the most popular solution to manage git repositories on-premises.
+ = _('Used by more than 100,000 organizations, GitLab is the most popular solution to manage git repositories on-premises.')
%br
- Read more about GitLab at #{link_to promo_host, promo_url, target: '_blank', rel: 'noopener noreferrer'}.
+ - link_to_promo = link_to(promo_host, promo_url, target: '_blank', rel: 'noopener noreferrer')
+ = _("Read more about GitLab at %{link_to_promo}.").html_safe % { link_to_promo: link_to_promo }
-%p= link_to 'Check the current instance configuration ', help_instance_configuration_url
+%p= link_to _('Check the current instance configuration '), help_instance_configuration_url
%hr
.row.gl-mt-3
@@ -35,15 +36,15 @@
.col-md-4
.card.links-card
.card-header
- Quick help
+ = _('Quick help')
%ul.content-list
- %li= link_to 'See our website for getting help', support_url
+ %li= link_to _('See our website for getting help'), support_url
%li
%button.btn-blank.btn-link.js-trigger-search-bar{ type: 'button' }
- Use the search bar on the top of this page
+ = _('Use the search bar on the top of this page')
%li
%button.btn-blank.btn-link.js-trigger-shortcut{ type: 'button' }
- Use shortcuts
+ = _('Use shortcuts')
- unless Gitlab::CurrentSettings.help_page_hide_commercial_content?
- %li= link_to 'Get a support subscription', 'https://about.gitlab.com/pricing/'
- %li= link_to 'Compare GitLab editions', 'https://about.gitlab.com/features/#compare'
+ %li= link_to _('Get a support subscription'), 'https://about.gitlab.com/pricing/'
+ %li= link_to _('Compare GitLab editions'), 'https://about.gitlab.com/features/#compare'
diff --git a/app/views/help/instance_configuration.html.haml b/app/views/help/instance_configuration.html.haml
index 260566b1441..1cd05dcf65e 100644
--- a/app/views/help/instance_configuration.html.haml
+++ b/app/views/help/instance_configuration.html.haml
@@ -1,15 +1,15 @@
- page_title _('Instance Configuration')
.documentation.md
- %h1 Instance Configuration
+ %h1= _('Instance Configuration')
%p
- In this page you will find information about the settings that are used in your current instance.
+ = _("In this page you will find information about the settings that are used in your current instance.")
= render 'help/instance_configuration/ssh_info'
= render 'help/instance_configuration/gitlab_pages'
= render 'help/instance_configuration/gitlab_ci'
%p
- %strong Table of contents
+ %strong= _("Table of contents")
%ul
= content_for :table_content
diff --git a/app/views/help/instance_configuration/_gitlab_ci.html.haml b/app/views/help/instance_configuration/_gitlab_ci.html.haml
index 7fa8bd086d4..53fa3f89873 100644
--- a/app/views/help/instance_configuration/_gitlab_ci.html.haml
+++ b/app/views/help/instance_configuration/_gitlab_ci.html.haml
@@ -1,24 +1,24 @@
- content_for :table_content do
- %li= link_to 'GitLab CI', '#gitlab-ci'
+ %li= link_to _('GitLab CI'), '#gitlab-ci'
- content_for :settings_content do
%h2#gitlab-ci
- GitLab CI
+ = _('GitLab CI')
%p
- Below are the current settings regarding
- = succeed('.') { link_to('GitLab CI', 'https://about.gitlab.com/gitlab-ci', target: '_blank') }
+ = _('Below are the current settings regarding')
+ = succeed('.') { link_to(_('GitLab CI'), 'https://about.gitlab.com/gitlab-ci', target: '_blank') }
.table-responsive
%table
%thead
%tr
- %th Setting
+ %th= _('Setting')
%th= instance_configuration_host(@instance_configuration.settings[:host])
- %th Default
+ %th= _('Default')
%tbody
%tr
- artifacts_size = @instance_configuration.settings[:gitlab_ci][:artifacts_max_size]
- %td Artifacts maximum size
+ %td= _('Artifacts maximum size')
%td= instance_configuration_human_size_cell(artifacts_size[:value])
%td= instance_configuration_human_size_cell(artifacts_size[:default])
diff --git a/app/views/help/instance_configuration/_gitlab_pages.html.haml b/app/views/help/instance_configuration/_gitlab_pages.html.haml
index 94c25edaf82..55f043214f6 100644
--- a/app/views/help/instance_configuration/_gitlab_pages.html.haml
+++ b/app/views/help/instance_configuration/_gitlab_pages.html.haml
@@ -1,35 +1,35 @@
- gitlab_pages = @instance_configuration.settings[:gitlab_pages]
- content_for :table_content do
- %li= link_to 'GitLab Pages', '#gitlab-pages'
+ %li= link_to _('GitLab Pages'), '#gitlab-pages'
- content_for :settings_content do
%h2#gitlab-pages
- GitLab Pages
+ = _('GitLab Pages')
%p
- Below are the settings for
- = succeed('.') { link_to('GitLab Pages', gitlab_pages[:url], target: '_blank') }
+ - link_to_gitlab_pages = link_to(_('GitLab Pages'), gitlab_pages[:url], target: '_blank')
+ = _('Below are the settings for %{link_to_gitlab_pages}.').html_safe % { link_to_gitlab_pages: link_to_gitlab_pages }
.table-responsive
%table
%thead
%tr
- %th Setting
+ %th= _('Setting')
%th= instance_configuration_host(@instance_configuration.settings[:host])
%tbody
%tr
- %td Domain Name
+ %td= _('Domain Name')
%td
%code= instance_configuration_cell_html(gitlab_pages[:host])
%tr
- %td IP Address
+ %td= _('IP Address')
%td
%code= instance_configuration_cell_html(gitlab_pages[:ip_address])
%tr
- %td Port
+ %td= _('Port')
%td
%code= instance_configuration_cell_html(gitlab_pages[:port])
%br
%p
- The maximum size of your Pages site is regulated by the artifacts maximum
- size which is part of #{succeed('.') { link_to('GitLab CI', '#gitlab-ci') }}
+ - link_to_gitlab_ci = link_to(_('GitLab CI'), '#gitlab-ci')
+ = _("The maximum size of your Pages site is regulated by the artifacts maximum size which is part of %{link_to_gitlab_ci}.").html_safe % { link_to_gitlab_ci: link_to_gitlab_ci }
diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml
index 47139917379..832289c3166 100644
--- a/app/views/import/fogbugz/new_user_map.html.haml
+++ b/app/views/import/fogbugz/new_user_map.html.haml
@@ -32,7 +32,7 @@
%tbody
- @user_map.each do |id, user|
%tr
- %td= (id)
+ %td= id
%td= text_field_tag "users[#{id}][name]", user[:name], class: 'form-control'
%td= text_field_tag "users[#{id}][email]", user[:email], class: 'form-control'
%td
diff --git a/app/views/import/shared/_new_project_form.html.haml b/app/views/import/shared/_new_project_form.html.haml
index bfc4e65e23d..561c14dc68a 100644
--- a/app/views/import/shared/_new_project_form.html.haml
+++ b/app/views/import/shared/_new_project_form.html.haml
@@ -5,7 +5,7 @@
.form-group.col-12.col-sm-6
= label_tag :namespace_id, _('Project URL'), class: 'label-bold'
.form-group
- .input-group.flex-nowrap
+ .input-group.gl-flex-nowrap
- if current_user.can_select_namespace?
.input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url }
.input-group-text
diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml
index 1492fea7fb2..ae13ef831dd 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), method: :post, class: "btn gl-button btn-success"
+ = link_to _("Accept invitation"), accept_invite_url(@token), method: :post, class: "btn gl-button btn-confirm"
= 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 c7873991010..43672551caf 100644
--- a/app/views/jira_connect/subscriptions/index.html.haml
+++ b/app/views/jira_connect/subscriptions/index.html.haml
@@ -10,7 +10,7 @@
%main.jira-connect-app.gl-px-5.gl-pt-7.gl-mx-auto
- if current_user.blank? && @subscriptions.empty?
- .jira-connect-app-body.gl-text-center
+ .jira-connect-app-body.gl-px-5.gl-text-center
%h2= s_('JiraService|GitLab for Jira Configuration')
%p= s_('JiraService|Sign in to GitLab.com to get started.')
@@ -22,30 +22,11 @@
- else
.js-jira-connect-app{ data: jira_connect_app_data(@subscriptions) }
- .jira-connect-app-body
- - if @subscriptions.present?
- %table.subscriptions.gl-w-full
- %thead
- %tr
- %th= _('Namespace')
- %th= _('Added')
- %th
- %tbody
- - @subscriptions.each do |subscription|
- %tr
- %td= subscription.namespace.full_path
- %td= subscription.created_at
- %td= link_to _('Remove'), jira_connect_subscription_path(subscription), class: 'js-jira-connect-remove-subscription'
- - else
- .gl-text-center
- %h4= s_('Integrations|No linked namespaces')
- %p= s_('Integrations|Namespaces are your GitLab groups and subgroups that will be linked to this Jira instance.')
-
- %p.jira-connect-app-body.gl-mt-7.gl-font-base.gl-text-center
+ %p.jira-connect-app-body.gl-px-5.gl-mt-7.gl-font-base.gl-text-center
%strong= s_('Integrations|Browser limitations')
- firefox_link_url = 'https://www.mozilla.org/en-US/firefox/'
- firefox_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: firefox_link_url }
- = s_('Integrations|Adding a namespace currently works only in browsers that allow cross‑site cookies. Please make sure to use %{firefox_link_start}Firefox%{firefox_link_end} or enable cross‑site cookies in your browser when adding a namespace.').html_safe % { firefox_link_start: firefox_link_start, firefox_link_end: '</a>'.html_safe }
+ = s_('Integrations|Adding a namespace works only in browsers that allow cross‑site cookies. Use %{firefox_link_start}Firefox%{firefox_link_end}, or enable cross‑site cookies in your browser, when adding a namespace.').html_safe % { firefox_link_start: firefox_link_start, firefox_link_end: '</a>'.html_safe }
= link_to _('Learn more'), 'https://gitlab.com/gitlab-org/gitlab/-/issues/284211', target: '_blank', rel: 'noopener noreferrer'
= webpack_bundle_tag 'performance_bar' if performance_bar_enabled?
diff --git a/app/views/jira_connect/users/show.html.haml b/app/views/jira_connect/users/show.html.haml
index 2ff92ab0dc8..cf88acd6976 100644
--- a/app/views/jira_connect/users/show.html.haml
+++ b/app/views/jira_connect/users/show.html.haml
@@ -1,11 +1,11 @@
.jira-connect-users-container.gl-text-center
- user_link = link_to(current_user.to_reference, user_path(current_user), target: '_blank', rel: 'noopener noreferrer')
- %h2= _('You are signed into GitLab as %{user_link}').html_safe % { user_link: user_link }
+ %h2= _('You are signed in to GitLab as %{user_link}').html_safe % { user_link: user_link }
%p= s_('Integrations|You can now close this window and return to the GitLab for Jira application.')
- if @jira_app_link
- %p= external_link s_('Integrations|Return to GitLab for Jira'), @jira_app_link, class: 'btn btn-success'
+ %p= external_link s_('Integrations|Return to GitLab for Jira'), @jira_app_link, class: 'gl-button btn btn-confirm'
%p= link_to _('Sign out'), destroy_user_session_path, method: :post
diff --git a/app/views/kaminari/gitlab/_page.html.haml b/app/views/kaminari/gitlab/_page.html.haml
index b000a490e3e..92df78f358d 100644
--- a/app/views/kaminari/gitlab/_page.html.haml
+++ b/app/views/kaminari/gitlab/_page.html.haml
@@ -10,5 +10,5 @@
('sibling' if page.next? || page.prev?),
('js-first-button' if page.first?),
('js-last-button' if page.last?),
- ('d-none d-md-block' if !page.current?) ] }
+ ('d-none d-md-block' if !page.current?)] }
= link_to page, url, { remote: remote, rel: page.next? ? 'next' : page.prev? ? 'prev' : nil, class: ['page-link', active_when(page.current?)] }
diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml
index 35fefe40d39..433337602f1 100644
--- a/app/views/layouts/_flash.html.haml
+++ b/app/views/layouts/_flash.html.haml
@@ -1,5 +1,5 @@
-# We currently only support `alert`, `notice`, `success`, 'toast'
-- icons = {'alert' => 'error', 'notice' => 'information-o', 'success' => 'check-circle'};
+- icons = {'alert' => 'error', 'notice' => 'information-o', 'success' => 'check-circle'}
.flash-container.flash-container-page.sticky{ data: { qa_selector: 'flash_container' } }
- flash.each do |key, value|
- if key == 'toast' && value
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 601598d65da..6694ad5968a 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -52,6 +52,8 @@
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
+ -# Rendering this above Gon, to use in JS later
+ = render 'layouts/header/new_repo_experiment'
= Gon::Base.render_data(nonce: content_security_policy_nonce)
= javascript_include_tag locale_path unless I18n.locale == :en
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 1f2fcd1c70b..c91d27e3ed1 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -11,6 +11,7 @@
= render "layouts/broadcast"
= render "layouts/header/read_only_banner"
= render "layouts/header/registration_enabled_callout"
+ = render "layouts/header/service_templates_deprecation_callout"
= render "layouts/nav/classification_level_banner"
= yield :flash_message
= render "shared/ping_consent"
diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml
index c902c687378..2032d1e95a6 100644
--- a/app/views/layouts/_search.html.haml
+++ b/app/views/layouts/_search.html.haml
@@ -25,7 +25,7 @@
= hidden_field_tag :group_id, search_context.for_group? ? search_context.group.id : '', class: 'js-search-group-options', data: search_context.group_metadata
= hidden_field_tag :project_id, search_context.for_project? ? search_context.project.id : '', id: 'search_project_id', class: 'js-search-project-options', data: search_context.project_metadata
- - if search_context.for_project?
+ - if search_context.for_project? || search_context.for_group?
= hidden_field_tag :scope, search_context.scope
= hidden_field_tag :search_code, search_context.code_search?
diff --git a/app/views/layouts/errors.html.haml b/app/views/layouts/errors.html.haml
index 25fe4c898ca..57260ccedea 100644
--- a/app/views/layouts/errors.html.haml
+++ b/app/views/layouts/errors.html.haml
@@ -20,4 +20,10 @@
history.back();
});
}
+
+ // We do not have rails_ujs here, so we're manually making a link trigger a form submit.
+ document.getElementById('sign_out_link').addEventListener('click', function(e) {
+ e.preventDefault();
+ document.getElementById('sign_out_form').submit();
+ });
}());
diff --git a/app/views/layouts/group_settings.html.haml b/app/views/layouts/group_settings.html.haml
index 9db78ec58e4..c4e5e811280 100644
--- a/app/views/layouts/group_settings.html.haml
+++ b/app/views/layouts/group_settings.html.haml
@@ -1,4 +1,5 @@
- page_title _("Settings")
- nav "group"
+- enable_search_settings locals: { container_class: 'gl-my-5' }
= render template: "layouts/group"
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index 5ac0db4137f..0251a8b6d7c 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -11,7 +11,7 @@
%li.divider
- if can?(current_user, :update_user_status, current_user)
%li
- %button.btn.menu-item.js-set-status-modal-trigger{ type: 'button' }
+ %button.gl-button.btn.btn-link.menu-item.js-set-status-modal-trigger{ type: 'button' }
- if show_status_emoji?(current_user.status) || user_status_set_to_busy?(current_user.status)
= s_('SetStatusModal|Edit status')
- else
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index c54ad23c094..481e83c9701 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -2,7 +2,7 @@
- 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
+ %a.gl-sr-only.gl-accessibility{ href: "#content-body" } Skip to content
.container-fluid
.header-content
.title-container
@@ -19,10 +19,13 @@
%span.gl-badge.gl-bg-green-500.gl-text-white.gl-rounded-pill.gl-font-weight-bold.gl-py-1
= _('Next')
- - if current_user
- = render "layouts/nav/dashboard"
+ - if Feature.enabled?(:combined_menu, current_user, default_enabled: :yaml)
+ = render "layouts/nav/combined_menu"
- else
- = render "layouts/nav/explore"
+ - if current_user
+ = render "layouts/nav/dashboard"
+ - else
+ = render "layouts/nav/explore"
.navbar-collapse.collapse
%ul.nav.navbar-nav
@@ -110,14 +113,15 @@
%li.nav-item
%div
- sign_in_text = allow_signup? ? _('Sign in / Register') : _('Sign in')
- = link_to sign_in_text, new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in'
+ = link_to sign_in_text, new_session_path(:user, redirect_to_referer: 'yes'), class: 'gl-button btn btn-default btn-sign-in'
%button.navbar-toggler.d-block.d-sm-none{ type: 'button' }
%span.sr-only= _('Toggle navigation')
= sprite_icon('ellipsis_h', size: 12, css_class: 'more-icon js-navbar-toggle-right')
= sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left')
-#whats-new-app{ data: { storage_key: whats_new_storage_key, versions: whats_new_versions, gitlab_dot_com: Gitlab.dev_env_org_or_com? } }
+- if display_whats_new?
+ #whats-new-app{ data: { version_digest: whats_new_version_digest } }
- if can?(current_user, :update_user_status, current_user)
.js-set-status-modal-wrapper{ data: user_status_data }
diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.html.haml
index 1fc9831d271..7b49e6f716e 100644
--- a/app/views/layouts/header/_new_dropdown.haml
+++ b/app/views/layouts/header/_new_dropdown.html.haml
@@ -1,4 +1,4 @@
-%li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown", track_value: "" } }
+%li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" } }
= link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", id: "js-onboarding-new-project-link", title: _("New..."), ref: 'tooltip', aria: { label: _("New...") }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static' } do
= sprite_icon('plus-square')
= sprite_icon('chevron-down', css_class: 'caret-down')
@@ -37,8 +37,7 @@
= render 'layouts/header/project_invite_members_new_dropdown_item'
%li.divider
%li.dropdown-bold-header GitLab
- - if current_user.can_create_project?
- %li= link_to _('New project'), new_project_path, class: 'qa-global-new-project-link'
+ = content_for :new_repo_experiment
- if current_user.can_create_group?
%li= link_to _('New group'), new_group_path
- if current_user.can?(:create_snippet)
diff --git a/app/views/layouts/header/_new_repo_experiment.html.haml b/app/views/layouts/header/_new_repo_experiment.html.haml
new file mode 100644
index 00000000000..73f960844cb
--- /dev/null
+++ b/app/views/layouts/header/_new_repo_experiment.html.haml
@@ -0,0 +1,7 @@
+- content_for :new_repo_experiment do
+ - if current_user&.can_create_project?
+ - experiment(:new_repo, user: current_user) do |e|
+ - e.use do
+ %li= link_to _('New project'), new_project_path, class: 'qa-global-new-project-link', data: { track_experiment: 'new_repo', track_event: 'click_link', track_label: 'plus_menu_dropdown' }
+ - e.try do
+ %li= link_to _('New project/repository'), new_project_path, class: 'qa-global-new-project-link', data: { track_experiment: 'new_repo', track_event: 'click_link', track_label: 'plus_menu_dropdown' }
diff --git a/app/views/layouts/header/_read_only_banner.html.haml b/app/views/layouts/header/_read_only_banner.html.haml
index f3d563c362f..86c1d34c0b7 100644
--- a/app/views/layouts/header/_read_only_banner.html.haml
+++ b/app/views/layouts/header/_read_only_banner.html.haml
@@ -2,6 +2,6 @@
- if message
.flash-container.flash-container-page
.flash-notice
- %div{ class: (container_class) }
+ %div{ class: container_class }
%span
= message
diff --git a/app/views/layouts/header/_service_templates_deprecation_callout.html.haml b/app/views/layouts/header/_service_templates_deprecation_callout.html.haml
new file mode 100644
index 00000000000..056d4426d5a
--- /dev/null
+++ b/app/views/layouts/header/_service_templates_deprecation_callout.html.haml
@@ -0,0 +1,21 @@
+- return unless show_service_templates_deprecated_callout?
+
+- doc_link_start = "<a href=\"#{integrations_help_page_path}\" target='_blank' rel='noopener noreferrer'>".html_safe
+- settings_link_start = "<a href=\"#{integrations_admin_application_settings_path}\">".html_safe
+
+%div{ class: [container_class, @content_class, 'gl-pt-5!'] }
+ .gl-alert.gl-alert-warning.js-service-templates-deprecated-callout{ role: 'alert', data: { feature_id: UserCalloutsHelper::SERVICE_TEMPLATES_DEPRECATED_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-service-templates-deprecated-callout' } }
+ = sprite_icon('close', size: 16)
+ .gl-alert-title
+ = s_('AdminSettings|Service templates are deprecated and will be removed in GitLab 14.0.')
+ .gl-alert-body
+ = html_escape_once(s_('AdminSettings|You should migrate to %{doc_link_start}Project integration management%{link_end}, available at %{settings_link_start}Settings &gt; Integrations.%{link_end}')).html_safe % { doc_link_start: doc_link_start, settings_link_start: settings_link_start, link_end: '</a>'.html_safe }
+ .gl-alert-actions
+ = link_to admin_application_settings_services_path, class: 'btn gl-alert-action btn-info btn-md gl-button' do
+ %span.gl-button-text
+ = s_('AdminSettings|See affected service templates')
+ = link_to "https://gitlab.com/gitlab-org/gitlab/-/issues/325905", class: 'btn gl-alert-action btn-default btn-md gl-button', target: '_blank', rel: 'noopener noreferrer' do
+ %span.gl-button-text
+ = _('Leave feedback')
diff --git a/app/views/layouts/header/_whats_new_dropdown_item.html.haml b/app/views/layouts/header/_whats_new_dropdown_item.html.haml
index f79b741ced0..9fe98a54aae 100644
--- a/app/views/layouts/header/_whats_new_dropdown_item.html.haml
+++ b/app/views/layouts/header/_whats_new_dropdown_item.html.haml
@@ -1,5 +1,6 @@
-%li
- %button.gl-justify-content-space-between.gl-align-items-center.js-whats-new-trigger{ type: 'button', data: { storage_key: whats_new_storage_key }, class: 'gl-display-flex!' }
- = _("What's new")
- %span.js-whats-new-notification-count.whats-new-notification-count
- = whats_new_most_recent_release_items_count
+- if display_whats_new?
+ %li
+ %button.gl-justify-content-space-between.gl-align-items-center.js-whats-new-trigger{ type: 'button', class: 'gl-display-flex!' }
+ = _("What's new")
+ %span.js-whats-new-notification-count.whats-new-notification-count
+ = whats_new_most_recent_release_items_count
diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml
index aeeffb6f4b6..c111714f552 100644
--- a/app/views/layouts/nav/_breadcrumbs.html.haml
+++ b/app/views/layouts/nav/_breadcrumbs.html.haml
@@ -9,7 +9,7 @@
= button_tag class: 'toggle-mobile-nav', type: 'button' do
%span.sr-only= _("Open sidebar")
= sprite_icon('hamburger', size: 18)
- .breadcrumbs-links.js-title-container{ data: { qa_selector: 'breadcrumb_links_content' } }
+ .breadcrumbs-links{ data: { testid: 'breadcrumb-links', qa_selector: 'breadcrumb_links_content' } }
%ul.list-unstyled.breadcrumbs-list.js-breadcrumbs-list
- unless hide_top_links
= header_title
@@ -21,7 +21,7 @@
%li
%h2.breadcrumbs-sub-title
= link_to @breadcrumb_title, breadcrumb_title_link
- %script{ type:'application/ld+json' }
+ %script{ type: 'application/ld+json' }
:plain
#{schema_breadcrumb_json}
= yield :header_content
diff --git a/app/views/layouts/nav/_combined_menu.html.haml b/app/views/layouts/nav/_combined_menu.html.haml
new file mode 100644
index 00000000000..db5a7012e8f
--- /dev/null
+++ b/app/views/layouts/nav/_combined_menu.html.haml
@@ -0,0 +1,3 @@
+%button{ type: 'button', data: { toggle: "dropdown" } }
+ = sprite_icon('ellipsis_v')
+ = _('Projects')
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index 7cbef6b00b1..42e3ae7e717 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" } }) 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", track_experiment: "new_repo" } }) do
%button{ type: 'button', data: { toggle: "dropdown" } }
= _('Projects')
= sprite_icon('chevron-down', css_class: 'caret-down')
@@ -50,7 +50,7 @@
= nav_link(controller: 'admin/dashboard') do
= link_to admin_root_path, class: 'admin-icon qa-admin-area-link d-xl-none' do
= _('Admin Area')
- - if Feature.enabled?(:user_mode_in_session)
+ - if Gitlab::CurrentSettings.admin_mode
- if header_link?(:admin_mode)
= nav_link(controller: 'admin/sessions') do
= link_to destroy_admin_session_path, method: :post, class: 'd-lg-none lock-open-icon' do
@@ -69,7 +69,7 @@
= link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('admin', size: 18)
- - if Feature.enabled?(:user_mode_in_session)
+ - if Gitlab::CurrentSettings.admin_mode
- if header_link?(:admin_mode)
= 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
diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml
index d8bf64fab64..b95a9cdb00f 100644
--- a/app/views/layouts/nav/projects_dropdown/_show.html.haml
+++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml
@@ -11,14 +11,21 @@
= nav_link(path: 'projects#trending') do
= link_to explore_root_path, data: { track_label: "projects_dropdown_explore_projects", track_event: "click_link" } do
= _('Explore projects')
- = nav_link(path: 'projects/new#blank_project',
- html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' },
- data: { track_label: "projects_dropdown_blank_project", track_event: "click_link" }) do
- = link_to new_project_path(anchor: 'blank_project') do
- = _('Create blank project')
- = nav_link(path: 'projects/new#import_project') do
- = link_to new_project_path(anchor: 'import_project'), data: { track_label: "projects_dropdown_import_project", track_event: "click_link" } do
- = _('Import project')
+ - experiment(:new_repo, user: current_user) do |e|
+ - e.use do
+ = nav_link(path: 'projects/new#blank_project', html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' }) do
+ = link_to new_project_path(anchor: 'blank_project'), data: { track_label: "projects_dropdown_blank_project", track_event: "click_link", track_experiment: "new_repo" } do
+ = _('Create blank project')
+ = nav_link(path: 'projects/new#import_project') do
+ = link_to new_project_path(anchor: 'import_project'), data: { track_label: "projects_dropdown_import_project", track_event: "click_link", track_experiment: "new_repo" } do
+ = _('Import project')
+ - e.try do
+ = nav_link(path: 'projects/new#blank_project', html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' }) do
+ = link_to new_project_path(anchor: 'blank_project'), data: { track_label: "projects_dropdown_blank_project", track_event: "click_link", track_experiment: "new_repo" } do
+ = _('Create blank project/repository')
+ = nav_link(path: 'projects/new#import_project') do
+ = link_to new_project_path(anchor: 'import_project'), data: { track_label: "projects_dropdown_import_project", track_event: "click_link", track_experiment: "new_repo" } do
+ = _('Import project/repository')
= nav_link(path: 'projects/new#create_from_template') do
= link_to new_project_path(anchor: 'create_from_template'), data: { track_label: "projects_dropdown_create_from_template", track_event: "click_link" } do
= _('Create from template')
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 7350db64462..41bec996de1 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -1,9 +1,9 @@
-- issues_count = group_open_issues_count(@group)
-- merge_requests_count = group_merge_requests_count(state: 'opened')
+- issues_count = cached_issuables_count(@group, type: :issues)
+- merge_requests_count = group_open_merge_requests_count(@group)
- aside_title = @group.subgroup? ? _('Subgroup navigation') : _('Group navigation')
- overview_title = @group.subgroup? ? _('Subgroup overview') : _('Group overview')
-%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **tracking_attrs('groups_side_navigation', 'render', 'groups_side_navigation'), 'aria-label': aside_title }
+%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(@group), 'aria-label': aside_title }
.nav-sidebar-inner-scroll
.context-header
= link_to group_path(@group), title: @group.name do
@@ -91,14 +91,14 @@
.nav-icon-container
= sprite_icon('git-merge')
%span.nav-item-name
- = _('Merge Requests')
- %span.badge.badge-pill.count= number_with_delimiter(merge_requests_count)
+ = _('Merge requests')
+ %span.badge.badge-pill.count= merge_requests_count
%ul.sidebar-sub-level-items.is-fly-out-only
= nav_link(path: 'groups#merge_requests', html_options: { class: "fly-out-top-item" } ) do
= link_to merge_requests_group_path(@group) do
%strong.fly-out-top-item-name
- = _('Merge Requests')
- %span.badge.badge-pill.count.merge_counter.js-merge-counter.fly-out-badge= number_with_delimiter(merge_requests_count)
+ = _('Merge requests')
+ %span.badge.badge-pill.count.merge_counter.js-merge-counter.fly-out-badge= merge_requests_count
= render_if_exists "layouts/nav/ee/security_link" # EE-specific
@@ -145,7 +145,7 @@
%span.nav-item-name.qa-group-settings-item
= _('Settings')
%ul.sidebar-sub-level-items.qa-group-sidebar-submenu{ data: { testid: 'group-settings-menu' } }
- = nav_link(path: %w[groups#projects groups#edit badges#index ci_cd#show], html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(path: %w[groups#projects groups#edit badges#index ci_cd#show groups/applications#index], html_options: { class: "fly-out-top-item" } ) do
= link_to edit_group_path(@group) do
%strong.fly-out-top-item-name
= _('Settings')
@@ -175,6 +175,11 @@
%span
= _('CI/CD')
+ = nav_link(controller: :applications) do
+ = link_to group_settings_applications_path(@group), title: _('Applications') do
+ %span
+ = _('Applications')
+
= render 'groups/sidebar/packages_settings'
= render_if_exists "groups/ee/settings_nav"
diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml
index 4ae81d69c16..dda5e6b9636 100644
--- a/app/views/layouts/nav/sidebar/_profile.html.haml
+++ b/app/views/layouts/nav/sidebar/_profile.html.haml
@@ -1,4 +1,4 @@
-%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **tracking_attrs('user_side_navigation', 'render', 'user_side_navigation'), 'aria-label': _('User settings') }
+%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(current_user), 'aria-label': _('User settings') }
.nav-sidebar-inner-scroll
.context-header
= link_to profile_path, title: _('Profile Settings') do
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 4c331dbd69d..3d0c6baffd5 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -1,469 +1,3 @@
-%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **tracking_attrs('projects_side_navigation', 'render', 'projects_side_navigation'), 'aria-label': _('Project navigation') }
- .nav-sidebar-inner-scroll
- .context-header
- = link_to project_path(@project), title: @project.name do
- .avatar-container.rect-avatar.s40.project-avatar
- = project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile', width: 40, height: 40)
- .sidebar-context-title
- = @project.name
- %ul.sidebar-top-level-items.qa-project-sidebar
- = nav_link(path: sidebar_projects_paths, html_options: { class: 'home' }) do
- = link_to project_path(@project), class: 'shortcuts-project rspec-project-link', data: { qa_selector: 'project_link' } do
- .nav-icon-container
- = sprite_icon('home')
- %span.nav-item-name
- = _('Project overview')
-
- %ul.sidebar-sub-level-items
- = nav_link(path: 'projects#show', html_options: { class: "fly-out-top-item" } ) do
- = link_to project_path(@project) do
- %strong.fly-out-top-item-name
- = _('Project overview')
- %li.divider.fly-out-top-item
- = nav_link(path: 'projects#show') do
- = link_to project_path(@project), title: _('Project details'), class: 'shortcuts-project' do
- %span= _('Details')
-
- = nav_link(path: 'projects#activity') do
- = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity', data: { qa_selector: 'activity_link' } do
- %span= _('Activity')
-
- - if project_nav_tab?(:releases)
- = nav_link(controller: :releases) do
- = link_to project_releases_path(@project), title: _('Releases'), class: 'shortcuts-project-releases' do
- %span= _('Releases')
-
- - if project_nav_tab? :learn_gitlab
- = nav_link(controller: :learn_gitlab, html_options: { class: 'home' }) do
- = link_to project_learn_gitlab_path(@project) do
- .nav-icon-container
- = sprite_icon('home')
- %span.nav-item-name
- = _('Learn GitLab')
-
- - if project_nav_tab? :files
- = nav_link(controller: sidebar_repository_paths, unless: -> { current_path?('projects/graphs#charts') }) do
- = link_to project_tree_path(@project), class: 'shortcuts-tree', data: { qa_selector: "repository_link" } do
- .nav-icon-container
- = sprite_icon('doc-text')
- %span.nav-item-name#js-onboarding-repo-link
- = _('Repository')
-
- %ul.sidebar-sub-level-items
- = nav_link(controller: sidebar_repository_paths, html_options: { class: "fly-out-top-item" } ) do
- = link_to project_tree_path(@project) do
- %strong.fly-out-top-item-name
- = _('Repository')
- %li.divider.fly-out-top-item
- = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file)) do
- = link_to project_tree_path(@project) do
- = _('Files')
-
- = nav_link(controller: [:commit, :commits]) do
- = link_to project_commits_path(@project, current_ref), id: 'js-onboarding-commits-link' do
- = _('Commits')
-
- = nav_link(html_options: {class: branches_tab_class}) do
- = link_to project_branches_path(@project), data: { qa_selector: "branches_link" }, id: 'js-onboarding-branches-link' do
- = _('Branches')
-
- = nav_link(controller: [:tags]) do
- = link_to project_tags_path(@project), data: { qa_selector: "tags_link" } do
- = _('Tags')
-
- = nav_link(path: 'graphs#show') do
- = link_to project_graph_path(@project, current_ref) do
- = _('Contributors')
-
- = nav_link(controller: %w(network)) do
- = link_to project_network_path(@project, current_ref) do
- = _('Graph')
-
- = nav_link(controller: :compare) do
- = link_to project_compare_index_path(@project, from: @repository.root_ref, to: current_ref) do
- = _('Compare')
-
- = render_if_exists 'projects/sidebar/repository_locked_files'
-
- - if project_nav_tab? :issues
- = nav_link(controller: @project.issues_enabled? ? ['projects/issues', :labels, :milestones, :boards, :iterations] : 'projects/issues') do
- = link_to project_issues_path(@project), class: 'shortcuts-issues qa-issues-item' do
- .nav-icon-container
- = sprite_icon('issues')
- %span.nav-item-name#js-onboarding-issues-link
- = _('Issues')
- - if @project.issues_enabled?
- %span.badge.badge-pill.count.issue_counter
- = number_with_delimiter(@project.open_issues_count(current_user))
-
- %ul.sidebar-sub-level-items
- = nav_link(controller: 'projects/issues', action: :index, html_options: { class: "fly-out-top-item" } ) do
- = link_to project_issues_path(@project) do
- %strong.fly-out-top-item-name
- = _('Issues')
- - if @project.issues_enabled?
- %span.badge.badge-pill.count.issue_counter.fly-out-badge
- = number_with_delimiter(@project.open_issues_count(current_user))
- %li.divider.fly-out-top-item
- = nav_link(controller: :issues, action: :index) do
- = link_to project_issues_path(@project), title: _('Issues') do
- %span
- = _('List')
-
- = nav_link(controller: :boards) do
- = link_to project_boards_path(@project), title: boards_link_text, data: { qa_selector: "issue_boards_link" } do
- %span
- = boards_link_text
-
- = nav_link(controller: :labels) do
- = link_to project_labels_path(@project), title: _('Labels'), class: 'qa-labels-link' do
- %span
- = _('Labels')
-
- = render 'projects/sidebar/issues_service_desk'
-
- = nav_link(controller: :milestones) do
- = link_to project_milestones_path(@project), title: _('Milestones'), class: 'qa-milestones-link' do
- %span
- = _('Milestones')
-
- = render_if_exists 'layouts/nav/sidebar/project_iterations_link'
-
- - if project_nav_tab?(:external_issue_tracker)
- - issue_tracker = @project.external_issue_tracker
- - if issue_tracker.is_a?(JiraService) && project_jira_issues_integration?
- = render_if_exists 'layouts/nav/sidebar/project_jira_issues_link', issue_tracker: issue_tracker
- - else
- = nav_link do
- = link_to issue_tracker.issue_tracker_path, target: '_blank', rel: 'noopener noreferrer', class: 'shortcuts-external_tracker' do
- .nav-icon-container
- = sprite_icon('external-link')
- %span.nav-item-name
- = issue_tracker.title
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(html_options: { class: "fly-out-top-item" } ) do
- = link_to issue_tracker.issue_tracker_path, target: '_blank', rel: 'noopener noreferrer' do
- %strong.fly-out-top-item-name
- = issue_tracker.title
-
- - if (project_nav_tab? :labels) && !@project.issues_enabled?
- = nav_link(controller: [:labels]) do
- = link_to project_labels_path(@project), title: _('Labels'), class: 'shortcuts-labels qa-labels-items' do
- .nav-icon-container
- = sprite_icon('label')
- %span.nav-item-name#js-onboarding-labels-link
- = _('Labels')
-
- - if project_nav_tab? :merge_requests
- = nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :milestones]) do
- = link_to project_merge_requests_path(@project), class: 'shortcuts-merge_requests', data: { qa_selector: 'merge_requests_link' } do
- .nav-icon-container
- = sprite_icon('git-merge')
- %span.nav-item-name#js-onboarding-mr-link
- = _('Merge Requests')
- %span.badge.badge-pill.count.merge_counter.js-merge-counter
- = number_with_delimiter(@project.open_merge_requests_count)
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :merge_requests, html_options: { class: "fly-out-top-item" } ) do
- = link_to project_merge_requests_path(@project) do
- %strong.fly-out-top-item-name
- = _('Merge Requests')
- %span.badge.badge-pill.count.merge_counter.js-merge-counter.fly-out-badge
- = number_with_delimiter(@project.open_merge_requests_count)
-
- = 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, :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')
- %span.nav-item-name#js-onboarding-pipelines-link
- = _('CI/CD')
-
- %ul.sidebar-sub-level-items
- = 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')
- %li.divider.fly-out-top-item
- - if project_nav_tab? :pipelines
- = nav_link(path: ['pipelines#index', 'pipelines#show']) do
- = link_to project_pipelines_path(@project), title: _('Pipelines'), class: 'shortcuts-pipelines' do
- %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
- %span
- = _('Jobs')
-
- - if Feature.enabled?(:artifacts_management_page, @project)
- = nav_link(controller: :artifacts, action: :index) do
- = link_to project_artifacts_path(@project), title: _('Artifacts'), class: 'shortcuts-builds' do
- %span
- = _('Artifacts')
-
- - if project_nav_tab?(:pipelines)
- = nav_link(controller: :pipeline_schedules) do
- = link_to pipeline_schedules_path(@project), title: _('Schedules'), class: 'shortcuts-builds' do
- %span
- = _('Schedules')
-
- = render_if_exists "layouts/nav/test_cases_link", project: @project
-
- - if project_nav_tab? :security_and_compliance
- = render_if_exists 'layouts/nav/sidebar/project_security_link' # EE-specific
-
- - if project_nav_tab? :operations
- = nav_link(controller: sidebar_operations_paths) do
- = link_to sidebar_operations_link_path, class: 'shortcuts-operations', data: { qa_selector: 'operations_link' } do
- .nav-icon-container
- = sprite_icon('cloud-gear')
- %span.nav-item-name
- = _('Operations')
-
- %ul.sidebar-sub-level-items
- = nav_link(controller: sidebar_operations_paths, html_options: { class: "fly-out-top-item" } ) do
- = link_to sidebar_operations_link_path do
- %strong.fly-out-top-item-name
- = _('Operations')
- %li.divider.fly-out-top-item
-
- - if project_nav_tab? :metrics_dashboards
- = nav_link(controller: :metrics_dashboard, action: [:show]) do
- = link_to project_metrics_dashboard_path(@project), title: _('Metrics'), class: 'shortcuts-metrics', data: { qa_selector: 'operations_metrics_link' } do
- %span
- = _('Metrics')
-
- - if project_nav_tab?(:environments) && can?(current_user, :read_pod_logs, @project)
- = nav_link(controller: :logs, action: [:index]) do
- = link_to project_logs_path(@project), title: _('Logs') do
- %span
- = _('Logs')
-
- - if project_nav_tab? :environments
- = render "layouts/nav/sidebar/tracing_link"
-
- - if project_nav_tab?(:error_tracking)
- = nav_link(controller: :error_tracking) do
- = link_to project_error_tracking_index_path(@project), title: _('Error Tracking') do
- %span
- = _('Error Tracking')
-
- - if project_nav_tab?(:alert_management)
- = nav_link(controller: :alert_management) do
- = link_to project_alert_management_index_path(@project), title: _('Alerts') do
- %span
- = _('Alerts')
-
- - if project_nav_tab?(:incidents)
- = nav_link(controller: :incidents) do
- = link_to project_incidents_path(@project), title: _('Incidents'), data: { qa_selector: 'operations_incidents_link' } do
- %span
- = _('Incidents')
-
- = render_if_exists 'projects/sidebar/oncall_schedules'
-
- - if project_nav_tab? :serverless
- = nav_link(controller: :functions) do
- = link_to project_serverless_functions_path(@project), title: _('Serverless') do
- %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: [:cluster_agents, :clusters]) do
- = link_to project_clusters_path(@project), title: _('Kubernetes'), class: 'shortcuts-kubernetes' do
- %span
- = _('Kubernetes')
- - if show_cluster_hint
- .js-feature-highlight{ disabled: true,
- data: { trigger: 'manual',
- container: 'body',
- placement: 'right',
- highlight: UserCalloutsHelper::GKE_CLUSTER_INTEGRATION,
- highlight_priority: UserCallout.feature_names[:GKE_CLUSTER_INTEGRATION],
- dismiss_endpoint: user_callouts_path,
- auto_devops_help_path: help_page_path('topics/autodevops/index.md') } }
- - if project_nav_tab? :environments
- = nav_link(controller: :environments, action: [:index, :folder, :show, :new, :edit, :create, :update, :stop, :terminal]) do
- = link_to project_environments_path(@project), title: _('Environments'), class: 'shortcuts-environments qa-operations-environments-link' do
- %span
- = _('Environments')
-
- - if project_nav_tab? :feature_flags
- = nav_link(controller: :feature_flags) do
- = link_to project_feature_flags_path(@project), title: _('Feature Flags'), class: 'shortcuts-feature-flags' do
- %span
- = _('Feature Flags')
-
- - if project_nav_tab?(:product_analytics)
- = nav_link(controller: :product_analytics) do
- = link_to project_product_analytics_path(@project), title: _('Product Analytics') do
- %span
- = _('Product Analytics')
-
- = render_if_exists 'layouts/nav/sidebar/project_packages_link'
-
- - if project_nav_tab? :analytics
- = render 'layouts/nav/sidebar/analytics_links', links: project_analytics_navbar_links(@project, current_user)
-
- - if project_nav_tab?(:confluence)
- - confluence_url = project_wikis_confluence_path(@project)
- = nav_link do
- = link_to confluence_url, class: 'shortcuts-confluence' do
- .nav-icon-container
- = image_tag 'confluence.svg', alt: _('Confluence')
- %span.nav-item-name
- = _('Confluence')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(html_options: { class: 'fly-out-top-item' } ) do
- = link_to confluence_url, target: '_blank', rel: 'noopener noreferrer' do
- %strong.fly-out-top-item-name
- = _('Confluence')
-
- - if project_nav_tab? :wiki
- = render 'layouts/nav/sidebar/wiki_link', wiki_url: wiki_path(@project.wiki)
-
- - if project_nav_tab?(:external_wiki)
- - external_wiki_url = @project.external_wiki.external_wiki_url
- = nav_link do
- = link_to external_wiki_url, class: 'shortcuts-external_wiki' do
- .nav-icon-container
- = sprite_icon('external-link')
- %span.nav-item-name
- = _('External Wiki')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(html_options: { class: "fly-out-top-item" } ) do
- = link_to external_wiki_url do
- %strong.fly-out-top-item-name
- = _('External Wiki')
-
- - if project_nav_tab? :snippets
- = nav_link(controller: :snippets) do
- = link_to project_snippets_path(@project), class: 'shortcuts-snippets', data: { qa_selector: 'snippets_link' } do
- .nav-icon-container
- = sprite_icon('snippet')
- %span.nav-item-name
- = _('Snippets')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :snippets, html_options: { class: "fly-out-top-item" } ) do
- = link_to project_snippets_path(@project) do
- %strong.fly-out-top-item-name
- = _('Snippets')
-
- = nav_link(controller: :project_members) do
- = link_to project_project_members_path(@project), title: _('Members'), class: 'qa-members-link', id: 'js-onboarding-members-link' do
- .nav-icon-container
- = sprite_icon('users')
- %span.nav-item-name
- = _('Members')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(path: %w[members#show], html_options: { class: "fly-out-top-item" } ) do
- = link_to project_project_members_path(@project) do
- %strong.fly-out-top-item-name
- = _('Members')
-
- - if project_nav_tab? :settings
- = nav_link(path: sidebar_settings_paths) do
- = link_to edit_project_path(@project) do
- .nav-icon-container
- = sprite_icon('settings')
- %span.nav-item-name.qa-settings-item#js-onboarding-settings-link
- = _('Settings')
-
- %ul.sidebar-sub-level-items
- - can_edit = can?(current_user, :admin_project, @project)
- - if can_edit
- = nav_link(path: sidebar_settings_paths, html_options: { class: "fly-out-top-item" } ) do
- = link_to edit_project_path(@project) do
- %strong.fly-out-top-item-name
- = _('Settings')
- %li.divider.fly-out-top-item
- = nav_link(path: %w[projects#edit]) do
- = link_to edit_project_path(@project), title: _('General'), class: 'qa-general-settings-link' do
- %span
- = _('General')
- - if can_edit
- = nav_link(controller: [:integrations, :services]) do
- = link_to project_settings_integrations_path(@project), title: _('Integrations'), data: { qa_selector: 'integrations_settings_link' } do
- %span
- = _('Integrations')
- = nav_link(controller: [:hooks, :hook_logs]) do
- = link_to project_hooks_path(@project), title: _('Webhooks'), data: { qa_selector: 'webhooks_settings_link' } do
- %span
- = _('Webhooks')
- - if project_access_token_available?(@project)
- = nav_link(controller: [:access_tokens]) do
- = link_to project_settings_access_tokens_path(@project), title: _('Access Tokens'), data: { qa_selector: 'access_tokens_settings_link' } do
- %span
- = _('Access Tokens')
- = nav_link(controller: :repository) do
- = link_to project_settings_repository_path(@project), title: _('Repository') do
- %span
- = _('Repository')
- - if !@project.archived? && @project.feature_available?(:builds, current_user)
- = nav_link(controller: :ci_cd) do
- = link_to project_settings_ci_cd_path(@project), title: _('CI/CD') do
- %span
- = _('CI/CD')
- - 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')
- - if @project.pages_available?
- = nav_link(controller: :pages) do
- = link_to project_pages_path(@project), title: _('Pages') do
- %span
- = _('Pages')
-
- -# Shortcut to Project > Activity
- %li.hidden
- = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do
- %span
- = _('Activity')
-
- -# Shortcut to Repository > Graph (formerly, Network)
- - if project_nav_tab? :network
- %li.hidden
- = link_to project_network_path(@project, current_ref), title: _('Network'), class: 'shortcuts-network' do
- = _('Graph')
-
- -# Shortcut to Issues > New Issue
- - if project_nav_tab?(:issues)
- %li.hidden
- = link_to new_project_issue_path(@project), class: 'shortcuts-new-issue' do
- = _('Create a new issue')
-
- -# Shortcut to Pipelines > Jobs
- - if project_nav_tab? :builds
- %li.hidden
- = link_to project_jobs_path(@project), title: _('Jobs'), class: 'shortcuts-builds' do
- = _('Jobs')
-
- -# Shortcut to commits page
- - if project_nav_tab? :commits
- %li.hidden
- = link_to project_commits_path(@project), title: _('Commits'), class: 'shortcuts-commits' do
- = _('Commits')
-
- -# Shortcut to issue boards
- - if project_nav_tab?(:issues)
- %li.hidden
- = link_to _('Issue Boards'), project_boards_path(@project), title: _('Issue Boards'), class: 'shortcuts-issue-boards'
-
- = render 'shared/sidebar_toggle_button'
+-# We're migration the project sidebar to a logical model based structure. If you need to update
+-# any of the existing menus, you can find them in app/views/layouts/nav/sidebar/_project_menus.html.haml.
+= render partial: 'shared/nav/sidebar', object: Sidebars::Projects::Panel.new(project_sidebar_context(@project, current_user, current_ref))
diff --git a/app/views/layouts/nav/sidebar/_project_menus.html.haml b/app/views/layouts/nav/sidebar/_project_menus.html.haml
new file mode 100644
index 00000000000..ed072c0f6a2
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_project_menus.html.haml
@@ -0,0 +1,380 @@
+- if project_nav_tab? :issues
+ = nav_link(controller: @project.issues_enabled? ? ['projects/issues', :labels, :milestones, :boards, :iterations] : 'projects/issues') do
+ = link_to project_issues_path(@project), class: 'shortcuts-issues qa-issues-item' do
+ .nav-icon-container
+ = sprite_icon('issues')
+ %span.nav-item-name#js-onboarding-issues-link
+ = _('Issues')
+ - if @project.issues_enabled?
+ %span.badge.badge-pill.count.issue_counter
+ = number_with_delimiter(@project.open_issues_count(current_user))
+
+ %ul.sidebar-sub-level-items
+ = nav_link(controller: 'projects/issues', action: :index, html_options: { class: "fly-out-top-item" } ) do
+ = link_to project_issues_path(@project) do
+ %strong.fly-out-top-item-name
+ = _('Issues')
+ - if @project.issues_enabled?
+ %span.badge.badge-pill.count.issue_counter.fly-out-badge
+ = number_with_delimiter(@project.open_issues_count(current_user))
+ %li.divider.fly-out-top-item
+ = nav_link(controller: :issues, action: :index) do
+ = link_to project_issues_path(@project), title: _('Issues') do
+ %span
+ = _('List')
+
+ = nav_link(controller: :boards) do
+ = link_to project_boards_path(@project), title: boards_link_text, data: { qa_selector: "issue_boards_link" } do
+ %span
+ = boards_link_text
+
+ = nav_link(controller: :labels) do
+ = link_to project_labels_path(@project), title: _('Labels'), class: 'qa-labels-link' do
+ %span
+ = _('Labels')
+
+ = render 'projects/sidebar/issues_service_desk'
+
+ = nav_link(controller: :milestones) do
+ = link_to project_milestones_path(@project), title: _('Milestones'), class: 'qa-milestones-link' do
+ %span
+ = _('Milestones')
+
+ = render_if_exists 'layouts/nav/sidebar/project_iterations_link'
+
+- if project_nav_tab?(:external_issue_tracker)
+ - issue_tracker = @project.external_issue_tracker
+ - if issue_tracker.is_a?(JiraService) && project_jira_issues_integration?
+ = render_if_exists 'layouts/nav/sidebar/project_jira_issues_link', issue_tracker: issue_tracker
+ - else
+ = nav_link do
+ = link_to issue_tracker.issue_tracker_path, target: '_blank', rel: 'noopener noreferrer', class: 'shortcuts-external_tracker' do
+ .nav-icon-container
+ = sprite_icon('external-link')
+ %span.nav-item-name
+ = issue_tracker.title
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(html_options: { class: "fly-out-top-item" } ) do
+ = link_to issue_tracker.issue_tracker_path, target: '_blank', rel: 'noopener noreferrer' do
+ %strong.fly-out-top-item-name
+ = issue_tracker.title
+
+- if (project_nav_tab? :labels) && !@project.issues_enabled?
+ = nav_link(controller: [:labels]) do
+ = link_to project_labels_path(@project), title: _('Labels'), class: 'shortcuts-labels qa-labels-items' do
+ .nav-icon-container
+ = sprite_icon('label')
+ %span.nav-item-name#js-onboarding-labels-link
+ = _('Labels')
+
+- if project_nav_tab? :merge_requests
+ = nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :milestones]) do
+ = link_to project_merge_requests_path(@project), class: 'shortcuts-merge_requests', data: { qa_selector: 'merge_requests_link' } do
+ .nav-icon-container
+ = sprite_icon('git-merge')
+ %span.nav-item-name#js-onboarding-mr-link
+ = _('Merge requests')
+ %span.badge.badge-pill.count.merge_counter.js-merge-counter
+ = number_with_delimiter(@project.open_merge_requests_count)
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :merge_requests, html_options: { class: "fly-out-top-item" } ) do
+ = link_to project_merge_requests_path(@project) do
+ %strong.fly-out-top-item-name
+ = _('Merge requests')
+ %span.badge.badge-pill.count.merge_counter.js-merge-counter.fly-out-badge
+ = number_with_delimiter(@project.open_merge_requests_count)
+
+= 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, :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')
+ %span.nav-item-name#js-onboarding-pipelines-link
+ = _('CI/CD')
+
+ %ul.sidebar-sub-level-items
+ = 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')
+ %li.divider.fly-out-top-item
+ - if project_nav_tab? :pipelines
+ = nav_link(path: ['pipelines#index', 'pipelines#show']) do
+ = link_to project_pipelines_path(@project), title: _('Pipelines'), class: 'shortcuts-pipelines' do
+ %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
+ %span
+ = _('Jobs')
+
+ - if Feature.enabled?(:artifacts_management_page, @project)
+ = nav_link(controller: :artifacts, action: :index) do
+ = link_to project_artifacts_path(@project), title: _('Artifacts'), class: 'shortcuts-builds' do
+ %span
+ = _('Artifacts')
+
+ - if project_nav_tab?(:pipelines)
+ = nav_link(controller: :pipeline_schedules) do
+ = link_to pipeline_schedules_path(@project), title: _('Schedules'), class: 'shortcuts-builds' do
+ %span
+ = _('Schedules')
+
+ = render_if_exists "layouts/nav/test_cases_link", project: @project
+
+- if project_nav_tab? :security_and_compliance
+ = render_if_exists 'layouts/nav/sidebar/project_security_link' # EE-specific
+
+- if project_nav_tab? :operations
+ = nav_link(controller: sidebar_operations_paths) do
+ = link_to sidebar_operations_link_path, class: 'shortcuts-operations', data: { qa_selector: 'operations_link' } do
+ .nav-icon-container
+ = sprite_icon('cloud-gear')
+ %span.nav-item-name
+ = _('Operations')
+
+ %ul.sidebar-sub-level-items
+ = nav_link(controller: sidebar_operations_paths, html_options: { class: "fly-out-top-item" } ) do
+ = link_to sidebar_operations_link_path do
+ %strong.fly-out-top-item-name
+ = _('Operations')
+ %li.divider.fly-out-top-item
+
+ - if project_nav_tab? :metrics_dashboards
+ = nav_link(controller: :metrics_dashboard, action: [:show]) do
+ = link_to project_metrics_dashboard_path(@project), title: _('Metrics'), class: 'shortcuts-metrics', data: { qa_selector: 'operations_metrics_link' } do
+ %span
+ = _('Metrics')
+
+ - if project_nav_tab?(:environments) && can?(current_user, :read_pod_logs, @project)
+ = nav_link(controller: :logs, action: [:index]) do
+ = link_to project_logs_path(@project), title: _('Logs') do
+ %span
+ = _('Logs')
+
+ - if project_nav_tab? :environments
+ = render "layouts/nav/sidebar/tracing_link"
+
+ - if project_nav_tab?(:error_tracking)
+ = nav_link(controller: :error_tracking) do
+ = link_to project_error_tracking_index_path(@project), title: _('Error Tracking') do
+ %span
+ = _('Error Tracking')
+
+ - if project_nav_tab?(:alert_management)
+ = nav_link(controller: :alert_management) do
+ = link_to project_alert_management_index_path(@project), title: _('Alerts') do
+ %span
+ = _('Alerts')
+
+ - if project_nav_tab?(:incidents)
+ = nav_link(controller: :incidents) do
+ = link_to project_incidents_path(@project), title: _('Incidents'), data: { qa_selector: 'operations_incidents_link' } do
+ %span
+ = _('Incidents')
+
+ = render_if_exists 'projects/sidebar/oncall_schedules'
+
+ - if project_nav_tab? :serverless
+ = nav_link(controller: :functions) do
+ = link_to project_serverless_functions_path(@project), title: _('Serverless') do
+ %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: [:cluster_agents, :clusters]) do
+ = link_to project_clusters_path(@project), title: _('Kubernetes'), class: 'shortcuts-kubernetes' do
+ %span
+ = _('Kubernetes')
+ - if show_cluster_hint
+ .js-feature-highlight{ disabled: true,
+ data: { trigger: 'manual',
+ container: 'body',
+ placement: 'right',
+ highlight: UserCalloutsHelper::GKE_CLUSTER_INTEGRATION,
+ highlight_priority: UserCallout.feature_names[:GKE_CLUSTER_INTEGRATION],
+ dismiss_endpoint: user_callouts_path,
+ auto_devops_help_path: help_page_path('topics/autodevops/index.md') } }
+ - if project_nav_tab? :environments
+ = nav_link(controller: :environments, action: [:index, :folder, :show, :new, :edit, :create, :update, :stop, :terminal]) do
+ = link_to project_environments_path(@project), title: _('Environments'), class: 'shortcuts-environments qa-operations-environments-link' do
+ %span
+ = _('Environments')
+
+ - if project_nav_tab? :feature_flags
+ = nav_link(controller: :feature_flags) do
+ = link_to project_feature_flags_path(@project), title: _('Feature Flags'), class: 'shortcuts-feature-flags' do
+ %span
+ = _('Feature Flags')
+
+ - if project_nav_tab?(:product_analytics)
+ = nav_link(controller: :product_analytics) do
+ = link_to project_product_analytics_path(@project), title: _('Product Analytics') do
+ %span
+ = _('Product Analytics')
+
+= render_if_exists 'layouts/nav/sidebar/project_packages_link'
+
+- if project_nav_tab? :analytics
+ = render 'layouts/nav/sidebar/analytics_links', links: project_analytics_navbar_links(@project, current_user)
+
+- if project_nav_tab?(:confluence)
+ - confluence_url = project_wikis_confluence_path(@project)
+ = nav_link do
+ = link_to confluence_url, class: 'shortcuts-confluence' do
+ .nav-icon-container
+ = image_tag 'confluence.svg', alt: _('Confluence')
+ %span.nav-item-name
+ = _('Confluence')
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(html_options: { class: 'fly-out-top-item' } ) do
+ = link_to confluence_url, target: '_blank', rel: 'noopener noreferrer' do
+ %strong.fly-out-top-item-name
+ = _('Confluence')
+
+- if project_nav_tab? :wiki
+ = render 'layouts/nav/sidebar/wiki_link', wiki_url: wiki_path(@project.wiki)
+
+- if project_nav_tab?(:external_wiki)
+ - external_wiki_url = @project.external_wiki.external_wiki_url
+ = nav_link do
+ = link_to external_wiki_url, class: 'shortcuts-external_wiki' do
+ .nav-icon-container
+ = sprite_icon('external-link')
+ %span.nav-item-name
+ = s_('ExternalWikiService|External wiki')
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(html_options: { class: "fly-out-top-item" } ) do
+ = link_to external_wiki_url do
+ %strong.fly-out-top-item-name
+ = s_('ExternalWikiService|External wiki')
+
+- if project_nav_tab? :snippets
+ = nav_link(controller: :snippets) do
+ = link_to project_snippets_path(@project), class: 'shortcuts-snippets', data: { qa_selector: 'snippets_link' } do
+ .nav-icon-container
+ = sprite_icon('snippet')
+ %span.nav-item-name
+ = _('Snippets')
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :snippets, html_options: { class: "fly-out-top-item" } ) do
+ = link_to project_snippets_path(@project) do
+ %strong.fly-out-top-item-name
+ = _('Snippets')
+
+= nav_link(controller: :project_members) do
+ = link_to project_project_members_path(@project), title: _('Members'), class: 'qa-members-link', id: 'js-onboarding-members-link' do
+ .nav-icon-container
+ = sprite_icon('users')
+ %span.nav-item-name
+ = _('Members')
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(path: %w[members#show], html_options: { class: "fly-out-top-item" } ) do
+ = link_to project_project_members_path(@project) do
+ %strong.fly-out-top-item-name
+ = _('Members')
+
+- if project_nav_tab? :settings
+ = nav_link(path: sidebar_settings_paths) do
+ = link_to edit_project_path(@project) do
+ .nav-icon-container
+ = sprite_icon('settings')
+ %span.nav-item-name.qa-settings-item#js-onboarding-settings-link
+ = _('Settings')
+
+ %ul.sidebar-sub-level-items
+ - can_edit = can?(current_user, :admin_project, @project)
+ - if can_edit
+ = nav_link(path: sidebar_settings_paths, html_options: { class: "fly-out-top-item" } ) do
+ = link_to edit_project_path(@project) do
+ %strong.fly-out-top-item-name
+ = _('Settings')
+ %li.divider.fly-out-top-item
+ = nav_link(path: %w[projects#edit]) do
+ = link_to edit_project_path(@project), title: _('General'), class: 'qa-general-settings-link' do
+ %span
+ = _('General')
+ - if can_edit
+ = nav_link(controller: [:integrations, :services]) do
+ = link_to project_settings_integrations_path(@project), title: _('Integrations'), data: { qa_selector: 'integrations_settings_link' } do
+ %span
+ = _('Integrations')
+ = nav_link(controller: [:hooks, :hook_logs]) do
+ = link_to project_hooks_path(@project), title: _('Webhooks'), data: { qa_selector: 'webhooks_settings_link' } do
+ %span
+ = _('Webhooks')
+ - if can?(current_user, :read_resource_access_tokens, @project)
+ = nav_link(controller: [:access_tokens]) do
+ = link_to project_settings_access_tokens_path(@project), title: _('Access Tokens'), data: { qa_selector: 'access_tokens_settings_link' } do
+ %span
+ = _('Access Tokens')
+ = nav_link(controller: :repository) do
+ = link_to project_settings_repository_path(@project), title: _('Repository') do
+ %span
+ = _('Repository')
+ - if !@project.archived? && @project.feature_available?(:builds, current_user)
+ = nav_link(controller: :ci_cd) do
+ = link_to project_settings_ci_cd_path(@project), title: _('CI/CD') do
+ %span
+ = _('CI/CD')
+ - 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')
+ - if @project.pages_available?
+ = nav_link(controller: :pages) do
+ = link_to project_pages_path(@project), title: _('Pages') do
+ %span
+ = _('Pages')
+
+-# Shortcut to Project > Activity
+%li.hidden
+ = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do
+ %span
+ = _('Activity')
+
+-# Shortcut to Repository > Graph (formerly, Network)
+- if project_nav_tab? :network
+ %li.hidden
+ = link_to project_network_path(@project, current_ref), title: _('Network'), class: 'shortcuts-network' do
+ = _('Graph')
+
+-# Shortcut to Issues > New Issue
+- if project_nav_tab?(:issues)
+ %li.hidden
+ = link_to new_project_issue_path(@project), class: 'shortcuts-new-issue' do
+ = _('Create a new issue')
+
+-# Shortcut to Pipelines > Jobs
+- if project_nav_tab? :builds
+ %li.hidden
+ = link_to project_jobs_path(@project), title: _('Jobs'), class: 'shortcuts-builds' do
+ = _('Jobs')
+
+-# Shortcut to commits page
+- if project_nav_tab? :commits
+ %li.hidden
+ = link_to project_commits_path(@project), title: _('Commits'), class: 'shortcuts-commits' do
+ = _('Commits')
+
+-# Shortcut to issue boards
+- if project_nav_tab?(:issues)
+ %li.hidden
+ = link_to _('Issue Boards'), project_boards_path(@project), title: _('Issue Boards'), class: 'shortcuts-issue-boards'
diff --git a/app/views/layouts/nav/sidebar/_project_packages_link.html.haml b/app/views/layouts/nav/sidebar/_project_packages_link.html.haml
index e9989abe5a0..b28468a7969 100644
--- a/app/views/layouts/nav/sidebar/_project_packages_link.html.haml
+++ b/app/views/layouts/nav/sidebar/_project_packages_link.html.haml
@@ -1,14 +1,14 @@
- packages_link = project_nav_tab?(:packages) ? project_packages_path(@project) : project_container_registry_index_path(@project)
- if (project_nav_tab?(:packages) || project_nav_tab?(:container_registry))
- = nav_link controller: [:packages, :repositories] do
+ = nav_link controller: [:packages, :repositories, :infrastructure_registry] do
= link_to packages_link, data: { qa_selector: 'packages_link' } do
.nav-icon-container
= sprite_icon('package')
%span.nav-item-name
= _('Packages & Registries')
%ul.sidebar-sub-level-items
- = nav_link(controller: [:packages, :repositories], html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(controller: [:packages, :repositories, :infrastructure_registry], html_options: { class: "fly-out-top-item" } ) do
= link_to packages_link do
%strong.fly-out-top-item-name
= _('Packages & Registries')
@@ -21,3 +21,7 @@
= nav_link controller: :repositories do
= link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry', title: _('Container Registry') do
%span= _('Container Registry')
+ - if project_nav_tab? :infrastructure_registry
+ = nav_link controller: :infrastructure_registry do
+ = link_to project_infrastructure_registry_index_path(@project), title: _('Infrastructure Registry') do
+ %span= _('Infrastructure Registry')
diff --git a/app/views/layouts/project_settings.html.haml b/app/views/layouts/project_settings.html.haml
index 93214c2a674..97d9f2fbc78 100644
--- a/app/views/layouts/project_settings.html.haml
+++ b/app/views/layouts/project_settings.html.haml
@@ -1,4 +1,6 @@
- page_title _("Settings")
- nav "project"
+- enable_search_settings locals: { container_class: 'gl-my-5' }
+
= render template: "layouts/project"
diff --git a/app/views/notify/_successful_pipeline.text.erb b/app/views/notify/_successful_pipeline.text.erb
index 628976e2dda..5798a2346fa 100644
--- a/app/views/notify/_successful_pipeline.text.erb
+++ b/app/views/notify/_successful_pipeline.text.erb
@@ -3,7 +3,7 @@
Project: <%= @project.name %> ( <%= project_url(@project) %> )
Branch: <%= @pipeline.source_ref %> ( <%= commits_url(@pipeline) %> )
<% if @merge_request -%>
-Merge Request: <%= @merge_request.to_reference %> ( <%= merge_request_url(@merge_request) %> )
+Merge request: <%= @merge_request.to_reference %> ( <%= merge_request_url(@merge_request) %> )
<% end -%>
Commit: <%= @pipeline.short_sha %> ( <%= commit_url(@pipeline) %> )
diff --git a/app/views/notify/closed_merge_request_email.html.haml b/app/views/notify/closed_merge_request_email.html.haml
index 6caa0e59e8f..749584a7044 100644
--- a/app/views/notify/closed_merge_request_email.html.haml
+++ b/app/views/notify/closed_merge_request_email.html.haml
@@ -1,3 +1,3 @@
%p
- Merge Request #{merge_request_reference_link(@merge_request)}
+ Merge request #{merge_request_reference_link(@merge_request)}
was closed by #{sanitize_name(@updated_by.name)}
diff --git a/app/views/notify/closed_merge_request_email.text.haml b/app/views/notify/closed_merge_request_email.text.haml
index 28766f861d9..942e771261a 100644
--- a/app/views/notify/closed_merge_request_email.text.haml
+++ b/app/views/notify/closed_merge_request_email.text.haml
@@ -1,6 +1,6 @@
-Merge Request #{@merge_request.to_reference} was closed by #{sanitize_name(@updated_by.name)}
+Merge request #{@merge_request.to_reference} was closed by #{sanitize_name(@updated_by.name)}
-Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
+Merge request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
diff --git a/app/views/notify/merge_request_status_email.html.haml b/app/views/notify/merge_request_status_email.html.haml
index a15c5a752d4..49f2366c594 100644
--- a/app/views/notify/merge_request_status_email.html.haml
+++ b/app/views/notify/merge_request_status_email.html.haml
@@ -1,3 +1,3 @@
%p
- Merge Request #{merge_request_reference_link(@merge_request)}
+ Merge request #{merge_request_reference_link(@merge_request)}
was #{@mr_status} by #{sanitize_name(@updated_by.name)}
diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml
index ab663b65199..1a8f848218c 100644
--- a/app/views/notify/merge_request_status_email.text.haml
+++ b/app/views/notify/merge_request_status_email.text.haml
@@ -1,6 +1,6 @@
-Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{sanitize_name(@updated_by.name)}
+Merge request #{@merge_request.to_reference} was #{@mr_status} by #{sanitize_name(@updated_by.name)}
-Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
+Merge request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
diff --git a/app/views/notify/merge_request_unmergeable_email.html.haml b/app/views/notify/merge_request_unmergeable_email.html.haml
index ee459a26551..fddf9eaf95a 100644
--- a/app/views/notify/merge_request_unmergeable_email.html.haml
+++ b/app/views/notify/merge_request_unmergeable_email.html.haml
@@ -1,2 +1,2 @@
%p
- Merge Request #{merge_request_reference_link(@merge_request)} can no longer be merged due to conflict.
+ Merge request #{merge_request_reference_link(@merge_request)} can no longer be merged due to conflict.
diff --git a/app/views/notify/merge_request_unmergeable_email.text.haml b/app/views/notify/merge_request_unmergeable_email.text.haml
index a23d083747c..3db5f21e6c2 100644
--- a/app/views/notify/merge_request_unmergeable_email.text.haml
+++ b/app/views/notify/merge_request_unmergeable_email.text.haml
@@ -1,6 +1,6 @@
-Merge Request #{@merge_request.to_reference} can no longer be merged due to conflict.
+Merge request #{@merge_request.to_reference} can no longer be merged due to conflict.
-Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
+Merge request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.text.haml b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml
index de29dda6c71..568ca995e04 100644
--- a/app/views/notify/merge_when_pipeline_succeeds_email.text.haml
+++ b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml
@@ -1,6 +1,6 @@
-Merge Request #{@merge_request.to_reference} was scheduled to merge after pipeline succeeds by #{sanitize_name(@mwps_set_by.name)}
+Merge request #{@merge_request.to_reference} was scheduled to merge after pipeline succeeds by #{sanitize_name(@mwps_set_by.name)}
-Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
+Merge request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
diff --git a/app/views/notify/merged_merge_request_email.html.haml b/app/views/notify/merged_merge_request_email.html.haml
index c84c0d1d14b..f0dadd9ce91 100644
--- a/app/views/notify/merged_merge_request_email.html.haml
+++ b/app/views/notify/merged_merge_request_email.html.haml
@@ -1,2 +1,2 @@
%p
- Merge Request #{merge_request_reference_link(@merge_request)} was merged
+ Merge request #{merge_request_reference_link(@merge_request)} was merged
diff --git a/app/views/notify/merged_merge_request_email.text.haml b/app/views/notify/merged_merge_request_email.text.haml
index a8e07fa8d1c..91f920dec21 100644
--- a/app/views/notify/merged_merge_request_email.text.haml
+++ b/app/views/notify/merged_merge_request_email.text.haml
@@ -1,6 +1,6 @@
-Merge Request #{@merge_request.to_reference} was merged
+Merge request #{@merge_request.to_reference} was merged
-Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
+Merge request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
diff --git a/app/views/notify/new_mention_in_merge_request_email.html.haml b/app/views/notify/new_mention_in_merge_request_email.html.haml
index ddcf287e501..a28d944529f 100644
--- a/app/views/notify/new_mention_in_merge_request_email.html.haml
+++ b/app/views/notify/new_mention_in_merge_request_email.html.haml
@@ -1,4 +1,4 @@
%p
- You have been mentioned in Merge Request #{merge_request_reference_link(@merge_request)}
+ You have been mentioned in merge request #{merge_request_reference_link(@merge_request)}
= render template: 'notify/new_merge_request_email'
diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb
index 0121006852c..9ba86f17ef6 100644
--- a/app/views/notify/new_mention_in_merge_request_email.text.erb
+++ b/app/views/notify/new_mention_in_merge_request_email.text.erb
@@ -1,4 +1,4 @@
-You have been mentioned in Merge Request <%= @merge_request.to_reference %>
+You have been mentioned in merge request <%= @merge_request.to_reference %>
<%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %>
diff --git a/app/views/notify/new_review_email.html.haml b/app/views/notify/new_review_email.html.haml
index ad870473681..11da7723d8d 100644
--- a/app/views/notify/new_review_email.html.haml
+++ b/app/views/notify/new_review_email.html.haml
@@ -1,4 +1,4 @@
-%table{ border: "0", cellpadding:"0", cellspacing: "0", style: "width:100%;margin:0 auto;border-collapse:separate;border-spacing:0;" }
+%table{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;margin:0 auto;border-collapse:separate;border-spacing:0;" }
%tbody
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#ffffff;text-align:left;overflow:hidden;" }
diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb
index 2deca375db1..1fe7d554bc3 100644
--- a/app/views/notify/pipeline_failed_email.text.erb
+++ b/app/views/notify/pipeline_failed_email.text.erb
@@ -3,7 +3,7 @@ Pipeline #<%= @pipeline.id %> has failed!
Project: <%= @project.name %> ( <%= project_url(@project) %> )
Branch: <%= @pipeline.source_ref %> ( <%= commits_url(@pipeline) %> )
<% if @merge_request -%>
-Merge Request: <%= @merge_request.to_reference %> ( <%= merge_request_url(@merge_request) %> )
+Merge request: <%= @merge_request.to_reference %> ( <%= merge_request_url(@merge_request) %> )
<% end -%>
Commit: <%= @pipeline.short_sha %> ( <%= commit_url(@pipeline) %> )
diff --git a/app/views/notify/push_to_merge_request_email.text.haml b/app/views/notify/push_to_merge_request_email.text.haml
index 5c2005a47e5..8ab9cb0fb8d 100644
--- a/app/views/notify/push_to_merge_request_email.text.haml
+++ b/app/views/notify/push_to_merge_request_email.text.haml
@@ -1,6 +1,6 @@
#{sanitize_name(@updated_by_user.name)} pushed new commits to merge request #{@merge_request.to_reference}
-Merge Request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
+Merge request URL: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
\
- if @existing_commits.any?
- count = @existing_commits.size
diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb
index 2b51f48db3a..888b995b67c 100644
--- a/app/views/notify/reassigned_merge_request_email.text.erb
+++ b/app/views/notify/reassigned_merge_request_email.text.erb
@@ -1,4 +1,4 @@
-Reassigned Merge Request <%= @merge_request.iid %>
+Reassigned merge request <%= @merge_request.iid %>
<%= url_for([@merge_request.project, @merge_request, { only_path: false }]) %>
diff --git a/app/views/notify/resolved_all_discussions_email.html.haml b/app/views/notify/resolved_all_discussions_email.html.haml
index 0b3c56c9bd1..209415e0aee 100644
--- a/app/views/notify/resolved_all_discussions_email.html.haml
+++ b/app/views/notify/resolved_all_discussions_email.html.haml
@@ -1,3 +1,3 @@
%p
- All discussions on Merge Request #{merge_request_reference_link(@merge_request)}
+ All discussions on merge request #{merge_request_reference_link(@merge_request)}
were resolved by #{sanitize_name(@resolved_by.name)}
diff --git a/app/views/notify/resolved_all_discussions_email.text.erb b/app/views/notify/resolved_all_discussions_email.text.erb
index c4b36bfe1a8..226ea3fb445 100644
--- a/app/views/notify/resolved_all_discussions_email.text.erb
+++ b/app/views/notify/resolved_all_discussions_email.text.erb
@@ -1,3 +1,3 @@
-All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= sanitize_name(@resolved_by.name) %>
+All discussions on merge request <%= @merge_request.to_reference %> were resolved by <%= sanitize_name(@resolved_by.name) %>
<%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %>
diff --git a/app/views/notify/ssh_key_expired_email.html.haml b/app/views/notify/ssh_key_expired_email.html.haml
new file mode 100644
index 00000000000..21138bb0113
--- /dev/null
+++ b/app/views/notify/ssh_key_expired_email.html.haml
@@ -0,0 +1,13 @@
+%p
+ = _('Hi %{username}!') % { username: sanitize_name(@user.name) }
+%p
+ = _('Your SSH keys with the following fingerprints has expired:')
+%table
+ %tbody
+ - @fingerprints.each do |fingerprint|
+ %tr
+ %td= fingerprint
+
+%p
+ - ssh_key_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
+ = html_escape(_('You can create a new one or check them in your %{ssh_key_link_start}SSH keys%{ssh_key_link_end} settings.')) % { ssh_key_link_start: ssh_key_link_start, ssh_key_link_end: '</a>'.html_safe }
diff --git a/app/views/notify/ssh_key_expired_email.text.erb b/app/views/notify/ssh_key_expired_email.text.erb
new file mode 100644
index 00000000000..77b76084606
--- /dev/null
+++ b/app/views/notify/ssh_key_expired_email.text.erb
@@ -0,0 +1,9 @@
+<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %>
+
+<%= _('Your SSH keys with the following fingerprints has expired:') %>
+
+<% @fingerprints.each do |fingerprint| %>
+ - <%= fingerprint %>
+<% end %>
+
+<%= _('You can create a new one or check them in your SSH keys settings %{ssh_key_link}.') % { ssh_key_link: @target_url } %>
diff --git a/app/views/notify/ssh_key_expiring_soon.text.erb b/app/views/notify/ssh_key_expiring_soon.text.erb
new file mode 100644
index 00000000000..2a7c0cafe83
--- /dev/null
+++ b/app/views/notify/ssh_key_expiring_soon.text.erb
@@ -0,0 +1,9 @@
+<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %>
+
+<%= _('Your SSH keys with the following fingerprints are scheduled to expire soon:') %>
+
+<% @fingerprints.each do |fingerprint| %>
+ - <%= fingerprint %>
+<% end %>
+
+<%= _('You can create a new one or check them in your SSH keys settings %{ssh_key_link}.') % { ssh_key_link: @target_url } %>
diff --git a/app/views/notify/ssh_key_expiring_soon_email.html.haml b/app/views/notify/ssh_key_expiring_soon_email.html.haml
new file mode 100644
index 00000000000..f4aee9c5fde
--- /dev/null
+++ b/app/views/notify/ssh_key_expiring_soon_email.html.haml
@@ -0,0 +1,13 @@
+%p
+ = _('Hi %{username}!') % { username: sanitize_name(@user.name) }
+%p
+ = _('Your SSH keys with the following fingerprints are scheduled to expire soon:')
+%table
+ %tbody
+ - @fingerprints.each do |fingerprint|
+ %tr
+ %td= fingerprint
+
+%p
+ - ssh_key_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
+ = html_escape(_('You can create a new one or check them in your %{ssh_key_link_start}SSH keys%{ssh_key_link_end} settings.')) % { ssh_key_link_start: ssh_key_link_start, ssh_key_link_end: '</a>'.html_safe }
diff --git a/app/views/notify/unknown_sign_in_email.html.haml b/app/views/notify/unknown_sign_in_email.html.haml
index 914242da5c6..8d0993e9ff8 100644
--- a/app/views/notify/unknown_sign_in_email.html.haml
+++ b/app/views/notify/unknown_sign_in_email.html.haml
@@ -44,9 +44,11 @@
%td{ style: "#{default_style}text-align:center;" }
- password_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://docs.gitlab.com/ee/user/profile/#changing-your-password' }
= _('If you recently signed in and recognize the IP address, you may disregard this email.')
- %p
- = _('If you did not recently sign in, you should immediately %{password_link_start}change your password%{password_link_end}.').html_safe % { password_link_start: password_link_start, password_link_end: '</a>'.html_safe }
- = _('Passwords should be unique and not used for any other sites or services.')
+
+ - if password_authentication_enabled_for_web?
+ %p
+ = _('If you did not recently sign in, you should immediately %{password_link_start}change your password%{password_link_end}.').html_safe % { password_link_start: password_link_start, password_link_end: '</a>'.html_safe }
+ = _('Passwords should be unique and not used for any other sites or services.')
- unless @user.two_factor_enabled?
%p
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index 1f09f3097ad..c4de47f276c 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -28,7 +28,7 @@
= link_to _('Manage two-factor authentication'), profile_two_factor_auth_path, class: 'gl-button btn btn-confirm'
- else
.gl-mb-3
- = link_to _('Enable two-factor authentication'), profile_two_factor_auth_path, class: 'gl-button btn btn-success', data: { qa_selector: 'enable_2fa_button' }
+ = link_to _('Enable two-factor authentication'), profile_two_factor_auth_path, class: 'gl-button btn btn-confirm', data: { qa_selector: 'enable_2fa_button' }
.col-lg-12
%hr
@@ -69,7 +69,7 @@
= render 'users/deletion_guidance', user: current_user
-# Delete button here
- %button#delete-account-button.btn.btn-danger.disabled{ data: { qa_selector: 'delete_account_button' } }
+ %button#delete-account-button.gl-button.btn.btn-danger.disabled{ data: { qa_selector: 'delete_account_button' } }
= s_('Profiles|Delete account')
#delete-account-modal{ data: { action_url: user_registration_path,
diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml
index 6805824cebc..ca895972b71 100644
--- a/app/views/profiles/chat_names/_chat_name.html.haml
+++ b/app/views/profiles/chat_names/_chat_name.html.haml
@@ -6,7 +6,7 @@
- if can?(current_user, :read_project, project)
= link_to project.full_name, project_path(project)
- else
- .light N/A
+ .light= _('N/A')
%td
%strong
- if can?(current_user, :admin_project, project)
diff --git a/app/views/profiles/chat_names/new.html.haml b/app/views/profiles/chat_names/new.html.haml
index 4651854a551..f008abf376d 100644
--- a/app/views/profiles/chat_names/new.html.haml
+++ b/app/views/profiles/chat_names/new.html.haml
@@ -8,7 +8,7 @@
.actions
= form_tag profile_chat_names_path, method: :post do
= hidden_field_tag :token, @chat_name_token.token
- = submit_tag _("Authorize"), class: "gl-button btn btn-success wide float-left"
+ = submit_tag _("Authorize"), class: "gl-button btn btn-confirm wide float-left"
= form_tag deny_profile_chat_names_path, method: :delete do
= hidden_field_tag :token, @chat_name_token.token
= submit_tag _("Deny"), class: "gl-button btn btn-danger gl-ml-3"
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index 89198b0a65b..d78b542ae8a 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -15,7 +15,7 @@
= f.label :email, _('Email'), class: 'label-bold'
= f.text_field :email, class: 'form-control gl-form-input', data: { qa_selector: 'email_address_field' }
.gl-mt-3
- = f.submit _('Add email address'), class: 'gl-button btn btn-success', data: { qa_selector: 'add_email_address_button' }
+ = f.submit _('Add email address'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'add_email_address_button' }
%hr
%h4.gl-mt-0
= _('Linked emails (%{email_count})') % { email_count: @emails.load.size + 1 }
diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml
index 7a7b5802cd8..9804a3b7735 100644
--- a/app/views/profiles/gpg_keys/_form.html.haml
+++ b/app/views/profiles/gpg_keys/_form.html.haml
@@ -4,7 +4,7 @@
.form-group
= f.label :key, s_('Profiles|Key'), class: 'label-bold'
- = f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: _("Don't paste the private part of the GPG key. Paste the public part which begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'.")
+ = f.text_area :key, class: "form-control gl-form-input", rows: 8, required: true, placeholder: _("Don't paste the private part of the GPG key. Paste the public part which begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'.")
.gl-mt-3
- = f.submit s_('Profiles|Add key'), class: "gl-button btn btn-success"
+ = f.submit s_('Profiles|Add key'), class: "gl-button btn btn-confirm"
diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml
index c851601d4c3..ec48a611377 100644
--- a/app/views/profiles/gpg_keys/_key.html.haml
+++ b/app/views/profiles/gpg_keys/_key.html.haml
@@ -18,8 +18,8 @@
%code= subkey.fingerprint
.float-right
%span.key-created-at
- = s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)}
- = link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "gl-button btn btn-danger gl-ml-3" do
+ = s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago: time_ago_with_tooltip(key.created_at) }
+ = link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "gl-button btn btn-icon btn-danger gl-ml-3" do
%span.sr-only= _('Remove')
= sprite_icon('remove')
= link_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: "gl-button btn btn-danger gl-ml-3" do
diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml
index 053cb3547ba..bf9c77cb3ec 100644
--- a/app/views/profiles/gpg_keys/index.html.haml
+++ b/app/views/profiles/gpg_keys/index.html.haml
@@ -12,10 +12,10 @@
= _('Add a GPG key')
%p.profile-settings-content
- help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/repository/gpg_signed_commits/index.md') }
- = _('Before you can add a GPG key you need to %{help_link_start}Generate it.%{help_link_end}'.html_safe) % {help_link_start: help_link_start, help_link_end:'</a>'.html_safe }
+ = _('Before you can add a GPG key you need to %{help_link_start}Generate it.%{help_link_end}'.html_safe) % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
= render 'form'
%hr
%h5
- = _('Your GPG keys (%{count})') % { count:@gpg_keys.count}
+ = _('Your GPG keys (%{count})') % { count: @gpg_keys.count }
.gl-mb-3
= render 'key_table'
diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml
index 81a543de7a3..35335f3ef80 100644
--- a/app/views/profiles/keys/_form.html.haml
+++ b/app/views/profiles/keys/_form.html.haml
@@ -15,13 +15,14 @@
.col.form-group
= f.label :expires_at, s_('Profiles|Expires at'), class: 'label-bold'
= f.date_field :expires_at, class: "form-control input-lg", min: Date.tomorrow, data: { qa_selector: 'key_expiry_date_field' }
+ %p.form-text.text-muted{ data: { qa_selector: 'key_expiry_date_field_description' } }= ssh_key_expires_field_description
.js-add-ssh-key-validation-warning.hide
.bs-callout.bs-callout-warning{ role: 'alert', aria_live: 'assertive' }
%strong= _('Oops, are you sure?')
- %p= s_("Profiles|This doesn't look like a public SSH key, are you sure you want to add it? It will be publicly visible.")
+ %p= s_("Profiles|Publicly visible private SSH keys can compromise your system.")
- %button.btn.gl-button.btn-success.js-add-ssh-key-validation-confirm-submit= _("Yes, add it")
+ %button.btn.gl-button.btn-confirm.js-add-ssh-key-validation-confirm-submit= _("Yes, add it")
.gl-mt-3
- = f.submit s_('Profiles|Add key'), class: "gl-button btn btn-success js-add-ssh-key-validation-original-submit qa-add-key-button"
+ = f.submit s_('Profiles|Add key'), class: "gl-button btn btn-confirm js-add-ssh-key-validation-original-submit qa-add-key-button"
diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml
index cc2e2a30052..4eb321050ad 100644
--- a/app/views/profiles/keys/_key.html.haml
+++ b/app/views/profiles/keys/_key.html.haml
@@ -1,3 +1,5 @@
+- icon_classes = 'settings-list-icon gl-display-none gl-sm-display-block'
+
%li.key-list-item
.gl-display-flex.gl-align-items-flex-start
.key-list-item-info.gl-w-full.float-none
@@ -5,15 +7,11 @@
= key.title
.gl-display-flex.gl-align-items-center.gl-mt-2
- - if key.valid?
- - if key.expired?
- %span.gl-display-inline-block.has-tooltip{ title: s_('Profiles|Your key has expired') }
- = sprite_icon('warning-solid', css_class: 'settings-list-icon gl-display-none gl-sm-display-block')
- - else
- = sprite_icon('key', css_class: 'settings-list-icon gl-display-none gl-sm-display-block')
+ - if key.valid? && !key.expired?
+ = sprite_icon('key', css_class: icon_classes)
- else
- %span.gl-display-inline-block.has-tooltip{ title: key.errors.full_messages.join(', ') }
- = sprite_icon('warning-solid', css_class: 'settings-list-icon gl-display-none gl-sm-display-block')
+ %span.gl-display-inline-block.has-tooltip{ title: ssh_key_expiration_tooltip(key) }
+ = sprite_icon('warning-solid', css_class: icon_classes)
%span.gl-text-truncate.gl-sm-ml-3
= key.fingerprint
@@ -25,7 +23,7 @@
= s_('Profiles|Last used:')
= key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : _('Never')
%span.expires.gl-mr-3
- = s_('Profiles|Expires:')
+ = key.expired? ? s_('Profiles|Expired:') : s_('Profiles|Expires:')
= key.expires_at ? key.expires_at.to_date : _('Never')
%span.key-created-at.gl-display-flex.gl-align-items-center
- if key.can_delete?
diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml
index 80027cdfed0..69b8d2ddafe 100644
--- a/app/views/profiles/keys/index.html.haml
+++ b/app/views/profiles/keys/index.html.haml
@@ -19,6 +19,6 @@
= render 'form'
%hr
%h5
- = _('Your SSH keys (%{count})') % { count:@keys.count }
+ = _('Your SSH keys (%{count})') % { count: @keys.count }
.gl-mb-3
= render 'key_table'
diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml
index b281dbb4367..2cc919fc70e 100644
--- a/app/views/profiles/passwords/edit.html.haml
+++ b/app/views/profiles/passwords/edit.html.haml
@@ -30,6 +30,6 @@
= f.label :password_confirmation, _('Password confirmation'), class: 'label-bold'
= f.password_field :password_confirmation, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' }
.gl-mt-3.gl-mb-3
- = f.submit _('Save password'), class: "gl-button btn btn-success gl-mr-3", data: { qa_selector: 'save_password_button' }
+ = f.submit _('Save password'), class: "gl-button btn btn-confirm gl-mr-3", data: { qa_selector: 'save_password_button' }
- unless @user.password_automatically_set?
= link_to _('I forgot my password'), reset_profile_password_path, method: :put
diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml
index ffec6baa20e..efcd0bb621f 100644
--- a/app/views/profiles/passwords/new.html.haml
+++ b/app/views/profiles/passwords/new.html.haml
@@ -28,4 +28,4 @@
.col-sm-10
= f.password_field :password_confirmation, required: true, class: 'form-control gl-form-input'
.form-actions
- = f.submit _('Set new password'), class: 'gl-button btn btn-success'
+ = f.submit _('Set new password'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 7995231c739..15544fb9c45 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -33,7 +33,7 @@
%h5.gl-mt-0= s_("Profiles|Upload new avatar")
.gl-mt-2.gl-mb-3
%button.gl-button.btn.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...")
- %span.avatar-file-name.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen")
+ %span.avatar-file-name.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.")
= f.file_field_without_bootstrap :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
.form-text.text-muted= s_("Profiles|The maximum file size allowed is 200KB.")
- if @user.avatar?
@@ -84,7 +84,7 @@
.col-lg-8
-# TODO: might need an entry in user/profile.md to describe some of these settings
-# https://gitlab.com/gitlab-org/gitlab-foss/issues/60070
- %h5= ("Time zone")
+ %h5= _("Time zone")
= dropdown_tag(_("Select a timezone"), options: { toggle_class: 'gl-button btn js-timezone-dropdown input-lg', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
%input.hidden{ :type => 'hidden', :id => 'user_timezone', :name => 'user[timezone]', value: @user.timezone }
.col-lg-12
@@ -127,7 +127,7 @@
= s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information")
.row.gl-mt-3.gl-mb-3.gl-justify-content-end
.col-lg-8
- = f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-success'
+ = f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm'
= link_to _("Cancel"), user_path(current_user), class: 'gl-button btn btn-cancel'
.modal.modal-profile-crop{ data: { cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css') } }
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 3853f428447..a9134057777 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -51,7 +51,7 @@
= label_tag :pin_code, _('Pin code'), class: "label-bold"
= text_field_tag :pin_code, nil, class: "form-control", required: true, data: { qa_selector: 'pin_code_field' }
.gl-mt-3
- = submit_tag _('Register with two-factor app'), class: 'gl-button btn btn-success', data: { qa_selector: 'register_2fa_app_button' }
+ = submit_tag _('Register with two-factor app'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'register_2fa_app_button' }
%hr
diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml
index 87c0933747d..4b41231ba20 100644
--- a/app/views/projects/_commit_button.html.haml
+++ b/app/views/projects/_commit_button.html.haml
@@ -1,5 +1,5 @@
.form-actions
- = button_tag 'Commit changes', id: 'commit-changes', class: 'gl-button btn btn-success js-commit-button qa-commit-button'
+ = button_tag 'Commit changes', id: 'commit-changes', class: 'gl-button btn btn-confirm js-commit-button qa-commit-button'
= link_to 'Cancel', cancel_path,
class: 'gl-button btn btn-default btn-cancel', data: {confirm: leave_edit_message}
diff --git a/app/views/projects/_customize_workflow.html.haml b/app/views/projects/_customize_workflow.html.haml
index 8e4e5ca93e0..ded43a34b48 100644
--- a/app/views/projects/_customize_workflow.html.haml
+++ b/app/views/projects/_customize_workflow.html.haml
@@ -5,4 +5,4 @@
%p
Get started with GitLab by enabling features that work best for your project. From issues and wikis, to merge requests and pipelines, GitLab can help manage your workflow from idea to production!
- if can?(current_user, :admin_project, @project)
- = link_to "Get started", edit_project_path(@project), class: "gl-button btn btn-success"
+ = link_to "Get started", edit_project_path(@project), class: "gl-button btn btn-confirm"
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 30d885964b5..0369ee50c40 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -21,5 +21,4 @@
#js-tree-list{ data: vue_file_list_data(project, ref) }
- if can_edit_tree?
- = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post
= render 'projects/blob/new_dir'
diff --git a/app/views/projects/_fork_suggestion.html.haml b/app/views/projects/_fork_suggestion.html.haml
index 59c9c279a39..9888ce417f8 100644
--- a/app/views/projects/_fork_suggestion.html.haml
+++ b/app/views/projects/_fork_suggestion.html.haml
@@ -5,6 +5,6 @@
edit
files in this project directly. Please fork this project,
make your changes there, and submit a merge request.
- = link_to 'Fork', nil, method: :post, class: 'js-fork-suggestion-button gl-button btn btn-grouped btn-inverted btn-success'
+ = link_to 'Fork', nil, method: :post, class: 'js-fork-suggestion-button gl-button btn btn-grouped btn-confirm-secondary'
%button.js-cancel-fork-suggestion-button.gl-button.btn.btn-grouped{ type: 'button' }
Cancel
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index f5eea9aa9c6..b2380a3ba57 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -6,19 +6,20 @@
.project-home-panel.js-show-on-project-root.gl-my-5{ class: [("empty-project" if empty_repo)] }
.gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-sm-flex-direction-column.gl-mb-3
.home-panel-title-row.gl-display-flex
- .avatar-container.rect-avatar.s64.home-panel-avatar.gl-flex-shrink-0.gl-w-11.gl-h-11.gl-mr-3.float-none
+ %div{ class: '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, 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' }, itemprop: 'name' }
+ %h1.home-panel-title.gl-mt-3.gl-mb-2.gl-font-size-h1.gl-line-height-24.gl-font-weight-bold.gl-ml-3{ 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{ itemprop: 'identifier', data: { qa_selector: 'project_id_content' } }
- = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id }
+ - button_class = "btn gl-button btn-sm btn-tertiary btn-default-tertiary home-panel-metadata"
+ - button_text = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id }
+ = clipboard_button(title: s_('ProjectPage|Copy project ID'), text: @project.id, hide_button_icon: true, button_text: button_text, class: button_class, qa_selector: 'project_id_content', itemprop: 'identifier')
- if current_user
%span.access-request-links.gl-ml-3
= render 'shared/members/access_request_links', source: @project
diff --git a/app/views/projects/_invite_members.html.haml b/app/views/projects/_invite_members.html.haml
index ef030cabc93..e3a512d6451 100644
--- a/app/views/projects/_invite_members.html.haml
+++ b/app/views/projects/_invite_members.html.haml
@@ -4,5 +4,5 @@
= s_('InviteMember|Invite your team')
%p= s_('InviteMember|Add members to this project and start collaborating with your team.')
= link_to s_('InviteMember|Invite members'), project_project_members_path(@project, sort: :access_level_desc),
- class: 'gl-button btn btn-success gl-mb-8 gl-xs-w-full',
+ class: 'gl-button btn btn-confirm gl-mb-8 gl-xs-w-full',
data: { track_event: 'click_button', track_label: 'invite_members_empty_project' }
diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml
index a54eb2dddac..f595b4f709b 100644
--- a/app/views/projects/_merge_request_settings.html.haml
+++ b/app/views/projects/_merge_request_settings.html.haml
@@ -9,3 +9,6 @@
= render 'projects/merge_request_merge_checks_settings', project: @project, form: form
= render 'projects/merge_request_merge_suggestions_settings', project: @project, form: form
+
+- if @project.forked?
+ = render 'projects/merge_request_target_project_settings', project: @project, form: form
diff --git a/app/views/projects/_merge_request_target_project_settings.html.haml b/app/views/projects/_merge_request_target_project_settings.html.haml
new file mode 100644
index 00000000000..41d37884ac9
--- /dev/null
+++ b/app/views/projects/_merge_request_target_project_settings.html.haml
@@ -0,0 +1,23 @@
+- return unless @project.mr_can_target_upstream? && can?(current_user, :read_project, @project.forked_from_project)
+
+- form = local_assigns.fetch(:form)
+
+= form.fields_for :project_setting do |settings|
+ .form-group
+ %b= s_('ProjectSettings|Target project')
+ %p.text-secondary
+ = s_('ProjectSettings|The default target project for merge requests created in this fork project.')
+
+ .form-check.gl-mb-2
+ = settings.radio_button :mr_default_target_self, false, class: "form-check-input"
+ = label_tag :project_project_setting_attributes_mr_default_target_self_false, class: 'form-check-label' do
+ .gl-font-weight-bold
+ = s_('ProjectSettings|Upstream project')
+ = @project.forked_from_project.full_name
+
+ .form-check.gl-mb-2
+ = settings.radio_button :mr_default_target_self, true, class: "form-check-input"
+ = label_tag :project_project_setting_attributes_mr_default_target_self_true, class: 'form-check-label' do
+ .gl-font-weight-bold
+ = s_('ProjectSettings|This project')
+ = @project.full_name
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index 8b1bf37ff10..4695cd59f32 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -12,7 +12,7 @@
.form-group.project-path.col-sm-6
= f.label :namespace_id, class: 'label-bold' do
%span= s_("Project URL")
- .input-group.flex-nowrap
+ .input-group.gl-flex-nowrap
- if current_user.can_select_namespace?
.input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url }
.input-group-text
@@ -62,5 +62,5 @@
.option-description
= s_('ProjectsNew|Allows you to immediately clone this project’s repository. Skip this if you plan to push up an existing repository.')
-= f.submit _('Create project'), class: "btn gl-button btn-success", data: { track_label: "#{track_label}", track_event: "click_button", track_property: "create_project", track_value: "" }
+= f.submit _('Create project'), class: "btn gl-button btn-confirm", data: { track_label: "#{track_label}", track_event: "click_button", track_property: "create_project", track_value: "" }
= link_to _('Cancel'), dashboard_projects_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "cancel", track_value: "" }
diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml
index da3133dfe15..85a53edc160 100644
--- a/app/views/projects/_readme.html.haml
+++ b/app/views/projects/_readme.html.haml
@@ -24,4 +24,4 @@
distributed with computer software, forming part of its documentation.
GitLab will render it here instead of this message.
%p
- = link_to "Add Readme", @project.add_readme_path, class: 'btn btn-success'
+ = link_to "Add Readme", @project.add_readme_path, class: 'gl-button btn btn-confirm'
diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml
index 53b9e7f3d65..7b345941cf7 100644
--- a/app/views/projects/_service_desk_settings.html.haml
+++ b/app/views/projects/_service_desk_settings.html.haml
@@ -2,7 +2,7 @@
%section.settings.js-service-desk-setting-wrapper.no-animate#js-service-desk{ class: ('expanded' if expanded), data: { qa_selector: 'service_desk_settings_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Service Desk')
- %button.btn.gl-button.js-settings-toggle
+ %button.btn.gl-button.btn-default.js-settings-toggle
= expanded ? _('Collapse') : _('Expand')
- link_start = "<a href='#{help_page_path('user/project/service_desk')}' target='_blank' rel='noopener noreferrer'>".html_safe
%p= _('Enable and disable Service Desk. Some additional configuration might be required. %{link_start}Learn more%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml
index 2669c4c0042..45d0aee4332 100644
--- a/app/views/projects/_wiki.html.haml
+++ b/app/views/projects/_wiki.html.haml
@@ -14,4 +14,4 @@
- if can_create_wiki
%p
= _("Add a homepage to your wiki that contains information about your project and GitLab will display it here instead of this message.")
- = link_to _("Create your first page"), wiki_path(@project.wiki) + '?view=create', class: "btn gl-button btn-primary"
+ = link_to _("Create your first page"), wiki_path(@project.wiki) + '?view=create', class: "btn gl-button btn-confirm"
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index a0f644717ad..84f2d352bc9 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -11,6 +11,11 @@
#blob-content-holder.blob-content-holder
- if @code_navigation_path
#js-code-navigation{ data: { code_navigation_path: @code_navigation_path, blob_path: blob.path, definition_path_prefix: project_blob_path(@project, @ref) } }
- %article.file-holder
- = render 'projects/blob/header', blob: blob
- = render 'projects/blob/content', blob: blob
+ - if Feature.enabled?(:refactor_blob_viewer, @project, default_enabled: :yaml)
+ #js-view-blob-app{ data: { blob_path: blob.path } }
+ .gl-spinner-container
+ = loading_icon(size: 'md')
+ - else
+ %article.file-holder
+ = render 'projects/blob/header', blob: blob
+ = render 'projects/blob/content', blob: blob
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index a7f13989ca7..d7668dd1c91 100644
--- a/app/views/projects/blob/_header.html.haml
+++ b/app/views/projects/blob/_header.html.haml
@@ -9,6 +9,8 @@
- else
= edit_blob_button(@project, @ref, @path, blob: blob)
= ide_edit_button(@project, @ref, @path, blob: blob)
+ - if can_view_pipeline_editor?(@project) && @path == @project.ci_config_path_or_default
+ = link_to "Pipeline Editor", project_ci_pipeline_editor_path(@project), class: "btn gl-button btn-confirm-secondary gl-ml-3"
.btn-group{ role: "group", class: ("gl-ml-3" if current_user) }>
= render_if_exists 'projects/blob/header_file_locks_link'
- if current_user
diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml
index ca60827863a..57477e59167 100644
--- a/app/views/projects/blob/_new_dir.html.haml
+++ b/app/views/projects/blob/_new_dir.html.haml
@@ -15,7 +15,7 @@
= render 'shared/new_commit_form', placeholder: _("Add new directory")
.form-actions
- = submit_tag _("Create directory"), class: 'btn gl-button btn-success'
- = link_to "Cancel", '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal"
+ = submit_tag _("Create directory"), class: 'btn gl-button btn-confirm'
+ = link_to "Cancel", '#', class: "btn gl-button btn-default btn-cancel", "data-dismiss" => "modal"
= render 'shared/projects/edit_information'
diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml
index 717c03ad27d..24a4db010c8 100644
--- a/app/views/projects/blob/_template_selectors.html.haml
+++ b/app/views/projects/blob/_template_selectors.html.haml
@@ -10,7 +10,7 @@
.metrics-dashboard-selector.js-metrics-dashboard-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-metrics-dashboard-selector qa-metrics-dashboard-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: metrics_dashboard_ymls(@project) } } )
#gitlab-ci-yml-selector.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden
- = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector qa-gitlab-ci-yml-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project) } } )
+ = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector qa-gitlab-ci-yml-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project), selected: params[:template] } } )
- if experiment_enabled?(:ci_syntax_templates_b, subject: current_user) && @project.namespace.recent?
.gitlab-ci-syntax-yml-selector.js-gitlab-ci-syntax-yml-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag(_("Learn CI/CD syntax"), options: { toggle_class: 'js-gitlab-ci-syntax-yml-selector qa-gitlab-ci-syntax-yml-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_syntax_ymls(@project) } } )
diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml
index b68c75701b9..c42b54ec61d 100644
--- a/app/views/projects/blob/_upload.html.haml
+++ b/app/views/projects/blob/_upload.html.haml
@@ -20,9 +20,9 @@
= render 'shared/new_commit_form', placeholder: placeholder, ref: local_assigns[:ref]
.form-actions
- = button_tag class: 'btn gl-button btn-success btn-upload-file', id: 'submit-all', type: 'button' do
+ = button_tag class: 'btn gl-button btn-confirm btn-upload-file', id: 'submit-all', type: 'button' do
.spinner.spinner-sm.gl-mr-2.js-loading-icon.hidden
= button_title
- = link_to _("Cancel"), '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal"
+ = link_to _("Cancel"), '#', class: "btn gl-button btn-default btn-cancel", "data-dismiss" => "modal"
= render 'shared/projects/edit_information'
diff --git a/app/views/projects/blob/viewers/_empty.html.haml b/app/views/projects/blob/viewers/_empty.html.haml
index a293a8de231..c15dabc9111 100644
--- a/app/views/projects/blob/viewers/_empty.html.haml
+++ b/app/views/projects/blob/viewers/_empty.html.haml
@@ -1,3 +1,3 @@
.file-content.code
.nothing-here-block
- Empty file
+ = _("Empty file")
diff --git a/app/views/projects/blob/viewers/_loading_auxiliary.html.haml b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml
index 5a6c1a493a5..5a2212e0b4e 100644
--- a/app/views/projects/blob/viewers/_loading_auxiliary.html.haml
+++ b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml
@@ -1,2 +1,2 @@
= loading_icon(css_class: "gl-vertical-align-text-bottom")
-Analyzing file…
+= _("Analyzing file…")
diff --git a/app/views/projects/blob/viewers/_stl.html.haml b/app/views/projects/blob/viewers/_stl.html.haml
index 44c986595df..f98deebacf9 100644
--- a/app/views/projects/blob/viewers/_stl.html.haml
+++ b/app/views/projects/blob/viewers/_stl.html.haml
@@ -3,7 +3,7 @@
= loading_icon(size: "md", css_class: "gl-mt-4 gl-mb-3")
.text-center.gl-mt-3.gl-mb-3.stl-controls
.btn-group
- %button.btn.btn-default.btn-sm.js-material-changer{ data: { type: 'wireframe' } }
+ %button.gl-button.btn.btn-default.btn-sm.js-material-changer{ data: { type: 'wireframe' } }
Wireframe
- %button.btn.btn-default.btn-sm.active.js-material-changer{ data: { type: 'default' } }
+ %button.gl-button.btn.btn-default.btn-sm.selected.js-material-changer{ data: { type: 'default' } }
Solid
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 6f86ccd7824..a5414ce7ef2 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -52,11 +52,11 @@
- if branch.name == @project.repository.root_ref
- delete_default_branch_tooltip = s_('Branches|The default branch cannot be deleted')
%span.has-tooltip{ title: delete_default_branch_tooltip }
- %button{ class: "gl-button btn btn-danger remove-row disabled", disabled: true, 'aria-label' => delete_default_branch_tooltip }
+ %button{ class: "gl-button btn btn-danger disabled", disabled: true, 'aria-label' => delete_default_branch_tooltip }
= sprite_icon("remove")
- elsif protected_branch?(@project, branch)
- if can?(current_user, :push_to_delete_protected_branch, @project)
- %button{ class: "gl-button btn btn-danger remove-row has-tooltip",
+ %button{ class: "gl-button btn btn-danger has-tooltip",
title: s_('Branches|Delete protected branch'),
data: { toggle: "modal",
target: "#modal-delete-branch",
@@ -67,11 +67,11 @@
- else
- delete_protected_branch_tooltip = s_('Branches|Only a project maintainer or owner can delete a protected branch')
%span.has-tooltip{ title: delete_protected_branch_tooltip }
- %button{ class: "gl-button btn btn-danger remove-row disabled", disabled: true, 'aria-label' => delete_protected_branch_tooltip }
+ %button{ class: "gl-button btn btn-danger disabled", disabled: true, 'aria-label' => delete_protected_branch_tooltip }
= sprite_icon("remove")
- else
= link_to project_branch_path(@project, branch.name),
- class: "gl-button btn btn-danger remove-row qa-remove-btn js-ajax-loading-spinner has-tooltip",
+ class: "gl-button btn btn-danger js-remove-row qa-remove-btn js-ajax-loading-spinner has-tooltip",
title: s_('Branches|Delete branch'),
method: :delete,
data: { confirm: s_("Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?") % { branch_name: branch.name } },
diff --git a/app/views/projects/branches/_delete_protected_modal.html.haml b/app/views/projects/branches/_delete_protected_modal.html.haml
index 24beeeb0ae1..5c5653401fb 100644
--- a/app/views/projects/branches/_delete_protected_modal.html.haml
+++ b/app/views/projects/branches/_delete_protected_modal.html.haml
@@ -34,7 +34,7 @@
= text_field_tag 'delete_branch_input', '', class: 'form-control js-delete-branch-input'
.modal-footer
- %button.btn{ data: { dismiss: 'modal' } } Cancel
+ %button.gl-button.btn.btn-default{ data: { dismiss: 'modal' } } Cancel
= link_to s_('Branches|Delete protected branch'), '',
class: "gl-button btn btn-danger js-delete-branch",
title: s_('Branches|Delete branch'),
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index beccf458138..129b207a26f 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -16,21 +16,7 @@
= link_to s_('Branches|All'), project_branches_filtered_path(@project, state: 'all'), title: s_('Branches|Show all branches')
.nav-controls
- = form_tag(project_branches_filtered_path(@project, state: 'all'), method: :get) do
- = search_field_tag :search, params[:search], { placeholder: s_('Branches|Filter by branch name'), id: 'branch-search', class: 'form-control search-text-input input-short', spellcheck: false }
-
- - unless @mode == 'overview'
- .dropdown.inline>
- %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
- %span.light
- = branches_sort_options_hash[@sort]
- = 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')
- - branches_sort_options_hash.each do |value, title|
- %li
- = link_to title, project_branches_filtered_path(@project, state: 'all', search: params[:search], sort: value), class: ("is-active" if @sort == value)
+ #js-branches-sort-dropdown{ data: { project_branches_filtered_path: project_branches_path(@project, state: 'all'), sort_options: branches_sort_options_hash.to_json, mode: @mode } }
- if can? current_user, :push_code, @project
= link_to project_merged_branches_path(@project),
@@ -40,12 +26,12 @@
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: 'gl-button btn btn-success' do
+ = link_to new_project_branch_path(@project), class: 'gl-button btn btn-confirm' do
= s_('Branches|New branch')
= render_if_exists 'projects/commits/mirror_status'
-.js-branch-list{ data: { diverging_counts_endpoint: diverging_commit_counts_namespace_project_branches_path(@project.namespace, @project, format: :json) } }
+.js-branch-list{ data: { diverging_counts_endpoint: diverging_commit_counts_namespace_project_branches_path(@project.namespace, @project, format: :json), default_branch: @project.default_branch } }
- if can?(current_user, :admin_project, @project)
- project_settings_link = link_to s_('Branches|project settings'), project_protected_branches_path(@project)
.row-content-block
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index 17314cd7c5a..6cb2c435a30 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -29,6 +29,6 @@
= 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: 'gl-button btn btn-success'
- = link_to 'Cancel', project_branches_path(@project), class: 'gl-button btn btn-cancel'
+ = button_tag 'Create branch', class: 'gl-button btn btn-confirm'
+ = link_to 'Cancel', project_branches_path(@project), class: 'gl-button btn btn-default btn-cancel'
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/buttons/_remove_tag.html.haml b/app/views/projects/buttons/_remove_tag.html.haml
index 68a9d715674..cdf6336a259 100644
--- a/app/views/projects/buttons/_remove_tag.html.haml
+++ b/app/views/projects/buttons/_remove_tag.html.haml
@@ -2,5 +2,5 @@
- tag = local_assigns.fetch(:tag, nil)
- return unless project && tag
-%button{ type: "button", class: "js-remove-tag js-confirm-modal-button gl-button btn btn-danger btn-icon remove-row has-tooltip gl-ml-3 #{protected_tag?(project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), data: { container: 'body', path: project_tag_path(@project, tag.name), modal_attributes: delete_tag_modal_attributes(tag.name) } }
+%button{ type: "button", class: "js-remove-tag js-confirm-modal-button gl-button btn btn-danger btn-icon has-tooltip gl-ml-3 #{protected_tag?(project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), data: { container: 'body', path: project_tag_path(@project, tag.name), modal_attributes: delete_tag_modal_attributes(tag.name) } }
= sprite_icon("remove")
diff --git a/app/views/projects/ci/pipeline_editor/show.html.haml b/app/views/projects/ci/pipeline_editor/show.html.haml
index 3e10cf49b66..eb588e150f7 100644
--- a/app/views/projects/ci/pipeline_editor/show.html.haml
+++ b/app/views/projects/ci/pipeline_editor/show.html.haml
@@ -1,14 +1,3 @@
- page_title s_('Pipelines|Pipeline Editor')
-#js-pipeline-editor{ data: { "ci-config-path": @project.ci_config_path_or_default,
- "commit-sha" => @project.commit ? @project.commit.sha : '',
- "default-branch" => @project.default_branch,
- "empty-state-illustration-path" => image_path('illustrations/empty-state/empty-dag-md.svg'),
- "initial-branch-name": params[:branch_name],
- "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'),
- "new-merge-request-path" => namespace_project_new_merge_request_path,
- "project-path" => @project.path,
- "project-full-path" => @project.full_path,
- "project-namespace" => @project.namespace.full_path,
- "yml-help-page-path" => help_page_path('ci/yaml/README'),
-} }
+#js-pipeline-editor{ data: js_pipeline_editor_data(@project) }
diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml
index b0112be0e3d..5e14b6dacfd 100644
--- a/app/views/projects/cleanup/_show.html.haml
+++ b/app/views/projects/cleanup/_show.html.haml
@@ -21,7 +21,7 @@
.gl-mb-3
%h5.gl-mt-0
= _("Upload object map")
- %button.btn.btn-default.js-choose-file{ type: "button" }
+ %button.gl-button.btn.btn-default.js-choose-file{ type: "button" }
= _("Choose a file")
%span.gl-ml-3.js-filename
= _("No file selected")
@@ -29,4 +29,4 @@
.form-text.text-muted
= _("The maximum file size is %{size}.") % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) }
- = f.submit _('Start cleanup'), class: 'gl-button btn btn-success'
+ = f.submit _('Start cleanup'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 974393b893b..1b28136e82c 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -1,5 +1,3 @@
-- can_collaborate = can_collaborate_with_project?(@project)
-
.page-content-header
.header-main-content
= render partial: 'signature', object: @commit.signature
@@ -20,36 +18,9 @@
= commit_committer_link(@commit, avatar: true, size: 24)
#{time_ago_with_tooltip(@commit.committed_date)}
- .header-action-buttons
- - if defined?(@notes_count) && @notes_count > 0
- %span.btn.gl-button.btn-default.disabled.gl-button.btn-icon.d-none.d-sm-inline.gl-mr-3.has-tooltip{ title: n_("%d comment on this commit", "%d comments on this commit", @notes_count) % @notes_count }
- = sprite_icon('comment')
- = @notes_count
- = link_to project_tree_path(@project, @commit), class: "btn gl-button btn-default gl-mr-3 d-none d-md-inline" do
- #{ _('Browse files') }
- .dropdown.inline
- %a.btn.gl-button.btn-default.dropdown-toggle.qa-options-button.d-md-inline{ data: { toggle: "dropdown" } }
- %span= _('Options')
- = sprite_icon('chevron-down', css_class: 'gl-text-gray-500')
- %ul.dropdown-menu.dropdown-menu-right
- %li.d-block.d-sm-none
- = link_to project_tree_path(@project, @commit) do
- #{ _('Browse Files') }
- - if can_collaborate && !@commit.has_been_reverted?(current_user)
- %li.clearfix
- = revert_commit_link
- - if can_collaborate
- %li.clearfix
- = cherry_pick_commit_link
- - if can?(current_user, :push_code, @project)
- %li.clearfix
- = link_to s_('CreateTag|Tag'), new_project_tag_path(@project, ref: @commit)
- %li.divider
- %li.dropdown-header
- #{ _('Download') }
- - unless @commit.parents.length > 1
- %li= link_to s_('DownloadCommit|Email Patches'), project_commit_path(@project, @commit, format: :patch), class: "qa-email-patches", rel: 'nofollow', download: ''
- %li= link_to s_('DownloadCommit|Plain Diff'), project_commit_path(@project, @commit, format: :diff), class: "qa-plain-diff", rel: 'nofollow', download: ''
+ #js-commit-comments-button{ data: { comments_count: @notes_count.to_i } }
+ = link_to _('Browse files'), project_tree_path(@project, @commit), class: "btn gl-button btn-default gl-mr-3 gl-xs-w-full gl-xs-mb-3"
+ #js-commit-options-dropdown{ data: commit_options_dropdown_data(@project, @commit) }
.commit-box{ data: { project_path: project_path(@project) } }
%h3.commit-title
@@ -85,11 +56,8 @@
- if @last_pipeline.stages_count.nonzero?
#{ n_(s_('Pipeline|with stage'), s_('Pipeline|with stages'), @last_pipeline.stages_count) }
.mr-widget-pipeline-graph
- - if ::Gitlab::Ci::Features.ci_commit_pipeline_mini_graph_vue_enabled?(@project)
- .stage-cell
- .js-commit-pipeline-mini-graph{ data: { stages: @last_pipeline_stages.to_json.html_safe } }
- - else
- = render 'shared/mini_pipeline_graph', pipeline: @last_pipeline, klass: 'js-commit-pipeline-graph'
+ .stage-cell
+ .js-commit-pipeline-mini-graph{ data: { stages: @last_pipeline_stages.to_json.html_safe } }
- if @last_pipeline.duration
in
= time_interval_in_words @last_pipeline.duration
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index a8a928515fe..9e2dca3ad71 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -20,7 +20,7 @@
%li.commit-header.js-commit-header
%span.font-weight-bold= n_("%d previously merged commit", "%d previously merged commits", context_commits.count) % context_commits.count
- if project.context_commits_enabled? && can_update_merge_request
- %button.btn.btn-default.ml-3.add-review-item-modal-trigger{ type: "button", data: { context_commits_empty: 'false' } }
+ %button.gl-button.btn.btn-default.ml-3.add-review-item-modal-trigger{ type: "button", data: { context_commits_empty: 'false' } }
= _('Add/remove')
%li.commits-row
@@ -33,7 +33,7 @@
= n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden)
- if project.context_commits_enabled? && can_update_merge_request && context_commits&.empty?
- %button.btn.btn-default.mt-3.add-review-item-modal-trigger{ type: "button", data: { context_commits_empty: 'true' } }
+ %button.gl-button.btn.btn-default.mt-3.add-review-item-modal-trigger{ type: "button", data: { context_commits_empty: 'true' } }
= _('Add previously merged commits')
- if commits.size == 0 && context_commits.nil?
diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml
index 728f035555e..9e9fc08dac0 100644
--- a/app/views/projects/default_branch/_show.html.haml
+++ b/app/views/projects/default_branch/_show.html.haml
@@ -28,4 +28,4 @@
= _("When merge requests and commits in the default branch close, any issues they reference also close.")
= 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: "gl-button btn btn-success"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml
index 780ec128d63..f0214ade313 100644
--- a/app/views/projects/deploy_keys/edit.html.haml
+++ b/app/views/projects/deploy_keys/edit.html.haml
@@ -6,5 +6,5 @@
= form_for [@project, @deploy_key], include_id: false, html: { class: 'js-requires-input' } do |f|
= render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
.form-actions
- = f.submit 'Save changes', class: 'btn-success btn'
- = link_to 'Cancel', project_settings_repository_path(@project), class: 'gl-button btn btn-cancel'
+ = f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
+ = link_to _('Cancel'), project_settings_repository_path(@project), class: 'gl-button btn btn-default btn-cancel'
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index cec8948aaa4..1c7a9ffe0bb 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -8,7 +8,7 @@
.content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed
.files-changed-inner
- .inline-parallel-buttons.d-none.d-md-block
+ .inline-parallel-buttons.gl-display-none.gl-md-display-flex
- 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: 'gl-button btn btn-default'
- if show_whitespace_toggle
@@ -20,7 +20,7 @@
= diff_compare_whitespace_link(diffs.project, params[:from], params[:to], class: 'd-none d-sm-inline-block')
- elsif current_controller?(:wikis)
= toggle_whitespace_link(url_for(params_with_whitespace), class: 'd-none d-sm-inline-block')
- .btn-group
+ .btn-group.gl-ml-3
= inline_diff_btn
= parallel_diff_btn
= render 'projects/diffs/stats', diff_files: diff_files
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index 9197b177b7b..35e2fe1b398 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -14,10 +14,10 @@
= submodule_diff_compare_link(diff_file)
- unless diff_file.submodule?
- .file-actions.d-none.d-sm-block
+ .file-actions.gl-display-none.gl-sm-display-flex
- if diff_file.blob&.readable_text?
- %span.has-tooltip{ title: _("Toggle comments for this file") }
- = link_to '#', class: 'js-toggle-diff-comments btn gl-button btn-default selected', disabled: @diff_notes_disabled do
+ %span.has-tooltip.gl-mr-3{ title: _("Toggle comments for this file") }
+ = link_to '#', class: 'js-toggle-diff-comments btn gl-button btn-default btn-icon selected', disabled: @diff_notes_disabled do
= sprite_icon('comment')
\
- if editable_diff?(diff_file)
diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml
index 4a00e0af9d9..d1792826522 100644
--- a/app/views/projects/diffs/_file_header.html.haml
+++ b/app/views/projects/diffs/_file_header.html.haml
@@ -23,7 +23,7 @@
%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.gl-word-break-all{ 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', qa_selector: 'file_name_content' } }
= diff_file.file_path
- if diff_file.deleted_file?
@@ -37,3 +37,4 @@
- if diff_file.stored_externally? && diff_file.external_storage == :lfs
%span.badge.label-lfs.gl-mr-2 LFS
+
diff --git a/app/views/projects/diffs/viewers/_collapsed.html.haml b/app/views/projects/diffs/viewers/_collapsed.html.haml
index 02f499144c0..578b0af3241 100644
--- a/app/views/projects/diffs/viewers/_collapsed.html.haml
+++ b/app/views/projects/diffs/viewers/_collapsed.html.haml
@@ -1,3 +1,3 @@
.nothing-here-block.diff-collapsed{ data: { diff_for_path: collapsed_diff_url(viewer.diff_file) } }
= _("This diff is collapsed.")
- %button.click-to-expand.btn.btn-link= _("Click to expand it.")
+ %button.click-to-expand.gl-button.btn.btn-link= _("Click to expand it.")
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 9388c5fad6d..ecaf3467cd2 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -3,8 +3,6 @@
- @content_class = "limit-container-width" unless fluid_layout
- expanded = expanded_by_default?
-- enable_search_settings
-
%section.settings.general-settings.no-animate.expanded#js-general-settings
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar')
@@ -25,7 +23,7 @@
.js-project-permissions-form
- if show_visibility_confirm_modal?(@project)
= render "visibility_modal"
- = f.submit _('Save changes'), class: "btn gl-button btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level }
+ = f.submit _('Save changes'), class: "btn gl-button btn-confirm #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level }
%section.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)], data: { qa_selector: 'merge_request_settings_content' } }
.settings-header
@@ -39,7 +37,7 @@
= form_for @project, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' }
= render 'projects/merge_request_settings', form: f
- = f.submit _('Save changes'), class: "btn gl-button btn-success rspec-save-merge-request-changes", data: { qa_selector: 'save_merge_request_changes_button' }
+ = f.submit _('Save changes'), class: "btn gl-button btn-confirm rspec-save-merge-request-changes", data: { qa_selector: 'save_merge_request_changes_button' }
= render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 0c682226df3..171222368d6 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -5,6 +5,7 @@
= render partial: 'flash_messages', locals: { project: @project }
= render "home_panel"
+= render "archived_notice", project: @project
= render "invite_members" if experiment_enabled?(:invite_members_empty_project_version_a) && can_import_members?
diff --git a/app/views/projects/environments/_form.html.haml b/app/views/projects/environments/_form.html.haml
index 10890bf1921..a295c8f6fb0 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: 'gl-button btn btn-success'
+ = f.submit _('Save'), class: 'gl-button btn btn-confirm'
= link_to _('Cancel'), project_environments_path(@project), class: 'gl-button btn btn-cancel'
diff --git a/app/views/projects/environments/empty_metrics.html.haml b/app/views/projects/environments/empty_metrics.html.haml
index 3ee51a318c6..df05909e8ef 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: 'gl-button btn btn-success'
+ = link_to s_("Environments|Learn about environments"), help_page_path('ci/environments/index.md'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml
index 5da9c25b780..06a2ed46805 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -6,4 +6,5 @@
"can-create-environment" => can?(current_user, :create_environment, @project).to_s,
"new-environment-path" => new_project_environment_path(@project),
"help-page-path" => help_page_path("ci/environments/index.md"),
- "project-path" => @project.full_path } }
+ "project-path" => @project.full_path,
+ "default-branch-name" => @project.default_branch_or_master } }
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index 0cb44bd03fb..b3e4b7a4998 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -67,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: "gl-button btn btn-success"
+ = link_to _("Read more"), help_page_path("ci/environments/index.md"), class: "gl-button btn btn-confirm"
- else
.table-holder.gl-overflow-visible
.ci-table.environments{ role: 'grid' }
diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml
index 60a4a5c9d70..84259890a44 100644
--- a/app/views/projects/forks/_fork_button.html.haml
+++ b/app/views/projects/forks/_fork_button.html.haml
@@ -17,4 +17,4 @@
= link_to _("Select"), project_forks_path(@project, namespace_key: namespace.id),
data: { qa_selector: 'fork_namespace_button', qa_name: namespace.human_name },
method: "POST",
- class: ["btn gl-button btn-success", ("disabled" unless can_create_project)]
+ class: ["btn gl-button btn-confirm", ("disabled" unless can_create_project)]
diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml
index 89c2c826067..ba4e40a8675 100644
--- a/app/views/projects/forks/index.html.haml
+++ b/app/views/projects/forks/index.html.haml
@@ -30,11 +30,11 @@
- if current_user && can?(current_user, :fork_project, @project)
- if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2
- = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn gl-button btn-success' do
+ = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn gl-button btn-confirm' do
= sprite_icon('fork', size: 12)
%span= _('Fork')
- else
- = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn gl-button btn-success' do
+ = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn gl-button btn-confirm' do
= sprite_icon('fork', size: 12)
%span= _('Fork')
diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml
index c62d4a35973..1973b23a062 100644
--- a/app/views/projects/graphs/show.html.haml
+++ b/app/views/projects/graphs/show.html.haml
@@ -5,4 +5,4 @@
= render 'shared/ref_switcher', destination: 'graphs'
= link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn gl-button btn-default'
-.js-contributors-graph{ class: container_class, 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json),'data-project-branch': current_ref }
+.js-contributors-graph{ class: container_class, data: { project_graph_path: project_graph_path(@project, current_ref, format: :json), project_branch: current_ref, default_branch: @project.default_branch } }
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index fb19b251d41..226cd7d89b6 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -10,7 +10,7 @@
= form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
- = f.submit 'Save changes', class: 'btn gl-button btn-success gl-mr-3'
+ = f.submit 'Save changes', class: 'btn gl-button btn-confirm gl-mr-3'
= render 'shared/web_hooks/test_button', hook: @hook
= link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn gl-button btn-danger float-right', data: { confirm: _('Are you sure?') }
diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml
index 03ea623f4c6..5ca65d55eea 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: 'gl-button btn btn-success'
+ = f.submit 'Add webhook', class: 'gl-button btn btn-confirm'
= 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 3064b8bf873..e2d8791b5d2 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: "gl-button btn btn-success"
+ = f.submit 'Start import', class: "gl-button btn btn-confirm"
diff --git a/app/views/projects/issuable/_show.html.haml b/app/views/projects/issuable/_show.html.haml
index 8015b205568..f311ed2d8ae 100644
--- a/app/views/projects/issuable/_show.html.haml
+++ b/app/views/projects/issuable/_show.html.haml
@@ -1,3 +1,4 @@
+- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
- page_description issuable.description_html
- page_card_attributes issuable.card_attributes
- if issuable.relocation_target
@@ -6,4 +7,4 @@
= 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
+= render 'shared/issue_type/details_content', issuable: issuable, api_awards_path: api_awards_path
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index d299d2846c6..45b2f86c03d 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -11,17 +11,18 @@
- refs_path = refs_namespace_project_path(@project.namespace, @project, search: '')
.create-mr-dropdown-wrap.d-inline-block.full-width-mobile.js-create-mr{ data: { project_path: @project.full_path, project_id: @project.id, can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path, is_confidential: can_create_confidential_merge_request?.to_s } }
- .btn-group.btn-group-sm.unavailable
- %button.btn.btn-grouped{ type: 'button', disabled: 'disabled' }
- .spinner.align-text-bottom.mr-1.hide
+ .btn-group.unavailable
+ %button.gl-button.btn{ type: 'button', disabled: 'disabled' }
+ .spinner.align-text-bottom.gl-button-icon.hide
%span.text
Checking branch availability…
- .btn-group.btn-group-sm.available.hidden
- %button.btn.js-create-merge-request.btn-success.btn-inverted{ type: 'button', data: { action: data_action } }
+ .btn-group.available.hidden
+ %button.gl-button.btn.js-create-merge-request.btn-confirm{ type: 'button', data: { action: data_action } }
+ .spinner.js-spinner.gl-mr-2.gl-display-none
= value
- %button.btn.gl-button.create-merge-request-dropdown-toggle.dropdown-toggle.btn-success.btn-inverted.js-dropdown-toggle.gl-flex-grow-0.gl-h-7{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } }
+ %button.gl-button.btn.btn-confirm.btn-icon.dropdown-toggle.create-merge-request-dropdown-toggle.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } }
= sprite_icon('chevron-down')
.droplab-dropdown
@@ -57,7 +58,7 @@
%span.js-ref-message.form-text.text-muted
.form-group
- %button.btn.gl-button.btn-success.js-create-target{ type: 'button', data: { action: 'create-mr' } }
+ %button.btn.gl-button.btn-confirm.js-create-target{ type: 'button', data: { action: 'create-mr' } }
= create_mr_text
- if can_create_confidential_merge_request?
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 1d300c42768..9b043ea3c47 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -13,29 +13,24 @@
issues_path: project_issues_path(@project),
project_path: @project.full_path } }
-- if project_issues(@project).exists?
+- if Feature.enabled?(:vue_issues_list, @project)
+ .js-issues-list{ data: issues_list_data(@project, current_user, finder) }
+ - if @can_bulk_update
+ = render 'shared/issuable/bulk_update_sidebar', type: :issues
+- elsif project_issues(@project).exists?
.top-area
= render 'shared/issuable/nav', type: :issues
= render "projects/issues/nav_btns"
+ = render 'shared/issuable/search_bar', type: :issues
- - if Feature.enabled?(:vue_issues_list, @project)
- - data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id)))
- .js-issues-list{ data: { endpoint: data_endpoint,
- full_path: @project.full_path,
- has_blocked_issues_feature: Gitlab.ee? && @project.feature_available?(:blocked_issues).to_s,
- has_issuable_health_status_feature: Gitlab.ee? && @project.feature_available?(:issuable_health_status).to_s,
- has_issue_weights_feature: Gitlab.ee? && @project.feature_available?(:issue_weights).to_s } }
- - else
- = render 'shared/issuable/search_bar', type: :issues
+ - if @can_bulk_update
+ = render 'shared/issuable/bulk_update_sidebar', type: :issues
- - if @can_bulk_update
- = render 'shared/issuable/bulk_update_sidebar', type: :issues
-
- .issues-holder
- = render 'issues'
- - if new_issue_email
- .issuable-footer.text-center
- .js-issueable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
+ .issues-holder
+ = render 'issues'
+ - if new_issue_email
+ .issuable-footer.text-center
+ .js-issueable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
- else
- new_project_issue_button_path = @project.archived? ? false : new_project_issue_path(@project)
= render 'shared/empty_states/issues', new_project_issue_button_path: new_project_issue_button_path, show_import_button: true
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index c3949a83e3f..a465f59c559 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -3,4 +3,5 @@
- breadcrumb_title @issue.to_reference
- page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues")
-= render 'projects/issuable/show', issuable: @issue
+= render 'projects/issuable/show', issuable: @issue, api_awards_path: award_emoji_issue_api_path(@issue)
+= render 'shared/issuable/invite_members_trigger', project: @project
diff --git a/app/views/projects/jobs/_table.html.haml b/app/views/projects/jobs/_table.html.haml
index 402f7ddb38d..819837a9eff 100644
--- a/app/views/projects/jobs/_table.html.haml
+++ b/app/views/projects/jobs/_table.html.haml
@@ -12,7 +12,7 @@
= s_('Jobs|Use jobs to automate your tasks')
%p
= s_('Jobs|Jobs are the building blocks of a GitLab CI/CD pipeline. Each job has a specific task, like testing code. To set up jobs in a CI/CD pipeline, add a CI/CD configuration file to your project.')
- = link_to s_('Jobs|Create CI/CD configuration file'), @project.present(current_user: current_user).add_ci_yml_path, class: 'btn gl-button btn-info js-empty-state-button'
+ = link_to s_('Jobs|Create CI/CD configuration file'), project_ci_pipeline_editor_path(project), class: 'btn gl-button btn-info js-empty-state-button'
- else
.nothing-here-block= s_('Jobs|No jobs to show')
- else
@@ -28,7 +28,7 @@
%th Runner
%th Stage
%th Name
- %th Timing
+ %th Duration
%th Coverage
%th
diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml
index a0ec6002db7..f2aab3d9394 100644
--- a/app/views/projects/jobs/index.html.haml
+++ b/app/views/projects/jobs/index.html.haml
@@ -1,15 +1,12 @@
- page_title _("Jobs")
- add_page_specific_style 'page_bundles/ci_status'
-.top-area
- - build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) }
- = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
+- if Feature.enabled?(:jobs_table_vue, @project, default_enabled: :yaml)
+ #js-jobs-table{ data: { full_path: @project.full_path, job_counts: job_counts.to_json, job_statuses: job_statuses.to_json } }
+- else
+ .top-area
+ - build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) }
+ = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
- .nav-controls
- - if can?(current_user, :update_build, @project)
- = link_to project_ci_lint_path(@project), class: 'btn gl-button btn-default' do
- %span
- = _('CI Lint')
-
-.content-list.builds-content-list
- = render "table", builds: @builds, project: @project
+ .content-list.builds-content-list
+ = render "table", builds: @builds, project: @project
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index 357d4d193df..e034e9c71ab 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -8,7 +8,7 @@
#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
+ .labels-container.gl-mt-3.gl-bg-gray-10.gl-border-solid.gl-border-1.gl-border-gray-100
- if can_admin_label && search.blank?
%p.text-muted
= _('Labels can be applied to issues and merge requests.')
diff --git a/app/views/projects/logs/empty_logs.html.haml b/app/views/projects/logs/empty_logs.html.haml
index 5e3db401d79..48403f5e55e 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: 'gl-button btn btn-success'
+ = link_to s_('Environments|Learn about environments'), help_page_path('ci/environments/index.md'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/projects/merge_requests/_awards_block.html.haml b/app/views/projects/merge_requests/_awards_block.html.haml
index e7577e13b68..09466ed2244 100644
--- a/app/views/projects/merge_requests/_awards_block.html.haml
+++ b/app/views/projects/merge_requests/_awards_block.html.haml
@@ -1,5 +1,5 @@
.content-block.content-block-small.emoji-list-container.js-noteable-awards
- = render 'award_emoji/awards_block', awardable: @merge_request, inline: true do
+ = render 'award_emoji/awards_block', awardable: @merge_request, inline: true, api_awards_path: award_emoji_merge_request_api_path(@merge_request) do
.ml-auto.mt-auto.mb-auto
#js-vue-sort-issue-discussions
= render "projects/merge_requests/discussion_filter"
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index 354c6665a50..26d8e571973 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -21,7 +21,7 @@
#js-issuable-header-warnings
= issuable_meta(@merge_request, @project)
- %a.btn.btn-default.float-right.d-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
+ %a.gl-button.btn.btn-default.btn-icon.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
@@ -50,4 +50,4 @@
- if can_update_merge_request && !are_close_and_open_buttons_hidden
= render 'projects/merge_requests/close_reopen_draft_report_toggle'
- elsif !@merge_request.merged?
- = link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'gl-display-none gl-md-display-block gl-button btn btn-warning-secondary float-right gl-ml-3', title: _('Report abuse')
+ = link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'gl-display-none gl-md-display-block gl-button btn btn-default float-right gl-ml-3', title: _('Report abuse')
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
index e02f126d165..ee296258d04 100644
--- a/app/views/projects/merge_requests/conflicts/show.html.haml
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -1,4 +1,4 @@
-- page_title _("Merge Conflicts"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge Requests")
+- page_title _("Merge Conflicts"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge requests")
- add_page_specific_style 'page_bundles/merge_conflicts'
= render "projects/merge_requests/mr_title"
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 2cb75d43d4b..7082bf4b8b0 100644
--- a/app/views/projects/merge_requests/creations/_new_compare.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml
@@ -1,5 +1,5 @@
%h3.page-title
- New Merge Request
+ New merge request
= 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?
@@ -64,4 +64,4 @@
- if @merge_request.errors.any?
= form_errors(@merge_request)
- = f.submit 'Compare branches and continue', class: "gl-button btn btn-success mr-compare-btn"
+ = f.submit 'Compare branches and continue', class: "gl-button btn btn-confirm mr-compare-btn"
diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml
index 79781e4a311..a8facf1c6fd 100644
--- a/app/views/projects/merge_requests/creations/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -1,5 +1,5 @@
%h3.page-title
- New Merge Request
+ New merge request
= form_for [@project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f|
= render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits, presenter: @mr_presenter
= f.hidden_field :source_project_id
@@ -33,7 +33,7 @@
Pipelines
%span.badge.badge-pill= @pipelines.size
%li.diffs-tab
- = link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tabvue'} do
+ = link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tabvue', qa_selector: 'diffs_tab'} do
Changes
%span.badge.badge-pill= @merge_request.diff_size
diff --git a/app/views/projects/merge_requests/creations/new.html.haml b/app/views/projects/merge_requests/creations/new.html.haml
index 0741b24a5a1..6a8894384df 100644
--- a/app/views/projects/merge_requests/creations/new.html.haml
+++ b/app/views/projects/merge_requests/creations/new.html.haml
@@ -1,6 +1,6 @@
-- add_to_breadcrumbs _("Merge Requests"), project_merge_requests_path(@project)
+- add_to_breadcrumbs _("Merge requests"), project_merge_requests_path(@project)
- breadcrumb_title _("New")
-- page_title _("New Merge Request")
+- page_title _("New merge request")
- add_page_specific_style 'page_bundles/pipelines'
- add_page_specific_style 'page_bundles/ci_status'
diff --git a/app/views/projects/merge_requests/edit.html.haml b/app/views/projects/merge_requests/edit.html.haml
index a4bb790ce0b..019015a4d86 100644
--- a/app/views/projects/merge_requests/edit.html.haml
+++ b/app/views/projects/merge_requests/edit.html.haml
@@ -1,5 +1,5 @@
-- page_title _("Edit"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge Requests")
+- page_title _("Edit"), "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge requests")
%h3.page-title
- Edit Merge Request #{@merge_request.to_reference}
+ Edit merge request #{@merge_request.to_reference}
= render 'form'
diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml
index 62a251c7015..22d78418c5b 100644
--- a/app/views/projects/merge_requests/index.html.haml
+++ b/app/views/projects/merge_requests/index.html.haml
@@ -3,7 +3,7 @@
- new_merge_request_path = project_new_merge_request_path(merge_project) if merge_project
- issuable_type = 'merge_request'
-- page_title _("Merge Requests")
+- page_title _("Merge requests")
- new_merge_request_email = @project.new_issuable_address(current_user, 'merge_request')
= render 'projects/last_push'
diff --git a/app/views/projects/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml
index df942c11883..f0bf5af7732 100644
--- a/app/views/projects/merge_requests/invalid.html.haml
+++ b/app/views/projects/merge_requests/invalid.html.haml
@@ -1,4 +1,4 @@
-- page_title "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge Requests")
+- page_title "#{@merge_request.title} (#{@merge_request.to_reference}", _("Merge requests")
- badge_css_classes = "badge gl-text-white"
- badge_info_css_classes = "#{badge_css_classes} badge-info"
- badge_inverse_css_classes = "#{badge_css_classes} badge-inverse"
@@ -25,4 +25,4 @@
of internal error
%strong
- Please close Merge Request or change branches with existing one
+ Please close merge request or change branches with existing one
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index d664ee709dd..416cb932ec9 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -1,8 +1,8 @@
- @gfm_form = true
- @content_class = "merge-request-container#{' limit-container-width' unless fluid_layout}"
-- add_to_breadcrumbs _("Merge Requests"), project_merge_requests_path(@project)
+- add_to_breadcrumbs _("Merge requests"), project_merge_requests_path(@project)
- breadcrumb_title @merge_request.to_reference
-- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", _("Merge Requests")
+- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", _("Merge requests")
- page_description @merge_request.description_html
- page_card_attributes @merge_request.card_attributes
- suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes')
@@ -13,6 +13,8 @@
- add_page_specific_style 'page_bundles/reports'
- add_page_specific_style 'page_bundles/ci_status'
+- add_page_startup_api_call @endpoint_metadata_url
+
.merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } }
= render "projects/merge_requests/mr_title"
@@ -24,21 +26,21 @@
= render "projects/merge_requests/tabs/tab", class: "notes-tab", qa_selector: "notes_tab" do
= tab_link_for @merge_request, :show, force_link: @commit.present? do
= _("Overview")
- %span.badge.badge-pill= @merge_request.related_notes.user.count
+ %span.badge.badge-pill.gl-badge.badge-muted.sm= @merge_request.related_notes.user.count
- if @merge_request.source_project
- = render "projects/merge_requests/tabs/tab", name: "commits", class: "commits-tab" do
+ = render "projects/merge_requests/tabs/tab", name: "commits", class: "commits-tab", qa_selector: "commits_tab" do
= tab_link_for @merge_request, :commits do
= _("Commits")
- %span.badge.badge-pill= @commits_count
+ %span.badge.badge-pill.gl-badge.badge-muted.sm= @commits_count
- if number_of_pipelines.nonzero?
= render "projects/merge_requests/tabs/tab", name: "pipelines", class: "pipelines-tab" do
= tab_link_for @merge_request, :pipelines do
= _("Pipelines")
- %span.badge.badge-pill.js-pipelines-mr-count= number_of_pipelines
+ %span.badge.badge-pill.gl-badge.badge-muted.sm.js-pipelines-mr-count= number_of_pipelines
= render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab", id: "diffs-tab", qa_selector: "diffs_tab" do
= tab_link_for @merge_request, :diffs do
= _("Changes")
- %span.badge.badge-pill= @merge_request.diff_size
+ %span.badge.badge-pill.gl-badge.badge-muted.sm= @merge_request.diff_size
.d-flex.flex-wrap.align-items-center.justify-content-lg-end
#js-vue-discussion-counter
@@ -60,9 +62,10 @@
- add_page_startup_api_call notes_url
- else
- add_page_startup_api_call discussions_path(@merge_request)
- - add_page_startup_api_call widget_project_json_merge_request_path(@project, @merge_request, format: :json)
+ - add_page_startup_api_call widget_project_json_merge_request_path(@project, @merge_request, async_mergeability_check: true, format: :json)
- add_page_startup_api_call cached_widget_project_json_merge_request_path(@project, @merge_request, format: :json)
#js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request, Feature.enabled?(:paginated_notes, @project)).to_json,
+ endpoint_metadata: @endpoint_metadata_url,
noteable_data: serialize_issuable(@merge_request, serializer: 'noteable'),
noteable_type: 'MergeRequest',
target_type: 'merge_request',
@@ -75,26 +78,10 @@
= render "projects/merge_requests/tabs/pane", name: "pipelines", id: "pipelines", class: "pipelines" do
- if number_of_pipelines.nonzero?
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
- - if mr_action === "diffs"
- - add_page_startup_api_call @endpoint_metadata_url
- params = request.query_parameters
- if Feature.enabled?(:default_merge_ref_for_diffs, @project, default_enabled: :yaml)
- params = params.merge(diff_head: true)
- = render "projects/merge_requests/tabs/pane", name: "diffs", id: "js-diffs-app", class: "diffs", data: { "is-locked": @merge_request.discussion_locked?,
- endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', params),
- endpoint_metadata: @endpoint_metadata_url,
- endpoint_batch: diffs_batch_project_json_merge_request_path(@project, @merge_request, 'json', params),
- endpoint_coverage: @coverage_path,
- help_page_path: suggest_changes_help_path,
- current_user_data: @current_user_data,
- project_path: project_path(@merge_request.project),
- changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg'),
- is_fluid_layout: fluid_layout.to_s,
- dismiss_endpoint: user_callouts_path,
- show_suggest_popover: show_suggest_popover?.to_s,
- show_whitespace_default: @show_whitespace_default.to_s,
- file_by_file_default: @file_by_file_default.to_s,
- default_suggestion_commit_message: default_suggestion_commit_message }
+ = render "projects/merge_requests/tabs/pane", name: "diffs", id: "js-diffs-app", class: "diffs", data: diffs_tab_pane_data(@project, @merge_request, params)
.mr-loading-status
.loading.hide
@@ -108,3 +95,6 @@
= render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit
#js-review-bar
+
+= render 'shared/issuable/invite_members_trigger', project: @project
+
diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml
index 774fbc79430..3cff85a4979 100644
--- a/app/views/projects/network/show.html.haml
+++ b/app/views/projects/network/show.html.haml
@@ -6,7 +6,7 @@
.controls.gl-bg-gray-50.gl-p-2.gl-font-base.gl-text-gray-400.gl-border-b-1.gl-border-b-solid.gl-border-b-gray-300
= form_tag project_network_path(@project, @id), method: :get, class: 'form-inline network-form' do |f|
= text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: _("Git revision"), class: 'search-input form-control gl-form-input input-mx-250 search-sha gl-mr-2'
- = button_tag class: 'btn gl-button btn-success btn-icon' do
+ = button_tag class: 'btn gl-button btn-confirm btn-icon' do
= sprite_icon('search')
.inline.gl-ml-5
.form-check.light
diff --git a/app/views/projects/packages/infrastructure_registry/index.html.haml b/app/views/projects/packages/infrastructure_registry/index.html.haml
new file mode 100644
index 00000000000..5a118997ff9
--- /dev/null
+++ b/app/views/projects/packages/infrastructure_registry/index.html.haml
@@ -0,0 +1,10 @@
+- page_title _("Infrastructure Registry")
+- @content_class = "limit-container-width" unless fluid_layout
+
+.row
+ .col-12
+ #js-vue-packages-list{ data: { resource_id: @project.id,
+ page_type: 'project',
+ empty_list_help_url: help_page_path('user/infrastructure/index'),
+ empty_list_illustration: image_path('illustrations/empty-state/empty-terraform-register-lg.svg'),
+ package_help_url: help_page_path('user/infrastructure/index') } }
diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml
index f39941f6f0d..483f192109b 100644
--- a/app/views/projects/pages/_pages_settings.html.haml
+++ b/app/views/projects/pages/_pages_settings.html.haml
@@ -9,6 +9,10 @@
= f.label :pages_https_only, class: pages_https_only_label_class do
%strong
= s_('GitLabPages|Force HTTPS (requires valid certificates)')
+ - docs_link_start = "<a href='#{help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index', anchor: 'force-https-for-gitlab-pages-websites')}' target='_blank' rel='noopener noreferrer'>".html_safe
+ - link_end = '</a>'.html_safe
+ %p
+ = s_("GitLabPages|When enabled, all attempts to visit your website through HTTP are automatically redirected to HTTPS using a response with status code 301. Requires a valid certificate for all domains. %{docs_link_start}Learn more.%{link_end}").html_safe % { docs_link_start: docs_link_start, link_end: link_end }
- .gl-mt-3
- = f.submit s_('GitLabPages|Save'), class: 'btn btn-confirm gl-button'
+ .gl-mt-3
+ = f.submit s_('GitLabPages|Save'), class: 'btn btn-confirm gl-button'
diff --git a/app/views/projects/pages/_ssl_limitations_warning.html.haml b/app/views/projects/pages/_ssl_limitations_warning.html.haml
index 1f2907d183e..de74b703e95 100644
--- a/app/views/projects/pages/_ssl_limitations_warning.html.haml
+++ b/app/views/projects/pages/_ssl_limitations_warning.html.haml
@@ -2,6 +2,6 @@
= 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 }
-
- %strong= external_link(s_("GitLabPages|Learn more."), "https://docs.gitlab.com/ee/user/project/pages/introduction.html#limitations")
+ - docs_link_start = "<a href='#{help_page_path('user/project/pages/introduction', anchor: 'limitations')}' target='_blank' rel='noopener noreferrer'>".html_safe
+ - link_end = '</a>'.html_safe
+ = 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. %{docs_link_start}Learn more.%{link_end}").html_safe % { pages_host: pages_host, docs_link_start: docs_link_start, link_end: link_end }
diff --git a/app/views/projects/pages/_use.html.haml b/app/views/projects/pages/_use.html.haml
index ec3fc27dc20..20e6338fa76 100644
--- a/app/views/projects/pages/_use.html.haml
+++ b/app/views/projects/pages/_use.html.haml
@@ -4,8 +4,7 @@
= s_('GitLabPages|Configure pages')
.card-body
%p.gl-mb-0
- - docs_link_start = "<a href='#{help_page_path('user/project/pages/index.md')}' target='_blank' rel='noopener noreferrer'>".html_safe
+ - docs_link_start = "<a href='#{help_page_path('user/project/pages/index')}' target='_blank' rel='noopener noreferrer'>".html_safe
- samples_link_start = "<a href='https://gitlab.com/pages' target='_blank' rel='noopener noreferrer'>".html_safe
- - templates_link_start = "<a href='https://gitlab.com/gitlab-org/project-templates' target='_blank' rel='noopener noreferrer'>".html_safe
- link_end = '</a>'.html_safe
- = s_('GitLabPages|See the %{docs_link_start}GitLab Pages documentation%{link_end} to learn how to upload your static site and have GitLab serve it. You can also follow a %{samples_link_start}sample project%{link_end} or use a %{templates_link_start}GitLab CI template%{link_end}.').html_safe % { docs_link_start: docs_link_start, samples_link_start: samples_link_start, templates_link_start: templates_link_start, link_end: link_end }
+ = s_('GitLabPages|Your Pages site is not configured yet. See the %{docs_link_start}GitLab Pages documentation%{link_end} to learn how to upload your static site and have GitLab serve it. You can also take some inspiration from the %{samples_link_start}sample Pages projects%{link_end}.').html_safe % { docs_link_start: docs_link_start, samples_link_start: samples_link_start, link_end: link_end }
diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml
index d0d5e675fcb..64760d8972f 100644
--- a/app/views/projects/pages/show.html.haml
+++ b/app/views/projects/pages/show.html.haml
@@ -9,7 +9,9 @@
= s_('GitLabPages|New Domain')
%p.light
- = s_('GitLabPages|With GitLab Pages you can host your static websites on GitLab. Combined with the power of GitLab CI and the help of GitLab Runner you can deploy static pages for your individual projects, your user or your group.')
+ - docs_link_start = "<a href='#{help_page_path('user/project/pages/index')}' target='_blank' rel='noopener noreferrer'>".html_safe
+ - link_end = '</a>'.html_safe
+ = s_('GitLabPages|With GitLab Pages you can host your static website directly from your GitLab repository. %{docs_link_start}Learn more.%{link_end}').html_safe % { docs_link_start: docs_link_start, link_end: link_end }
= render 'pages_settings'
diff --git a/app/views/projects/pages_domains/_dns.html.haml b/app/views/projects/pages_domains/_dns.html.haml
index dc8127ab068..267317196f8 100644
--- a/app/views/projects/pages_domains/_dns.html.haml
+++ b/app/views/projects/pages_domains/_dns.html.haml
@@ -23,7 +23,7 @@
- text, status = domain_presenter.unverified? ? [_('Unverified'), 'badge-danger'] : [_('Verified'), 'badge-success']
.badge{ class: status }
= text
- = link_to sprite_icon("redo"), verify_project_pages_domain_path(@project, domain_presenter), method: :post, class: "btn has-tooltip", title: _("Retry verification")
+ = link_to sprite_icon("redo"), verify_project_pages_domain_path(@project, domain_presenter), method: :post, class: "gl-button btn btn-default has-tooltip", title: _("Retry verification")
.input-group
= text_field_tag :domain_verification, verification_record, class: "monospace js-select-on-focus form-control", readonly: true
.input-group-append
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 9072312c100..d6c213571f2 100644
--- a/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml
+++ b/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml
@@ -9,7 +9,7 @@
= 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
+ = link_to s_('GitLabPagesDomains|Retry'), retry_auto_ssl_project_pages_domain_path(@project, domain_presenter), class: "gl-button btn btn-default btn-sm btn-grouped", method: :post
- elsif !domain_presenter.certificate_gitlab_provided?
.form-group.border-section.js-shown-if-auto-ssl{ class: ("d-none" unless auto_ssl_available_and_enabled) }
.row
diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml
index 8a369202555..628c4780cf2 100644
--- a/app/views/projects/pipeline_schedules/_form.html.haml
+++ b/app/views/projects/pipeline_schedules/_form.html.haml
@@ -3,7 +3,7 @@
.form-group.row
.col-md-9
= f.label :description, _('Description'), class: 'label-bold'
- = f.text_field :description, class: 'form-control', required: true, autofocus: true, placeholder: s_('PipelineSchedules|Provide a short description for this pipeline')
+ = f.text_field :description, class: 'form-control gl-form-input', required: true, autofocus: true, placeholder: s_('PipelineSchedules|Provide a short description for this pipeline')
.form-group.row
.col-md-9
= f.label :cron, _('Interval Pattern'), class: 'label-bold'
@@ -11,12 +11,12 @@
.form-group.row
.col-md-9
= f.label :cron_timezone, _('Cron Timezone'), class: 'label-bold'
- = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'btn js-timezone-dropdown w-100', dropdown_class: 'w-100', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
+ = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'gl-button btn btn-default js-timezone-dropdown w-100', dropdown_class: 'w-100', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
= f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true
.form-group.row
.col-md-9
= f.label :ref, _('Target Branch'), class: 'label-bold'
- = dropdown_tag(_("Select target branch"), options: { toggle_class: 'btn js-target-branch-dropdown w-100', dropdown_class: 'git-revision-dropdown w-100', title: _("Select target branch"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } )
+ = dropdown_tag(_("Select target branch"), options: { toggle_class: 'gl-button btn btn-default js-target-branch-dropdown w-100', dropdown_class: 'git-revision-dropdown w-100', title: _("Select target branch"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } )
= f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true
.form-group.row.js-ci-variable-list-section
.col-md-9
@@ -27,7 +27,7 @@
= render 'ci/variables/variable_row', form_field: 'schedule', variable: variable, only_key_value: true
= render 'ci/variables/variable_row', form_field: 'schedule', only_key_value: true
- if @schedule.variables.size > 0
- %button.btn.btn-info.btn-inverted.gl-mt-3.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@schedule.variables.size == 0}" } }
+ %button.gl-button.btn.btn-confirm-secondary.gl-mt-3.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@schedule.variables.size == 0}" } }
- if @schedule.variables.size == 0
= n_('Hide value', 'Hide values', @schedule.variables.size)
- else
@@ -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 gl-button btn-success'
+ = f.submit _('Save pipeline schedule'), class: 'btn gl-button btn-confirm'
= link_to _('Cancel'), pipeline_schedules_path(@project), class: 'btn gl-button btn-default btn-cancel'
diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml
index 558c12c04e4..a56e8f7f5c7 100644
--- a/app/views/projects/pipeline_schedules/index.html.haml
+++ b/app/views/projects/pipeline_schedules/index.html.haml
@@ -9,7 +9,7 @@
- if can?(current_user, :create_pipeline_schedule, @project)
.nav-controls
- = link_to new_project_pipeline_schedule_path(@project), class: 'btn gl-button btn-success' do
+ = link_to new_project_pipeline_schedule_path(@project), class: 'btn gl-button btn-confirm' do
%span= _('New schedule')
- if @schedules.present?
diff --git a/app/views/projects/pipelines/_stage.html.haml b/app/views/projects/pipelines/_stage.html.haml
deleted file mode 100644
index 0651ad6fdb8..00000000000
--- a/app/views/projects/pipelines/_stage.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-- grouped_statuses = @stage.statuses.latest_ordered.group_by(&:status)
-- Ci::HasStatus::ORDERED_STATUSES.each do |ordered_status|
- - grouped_statuses.fetch(ordered_status, []).each do |status|
- %li
- = render 'ci/status/dropdown_graph_badge', subject: status
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 58d125acc2d..f0b2349c493 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -28,7 +28,7 @@
#js-pipeline-graph-vue
#js-tab-builds.tab-pane
- - if pipeline.legacy_stages.present?
+ - if stages.present?
.table-holder.pipeline-holder
%table.table.ci-table.pipeline
%thead
@@ -39,7 +39,7 @@
%th
%th= _('Coverage')
%th
- = render partial: "projects/stage/stage", collection: pipeline.legacy_stages, as: :stage
+ = render partial: "projects/stage/stage", collection: stages, as: :stage
- if @pipeline.failed_builds.present?
#js-tab-failures.build-failures.tab-pane.build-page
diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml
index 139f6e3c94d..992407adf71 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -1,4 +1,4 @@
- page_title _('CI/CD Analytics')
#js-project-pipelines-charts-app{ data: { project_path: @project.full_path,
- should_render_deployment_frequency_charts: should_render_deployment_frequency_charts.to_s } }
+ should_render_dora_charts: should_render_dora_charts.to_s } }
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index 7d7b8a155ac..4b0487f4685 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -14,5 +14,7 @@
"can-create-pipeline" => can?(current_user, :create_pipeline, @project).to_s,
"new-pipeline-path" => can?(current_user, :create_pipeline, @project) && new_project_pipeline_path(@project),
"ci-lint-path" => can?(current_user, :create_pipeline, @project) && project_ci_lint_path(@project),
- "reset-cache-path" => can?(current_user, :admin_pipeline, @project) && reset_cache_project_settings_ci_cd_path(@project) ,
- "has-gitlab-ci" => has_gitlab_ci?(@project).to_s } }
+ "reset-cache-path" => can?(current_user, :admin_pipeline, @project) && reset_cache_project_settings_ci_cd_path(@project),
+ "has-gitlab-ci" => has_gitlab_ci?(@project).to_s,
+ "add-ci-yml-path" => can?(current_user, :create_pipeline, @project) && @project.present(current_user: current_user).add_ci_yml_path,
+ "suggested-ci-templates" => experiment_suggested_ci_templates.to_json } }
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index 7a3817fe87b..14de982e239 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -1,9 +1,9 @@
- breadcrumb_title _('Pipelines')
-- page_title s_('Pipeline|Run Pipeline')
+- page_title s_('Pipeline|Run pipeline')
- settings_link = link_to _('CI/CD settings'), project_settings_ci_cd_path(@project)
%h3.page-title
- = s_('Pipeline|Run Pipeline')
+ = s_('Pipeline|Run pipeline')
%hr
- if Feature.enabled?(:new_pipeline_form, @project, default_enabled: :yaml)
@@ -49,7 +49,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'
- = link_to _('Cancel'), project_pipelines_path(@project), class: 'btn btn-default float-right'
+ = f.submit s_('Pipeline|Run pipeline'), class: 'btn gl-button btn-confirm gl-mr-3 js-variables-save-button'
+ = link_to _('Cancel'), project_pipelines_path(@project), class: 'btn gl-button btn-default'
%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 68c80833299..98b1c5adcb5 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -24,6 +24,7 @@
- lint_link_start = '<a href="%{url}">'.html_safe % { url: lint_link_url }
= s_('You can also test your %{gitlab_ci_yml} in %{lint_link_start}CI Lint%{lint_link_end}').html_safe % { gitlab_ci_yml: '.gitlab-ci.yml', lint_link_start: lint_link_start, lint_link_end: '</a>'.html_safe }
- = render "projects/pipelines/with_tabs", pipeline: @pipeline, pipeline_has_errors: pipeline_has_errors
+ #js-pipeline-notification{ data: { dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs') } }
+ = render "projects/pipelines/with_tabs", pipeline: @pipeline, stages: @stages, pipeline_has_errors: pipeline_has_errors
.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline) } }
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index c88dae079ae..22bf61b6873 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -54,25 +54,25 @@
= link_to '#tab-members', class: ['nav-link', ('active' unless groups_tab_active?)], data: { toggle: 'tab' } do
%span
= _('Members')
- %span.badge.badge-pill= @project_members.total_count
+ %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @project_members.total_count
- if show_groups?(@group_links)
%li.nav-item
= link_to '#tab-groups', class: ['nav-link', ('active' if groups_tab_active?)] , data: { toggle: 'tab', qa_selector: 'groups_list_tab' } do
%span
= _('Groups')
- %span.badge.badge-pill= @group_links.count
+ %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @group_links.count
- if show_invited_members?(@project, @invited_members)
%li.nav-item
= link_to '#tab-invited-members', class: 'nav-link', data: { toggle: 'tab' } do
%span
= _('Invited')
- %span.badge.badge-pill= @invited_members.count
+ %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @invited_members.count
- if show_access_requests?(@project, @requesters)
%li.nav-item
= link_to '#tab-access-requests', class: 'nav-link', data: { toggle: 'tab' } do
%span
= _('Access requests')
- %span.badge.badge-pill= @requesters.count
+ %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @requesters.count
.tab-content
#tab-members.tab-pane{ class: ('active' unless groups_tab_active?) }
.js-project-members-list{ data: project_members_list_data_attributes(@project, @project_members) }
diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml
index 522e9888bc6..2691513c994 100644
--- a/app/views/projects/protected_branches/shared/_branches_list.html.haml
+++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml
@@ -23,7 +23,7 @@
%th
= s_("ProtectedBranch|Allowed to push")
- - if ::Feature.enabled?(:allow_force_push_to_protected_branches, @project)
+ - if ::Feature.enabled?(:allow_force_push_to_protected_branches, @project, default_enabled: :yaml)
%th
= s_("ProtectedBranch|Allow force push")
%span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Allow force push for all users with push access.'), 'aria-hidden': 'true' }
diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
index ae03b198bc9..9fdcea96c00 100644
--- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
@@ -21,7 +21,7 @@
= f.label :push_access_levels_attributes, s_("ProtectedBranch|Allowed to push:"), class: 'col-md-2 text-left text-md-right'
.col-md-10
= yield :push_access_levels
- - if ::Feature.enabled?(:allow_force_push_to_protected_branches, @project)
+ - if ::Feature.enabled?(:allow_force_push_to_protected_branches, @project, default_enabled: :yaml)
.form-group.row
= f.label :allow_force_push, s_("ProtectedBranch|Allow force push:"), class: 'col-md-2 gl-text-left text-md-right'
.col-md-10
@@ -30,4 +30,4 @@
= s_("ProtectedBranch|Allow force push for all users with push access.")
= render_if_exists 'projects/protected_branches/ee/code_owner_approval_form', f: f
.card-footer
- = f.submit s_('ProtectedBranch|Protect'), class: 'btn-success gl-button btn', disabled: true, data: { qa_selector: 'protect_button' }
+ = f.submit s_('ProtectedBranch|Protect'), class: 'gl-button btn btn-confirm', disabled: true, data: { qa_selector: 'protect_button' }
diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
index 332cdd98e4a..ba0935fff7d 100644
--- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
@@ -20,4 +20,4 @@
= yield :create_access_levels
.card-footer
- = f.submit _('Protect'), class: 'btn-success btn', disabled: true, data: { qa_selector: 'protect_tag_button' }
+ = f.submit _('Protect'), class: 'gl-button btn btn-confirm', disabled: true, data: { qa_selector: 'protect_tag_button' }
diff --git a/app/views/projects/protected_tags/shared/_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_protected_tag.html.haml
index 71c29f9b7b6..972c96dc882 100644
--- a/app/views/projects/protected_tags/shared/_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_protected_tag.html.haml
@@ -19,4 +19,4 @@
- if can? current_user, :admin_project, @project
%td
- = link_to 'Unprotect', [@project, protected_tag, { update_section: 'js-protected-tags-settings' }], data: { confirm: 'Tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
+ = link_to 'Unprotect', [@project, protected_tag, { update_section: 'js-protected-tags-settings' }], data: { confirm: 'Tag will be writable for developers. Are you sure?' }, method: :delete, class: 'gl-button btn btn-danger-secondary'
diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml
index 6e46423cde0..b37b530c33f 100644
--- a/app/views/projects/runners/_group_runners.html.haml
+++ b/app/views/projects/runners/_group_runners.html.haml
@@ -16,7 +16,7 @@
= link_to toggle_group_runners_project_runners_path(@project), class: 'btn gl-button btn-warning-secondary', method: :post do
= _('Disable group runners')
- else
- = link_to toggle_group_runners_project_runners_path(@project), class: 'btn gl-button btn-success btn-inverted', method: :post do
+ = link_to toggle_group_runners_project_runners_path(@project), class: 'btn gl-button btn-confirm-secondary', method: :post do
= _('Enable group runners')
&nbsp;
= _('for this project')
diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml
index 7f5acbbe890..bf2e746b4a4 100644
--- a/app/views/projects/runners/_runner.html.haml
+++ b/app/views/projects/runners/_runner.html.haml
@@ -1,44 +1,40 @@
-%li.runner{ id: dom_id(runner) }
- %h4
- = runner_status_icon(runner)
-
- - if @project_runners.include?(runner)
- = link_to _("%{token}...") % { token: runner.short_sha }, project_runner_path(@project, runner), class: 'commit-sha has-tooltip', title: _("Partial token for reference only")
-
+%li{ id: dom_id(runner) }
+ .gl-display-flex.gl-justify-content-space-between
+ %div
+ = runner_status_icon(runner, size: 16)
+ - if @project_runners.include?(runner)
+ = link_to "##{runner.id} (#{runner.short_sha})", project_runner_path(@project, runner)
+ - else
+ %span
+ = "##{runner.id} (#{runner.short_sha})"
- if runner.locked?
%span.has-tooltip{ title: _('Locked to current projects') }
= sprite_icon('lock')
-
- %small.edit-runner
- = link_to edit_project_runner_path(@project, runner), class: 'btn gl-button btn-edit' do
- = sprite_icon('pencil', css_class: 'gl-my-2')
- - else
- %span.commit-sha
- = runner.short_sha
-
- .float-right
- - if @project_runners.include?(runner)
- - if runner.active?
- = link_to _('Pause'), pause_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-sm btn-danger', data: { confirm: _("Are you sure?") }
- - else
- = link_to _('Resume'), resume_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-success btn-sm'
- - if runner.belongs_to_one_project?
- = link_to _('Remove runner'), project_runner_path(@project, runner), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn gl-button btn-danger btn-sm'
- - else
- - runner_project = @project.runner_projects.find_by(runner_id: runner) # rubocop: disable CodeReuse/ActiveRecord
- = link_to _('Disable for this project'), project_runner_project_path(@project, runner_project), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn gl-button btn-danger btn-sm'
- - elsif runner.project_type?
- = form_for [@project, @project.runner_projects.new] do |f|
- = f.hidden_field :runner_id, value: runner.id
- = f.submit _('Enable for this project'), class: 'btn gl-button btn-sm'
- .float-right
- %small.light
- \##{runner.id}
+ .gl-ml-2
+ .btn-group.btn-group-sm
+ - if @project_runners.include?(runner)
+ = link_to edit_project_runner_path(@project, runner), class: 'btn gl-button btn-icon', title: _('Edit'), aria: { label: _('Edit') }, data: { testid: 'edit-runner-link', toggle: 'tooltip', placement: 'top', container: 'body' } do
+ = sprite_icon('pencil')
+ - if runner.active?
+ = link_to pause_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-icon', title: _('Pause'), aria: { label: _('Pause') }, data: { toggle: 'tooltip', placement: 'top', container: 'body', confirm: _("Are you sure?") } do
+ = sprite_icon('pause')
+ - else
+ = link_to resume_project_runner_path(@project, runner), method: :post, class: 'btn gl-button btn-icon', title: _('Resume'), aria: { label: _('Resume') }, data: { toggle: 'tooltip', placement: 'top', container: 'body' } do
+ = sprite_icon('play')
+ - if runner.belongs_to_one_project?
+ = link_to _('Remove runner'), project_runner_path(@project, runner), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn gl-button btn-danger'
+ - else
+ - runner_project = @project.runner_projects.find_by(runner_id: runner) # rubocop: disable CodeReuse/ActiveRecord
+ = link_to _('Disable for this project'), project_runner_project_path(@project, runner_project), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn gl-button btn-danger'
+ - elsif runner.project_type?
+ = form_for [@project, @project.runner_projects.new] do |f|
+ = f.hidden_field :runner_id, value: runner.id
+ = f.submit _('Enable for this project'), class: 'btn gl-button'
- if runner.description.present?
- %p.runner-description
+ %p.gl-my-2
= runner.description
- if runner.tags.present?
- %p
+ .gl-my-2
- runner.tags.map(&:name).sort.each do |tag|
%span.badge.gl-badge.sm.badge-pill.badge-primary
= tag
diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml
index 484d8f8a40c..fccfca38013 100644
--- a/app/views/projects/runners/_shared_runners.html.haml
+++ b/app/views/projects/runners/_shared_runners.html.haml
@@ -12,7 +12,7 @@
= link_to toggle_shared_runners_project_runners_path(@project), class: 'btn gl-button btn-warning-secondary', method: :post do
= _('Disable shared runners')
- else
- = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn gl-button btn-success', method: :post do
+ = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn gl-button btn-confirm', method: :post do
= _('Enable shared runners')
&nbsp; for this project
diff --git a/app/views/projects/runners/edit.html.haml b/app/views/projects/runners/edit.html.haml
index f93cd23c83e..77150715158 100644
--- a/app/views/projects/runners/edit.html.haml
+++ b/app/views/projects/runners/edit.html.haml
@@ -1,6 +1,9 @@
-- page_title _('Edit'), "#{@runner.description} ##{@runner.id}", _('runners')
+- page_title _('Edit'), "#{@runner.description} ##{@runner.id}", _('Runners')
-%h4 Runner ##{@runner.id}
+%h2.page-title
+ = s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id })
+ = render 'shared/runners/runner_type_badge', runner: @runner
-%hr
- = render 'shared/runners/form', runner: @runner, runner_form_url: project_runner_path(@project, @runner)
+= render 'shared/runners/runner_type_alert', runner: @runner
+
+= render 'shared/runners/form', runner: @runner, runner_form_url: project_runner_path(@project, @runner)
diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
index 549ca36cb6a..fe983961657 100644
--- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
+++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
@@ -1,20 +1,31 @@
- pretty_name = @project&.full_name ? html_escape(@project&.full_name) : '<' + _('project name') + '>'
- run_actions_text = html_escape(s_("ProjectService|Perform common operations on GitLab project: %{project_name}")) % { project_name: pretty_name }
-%p= s_("ProjectService|To set up this service:")
+%p= s_("ProjectService|To configure this integration, you should:")
%ul.list-unstyled.indent-list
%li
1.
- = link_to 'https://docs.mattermost.com/developer/slash-commands.html#enabling-custom-commands', target: '_blank', rel: 'noopener noreferrer nofollow' do
+ = link_to 'https://docs.gitlab.com/ee/user/project/integrations/mattermost_slash_commands.html#enable-custom-slash-commands', target: '_blank', rel: 'noopener noreferrer nofollow' do
Enable custom slash commands
= sprite_icon('external-link')
- on your Mattermost installation
+ on your Mattermost installation.
%li
2.
- = link_to 'https://docs.mattermost.com/developer/slash-commands.html#set-up-a-custom-command', target: '_blank', rel: 'noopener noreferrer nofollow' do
+ = link_to 'https://docs.gitlab.com/ee/user/project/integrations/mattermost_slash_commands.html#create-a-slash-command', target: '_blank', rel: 'noopener noreferrer nofollow' do
Add a slash command
= sprite_icon('external-link')
- in your Mattermost team with these options:
+ in your Mattermost team with the options listed below.
+ %li
+ 3. Paste the token into the
+ %strong Token
+ field.
+ %li
+ 4. Select the
+ %strong Active
+ check box, then select
+ %strong Save changes
+ to start using GitLab inside Mattermost!
+
%hr
.help-form
@@ -85,17 +96,3 @@
= text_field_tag :autocomplete_description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly'
.input-group-append
= clipboard_button(target: '#autocomplete_description', class: 'input-group-text')
-
-%hr
-
-%ul.list-unstyled.indent-list
- %li
- 3. Paste the
- %strong Token
- into the field below
- %li
- 4. Select the
- %strong Active
- checkbox, press
- %strong Save changes
- and start using GitLab inside Mattermost!
diff --git a/app/views/projects/services/mattermost_slash_commands/_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_help.html.haml
index 1005d9f7990..4a7757daebc 100644
--- a/app/views/projects/services/mattermost_slash_commands/_help.html.haml
+++ b/app/views/projects/services/mattermost_slash_commands/_help.html.haml
@@ -3,15 +3,15 @@
.info-well
.well-segment
%p
- = s_("MattermostService|This service allows users to perform common operations on this project by entering slash commands in Mattermost.")
+ = s_("MattermostService|Use this service to perform common tasks in your project by entering slash commands in Mattermost.")
= link_to help_page_path('user/project/integrations/mattermost_slash_commands.md'), target: '_blank' do
- = _("View documentation")
+ = _("How do I configure this integration?")
= sprite_icon('external-link')
%p.inline
- = s_("MattermostService|See list of available commands in Mattermost after setting up this service, by entering")
+ = s_("MattermostService|After you configure the integration, view your new Mattermost commands by entering")
%kbd.inline /&lt;trigger&gt; help
- - unless enabled || @service.template?
+ - if !enabled && @service.project_level?
= render 'projects/services/mattermost_slash_commands/detailed_help', subject: @service
-- if enabled && !@service.template?
+- if enabled && @service.project_level?
= render 'projects/services/mattermost_slash_commands/installation_info', subject: @service
diff --git a/app/views/projects/services/prometheus/_configuration_banner.html.haml b/app/views/projects/services/prometheus/_configuration_banner.html.haml
index 9b8da857398..3786b845692 100644
--- a/app/views/projects/services/prometheus/_configuration_banner.html.haml
+++ b/app/views/projects/services/prometheus/_configuration_banner.html.haml
@@ -21,6 +21,6 @@
.col-sm-10
%p.gl-mt-3
= s_('PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments.')
- = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn gl-button btn-success'
+ = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn gl-button btn-confirm'
%hr
diff --git a/app/views/projects/services/prometheus/_custom_metrics.html.haml b/app/views/projects/services/prometheus/_custom_metrics.html.haml
index 70685a8a9eb..a901d5b3575 100644
--- a/app/views/projects/services/prometheus/_custom_metrics.html.haml
+++ b/app/views/projects/services/prometheus/_custom_metrics.html.haml
@@ -13,7 +13,7 @@
-# haml-lint:disable NoPlainNodes
%span.badge.badge-pill.js-custom-monitored-count 0
-# haml-lint:enable NoPlainNodes
- = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn gl-button btn-success js-new-metric-button hidden', data: { qa_selector: 'new_metric_button' }
+ = link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn gl-button btn-confirm gl-ml-auto js-new-metric-button hidden', data: { qa_selector: 'new_metric_button' }
.card-body
.flash-container.hidden
.flash-warning
diff --git a/app/views/projects/services/slack/_help.haml b/app/views/projects/services/slack/_help.haml
index 1fd448020a0..c5fcd5ca5fe 100644
--- a/app/views/projects/services/slack/_help.haml
+++ b/app/views/projects/services/slack/_help.haml
@@ -1,16 +1,4 @@
-- webhooks_link_url = 'https://slack.com/apps/A0F7XDUAZ-incoming-webhooks'
-- webhooks_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: webhooks_link_url }
-
.info-well
.well-segment
- %p= s_('SlackIntegration|This service sends notifications about project events to Slack channels. To set up this service:')
- %ol
- %li
- = html_escape(s_('SlackIntegration|%{webhooks_link_start}Add an incoming webhook%{webhooks_link_end} in your Slack team. The default channel can be overridden for each event.')) % { webhooks_link_start: webhooks_link_start.html_safe, webhooks_link_end: '</a>'.html_safe }
- %li
- = html_escape(s_('SlackIntegration|Paste the %{strong_open}Webhook URL%{strong_close} into the field below.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
- %li
- = html_escape(s_('SlackIntegration|Select events below to enable notifications. The %{strong_open}Slack channel names%{strong_close} and %{strong_open}Slack username%{strong_close} fields are optional.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
- %p.mt-3.mb-0
- = html_escape(s_('SlackIntegration|%{strong_open}Note:%{strong_close} Usernames and private channels are not supported.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
- = link_to _('Learn more'), help_page_path('user/project/integrations/slack')
+ %p= s_('SlackIntegration|Sends notifications about project events to Slack channels.')
+ = link_to _('How do I set up this service?'), help_page_path('user/project/integrations/slack')
diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml
index 67c43bd2f33..b68addcb093 100644
--- a/app/views/projects/services/slack_slash_commands/_help.html.haml
+++ b/app/views/projects/services/slack_slash_commands/_help.html.haml
@@ -11,7 +11,7 @@
%p.inline
= s_("SlackService|See list of available commands in Slack after setting up this service, by entering")
%kbd.inline /&lt;command&gt; help
- - unless @service.template?
+ - if @service.project_level?
%p= _("To set up this service:")
%ul.list-unstyled.indent-list
%li
@@ -58,7 +58,7 @@
= label_tag nil, _('Customize icon'), class: 'col-12 col-form-label label-bold'
.col-12
= image_tag(asset_url('slash-command-logo.png', skip_pipeline: true), width: 36, height: 36, class: 'mr-3')
- = link_to(_('Download image'), asset_url('gitlab_logo.png'), class: 'btn btn-sm', target: '_blank', rel: 'noopener noreferrer')
+ = link_to(_('Download image'), asset_url('gitlab_logo.png'), class: 'gl-button btn btn-default btn-sm', target: '_blank', rel: 'noopener noreferrer')
.form-group
= label_tag nil, _('Autocomplete'), class: 'col-12 col-form-label label-bold'
diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml
index 4300ebb4852..5e0f24cea21 100644
--- a/app/views/projects/settings/_archive.html.haml
+++ b/app/views/projects/settings/_archive.html.haml
@@ -11,7 +11,7 @@
%p= _("Unarchiving the project will restore its members' ability to make changes to it. The repository can be committed to, and issues, comments, and other entities can be created. %{strong_start}Once active, this project shows up in the search and on the dashboard.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe }
= link_to _('Unarchive project'), unarchive_project_path(@project),
data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' },
- method: :post, class: "gl-button btn btn-success"
+ method: :post, class: "gl-button btn btn-confirm"
- else
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'archiving-a-project') }
%p= _("Archiving the project will make it entirely read only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml
index d1a95886115..845fb299b74 100644
--- a/app/views/projects/settings/_general.html.haml
+++ b/app/views/projects/settings/_general.html.haml
@@ -37,6 +37,6 @@
= render 'shared/choose_avatar_button', f: f
- if @project.avatar?
%hr
- = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link'
+ = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'gl-button btn btn-danger-secondary'
- = f.submit _('Save changes'), class: "gl-button btn btn-success gl-mt-6", data: { qa_selector: 'save_naming_topics_avatar_button' }
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm gl-mt-6", data: { qa_selector: 'save_naming_topics_avatar_button' }
diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml
index 100eb5991dc..01f3e441eef 100644
--- a/app/views/projects/settings/access_tokens/index.html.haml
+++ b/app/views/projects/settings/access_tokens/index.html.haml
@@ -9,10 +9,20 @@
%h4.gl-mt-0
= page_title
%p
+ - if current_user.can?(:create_resource_access_tokens, @project)
= _('You can generate an access token scoped to this project for each application to use the GitLab API.')
- -# Commented out until https://gitlab.com/gitlab-org/gitlab/-/issues/219551 is fixed
- -# %p
- -# = _('You can also use project access tokens to authenticate against Git over HTTP.')
+ -# Commented out until https://gitlab.com/gitlab-org/gitlab/-/issues/219551 is fixed
+ -# %p
+ -# = _('You can also use project access tokens to authenticate against Git over HTTP.')
+ - else
+ = _('Project access token creation is disabled in this group. You can still use and manage existing tokens.')
+ %p
+ - root_group = @project.group.root_ancestor
+ - if current_user.can?(:admin_group, root_group)
+ - group_settings_link = edit_group_path(root_group)
+ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_settings_link }
+ = _('You can enable project access token creation in %{link_start}group settings%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+
.col-lg-8
- if @new_project_access_token
@@ -20,12 +30,13 @@
type: type,
new_token_value: @new_project_access_token
- = render 'shared/access_tokens/form',
- type: type,
- path: project_settings_access_tokens_path(@project),
- token: @project_access_token,
- scopes: @scopes,
- prefix: :project_access_token
+ - if current_user.can?(:create_resource_access_tokens, @project)
+ = render 'shared/access_tokens/form',
+ type: type,
+ path: project_settings_access_tokens_path(@project),
+ token: @project_access_token,
+ scopes: @scopes,
+ prefix: :project_access_token
= render 'shared/access_tokens/table',
active_tokens: @active_project_access_tokens,
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
index 5f79dd3d4bb..68e4bed8b9a 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -54,4 +54,4 @@
= s_('CICD|Automatic deployment to staging, manual deployment to production')
= link_to sprite_icon('question-o'), help_page_path('topics/autodevops/customize.md', anchor: 'incremental-rollout-to-production'), target: '_blank'
- = f.submit _('Save changes'), class: "btn gl-button btn-success gl-mt-5", data: { qa_selector: 'save_changes_button' }
+ = f.submit _('Save changes'), class: "btn gl-button btn-confirm gl-mt-5", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index 3b0073848a6..c4b5c23be13 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -96,7 +96,7 @@
= html_escape(_('The regular expression used to find test coverage output in the job log. For example, use %{regex} for Simplecov (Ruby). Leave blank to disable.')) % { regex: '<code>\(\d+.\d+%\)</code>'.html_safe }
= link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'test-coverage-parsing'), target: '_blank'
- = f.submit _('Save changes'), class: "btn gl-button btn-success", data: { qa_selector: 'save_general_pipelines_changes_button' }
+ = f.submit _('Save changes'), class: "btn gl-button btn-confirm", data: { qa_selector: 'save_general_pipelines_changes_button' }
%hr
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index cca980b5359..d955dabd04c 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -5,8 +5,6 @@
- expanded = expanded_by_default?
- general_expanded = @project.errors.empty? ? expanded : true
-- enable_search_settings locals: { container_class: 'gl-my-5' }
-
%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded), data: { qa_selector: 'general_pipelines_settings_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
diff --git a/app/views/projects/settings/integrations/show.html.haml b/app/views/projects/settings/integrations/show.html.haml
index 3f5fd765b11..af37795a7c5 100644
--- a/app/views/projects/settings/integrations/show.html.haml
+++ b/app/views/projects/settings/integrations/show.html.haml
@@ -12,7 +12,7 @@
.gl-alert-actions
= link_to _('Go to Webhooks'), project_hooks_path(@project), class: 'gl-button btn gl-alert-action btn-info'
-%h4= _('Integrations')
+%h3= _('Integrations')
- integrations_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('user/project/integrations/overview') }
- webhooks_link_start = '<a href="%{url}">'.html_safe % { url: project_hooks_path(@project) }
%p= _("%{integrations_link_start}Integrations%{link_end} enable you to make third-party applications part of your GitLab workflow. If the available integrations don't meet your needs, consider using a %{webhooks_link_start}webhook%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, webhooks_link_start: webhooks_link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/projects/settings/operations/_alert_management.html.haml b/app/views/projects/settings/operations/_alert_management.html.haml
index 079812f7077..0418d7df42d 100644
--- a/app/views/projects/settings/operations/_alert_management.html.haml
+++ b/app/views/projects/settings/operations/_alert_management.html.haml
@@ -5,12 +5,12 @@
%section.settings.no-animate#js-alert-management-settings{ class: ('expanded' if expanded) }
.settings-header
- %h4
- = _('Alerts')
- %button.btn.js-settings-toggle{ type: 'button' }
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
+ = _('Alert integrations')
+ %button.gl-button.btn.btn-default.js-settings-toggle{ type: 'button' }
= _('Expand')
%p
- = _('Display alerts from all your monitoring tools directly within GitLab.')
- = link_to _('More information'), help_page_path('operations/incident_management/index.md'), target: '_blank', rel: 'noopener noreferrer'
+ = _('Display alerts from all configured monitoring tools.')
+ = link_to _('Learn more.'), help_page_path('operations/incident_management/integrations.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
.js-alerts-settings{ data: alerts_settings_data }
diff --git a/app/views/projects/settings/operations/_configuration_banner.html.haml b/app/views/projects/settings/operations/_configuration_banner.html.haml
index 888625689f1..8551aa5380e 100644
--- a/app/views/projects/settings/operations/_configuration_banner.html.haml
+++ b/app/views/projects/settings/operations/_configuration_banner.html.haml
@@ -21,4 +21,4 @@
.col-sm-10
%p.gl-mt-3
= s_('PrometheusService|Monitor your project’s environments by deploying and configuring Prometheus on your clusters.')
- = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'btn btn-success'
+ = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(project), class: 'gl-button btn btn-confirm'
diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml
index fe302978da6..1e77f37ebb4 100644
--- a/app/views/projects/settings/operations/_error_tracking.html.haml
+++ b/app/views/projects/settings/operations/_error_tracking.html.haml
@@ -4,9 +4,9 @@
%section.settings.no-animate.js-error-tracking-settings
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Error tracking')
- %button.btn.js-settings-toggle{ type: 'button' }
+ %button.gl-button.btn.btn-default.js-settings-toggle{ type: 'button' }
= _('Expand')
%p
= _('To link Sentry to GitLab, enter your Sentry URL and Auth Token.')
diff --git a/app/views/projects/settings/operations/_prometheus.html.haml b/app/views/projects/settings/operations/_prometheus.html.haml
index ccf5b5dc75f..1c7bcbbca0b 100644
--- a/app/views/projects/settings/operations/_prometheus.html.haml
+++ b/app/views/projects/settings/operations/_prometheus.html.haml
@@ -1,8 +1,8 @@
%section.settings.no-animate.js-prometheus-settings
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Prometheus')
- %button.btn.js-settings-toggle{ type: 'button' }
+ %button.gl-button.btn.btn-default.js-settings-toggle{ type: 'button' }
= _('Expand')
%p
= _('Link Prometheus monitoring to GitLab.')
diff --git a/app/views/projects/settings/operations/_tracing.html.haml b/app/views/projects/settings/operations/_tracing.html.haml
index 03970dfe0b9..a591fa33096 100644
--- a/app/views/projects/settings/operations/_tracing.html.haml
+++ b/app/views/projects/settings/operations/_tracing.html.haml
@@ -3,9 +3,9 @@
%section.settings.border-0.no-animate
.settings-header{ :class => "border-top" }
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _("Jaeger tracing")
- %button.btn.gl-button.js-settings-toggle{ type: 'button' }
+ %button.btn.btn-default.gl-button.js-settings-toggle{ type: 'button' }
= _('Expand')
%p
- if has_jaeger_url
@@ -17,17 +17,18 @@
- tracing_link = link_to project_tracing_path(@project) do
%span
= _('Tracing')
- = _("To open Jaeger and easily view tracing from GitLab, link the %{link} page to your server").html_safe % { link: tracing_link }
+ = _("To open Jaeger from GitLab to view tracing from the %{link} page, add a URL to your Jaeger server.").html_safe % { link: tracing_link }
+ = link_to _('Learn more.'), help_page_path('operations/tracing'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= form_for @project, url: project_settings_operations_path(@project), method: :patch do |f|
= form_errors(@project)
.form-group
= f.fields_for :tracing_setting_attributes, setting do |form|
= form.label :external_url, _('Jaeger URL'), class: 'label-bold'
- = form.url_field :external_url, class: 'form-control gl-form-input', placeholder: 'e.g. https://jaeger.mycompany.com'
+ = form.url_field :external_url, class: 'form-control gl-form-input', placeholder: 'https://jaeger.example.com'
%p.form-text.text-muted
- - jaeger_help_url = "https://www.jaegertracing.io/docs/1.7/getting-started/"
+ - jaeger_help_url = "https://www.jaegertracing.io/docs/getting-started/"
- link_start_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: jaeger_help_url }
- link_end_tag = "#{sprite_icon('external-link', css_class: 'ml-1 vertical-align-middle')}</a>".html_safe
- = _("For more information, please review %{link_start_tag}Jaeger's configuration doc%{link_end_tag}").html_safe % { link_start_tag: link_start_tag, link_end_tag: link_end_tag }
- = f.submit _('Save changes'), class: 'btn btn-success'
+ = _("Learn more about %{link_start_tag}Jaeger configuration%{link_end_tag}.").html_safe % { link_start_tag: link_start_tag, link_end_tag: link_end_tag }
+ = f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index 5ba796f5720..73722a5a789 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -2,8 +2,6 @@
- page_title _('Operations Settings')
- breadcrumb_title _('Operations Settings')
-- enable_search_settings locals: { container_class: 'gl-my-5' }
-
= render 'projects/settings/operations/alert_management'
= render 'projects/settings/operations/incidents'
= render 'projects/settings/operations/error_tracking'
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index 8ac42ce3f81..24fc137fd29 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -3,8 +3,6 @@
- @content_class = "limit-container-width" unless fluid_layout
- deploy_token_description = s_('DeployTokens|Deploy tokens allow access to packages, your repository, and registry images.')
-- enable_search_settings locals: { container_class: 'gl-my-5' }
-
= render "projects/default_branch/show"
= render_if_exists "projects/push_rules/index"
= render "projects/mirrors/mirror_repos"
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index 40faf91eadf..90b79fddff1 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -1,4 +1,4 @@
-- current_route_path = request.fullpath.match(/-\/tree\/[^\/]+\/(.+$)/).to_a[1]
+- current_route_path = request.fullpath.match(%r{-/tree/[^/]+/(.+$)}).to_a[1]
- add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" })
- @content_class = "limit-container-width" unless fluid_layout
- @skip_current_level_breadcrumb = true
diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml
index a505b34f46c..f53b2051835 100644
--- a/app/views/projects/snippets/index.html.haml
+++ b/app/views/projects/snippets/index.html.haml
@@ -9,7 +9,7 @@
- if new_project_snippet_link.present?
.nav-controls
- = link_to _("New snippet"), new_project_snippet_link, class: "btn btn-success", title: _("New snippet")
+ = link_to _("New snippet"), new_project_snippet_link, class: "gl-button btn btn-confirm", title: _("New snippet")
= render 'shared/snippets/list'
- else
diff --git a/app/views/projects/stage/_stage.html.haml b/app/views/projects/stage/_stage.html.haml
index 387c8fb3234..92bfd5a48a8 100644
--- a/app/views/projects/stage/_stage.html.haml
+++ b/app/views/projects/stage/_stage.html.haml
@@ -1,5 +1,3 @@
-- stage = stage.present(current_user: current_user)
-
%tr
%th{ colspan: 10 }
%strong
@@ -8,8 +6,8 @@
= ci_icon_for_status(stage.status)
&nbsp;
= stage.name.titleize
-= render stage.latest_ordered_statuses, stage: false, ref: false, pipeline_link: false, allow_retry: true
-= render stage.retried_ordered_statuses, stage: false, ref: false, pipeline_link: false, retried: true
+= render stage.latest_statuses, stage: false, ref: false, pipeline_link: false, allow_retry: true
+= render stage.retried_statuses, stage: false, ref: false, pipeline_link: false, retried: true
%tr
%td{ colspan: 10 }
&nbsp;
diff --git a/app/views/projects/starrers/_starrer.html.haml b/app/views/projects/starrers/_starrer.html.haml
index d8a2c72d9ce..28ec1ed206a 100644
--- a/app/views/projects/starrers/_starrer.html.haml
+++ b/app/views/projects/starrers/_starrer.html.haml
@@ -13,7 +13,7 @@
%span.cgray= starrer.user.to_reference
- if starrer.user == current_user
- %span.badge.badge-success.gl-ml-2= _("It's you")
+ %span.badge-pill.badge-success.gl-badge.gl-ml-2.sm= _("It's you")
.block-truncated
= time_ago_with_tooltip(starrer.starred_since)
diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml
index 04d8c1f42bc..229f13d0ff3 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -9,22 +9,25 @@
= s_('TagsPage|Tags give the ability to mark specific points in history as being important')
.nav-controls
- = form_tag(filter_tags_path, method: :get) do
- = search_field_tag :search, params[:search], { placeholder: s_('TagsPage|Filter by tag name'), id: 'tag-search', class: 'form-control search-text-input input-short', spellcheck: false }
+ - unless Gitlab::Ci::Features.gldropdown_tags_enabled?
+ = form_tag(filter_tags_path, method: :get) do
+ = search_field_tag :search, params[:search], { placeholder: s_('TagsPage|Filter by tag name'), id: 'tag-search', class: 'form-control search-text-input input-short', spellcheck: false }
- .dropdown
- %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown'} }
- %span.light
- = tags_sort_options_hash[@sort]
- = 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_('TagsPage|Sort by')
- - tags_sort_options_hash.each do |value, title|
- %li
- = link_to title, filter_tags_path(sort: value), class: ("is-active" if @sort == value)
+ .dropdown
+ %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown'} }
+ %span.light
+ = tags_sort_options_hash[@sort]
+ = 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_('TagsPage|Sort by')
+ - tags_sort_options_hash.each do |value, title|
+ %li
+ = link_to title, filter_tags_path(sort: value), class: ("is-active" if @sort == value)
+ - else
+ #js-tags-sort-dropdown{ data: { filter_tags_path: filter_tags_path, sort_options: tags_sort_options_hash.to_json } }
- if can?(current_user, :admin_tag, @project)
- = link_to new_project_tag_path(@project), class: 'btn gl-button btn-success', data: { qa_selector: "new_tag_button" } do
+ = link_to new_project_tag_path(@project), class: 'btn gl-button btn-confirm', data: { qa_selector: "new_tag_button" } do
= s_('TagsPage|New tag')
= link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-block has-tooltip' do
= sprite_icon('rss', css_class: 'qa-rss-icon')
diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml
index 73b2a92dcc0..2ef1891089f 100644
--- a/app/views/projects/tags/new.html.haml
+++ b/app/views/projects/tags/new.html.haml
@@ -52,6 +52,6 @@
= render 'shared/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here…'), current_text: @release_description, qa_selector: 'release_notes_field'
= render 'shared/notes/hints'
.form-actions
- = button_tag s_('TagsPage|Create tag'), class: 'btn btn-success', data: { qa_selector: "create_tag_button" }
- = link_to s_('TagsPage|Cancel'), project_tags_path(@project), class: 'btn btn-cancel'
+ = button_tag s_('TagsPage|Create tag'), class: 'gl-button btn btn-confirm', data: { qa_selector: "create_tag_button" }
+ = link_to s_('TagsPage|Cancel'), project_tags_path(@project), class: 'gl-button btn btn-default btn-cancel'
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/tags/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml
index d82c89a3f9f..f181212b328 100644
--- a/app/views/projects/tags/releases/edit.html.haml
+++ b/app/views/projects/tags/releases/edit.html.haml
@@ -15,5 +15,5 @@
= render 'shared/notes/hints'
.error-alert
.gl-mt-3
- = f.submit 'Save changes', class: 'btn gl-button btn-success'
+ = f.submit 'Save changes', class: 'btn gl-button btn-confirm'
= link_to "Cancel", project_tag_path(@project, @tag.name), class: "btn gl-button btn-default btn-cancel"
diff --git a/app/views/projects/tracings/_tracing_button.html.haml b/app/views/projects/tracings/_tracing_button.html.haml
index b0ab6fa21e1..fe3af1c6a1a 100644
--- a/app/views/projects/tracings/_tracing_button.html.haml
+++ b/app/views/projects/tracings/_tracing_button.html.haml
@@ -1,2 +1,2 @@
-= link_to project_settings_operations_path(@project), title: _('Configure Tracing'), class: 'gl-button btn btn-success' do
+= link_to project_settings_operations_path(@project), title: _('Configure Tracing'), class: 'gl-button btn btn-confirm' do
= _('Add Jaeger URL')
diff --git a/app/views/projects/tracings/show.html.haml b/app/views/projects/tracings/show.html.haml
index 8c9bffc81bf..21c1d02d92e 100644
--- a/app/views/projects/tracings/show.html.haml
+++ b/app/views/projects/tracings/show.html.haml
@@ -24,10 +24,10 @@
.text-content
%h4.text-left= _('Troubleshoot and monitor your application with tracing')
%p
- - jaeger_help_url = "https://www.jaegertracing.io/docs/1.7/getting-started/"
+ - jaeger_help_url = "https://www.jaegertracing.io/docs/getting-started/"
- link_start_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: jaeger_help_url }
- link_end_tag = "#{sprite_icon('external-link', css_class: 'ml-1 vertical-align-middle')}</a>".html_safe
- = _('To get started, link this page to your Jaeger server, or find out how to %{link_start_tag}install Jaeger%{link_end_tag}').html_safe % { link_start_tag: link_start_tag, link_end_tag: link_end_tag }
+ = _('Add a Jaeger URL to replace this page with a link to your Jaeger server. You first need to %{link_start_tag}install Jaeger%{link_end_tag}.').html_safe % { link_start_tag: link_start_tag, link_end_tag: link_end_tag }
.text-center
= render 'tracing_button'
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index 355277b7d41..2d0c4cc20a0 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -1,4 +1,4 @@
-- current_route_path = request.fullpath.match(/-\/tree\/[^\/]+\/(.+$)/).to_a[1]
+- current_route_path = request.fullpath.match(%r{-/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/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 || "/"})
diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml
index 1dbf8addb57..9043b8e60fc 100644
--- a/app/views/projects/triggers/_form.html.haml
+++ b/app/views/projects/triggers/_form.html.haml
@@ -8,4 +8,4 @@
.form-group
= f.label :key, "Description", class: "label-bold"
= f.text_field :description, class: 'form-control gl-form-input', required: true, title: 'Trigger description is required.', placeholder: "Trigger description"
- = f.submit btn_text, class: "btn btn-success"
+ = f.submit btn_text, class: "gl-button btn btn-confirm"
diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml
index c7cb051e40d..85ecfe7a982 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -2,9 +2,9 @@
.col-lg-12
.card
.card-header
- = s_("Manage your project's triggers")
+ = _("Manage your project's triggers")
.card-body
- = render "projects/triggers/form", btn_text: "Add trigger"
+ = render 'projects/triggers/form', btn_text: _('Add trigger')
%hr
- if Feature.enabled?(:ci_pipeline_triggers_settings_vue_ui, @project)
#js-ci-pipeline-triggers-list.triggers-list{ data: { triggers: @triggers_json } }
@@ -15,32 +15,32 @@
%thead
%th
%strong
- = s_("Token")
+ = _('Token')
%th
%strong
- = s_("Description")
+ = _('Description')
%th
%strong
- = s_("Owner")
+ = _('Owner')
%th
%strong
- = s_("Last used")
+ = _('Last used')
%th
= render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger
- else
%p.settings-message.text-center.gl-mb-3{ data: { testid: 'no_triggers_content' } }
- = s_("No triggers exist yet. Use the form above to create one.")
+ = _('No triggers exist yet. Use the form above to create one.')
.card-footer
%p
- = s_("These examples show how to trigger this project's pipeline for a branch or tag.")
+ = _("These examples show how to trigger this project's pipeline for a branch or tag.")
%p.light
- = s_("Triggers|In each example, replace %{code_start}TOKEN%{code_end} with the trigger token you generated and replace %{code_start}REF_NAME%{code_end} with the branch or tag name.").html_safe % { code_start: "<code>".html_safe, code_end: "</code>".html_safe }
+ = _('In each example, replace %{code_start}TOKEN%{code_end} with the trigger token you generated and replace %{code_start}REF_NAME%{code_end} with the branch or tag name.').html_safe % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
%h5.gl-mt-3
- = s_("Use cURL")
+ = _('Use cURL')
%pre
:plain
@@ -49,26 +49,26 @@
-F ref=REF_NAME \
#{builds_trigger_url(@project.id)}
%h5.gl-mt-3
- = s_("Use .gitlab-ci.yml")
+ = _('Use .gitlab-ci.yml')
%pre
:plain
script:
- "curl -X POST -F token=TOKEN -F ref=REF_NAME #{builds_trigger_url(@project.id)}"
%h5.gl-mt-3
- = s_("Use webhook")
+ = _('Use webhook')
%pre
:plain
#{builds_trigger_url(@project.id, ref: 'REF_NAME')}?token=TOKEN
%h5.gl-mt-3
- = s_("Pass job variables")
+ = _('Pass job variables')
%p.light
- = s_("Triggers|To pass variables to the triggered pipeline, add %{code_start}variables[VARIABLE]=VALUE%{code_end} to the API request.").html_safe % { code_start: "<code>".html_safe, code_end: "</code>".html_safe }
+ = _('To pass variables to the triggered pipeline, add %{code_start}variables[VARIABLE]=VALUE%{code_end} to the API request.').html_safe % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
%p.light
- = s_("cURL:")
+ = _('cURL:')
%pre
:plain
@@ -78,7 +78,7 @@
-F "variables[RUN_NIGHTLY_BUILD]=true" \
#{builds_trigger_url(@project.id)}
%p.light
- = s_("Webhook:")
+ = _('Webhook:')
%pre.gl-mb-0
:plain
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index b25199b405a..2def6c06458 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -30,8 +30,8 @@
%td.text-right.trigger-actions
- revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?"
- if can?(current_user, :admin_trigger, trigger)
- = link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "btn btn-default btn-sm" do
+ = link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "gl-button btn btn-default btn-sm" do
= sprite_icon('pencil')
- if can?(current_user, :manage_trigger, trigger)
- = link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation, testid: 'trigger_revoke_button' }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do
+ = link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation, testid: 'trigger_revoke_button' }, method: :delete, title: "Revoke", class: "gl-button btn btn-default btn-sm btn-trigger-revoke" do
= sprite_icon('remove')
diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml
index d2a2853ecd7..bf5e35a1224 100644
--- a/app/views/registrations/welcome/show.html.haml
+++ b/app/views/registrations/welcome/show.html.haml
@@ -1,12 +1,16 @@
- page_title _('Your profile')
- add_page_specific_style 'page_bundles/signup'
+- gitlab_experience_text = _('To personalize your GitLab experience, we\'d like to know a bit more about you')
.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"
%h2.gl-text-center= html_escape(_('Welcome to GitLab,%{br_tag}%{name}!')) % { name: html_escape(current_user.first_name), br_tag: '<br/>'.html_safe }
- %p.gl-text-center= html_escape(_('To personalize your GitLab experience, we\'d like to know a bit more about you. We won\'t share this information with anyone.')) % { br_tag: '<br/>'.html_safe }
+ - if Gitlab.com?
+ %p.gl-text-center= html_escape(_('%{gitlab_experience_text}. We won\'t share this information with anyone.')) % { gitlab_experience_text: gitlab_experience_text }
+ - else
+ %p.gl-text-center= html_escape(_('%{gitlab_experience_text}. Don\'t worry, this information isn\'t shared outside of your self-managed GitLab instance.')) % { gitlab_experience_text: gitlab_experience_text }
= 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
@@ -16,13 +20,14 @@
= f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, {}, class: 'form-control js-user-role-dropdown', autofocus: true
- if Feature.enabled?(:user_other_role_details)
.row
- .form-group.col-sm-12.js-other-role-group{ class: ("hidden") }
+ .form-group.col-sm-12.js-other-role-group.hidden
= f.label :other_role, _('What is your job title? (optional)'), class: 'form-check-label gl-mb-3'
= f.text_field :other_role, class: 'form-control'
= render_if_exists "registrations/welcome/setup_for_company", f: f
+ = render 'devise/shared/email_opted_in', f: f
.row
.form-group.col-sm-12.gl-mb-0
- if partial_exists? "registrations/welcome/button"
= render "registrations/welcome/button"
- else
- = f.submit _('Get started!'), class: 'btn-success gl-button btn btn-block gl-mb-0 gl-p-3', data: { qa_selector: 'get_started_button' }
+ = f.submit _('Get started!'), class: 'btn-confirm gl-button btn btn-block gl-mb-0 gl-p-3', data: { qa_selector: 'get_started_button' }
diff --git a/app/views/search/results/_empty.html.haml b/app/views/search/results/_empty.html.haml
index 0462c29f5c1..2c9ffe3dc1d 100644
--- a/app/views/search/results/_empty.html.haml
+++ b/app/views/search/results/_empty.html.haml
@@ -1,4 +1,4 @@
-.search_box.gl-my-8
+.search_box.gl-my-8.gl-text-center
.search_glyph
%h4
= sprite_icon('search', size: 24, css_class: 'gl-vertical-align-text-bottom')
diff --git a/app/views/search/results/_note.html.haml b/app/views/search/results/_note.html.haml
index d88b7b32ed6..8d5d8670b5c 100644
--- a/app/views/search/results/_note.html.haml
+++ b/app/views/search/results/_note.html.haml
@@ -27,4 +27,4 @@
.note-search-result
.term
- = search_md_sanitize(note.note)
+ = simple_search_highlight_and_truncate(note.note, @search_term)
diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml
index fd52f7f40d2..80b50f7a3de 100644
--- a/app/views/shared/_clone_panel.html.haml
+++ b/app/views/shared/_clone_panel.html.haml
@@ -1,14 +1,14 @@
-.git-clone-holder.js-git-clone-holder.input-group
+.js-git-clone-holder.input-group.btn-group
.input-group-prepend
- if allowed_protocols_present?
.input-group-text.clone-dropdown-btn.btn
%span.js-clone-dropdown-label
= enabled_protocol_button(container, enabled_protocol)
- else
- %a#clone-dropdown.input-group-text.btn.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
+ %a#clone-dropdown.input-group-text.gl-button.btn.btn-default.btn-icon.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
%span.js-clone-dropdown-label
= default_clone_protocol.upcase
- = sprite_icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: 'gl-icon')
%ul.dropdown-menu.dropdown-menu-selectable.clone-options-dropdown
%li
= ssh_clone_button(container)
@@ -16,7 +16,7 @@
= http_clone_button(container)
= render_if_exists 'shared/kerberos_clone_button', container: container
- = text_field_tag :clone_url, default_url_to_repo(container), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }
+ = text_field_tag :clone_url, default_url_to_repo(container), class: "js-select-on-focus btn gl-button", readonly: true, aria: { label: _('Repository clone URL') }
.input-group-append
- = clipboard_button(target: '#clone_url', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard")
+ = clipboard_button(target: '#clone_url', title: _("Copy URL"), class: "input-group-text gl-button btn-default btn-clipboard")
diff --git a/app/views/shared/_confirm_fork_modal.html.haml b/app/views/shared/_confirm_fork_modal.html.haml
index b692dffce37..265396d3d8b 100644
--- a/app/views/shared/_confirm_fork_modal.html.haml
+++ b/app/views/shared/_confirm_fork_modal.html.haml
@@ -8,5 +8,5 @@
.modal-body.p-3
%p= _("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.") % { tag_start: '', tag_end: ''}
.modal-footer
- = link_to _('Cancel'), '#', class: "gl-button btn btn-default btn-cancel", "data-dismiss" => "modal"
- = link_to _('Fork project'), fork_path, class: 'gl-button btn btn-confirm', data: { qa_selector: 'fork_project_button' }, method: :post
+ = link_to _('Cancel'), '#', class: "btn gl-button btn-default", "data-dismiss" => "modal"
+ = link_to _('Fork project'), fork_path, class: 'btn gl-button btn-confirm', data: { qa_selector: 'fork_project_button' }, method: :post
diff --git a/app/views/shared/_confirm_modal.html.haml b/app/views/shared/_confirm_modal.html.haml
index ecb462205b0..4e7e5c9d3ba 100644
--- a/app/views/shared/_confirm_modal.html.haml
+++ b/app/views/shared/_confirm_modal.html.haml
@@ -18,4 +18,4 @@
.form-group
= text_field_tag 'confirm_name_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input'
.form-actions
- = submit_tag _('Confirm'), class: "btn btn-danger js-confirm-danger-submit qa-confirm-button"
+ = submit_tag _('Confirm'), class: "gl-button btn btn-danger js-confirm-danger-submit qa-confirm-button"
diff --git a/app/views/shared/_delete_label_modal.html.haml b/app/views/shared/_delete_label_modal.html.haml
deleted file mode 100644
index 8d761e3b9c4..00000000000
--- a/app/views/shared/_delete_label_modal.html.haml
+++ /dev/null
@@ -1,20 +0,0 @@
-.modal{ id: "modal-delete-label-#{label.id}", tabindex: -1 }
- .modal-dialog
- .modal-content
- .modal-header
- %h3.page-title= _('Delete label: %{label_name} ?') % { label_name: label.name }
- %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
- %span{ "aria-hidden": true } &times;
-
- .modal-body
- %p
- = html_escape(_('%{label_name} %{span_open}will be permanently deleted from %{subject_name}. This cannot be undone.%{span_close}')) % { label_name: tag.strong(label.name), subject_name: label.subject_name, span_open: '<span>'.html_safe, span_close: '</span>'.html_safe }
-
- .modal-footer
- %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' }= _('Cancel')
-
- = link_to _('Delete label'),
- label.destroy_path,
- title: _('Delete'),
- method: :delete,
- class: 'gl-button btn btn-danger'
diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml
index b1f53e4d0f6..f8ac3832a77 100644
--- a/app/views/shared/_file_highlight.html.haml
+++ b/app/views/shared/_file_highlight.html.haml
@@ -2,13 +2,11 @@
- 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|
- 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
+ %a.file-line-num.diff-line-num{ href: "#{link}#L#{i}", id: "L#{i}", 'data-line-number' => i }
= i
- 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' } }
diff --git a/app/views/shared/_file_picker_button.html.haml b/app/views/shared/_file_picker_button.html.haml
index 9e6a7626d89..1d688e7f4b0 100644
--- a/app/views/shared/_file_picker_button.html.haml
+++ b/app/views/shared/_file_picker_button.html.haml
@@ -1,8 +1,8 @@
- classes = local_assigns.fetch(:classes, '')
%span.js-filepicker
- %button.gl-button.btn.js-filepicker-button{ type: 'button', class: classes }= _("Choose file…")
- %span.file_name.js-filepicker-filename= _("No file chosen")
+ %button.gl-button.btn.btn-default.js-filepicker-button{ type: 'button', class: classes }= _("Choose file…")
+ %span.file_name.js-filepicker-filename= _("No file chosen.")
= f.file_field field, class: "js-filepicker-input hidden"
- if help_text.present?
.form-text.text-muted= help_text
diff --git a/app/views/shared/_flash_user_callout.html.haml b/app/views/shared/_flash_user_callout.html.haml
index fe175195e66..d8032ac521d 100644
--- a/app/views/shared/_flash_user_callout.html.haml
+++ b/app/views/shared/_flash_user_callout.html.haml
@@ -6,6 +6,6 @@
%div{ class: "flash-#{flash_type}" }
%div{ class: "#{(container_class unless fluid_layout)} #{(extra_flash_class unless @no_container)} #{@content_class}" }
%span= message
- %button.btn.btn-default.close.js-close{ type: 'button',
+ %button.btn.gl-button.btn-default.close.js-close{ type: 'button',
'aria-label' => _('Dismiss') }
= sprite_icon('close', css_class: 'dismiss-icon')
diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml
index 4b006bddbf6..6c3e15cbace 100644
--- a/app/views/shared/_issuable_meta_data.html.haml
+++ b/app/views/shared/_issuable_meta_data.html.haml
@@ -1,6 +1,7 @@
- note_count = @issuable_meta_data[issuable.id].user_notes_count
- issue_votes = @issuable_meta_data[issuable.id]
-- upvotes, downvotes = issue_votes.upvotes, issue_votes.downvotes
+- upvotes = issue_votes.upvotes
+- downvotes = issue_votes.downvotes
- issuable_path = issuable_path(issuable, anchor: 'notes')
- issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count
diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml
index 57575f89803..eb12e9d463c 100644
--- a/app/views/shared/_issues.html.haml
+++ b/app/views/shared/_issues.html.haml
@@ -3,4 +3,5 @@
= render partial: 'projects/issues/issue', collection: @issues
= paginate @issues, theme: "gitlab"
- else
- = render 'shared/empty_states/issues'
+ - project_select_button = local_assigns.fetch(:project_select_button, false)
+ = render 'shared/empty_states/issues', project_select_button: project_select_button
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index 95d7f075964..e4ef0a52eba 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -12,34 +12,34 @@
- if can?(current_user, :admin_label, @project)
%li.inline.js-toggle-priority{ data: { url: remove_priority_project_label_path(@project, label),
dom_id: dom_id(label), type: label.type } }
- %button.label-action.add-priority.btn.btn-transparent.has-tooltip{ title: _('Prioritize'), type: 'button', data: { placement: 'bottom' }, aria_label: _('Prioritize label') }
+ %button.add-priority.btn.gl-button.btn-default-tertiary.btn-sm.has-tooltip.gl-ml-3{ title: _('Prioritize'), type: 'button', data: { placement: 'bottom' }, aria_label: _('Prioritize label') }
= sprite_icon('star-o')
- %button.label-action.remove-priority.btn.btn-transparent.has-tooltip{ title: _('Remove priority'), type: 'button', data: { placement: 'bottom' }, aria_label: _('Deprioritize label') }
+ %button.remove-priority.btn.gl-button.btn-default-tertiary.btn-sm.has-tooltip.gl-ml-3{ title: _('Remove priority'), type: 'button', data: { placement: 'bottom' }, aria_label: _('Deprioritize label') }
= sprite_icon('star')
- if can?(current_user, :admin_label, label)
%li.inline
- = link_to label.edit_path, class: 'btn btn-transparent label-action edit has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
+ = link_to label.edit_path, class: 'btn gl-button btn-default-tertiary btn-sm edit has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
= sprite_icon('pencil')
- if can?(current_user, :admin_label, label)
%li.inline
.dropdown
- %button{ type: 'button', class: 'btn btn-transparent js-label-options-dropdown label-action', data: { toggle: 'dropdown' }, aria_label: _('Label actions dropdown') }
+ %button{ type: 'button', class: 'btn gl-button btn-default-tertiary btn-sm js-label-options-dropdown', data: { toggle: 'dropdown' }, aria_label: _('Label actions dropdown') }
= sprite_icon('ellipsis_v')
.dropdown-menu.dropdown-open-left
%ul
- if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group)
%li
- %button.js-promote-project-label-button.btn.btn-transparent{ disabled: true, type: 'button',
+ %button.js-promote-project-label-button.gl-button.btn.btn-default-tertiary{ disabled: true, type: 'button',
data: { url: promote_project_label_path(label.project, label),
label_title: label.title,
label_color: label.color,
label_text_color: label.text_color,
group_name: label.project.group.name } }
= _('Promote to group label')
- - if can?(current_user, :admin_label, label)
- %li
- %span{ data: { toggle: 'modal', target: "#modal-delete-label-#{label.id}" } }
- %button.text-danger.remove-row{ type: 'button' }= _('Delete')
+ %li
+ %span
+ %button.text-danger.js-delete-label-modal-button{ type: 'button', data: { label_name: label.name, subject_name: label.subject_name, destroy_path: label.destroy_path } }
+ = _('Delete')
- if current_user
%li.inline.label-subscription
- if label.can_subscribe_to_label_in_different_levels?
@@ -53,13 +53,11 @@
.dropdown-menu.dropdown-open-left
%ul
%li
- %button.js-subscribe-button.label-subscribe-button.btn.btn-default{ class: ('hidden' unless status.unsubscribed?), data: { status: status, url: toggle_subscription_project_label_path(@project, label) } }
+ %button.js-subscribe-button.label-subscribe-button.gl-button.btn.btn-default{ class: ('hidden' unless status.unsubscribed?), data: { status: status, url: toggle_subscription_project_label_path(@project, label) } }
%span= _('Subscribe at project level')
%li
- %button.js-subscribe-button.js-group-level.label-subscribe-button.btn.btn-default{ class: ('hidden' unless status.unsubscribed?), data: { status: status, url: toggle_subscription_group_label_path(label.group, label) } }
+ %button.js-subscribe-button.js-group-level.label-subscribe-button.gl-button.btn.btn-default{ class: ('hidden' unless status.unsubscribed?), data: { status: status, url: toggle_subscription_group_label_path(label.group, label) } }
%span= _('Subscribe at group level')
- else
%button.gl-button.js-subscribe-button.label-subscribe-button.btn.btn-default.gl-ml-3{ data: { status: status, url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title }
%span= label_subscription_toggle_button_text(label, @project)
-
-= render 'shared/delete_label_modal', label: label
diff --git a/app/views/shared/_milestone_expired.html.haml b/app/views/shared/_milestone_expired.html.haml
index 171ae9d2c07..925344ab2f7 100644
--- a/app/views/shared/_milestone_expired.html.haml
+++ b/app/views/shared/_milestone_expired.html.haml
@@ -1,4 +1,4 @@
-- if milestone.expired? and not milestone.closed?
+- if milestone.expired? && !milestone.closed?
.gl-badge.badge-warning.badge-pill.gl-mb-2= _('Expired')
- if milestone.upcoming?
.gl-badge.badge-primary.badge-pill.gl-mb-2= _('Upcoming')
diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml
deleted file mode 100644
index 172f3d85472..00000000000
--- a/app/views/shared/_mini_pipeline_graph.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-.stage-cell
- - pipeline.legacy_stages.each do |stage|
- - if stage.status
- - detailed_status = stage.detailed_status(current_user)
- - icon_status = "#{detailed_status.icon}_borderless"
-
- .stage-container.mt-0.ml-1.dropdown{ class: klass }
- %button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_ajax_project_pipeline_path(pipeline.project, pipeline, stage: stage.name) } }
- = sprite_icon(icon_status)
-
- %ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container
- %li.js-builds-dropdown-list.scrollable-menu
- %ul
-
- %li.js-builds-dropdown-loading.hidden
- .loading-container.text-center
- %span.spinner{ 'aria-label': _('Loading') }
diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml
index 3edfd502f13..8b7ef838d2b 100644
--- a/app/views/shared/_mobile_clone_panel.html.haml
+++ b/app/views/shared/_mobile_clone_panel.html.haml
@@ -3,8 +3,8 @@
- http_copy_label = _('Copy %{http_label} clone URL') % { http_label: gitlab_config.protocol.upcase }
.btn-group.mobile-git-clone.js-mobile-git-clone.btn-block
- = clipboard_button(button_text: default_clone_label, text: default_url_to_repo(project), hide_button_icon: true, class: "gl-button btn btn-confirm flex-fill input-group-text clone-dropdown-btn js-clone-dropdown-label")
- %button.gl-button.btn.btn-confirm.btn-icon.dropdown-toggle.js-dropdown-toggle.flex-grow-0.d-flex-center.w-auto.ml-0{ type: "button", data: { toggle: "dropdown" } }
+ = clipboard_button(button_text: default_clone_label, text: default_url_to_repo(project), hide_button_icon: true, class: "gl-button btn-confirm flex-fill bold justify-content-center input-group-text clone-dropdown-btn js-clone-dropdown-label")
+ %button.btn.gl-button.btn-confirm.dropdown-toggle.js-dropdown-toggle.flex-grow-0.d-flex-center.w-auto.ml-0{ type: "button", data: { toggle: "dropdown" } }
= sprite_icon("chevron-down", css_class: "dropdown-btn-icon icon")
%ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown{ data: { dropdown: true } }
- if ssh_enabled?
diff --git a/app/views/shared/_recaptcha_form.html.haml b/app/views/shared/_recaptcha_form.html.haml
index f524747dea0..5c5fc714aea 100644
--- a/app/views/shared/_recaptcha_form.html.haml
+++ b/app/views/shared/_recaptcha_form.html.haml
@@ -20,4 +20,4 @@
- if has_submit
.row-content-block.footer-block
- = f.submit _("Submit %{humanized_resource_name}") % { humanized_resource_name: humanized_resource_name }, class: 'gl-button btn btn-confirm'
+ = f.submit _("Create %{humanized_resource_name}") % { humanized_resource_name: humanized_resource_name }, class: 'gl-button btn btn-confirm'
diff --git a/app/views/shared/_search_settings.html.haml b/app/views/shared/_search_settings.html.haml
index d689e9ae5c0..2974b2bf4d0 100644
--- a/app/views/shared/_search_settings.html.haml
+++ b/app/views/shared/_search_settings.html.haml
@@ -1,6 +1,5 @@
- container_class = local_assigns.fetch(:container_class, 'gl-mt-5')
-- if Feature.enabled?(:search_settings_in_page, @project, default_enabled: false)
- %div{ class: container_class }
- .js-search-settings-app
- %input.gl-form-input.form-control{ type: "text", placeholder: _("Search settings"), aria_label: _("Search settings"), disabled: true }
+%div{ class: container_class }
+ .js-search-settings-app
+ %input.gl-form-input.form-control{ type: "text", placeholder: _("Search settings"), aria_label: _("Search settings"), disabled: true }
diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml
index 9709ad8428e..88c24a27497 100644
--- a/app/views/shared/access_tokens/_form.html.haml
+++ b/app/views/shared/access_tokens/_form.html.haml
@@ -23,7 +23,7 @@
= render_if_exists 'personal_access_tokens/callout_max_personal_access_token_lifetime'
.js-access-tokens-expires-at
- = f.text_field :expires_at, class: 'datepicker gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off'
+ = f.text_field :expires_at, class: 'datepicker gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' }
.form-group
= f.label :scopes, _('Scopes'), class: 'label-bold'
@@ -31,7 +31,7 @@
- if prefix == :personal_access_token && Feature.enabled?(:personal_access_tokens_scoped_to_projects, current_user)
.js-access-tokens-projects
- %input{ type: 'hidden', name: 'temporary-name', id: 'temporary-id' }
+ %input{ type: 'hidden', name: 'personal_access_token[projects]', id: 'personal_access_token_projects', data: { js_name: 'projects' } }
.gl-mt-3
= f.submit _('Create %{type}') % { type: type }, class: 'gl-button btn btn-confirm', data: { qa_selector: 'create_token_button' }
diff --git a/app/views/shared/access_tokens/_table.html.haml b/app/views/shared/access_tokens/_table.html.haml
index d7c74255578..2a2a1a911af 100644
--- a/app/views/shared/access_tokens/_table.html.haml
+++ b/app/views/shared/access_tokens/_table.html.haml
@@ -43,7 +43,7 @@
- else
%span.token-never-expires-label= _('Never')
%td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected')
- %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: 'gl-button btn btn-danger float-right qa-revoke-button', data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type } }
+ %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: 'gl-button btn btn-danger btn-sm float-right qa-revoke-button', data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type } }
- else
.settings-message.text-center
= no_active_tokens_message
diff --git a/app/views/shared/admin/_admin_note.html.haml b/app/views/shared/admin/_admin_note.html.haml
new file mode 100644
index 00000000000..82407705885
--- /dev/null
+++ b/app/views/shared/admin/_admin_note.html.haml
@@ -0,0 +1,7 @@
+- if @group.admin_note.present?
+ - text = @group.admin_note.note
+ .card.border-info
+ .card-header.bg-info.gl-text-white
+ = s_('Admin|Admin notes')
+ .card-body
+ %p= text
diff --git a/app/views/shared/admin/_admin_note_form.html.haml b/app/views/shared/admin/_admin_note_form.html.haml
new file mode 100644
index 00000000000..0bc26f9120f
--- /dev/null
+++ b/app/views/shared/admin/_admin_note_form.html.haml
@@ -0,0 +1,6 @@
+.form-group.row
+ = f.fields_for :admin_note do |an|
+ .col-sm-2.col-form-label.gl-text-right
+ = an.label :note, s_('Admin|Admin notes')
+ .col-sm-10
+ = an.text_area :note, class: 'form-control'
diff --git a/app/views/shared/blob/_markdown_buttons.html.haml b/app/views/shared/blob/_markdown_buttons.html.haml
index 085206714c6..73f3d2a8fcd 100644
--- a/app/views/shared/blob/_markdown_buttons.html.haml
+++ b/app/views/shared/blob/_markdown_buttons.html.haml
@@ -1,4 +1,4 @@
-- modifier_key = client_js_flags[:isMac] ? '⌘' : s_('KeyboardKey|Ctrl+');
+- modifier_key = client_js_flags[:isMac] ? '⌘' : s_('KeyboardKey|Ctrl+')
.md-header-toolbar.active
= markdown_toolbar_button({ icon: "bold",
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index 8c0893adaaa..bf70149812a 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -1,8 +1,5 @@
- board = local_assigns.fetch(:board, nil)
- group = local_assigns.fetch(:group, false)
--# TODO: Move group_id and can_admin_list to the board store
- See: https://gitlab.com/gitlab-org/gitlab/-/issues/213082
-- can_admin_list = can?(current_user, :admin_issue_board_list, current_board_parent) == true
- @no_breadcrumb_container = true
- @no_container = true
- @content_class = "issue-boards-content js-focus-mode-board"
@@ -13,23 +10,8 @@
- page_title("#{board.name}", _("Boards"))
- add_page_specific_style 'page_bundles/boards'
-- content_for :page_specific_javascripts do
-
- %script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal, show_sorting_dropdown: false
-
= 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 }" }
- %board-content{ "v-cloak" => "true",
- "ref" => "board_content",
- ":lists" => "state.lists",
- ":can-admin-list" => can_admin_list,
- ":disabled" => "disabled",
- data: { qa_selector: "boards_list" } }
+ %board-content{ ":lists" => "state.lists", ":disabled" => "disabled" }
= render "shared/boards/components/sidebar", group: group
- %board-settings-sidebar{ ":can-admin-list" => can_admin_list }
- - if @project
- %board-add-issues-modal{ "new-issue-path" => new_project_issue_path(@project),
- "milestone-path" => milestones_filter_dropdown_path,
- "label-path" => labels_filter_path_with_defaults,
- "empty-state-svg" => image_path('illustrations/issues.svg'),
- ":project-id" => @project.id }
+ %board-settings-sidebar
diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml
index 59dd571604b..8976e89b3d3 100644
--- a/app/views/shared/boards/components/_sidebar.html.haml
+++ b/app/views/shared/boards/components/_sidebar.html.haml
@@ -1,6 +1,6 @@
%board-sidebar{ "inline-template" => true, ":current-user" => (UserSerializer.new.represent(current_user) || {}).to_json }
%transition{ name: "boards-sidebar-slide" }
- %aside.right-sidebar.right-sidebar-expanded.issue-boards-sidebar{ "v-show" => "showSidebar", 'aria-label': s_('Boards|Board') }
+ %aside.right-sidebar.right-sidebar-expanded.boards-sidebar{ "v-show" => "showSidebar", 'aria-label': s_('Boards|Board'), 'data-testid': 'issue-boards-sidebar' }
.issuable-sidebar
.block.issuable-sidebar-header.position-relative
%span.issuable-header-text.hide-collapsed.float-left
diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml
index 2ddfcf43756..4d0858165a2 100644
--- a/app/views/shared/deploy_tokens/_form.html.haml
+++ b/app/views/shared/deploy_tokens/_form.html.haml
@@ -22,29 +22,29 @@
= f.label :scopes, _('Scopes [Select 1 or more]'), class: 'label-bold'
%fieldset.form-group.form-check
= f.check_box :read_repository, class: 'form-check-input qa-deploy-token-read-repository'
- = label_tag ("deploy_token_read_repository"), 'read_repository', class: 'label-bold form-check-label'
+ = f.label :read_repository, 'read_repository', class: 'label-bold form-check-label'
.text-secondary= s_('DeployTokens|Allows read-only access to the repository.')
- if container_registry_enabled?(group_or_project)
%fieldset.form-group.form-check
= f.check_box :read_registry, class: 'form-check-input qa-deploy-token-read-registry'
- = label_tag ("deploy_token_read_registry"), 'read_registry', class: 'label-bold form-check-label'
+ = f.label :read_registry, 'read_registry', class: 'label-bold form-check-label'
.text-secondary= s_('DeployTokens|Allows read-only access to registry images.')
%fieldset.form-group.form-check
= f.check_box :write_registry, class: 'form-check-input'
- = label_tag ("deploy_token_write_registry"), 'write_registry', class: 'label-bold form-check-label'
+ = f.label :write_registry, 'write_registry', class: 'label-bold form-check-label'
.text-secondary= s_('DeployTokens|Allows write access to registry images.')
- if packages_registry_enabled?(group_or_project)
%fieldset.form-group.form-check
= f.check_box :read_package_registry, class: 'form-check-input'
- = label_tag ("deploy_token_read_package_registry"), 'read_package_registry', class: 'label-bold form-check-label'
+ = f.label :read_package_registry, 'read_package_registry', class: 'label-bold form-check-label'
.text-secondary= s_('DeployTokens|Allows read access to the package registry.')
%fieldset.form-group.form-check
= f.check_box :write_package_registry, class: 'form-check-input'
- = label_tag ("deploy_token_write_package_registry"), 'write_package_registry', class: 'label-bold form-check-label'
+ = f.label :write_package_registry, 'write_package_registry', class: 'label-bold form-check-label'
.text-secondary= s_('DeployTokens|Allows write access to the package registry.')
.gl-mt-3
diff --git a/app/views/shared/deploy_tokens/_revoke_modal.html.haml b/app/views/shared/deploy_tokens/_revoke_modal.html.haml
deleted file mode 100644
index 2b31c675f74..00000000000
--- a/app/views/shared/deploy_tokens/_revoke_modal.html.haml
+++ /dev/null
@@ -1,15 +0,0 @@
-.modal{ id: "revoke-modal-#{token.id}", tabindex: -1 }
- .modal-dialog
- .modal-content
- .modal-header
- %h4.modal-title
- = s_('DeployTokens|Revoke %{b_start}%{name}%{b_end}?').html_safe % { b_start: '<b>'.html_safe, name: token.name, b_end: '</b>'.html_safe }
- %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
- %span{ "aria-hidden": true } &times;
- .modal-body
- %p
- = s_('DeployTokens|You are about to revoke %{b_start}%{name}%{b_end}.').html_safe % { b_start: '<b>'.html_safe, name: token.name, b_end: '</b>'.html_safe }
- = s_('DeployTokens|This action cannot be undone.')
- .modal-footer.gl-flex-direction-row
- %a{ href: '#', data: { dismiss: 'modal' }, class: 'gl-button btn btn-default' }= _('Cancel')
- = link_to s_('DeployTokens|Revoke %{name}') % { name: token.name }, revoke_deploy_token_path(group_or_project, token), method: :put, class: 'gl-button btn btn-danger text-truncate'
diff --git a/app/views/shared/deploy_tokens/_table.html.haml b/app/views/shared/deploy_tokens/_table.html.haml
index ad3c53c4925..fe32fcf94d0 100644
--- a/app/views/shared/deploy_tokens/_table.html.haml
+++ b/app/views/shared/deploy_tokens/_table.html.haml
@@ -24,8 +24,9 @@
- else
%span.token-never-expires-label= _('Never')
%td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected')
- %td= link_to s_('DeployTokens|Revoke'), "#", class: "gl-button btn btn-danger float-right", data: { toggle: "modal", target: "#revoke-modal-#{token.id}"}
- = render 'shared/deploy_tokens/revoke_modal', token: token, group_or_project: group_or_project
+ %td
+ .js-deploy-token-revoke-button{ data: { button_class: 'float-right', token: token.to_json, revoke_path: revoke_deploy_token_path(group_or_project, token) } }
+
- else
.settings-message.text-center
= s_('DeployTokens|This %{entity_type} has no active Deploy Tokens.') % { entity_type: group_or_project.class.name.downcase }
diff --git a/app/views/doorkeeper/applications/_delete_form.html.haml b/app/views/shared/doorkeeper/applications/_delete_form.html.haml
index 13ae18af2c5..caa553bc2ef 100644
--- a/app/views/doorkeeper/applications/_delete_form.html.haml
+++ b/app/views/shared/doorkeeper/applications/_delete_form.html.haml
@@ -1,8 +1,8 @@
- submit_btn_css ||= 'gl-button btn btn-danger btn-sm'
-= form_tag oauth_application_path(application) do
- %input{ :name => "_method", :type => "hidden", :value => "delete" }/
+= form_tag path do
+ %input{ :name => "_method", :type => "hidden", :value => "delete" }
- if defined? small
- = button_tag type: "submit", class: "gl-button btn btn-default", data: { confirm: _("Are you sure?") } do
+ = button_tag type: "submit", class: "gl-button btn btn-danger btn-icon", 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/shared/doorkeeper/applications/_form.html.haml
index 9f5d87a961f..91a32b55542 100644
--- a/app/views/doorkeeper/applications/_form.html.haml
+++ b/app/views/shared/doorkeeper/applications/_form.html.haml
@@ -1,5 +1,5 @@
-= form_for application, url: doorkeeper_submit_path(application), html: { role: 'form', class: 'doorkeeper-app-form' } do |f|
- = form_errors(application)
+= form_for @application, url: url, html: { role: 'form', class: 'doorkeeper-app-form' } do |f|
+ = form_errors(@application)
.form-group
= f.label :name, class: 'label-bold'
@@ -20,7 +20,7 @@
.form-group
= f.label :scopes, class: 'label-bold'
- = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes
+ = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: @application, scopes: @scopes
.gl-mt-3
= f.submit _('Save application'), class: "gl-button btn btn-confirm"
diff --git a/app/views/shared/doorkeeper/applications/_index.html.haml b/app/views/shared/doorkeeper/applications/_index.html.haml
new file mode 100644
index 00000000000..8ccb4bcdbe0
--- /dev/null
+++ b/app/views/shared/doorkeeper/applications/_index.html.haml
@@ -0,0 +1,88 @@
+- @content_class = "limit-container-width" unless fluid_layout
+
+.row.gl-mt-3
+ .col-lg-4.profile-settings-sidebar
+ %h4.gl-mt-0
+ = page_title
+ %p
+ - if oauth_applications_enabled
+ - if oauth_authorized_applications_enabled
+ = _("Manage applications that can use GitLab as an OAuth provider, and applications that you've authorized to use your account.")
+ - else
+ = _("Manage applications that can use GitLab as an OAuth provider.")
+ - else
+ = _("Manage applications that you've authorized to use your account.")
+ .col-lg-8
+ - if oauth_applications_enabled
+ %h5.gl-mt-0
+ = _('Add new application')
+ = render 'shared/doorkeeper/applications/form', url: form_url
+ %hr
+ - else
+ .bs-callout.bs-callout-disabled
+ = _('Adding new applications is disabled in your GitLab instance. Please contact your GitLab administrator to get the permission')
+ - if oauth_applications_enabled
+ .oauth-applications
+ %h5
+ = _("Your applications (%{size})") % { size: @applications.size }
+ - if @applications.any?
+ .table-responsive
+ %table.table
+ %thead
+ %tr
+ %th= _('Name')
+ %th= _('Callback URL')
+ %th= _('Clients')
+ %th.last-heading
+ %tbody
+ - @applications.each do |application|
+ %tr{ id: "application_#{application.id}" }
+ %td= link_to application.name, application_url.call(application)
+ %td
+ - application.redirect_uri.split.each do |uri|
+ %div= uri
+ %td= application.access_tokens.count
+ %td.gl-display-flex
+ = link_to edit_application_url.call(application), class: "gl-button btn btn-default btn-icon gl-mr-3" do
+ %span.sr-only
+ = _('Edit')
+ = sprite_icon('pencil')
+ = render 'shared/doorkeeper/applications/delete_form', path: application_url.call(application), small: true
+ - else
+ .settings-message.text-center
+ = _("You don't have any applications")
+ - if oauth_authorized_applications_enabled
+ .oauth-authorized-applications.prepend-top-20.gl-mb-3
+ - if oauth_applications_enabled
+ %h5
+ = _("Authorized applications (%{size})") % { size: @authorized_apps.size + @authorized_anonymous_tokens.size }
+
+ - if @authorized_tokens.any?
+ .table-responsive
+ %table.table.table-striped
+ %thead
+ %tr
+ %th= _('Name')
+ %th= _('Authorized At')
+ %th= _('Scope')
+ %th
+ %tbody
+ - @authorized_apps.each do |app|
+ - token = app.authorized_tokens.order('created_at desc').first # rubocop: disable CodeReuse/ActiveRecord
+ %tr{ id: "application_#{app.id}" }
+ %td= app.name
+ %td= token.created_at
+ %td= token.scopes
+ %td= render 'doorkeeper/authorized_applications/delete_form', application: app
+ - @authorized_anonymous_tokens.each do |token|
+ %tr
+ %td
+ = _('Anonymous')
+ .form-text.text-muted
+ %em= _("Authorization was granted by entering your username and password in the application.")
+ %td= token.created_at
+ %td= token.scopes
+ %td= render 'doorkeeper/authorized_applications/delete_form', token: token
+ - else
+ .settings-message.text-center
+ = _("You don't have any authorized applications")
diff --git a/app/views/shared/doorkeeper/applications/_show.html.haml b/app/views/shared/doorkeeper/applications/_show.html.haml
new file mode 100644
index 00000000000..b690aa74ff0
--- /dev/null
+++ b/app/views/shared/doorkeeper/applications/_show.html.haml
@@ -0,0 +1,39 @@
+.table-holder.oauth-application-show
+ %table.table
+ %tr
+ %td
+ = _('Application ID')
+ %td
+ .clipboard-group
+ .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: "gl-button btn btn-default")
+ %tr
+ %td
+ = _('Secret')
+ %td
+ .clipboard-group
+ .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: "gl-button btn btn-default")
+ %tr
+ %td
+ = _('Callback URL')
+ %td
+ - @application.redirect_uri.split.each do |uri|
+ %div
+ %span.monospace= uri
+
+ %tr
+ %td
+ = _('Confidential')
+ %td
+ = @application.confidential? ? _('Yes') : _('No')
+
+ = render "shared/tokens/scopes_list", token: @application
+
+.form-actions
+ = link_to _('Edit'), edit_path, class: 'gl-button btn btn-confirm wide float-left'
+ = render 'shared/doorkeeper/applications/delete_form', path: delete_path, submit_btn_css: 'gl-button btn btn-danger gl-ml-3'
diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml
index 8ccf14463c7..13d9d71d58e 100644
--- a/app/views/shared/empty_states/_issues.html.haml
+++ b/app/views/shared/empty_states/_issues.html.haml
@@ -43,7 +43,7 @@
.text-center
- if project_select_button
= render 'shared/new_project_item_select', path: 'issues/new', label: _('New issue'), type: :issues, with_feature_enabled: 'issues'
- - else
+ - elsif show_new_issue_link?(@project)
= link_to _('New issue'), button_path, class: 'gl-button btn btn-confirm', id: 'new_issue_link'
- if show_import_button
@@ -53,7 +53,7 @@
%strong
= s_('JiraService|Using Jira for issue tracking?')
%p.gl-text-center.gl-mb-0
- - jira_docs_link_url = help_page_url('user/project/integrations/jira', anchor: 'view-jira-issues')
+ - jira_docs_link_url = help_page_url('integration/jira/issues', anchor: 'view-jira-issues')
- jira_docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: jira_docs_link_url }
= html_escape(s_('JiraService|%{jira_docs_link_start}Enable the Jira integration%{jira_docs_link_end} to view your Jira issues in GitLab.')) % { jira_docs_link_start: jira_docs_link_start.html_safe, jira_docs_link_end: '</a>'.html_safe }
%p.gl-text-center.gl-mb-0.gl-text-gray-500
diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml
index 42a845846d1..c813fd691f1 100644
--- a/app/views/shared/empty_states/_profile_tabs.html.haml
+++ b/app/views/shared/empty_states/_profile_tabs.html.haml
@@ -6,7 +6,7 @@
.svg-content
= image_tag illustration_path, size: '75'
.text-content
- - if user_profile? and current_user.present? and current_user.username == params[:username]
+ - if user_profile? && current_user.present? && current_user.username == params[:username]
%h5= current_user_empty_message_header
- if current_user_empty_message_description.present?
diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml
index 0bddffa881a..917ef666e85 100644
--- a/app/views/shared/empty_states/_wikis.html.haml
+++ b/app/views/shared/empty_states/_wikis.html.haml
@@ -18,14 +18,14 @@
- elsif @project && can?(current_user, :read_issue, @project)
- issues_link = link_to s_('WikiEmptyIssueMessage|issue tracker'), project_issues_path(@project)
- - new_issue_link = link_to s_('WikiEmpty|Suggest wiki improvement'), new_project_issue_path(@project), class: 'btn gl-button btn-confirm', title: s_('WikiEmptyIssueMessage|Suggest wiki improvement')
= render layout: layout_path, locals: { image_path: 'illustrations/wiki_logout_empty.svg' } do
%h4
= messages.dig(:issuable, :title)
%p.text-left
= messages.dig(:issuable, :body).html_safe % { issues_link: issues_link }
- = new_issue_link
+ - if show_new_issue_link?(@project)
+ = link_to s_('WikiEmpty|Suggest wiki improvement'), new_project_issue_path(@project), class: 'btn gl-button btn-confirm', title: s_('WikiEmptyIssueMessage|Suggest wiki improvement')
- else
= render layout: layout_path, locals: { image_path: 'illustrations/wiki_logout_empty.svg' } do
diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index 7f4aed5d1f7..f8942dddfb4 100644
--- a/app/views/shared/form_elements/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -1,7 +1,7 @@
- project = local_assigns.fetch(:project)
- model = local_assigns.fetch(:model)
- form = local_assigns.fetch(:form)
-- placeholder = model.is_a?(MergeRequest) ? _('Describe the goal of the changes and what reviewers should be aware of.') : _('Write a comment or drag your files here…')
+- placeholder = model.is_a?(MergeRequest) ? _('Describe the goal of the changes and what reviewers should be aware of.') : _('Write a description or drag your files here…')
- supports_quick_actions = true
- preview_url = preview_markdown_path(project, target_type: model.class.name)
@@ -19,9 +19,10 @@
= 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',
+ classes: 'note-textarea rspec-issuable-form-description',
placeholder: placeholder,
- supports_quick_actions: supports_quick_actions
+ supports_quick_actions: supports_quick_actions,
+ qa_selector: 'issuable_form_description'
= render 'shared/notes/hints', supports_quick_actions: supports_quick_actions
.clearfix
.error-alert
diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml
index 6b056e93460..51f44afe55e 100644
--- a/app/views/shared/hook_logs/_content.html.haml
+++ b/app/views/shared/hook_logs/_content.html.haml
@@ -24,7 +24,7 @@
%h5 Request headers:
%pre
- - hook_log.request_headers.each do |k,v|
+ - hook_log.request_headers.each do |k, v|
<strong>#{k}:</strong> #{v}
%br
@@ -34,7 +34,7 @@
#{Gitlab::Json.pretty_generate(hook_log.request_data)}
%h5 Response headers:
%pre
- - hook_log.response_headers.each do |k,v|
+ - hook_log.response_headers.each do |k, v|
<strong>#{k}:</strong> #{v}
%br
diff --git a/app/views/shared/integrations/_index.html.haml b/app/views/shared/integrations/_index.html.haml
index ccc2c448f69..39365280e71 100644
--- a/app/views/shared/integrations/_index.html.haml
+++ b/app/views/shared/integrations/_index.html.haml
@@ -1,27 +1 @@
-%table.table.b-table.gl-table{ role: 'table', 'aria-busy': false, 'aria-colcount': 4 }
- %colgroup
- %col
- %col
- %col.d-none.d-sm-table-column
- %col{ width: 135 }
- %thead{ role: 'rowgroup' }
- %tr{ role: 'row' }
- %th{ role: 'columnheader', scope: 'col', 'aria-colindex': 1 }
- %th{ role: 'columnheader', scope: 'col', 'aria-colindex': 2 }= _('Integration')
- %th.d-none.d-sm-block{ role: 'columnheader', scope: 'col', 'aria-colindex': 3 }= _('Description')
- %th{ role: 'columnheader', scope: 'col', 'aria-colindex': 4 }= _('Last updated')
-
- %tbody{ role: 'rowgroup' }
- - integrations.each do |integration|
- - activated_label = (integration.activated? ? s_("ProjectService|%{service_title}: status on") : s_("ProjectService|%{service_title}: status off")) % { service_title: integration.title }
- %tr{ role: 'row' }
- %td{ role: 'cell', 'aria-colindex': 1, 'aria-label': activated_label, title: activated_label }
- - if integration.operating?
- = sprite_icon('check', css_class: 'gl-text-green-500')
- %td{ role: 'cell', 'aria-colindex': 2 }
- = link_to integration.title, scoped_edit_integration_path(integration), class: 'gl-font-weight-bold', data: { qa_selector: "#{integration.to_param}_link" }
- %td.d-none.d-sm-table-cell{ role: 'cell', 'aria-colindex': 3 }
- = integration.description
- %td{ role: 'cell', 'aria-colindex': 4 }
- - if integration.updated_at.present?
- = time_ago_with_tooltip integration.updated_at
+.js-integrations-list{ data: integration_list_data(integrations) }
diff --git a/app/views/shared/issuable/_approved_by_dropdown.html.haml b/app/views/shared/issuable/_approved_by_dropdown.html.haml
index 8014545ab85..c64d34d0da4 100644
--- a/app/views/shared/issuable/_approved_by_dropdown.html.haml
+++ b/app/views/shared/issuable/_approved_by_dropdown.html.haml
@@ -1,10 +1,10 @@
#js-dropdown-approved-by.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('None')
%li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('Any')
%li.divider.droplab-item-ignore
- if current_user
diff --git a/app/views/shared/issuable/_board_create_list_dropdown.html.haml b/app/views/shared/issuable/_board_create_list_dropdown.html.haml
index 1a22a66d185..74b064648c0 100644
--- a/app/views/shared/issuable/_board_create_list_dropdown.html.haml
+++ b/app/views/shared/issuable/_board_create_list_dropdown.html.haml
@@ -1,5 +1,5 @@
.dropdown.gl-display-flex.gl-align-items-center.gl-ml-3#js-add-list
- %button.gl-button.btn.btn-confirm.btn-confirm-secondary.js-new-board-list{ type: "button", data: board_list_data }
+ %button.gl-button.btn.btn-confirm.js-new-board-list{ type: "button", data: board_list_data }
Add list
.dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels
= render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" }
diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
index b6fc1f4955b..bbbb728d048 100644
--- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml
+++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
@@ -6,7 +6,7 @@
= form_tag [:bulk_update, @project, type], method: :post, class: "bulk-update" do
.block.issuable-sidebar-header
.filter-item.inline.update-issues-btn.float-left
- = button_tag _('Update all'), class: "gl-button btn update-selected-issues btn-info", disabled: true
+ = button_tag _('Update all'), class: "gl-button btn update-selected-issues btn-confirm", disabled: true
= button_tag _('Cancel'), class: "gl-button btn btn-default js-bulk-update-menu-hide float-right"
- if params[:state] != 'merged'
.block
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 41b7d7e9e1b..de657e39453 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -62,26 +62,24 @@
- is_footer = !(issuable.is_a?(MergeRequest) && issuable.new_record?)
.row-content-block{ class: (is_footer ? "footer-block" : "middle-block") }
- .float-right
- - if issuable.new_record?
- = link_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'gl-button btn btn-cancel'
- - else
- - if can?(current_user, :"destroy_#{issuable.to_ability_name}", @project)
- = link_to 'Delete', polymorphic_path([@project, issuable], params: { destroy_confirm: true }), data: { confirm: "#{issuable.human_class_name} will be removed! Are you sure?" }, method: :delete, class: 'btn btn-danger btn-grouped'
- = link_to _('Cancel'), polymorphic_path([@project, issuable]), class: 'gl-button btn btn-grouped btn-default btn-cancel'
-
- %span.gl-mr-3
- - if issuable.new_record?
- = form.submit "Submit #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-confirm', data: { qa_selector: 'issuable_create_button' }
- - else
- = form.submit 'Save changes', class: 'gl-button btn btn-confirm'
-
- if !issuable.persisted? && !issuable.project.empty_repo? && (guide_url = issuable.project.present.contribution_guide_path)
- .inline.gl-mt-3
+ .gl-mb-5
Please review the
%strong= link_to('contribution guidelines', guide_url)
for this project.
+ - if issuable.new_record?
+ = form.submit "Create #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-confirm gl-mr-2', data: { qa_selector: 'issuable_create_button' }
+ - else
+ = form.submit 'Save changes', class: 'gl-button btn btn-confirm gl-mr-2'
+
+ - if issuable.new_record?
+ = link_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'btn gl-button btn-default'
+ - else
+ = link_to _('Cancel'), polymorphic_path([@project, issuable]), class: 'gl-button btn btn-default'
+ - if can?(current_user, :"destroy_#{issuable.to_ability_name}", @project)
+ = link_to 'Delete', polymorphic_path([@project, issuable], params: { destroy_confirm: true }), data: { confirm: _('%{issuableType} will be removed! Are you sure?') % { issuableType: issuable.human_class_name } }, method: :delete, class: 'btn gl-button btn-danger btn-danger-secondary gl-float-right'
+
= render_if_exists 'shared/issuable/remove_approver'
- if issuable.respond_to?(:issue_type)
diff --git a/app/views/shared/issuable/_invite_members_trigger.html.haml b/app/views/shared/issuable/_invite_members_trigger.html.haml
new file mode 100644
index 00000000000..5dd6ec0addf
--- /dev/null
+++ b/app/views/shared/issuable/_invite_members_trigger.html.haml
@@ -0,0 +1,8 @@
+- return unless can_import_members?
+
+.js-invite-members-modal{ data: { id: project.id,
+ name: project.name,
+ is_project: 'true',
+ access_levels: ProjectMember.access_level_roles.to_json,
+ default_access_level: Gitlab::Access::GUEST,
+ help_link: help_page_url('user/permissions') } }
diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml
index a3d6a2c8e04..cff50eef88b 100644
--- a/app/views/shared/issuable/_nav.html.haml
+++ b/app/views/shared/issuable/_nav.html.haml
@@ -1,6 +1,6 @@
- type = local_assigns.fetch(:type, :issues)
- page_context_word = type.to_s.humanize(capitalize: false)
-- display_count = local_assigns.fetch(:display_count, :true)
+- display_count = local_assigns.fetch(:display_count, true)
%ul.nav-links.issues-state-filters.mobile-separator.nav.nav-tabs
%li{ class: active_when(params[:state] == 'opened') }>
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index f5b2868aa6c..1e340f033a1 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -3,15 +3,15 @@
- show_sorting_dropdown = local_assigns.fetch(:show_sorting_dropdown, true)
- disable_target_branch = local_assigns.fetch(:disable_target_branch, false)
- placeholder = local_assigns[:placeholder] || _('Search or filter results...')
-- is_not_boards_modal_or_productivity_analytics = type != :boards_modal && type != :productivity_analytics
-- block_css_class = is_not_boards_modal_or_productivity_analytics ? 'row-content-block second-block' : ''
-- if board && board.to_type == "EpicBoard"
+- block_css_class = type != :productivity_analytics ? 'row-content-block second-block' : ''
+- is_epic_board = board&.to_type == "EpicBoard"
+- if is_epic_board
- user_can_admin_list = can?(current_user, :admin_epic_board_list, board.resource_parent)
- elsif board
- user_can_admin_list = can?(current_user, :admin_issue_board_list, board.resource_parent)
-.issues-filters{ class: ("w-100" if type == :boards_modal) }
- .issues-details-filters.filtered-search-block.d-flex.flex-column.flex-lg-row{ class: block_css_class, "v-pre" => type == :boards_modal }
+.issues-filters
+ .issues-details-filters.filtered-search-block.d-flex.flex-column.flex-lg-row{ class: block_css_class }
.d-flex.flex-column.flex-md-row.flex-grow-1.mb-lg-0.mb-md-2.mb-sm-0.w-100
- if type == :boards
= render "shared/boards/switcher", board: board
@@ -21,16 +21,16 @@
- if @can_bulk_update
.check-all-holder.d-none.d-sm-block.hidden
= check_box_tag "check-all-issues", nil, false, class: "check-all-issues left"
- - if Feature.enabled?(:boards_filtered_search, @group)
+ - if Feature.enabled?(:boards_filtered_search, @group) && is_epic_board
#js-board-filtered-search
- else
.issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
.filtered-search-box
- - if type != :boards_modal && type != :boards
+ - if type != :boards
- text = tag.span(sprite_icon('history'), class: "d-md-none") + tag.span(_('Recent searches'), class: "d-none d-md-inline")
= dropdown_tag(text,
options: { wrapper_class: "filtered-search-history-dropdown-wrapper",
- toggle_class: "btn filtered-search-history-dropdown-toggle-button",
+ toggle_class: "gl-button btn btn-default filtered-search-history-dropdown-toggle-button",
dropdown_class: "filtered-search-history-dropdown",
content_class: "filtered-search-history-dropdown-content" }) do
.js-filtered-search-history-dropdown{ data: { full_path: search_history_storage_prefix } }
@@ -42,7 +42,7 @@
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
@@ -52,7 +52,7 @@
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
{{ title }}
%span.btn-helptext
{{ help }}
@@ -68,10 +68,10 @@
#js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('None')
%li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('Any')
%li.divider.droplab-item-ignore
- if current_user
@@ -84,10 +84,10 @@
#js-dropdown-reviewer.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('None')
%li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('Any')
%li.divider.droplab-item-ignore
- if current_user
@@ -102,92 +102,92 @@
#js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('None')
%li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('Any')
%li.filter-dropdown-item{ data: { value: 'Upcoming' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('Upcoming')
%li.filter-dropdown-item{ data: { value: 'Started' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('Started')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.btn.btn-link.js-data-value{ type: 'button' }
+ %button.gl-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' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('None')
%li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('Any')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.btn.btn-link.js-data-value{ type: 'button' }
+ %button.gl-button.btn.btn-link.js-data-value{ type: 'button' }
{{title}}
#js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('None')
%li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('Any')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
%span.dropdown-label-box{ style: 'background: {{color}}' }
%span.label-title.js-data-value
{{title}}
#js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('None')
%li.filter-dropdown-item{ data: { value: 'Any' } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('Any')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
%gl-emoji
%span.js-data-value.gl-ml-3
{{name}}
#js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('Yes')
%li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('No')
#js-dropdown-confidential.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('Yes')
%li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
- %button.btn.btn-link{ type: 'button' }
+ %button.gl-button.btn.btn-link{ type: 'button' }
= _('No')
- unless disable_target_branch
#js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.btn.btn-link.js-data-value.monospace
+ %button.gl-button.btn.btn-link.js-data-value.monospace
{{title}}
#js-dropdown-environment.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
- %button.btn.btn-link.js-data-value{ type: 'button' }
+ %button.gl-button.btn.btn-link.js-data-value{ type: 'button' }
{{title}}
= render_if_exists 'shared/issuable/filter_weight', type: type
@@ -207,8 +207,6 @@
.js-create-column-trigger{ data: board_list_data }
- else
= render 'shared/issuable/board_create_list_dropdown', board: board
- - if @project
- #js-add-issues-btn{ data: { can_admin_list: can?(current_user, :admin_issue_board_list, @project) } }
#js-toggle-focus-btn
- - elsif is_not_boards_modal_or_productivity_analytics && show_sorting_dropdown
+ - elsif type != :productivity_analytics && show_sorting_dropdown
= render 'shared/issuable/sort_dropdown'
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 787d9db1192..fb2019bef15 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -34,7 +34,7 @@
- if issuable_sidebar[:supports_milestone]
- milestone = issuable_sidebar[:milestone] || {}
- .block.milestone{ class: 'gl-border-b-0!', data: { qa_selector: 'milestone_block' } }
+ .block.milestone{ :class => ("gl-border-b-0!" if issuable_sidebar[:supports_iterations]), data: { qa_selector: 'milestone_block' } }
.sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
= sprite_icon('clock')
%span.milestone-title.collapse-truncated-title
@@ -70,41 +70,7 @@
= _('Time tracking')
= loading_icon(css_class: 'gl-vertical-align-text-bottom')
- if issuable_sidebar.has_key?(:due_date)
- .block.due_date
- .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable_sidebar[:due_date]) }
- = sprite_icon('calendar')
- %span.js-due-date-sidebar-value
- = issuable_sidebar[:due_date].try(:to_s, :medium) || _('None')
- .title.hide-collapsed
- = _('Due date')
- = loading_icon(css_class: 'gl-vertical-align-text-bottom hidden block-loading')
- - if can_edit_issuable
- = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "due_date", track_event: "click_edit_button", track_value: "" }
- .value.hide-collapsed
- %span.value-content
- - if issuable_sidebar[:due_date]
- %span.bold= issuable_sidebar[:due_date].to_s(:medium)
- - else
- %span.no-value
- = _('None')
- - if can_edit_issuable
- %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable_sidebar[:due_date].nil?) }
- \-
- %a.js-remove-due-date{ href: "#", role: "button" }
- = _('remove due date')
- - if can_edit_issuable
- .selectbox.hide-collapsed
- = f.hidden_field :due_date, value: issuable_sidebar[:due_date].try(:strftime, 'yy-mm-dd')
- .dropdown
- %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable_type}[due_date]", ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], display: 'static' } }
- %span.dropdown-toggle-text
- = _('Due date')
- = 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-due-date-entry-point
.js-sidebar-labels{ data: sidebar_labels_data(issuable_sidebar, @project) }
@@ -133,12 +99,12 @@
.block.with-sub-blocks
#js-reference-entry-point
- if issuable_type == 'merge_request'
- .sidebar-source-branch.sub-block
+ .sub-block.js-sidebar-source-branch
.sidebar-collapsed-icon.dont-change-state
= clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport')
- .sidebar-mr-source-branch.hide-collapsed
- %span
- = _('Source branch: %{source_branch_open}%{source_branch}%{source_branch_close}').html_safe % { source_branch_open: "<cite class='ref-name' title='#{html_escape(source_branch)}'>".html_safe, source_branch_close: "</cite>".html_safe, source_branch: html_escape(source_branch) }
+ .gl-display-flex.gl-align-items-center.gl-justify-content-space-between.gl-mb-2.hide-collapsed
+ %span.gl-overflow-hidden.gl-text-overflow-ellipsis.gl-white-space-nowrap
+ = _('Source branch: %{source_branch_open}%{source_branch}%{source_branch_close}').html_safe % { source_branch_open: "<span class='gl-font-monospace' data-testid='ref-name' title='#{html_escape(source_branch)}'>".html_safe, source_branch_close: "</span>".html_safe, source_branch: html_escape(source_branch) }
= clipboard_button(text: source_branch, title: _('Copy branch name'), placement: "left", boundary: 'viewport')
- if show_forwarding_email
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index 26986c913f0..47e7ff0e4bc 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -1,6 +1,7 @@
- issuable_type = issuable_sidebar[:type]
+- dropdown_options = assignees_dropdown_options(issuable_type)
-#js-vue-sidebar-assignees{ data: { field: issuable_type, signed_in: signed_in } }
+#js-vue-sidebar-assignees{ data: { field: issuable_type, signed_in: signed_in, max_assignees: dropdown_options[:data][:"max-select"], directly_invite_members: directly_invite_members?, indirectly_invite_members: indirectly_invite_members? } }
.title.hide-collapsed
= _('Assignee')
= loading_icon(css_class: 'gl-vertical-align-text-bottom')
@@ -29,7 +30,6 @@
null_user: true,
display: 'static' } }
- - dropdown_options = assignees_dropdown_options(issuable_type)
- title = dropdown_options[:title]
- options[:toggle_class] += ' js-multiselect js-save-user-data'
- data = { field_name: "#{issuable_type}[assignee_ids][]" }
@@ -53,10 +53,10 @@
%ul.dropdown-footer-list
%li
- if directly_invite_members?
- = link_to invite_text,
- project_project_members_path(@project),
- title: invite_text,
- data: { 'is-link': true, 'track-event': 'click_invite_members', 'track-label': track_label }
+ .js-invite-members-trigger{ data: { trigger_element: 'anchor',
+ display_text: invite_text,
+ event: 'click_invite_members',
+ label: track_label } }
- else
.js-invite-member-trigger{ data: { display_text: invite_text, event: 'click_invite_members_version_b', label: track_label } }
- else
diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml
index f60be3f3e4a..9e3caf62d77 100644
--- a/app/views/shared/issuable/_sort_dropdown.html.haml
+++ b/app/views/shared/issuable/_sort_dropdown.html.haml
@@ -5,7 +5,7 @@
.dropdown.inline.gl-ml-3.issue-sort-dropdown
.btn-group{ role: 'group' }
.btn-group{ role: 'group' }
- %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
+ %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'gl-button btn btn-default' }
= sort_title
= 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
diff --git a/app/views/shared/issuable/_user_dropdown_item.html.haml b/app/views/shared/issuable/_user_dropdown_item.html.haml
index 4a3547e9e70..b3bc7ba85ad 100644
--- a/app/views/shared/issuable/_user_dropdown_item.html.haml
+++ b/app/views/shared/issuable/_user_dropdown_item.html.haml
@@ -2,7 +2,7 @@
- avatar = local_assigns.fetch(:avatar, { })
%li.filter-dropdown-item{ class: ('js-current-user' if user == current_user) }
- %button.btn.btn-link.dropdown-user{ type: :button }
+ %button.gl-button.btn.btn-link.dropdown-user{ type: :button }
.avatar-container.s40
= user_avatar_without_link(user: user, lazy: avatar[:lazy], url: avatar[:url], size: 40, has_tooltip: false)
.dropdown-user-details
diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml
index 67bc4019a82..b5c5e2fa091 100644
--- a/app/views/shared/issuable/form/_type_selector.html.haml
+++ b/app/views/shared/issuable/form/_type_selector.html.haml
@@ -3,26 +3,30 @@
.form-group.row.gl-mb-0
= form.label :type, 'Type', class: 'col-form-label col-sm-2'
.col-sm-10
- .issuable-form-select-holder.selectbox.form-group.gl-mb-0
- .dropdown.js-issuable-type-filter-dropdown-wrap
- %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
- %span.dropdown-toggle-text.is-default
- = issuable.issue_type.capitalize || _("Select type")
- = 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
- = _("Select type")
- %button.dropdown-title-button.dropdown-menu-close.gl-ml-auto{ type: 'button', "aria-label" => _('Close') }
- = sprite_icon('close', size: 16, css_class: 'dropdown-menu-close-icon')
- .dropdown-content
- %ul
- %li.js-filter-issuable-type
- = link_to new_project_issue_path(@project), class: ("is-active" if issuable.issue?) do
- = _("Issue")
- %li.js-filter-issuable-type{ data: { track: { event: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } }
- = link_to new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }), class: ("is-active" if issuable.incident?) do
- = _("Incident")
+ .gl-display-flex.gl-align-items-center
+ .issuable-form-select-holder.selectbox.form-group.gl-mb-0
+ .dropdown.js-issuable-type-filter-dropdown-wrap
+ %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
+ %span.dropdown-toggle-text.is-default
+ = issuable.issue_type.capitalize || _("Select type")
+ = 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
+ = _("Select type")
+ %button.dropdown-title-button.dropdown-menu-close.gl-ml-auto{ type: 'button', "aria-label" => _('Close') }
+ = sprite_icon('close', size: 16, css_class: 'dropdown-menu-close-icon')
+ .dropdown-content
+ %ul
+ %li.js-filter-issuable-type
+ = link_to new_project_issue_path(@project), class: ("is-active" if issuable.issue?) do
+ = _("Issue")
+ %li.js-filter-issuable-type{ data: { track: { event: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } }
+ = link_to new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }), class: ("is-active" if issuable.incident?) do
+ = _("Incident")
+
+ #js-type-popover
+
- if issuable.incident?
%p.form-text.text-muted
- incident_docs_url = help_page_path('operations/incident_management/incidents.md')
diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml
index 7c1ec332ba4..ceedb5e5c59 100644
--- a/app/views/shared/issue_type/_details_content.html.haml
+++ b/app/views/shared/issue_type/_details_content.html.haml
@@ -1,4 +1,5 @@
- related_branches_path = related_branches_project_issue_path(@project, issuable)
+- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
.issue-details.issuable-details
.detail-page-description.content-block
@@ -24,7 +25,7 @@
#related-branches{ data: { url: related_branches_path } }
-# This element is filled in using JavaScript.
- = render 'shared/issue_type/emoji_block', issuable: issuable
+ = render 'shared/issue_type/emoji_block', issuable: issuable, api_awards_path: api_awards_path
= render 'projects/issues/discussion'
diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml
index 7e150c544bd..36a68dfdaa7 100644
--- a/app/views/shared/issue_type/_details_header.html.haml
+++ b/app/views/shared/issue_type/_details_header.html.haml
@@ -1,7 +1,7 @@
.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-sm-display-none!')
+ = sprite_icon('issue-close', css_class: 'gl-display-block gl-sm-display-none!')
.gl-display-none.gl-sm-display-block!
= issue_closed_text(issuable, current_user)
.issuable-status-box.status-box.status-box-open{ class: issue_status_visibility(issuable, status_box: :open) }
@@ -13,7 +13,7 @@
#js-issuable-header-warnings
= issuable_meta(issuable, @project)
- %a.btn.gl-button.btn-default.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
+ %a.btn.gl-button.btn-default.btn-icon.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
= sprite_icon('chevron-double-lg-left')
.js-issue-header-actions{ data: issue_header_actions_data(@project, issuable, current_user) }
diff --git a/app/views/shared/issue_type/_emoji_block.html.haml b/app/views/shared/issue_type/_emoji_block.html.haml
index 42d149b2ab3..ca2749b6bf9 100644
--- a/app/views/shared/issue_type/_emoji_block.html.haml
+++ b/app/views/shared/issue_type/_emoji_block.html.haml
@@ -1,7 +1,9 @@
+- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
+
.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
+ = render 'award_emoji/awards_block', awardable: issuable, inline: true, api_awards_path: api_awards_path
.new-branch-col
= render_if_exists "projects/issues/timeline_toggle", issuable: issuable
#js-vue-sort-issue-discussions
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index 0ba3e539357..88e2a74d235 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -67,7 +67,7 @@
- if member.can_resend_invite?
= link_to sprite_icon('paper-airplane'), polymorphic_path([:resend_invite, member]),
method: :post,
- class: 'btn btn-default align-self-center mr-sm-2',
+ class: 'gl-button btn btn-default align-self-center mr-sm-2',
title: _('Resend invite')
- if user != current_user && member.can_update?
@@ -120,7 +120,7 @@
= sprite_icon('leave', css_class: 'gl-icon')
= _('Leave')
- else
- %button{ data: { member_path: member_path(member.member), message: remove_member_message(member), is_access_request: member.request?.to_s, qa_selector: 'delete_member_button' },
+ %button{ data: { member_path: member_path(member.member), member_type: member.type, message: remove_member_message(member), is_access_request: member.request?.to_s, qa_selector: 'delete_member_button' },
class: "js-remove-member-button btn gl-button btn-danger align-self-center m-0 #{'ml-sm-2 btn-icon' unless force_mobile_view}",
title: remove_member_title(member) }
%span{ class: ('d-block d-sm-none' unless force_mobile_view) }
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index f52bf1551f4..44934a12559 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -44,22 +44,22 @@
= link_to pluralize(milestone.total_issues_count, _('Issue')), issues_path
- if milestone.merge_requests_enabled?
&middot;
- = link_to pluralize(milestone.merge_requests_visible_to_user(current_user).size, _('Merge Request')), merge_requests_path
+ = link_to pluralize(milestone.total_merge_requests_count, _('Merge request')), merge_requests_path
.float-lg-right.light #{milestone.percent_complete}% complete
.col-sm-2
.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.gl-button.btn-default-tertiary.btn-sm.btn-grouped.has-tooltip{ title: s_('Milestones|Promote to Group Milestone'),
+ %button.js-promote-project-milestone-button.btn.gl-button.btn-icon.btn-default-tertiary.btn-sm.has-tooltip{ title: s_('Milestones|Promote to Group Milestone'),
disabled: true,
type: 'button',
data: { url: promote_project_milestone_path(milestone.project, milestone),
milestone_title: milestone.title,
group_name: @project.group.name } }
- = sprite_icon('level-up', size: 14)
+ = sprite_icon('level-up', size: 14, css_class: 'gl-button-icon gl-icon')
- 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 gl-button btn-sm btn-grouped"
+ = link_to s_('Milestones|Reopen Milestone'), milestone_path(milestone, milestone: { state_event: :activate }), method: :put, class: "btn gl-button btn-sm gl-ml-3"
- else
- = 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"
+ = link_to s_('Milestones|Close Milestone'), milestone_path(milestone, milestone: { state_event: :close }), method: :put, class: "btn gl-button btn-default btn-default-secondary btn-sm gl-ml-3"
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index 661ace8feaa..0e54f1a7672 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -165,6 +165,6 @@
.cross-project-reference.hide-collapsed
%span
= s_('MilestoneSidebar|Reference:')
- %cite{ title: milestone_ref }
+ %span{ title: milestone_ref }
= milestone_ref
= clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport')
diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml
index 33e634c3e7b..3524a1b17ea 100644
--- a/app/views/shared/milestones/_tabs.html.haml
+++ b/app/views/shared/milestones/_tabs.html.haml
@@ -11,7 +11,7 @@
- if milestone.merge_requests_enabled?
%li.nav-item
= link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests', show_project_name: show_project_name) } do
- = _('Merge Requests')
+ = _('Merge requests')
%span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size
%li.nav-item
= link_to '#tab-participants', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'participants') } do
diff --git a/app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml b/app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml
new file mode 100644
index 00000000000..1e9aa4ec5ff
--- /dev/null
+++ b/app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml
@@ -0,0 +1,14 @@
+- attribute = local_assigns.fetch(:attribute, nil)
+- group = local_assigns.fetch(:group, nil)
+- form = local_assigns.fetch(:form, nil)
+
+- return unless attribute && group && form && cascading_namespace_settings_enabled?
+- return if group.namespace_settings.public_send("#{attribute}_locked?")
+
+- lock_attribute = "lock_#{attribute}"
+
+.gl-form-checkbox.custom-control.custom-checkbox
+ = form.check_box lock_attribute, checked: group.namespace_settings.public_send(lock_attribute), class: 'custom-control-input', data: { testid: 'enforce-for-all-subgroups-checkbox' }
+ = form.label lock_attribute, class: 'custom-control-label' do
+ %span= s_('CascadingSettings|Enforce for all subgroups')
+ %p.help-text= s_('CascadingSettings|Subgroups cannot change this setting.')
diff --git a/app/views/shared/namespaces/cascading_settings/_lock_popovers.html.haml b/app/views/shared/namespaces/cascading_settings/_lock_popovers.html.haml
new file mode 100644
index 00000000000..91458bf180b
--- /dev/null
+++ b/app/views/shared/namespaces/cascading_settings/_lock_popovers.html.haml
@@ -0,0 +1 @@
+.js-cascading-settings-lock-popovers
diff --git a/app/views/shared/namespaces/cascading_settings/_setting_label.html.haml b/app/views/shared/namespaces/cascading_settings/_setting_label.html.haml
new file mode 100644
index 00000000000..6596ce2bc73
--- /dev/null
+++ b/app/views/shared/namespaces/cascading_settings/_setting_label.html.haml
@@ -0,0 +1,21 @@
+- attribute = local_assigns.fetch(:attribute, nil)
+- group = local_assigns.fetch(:group, nil)
+- form = local_assigns.fetch(:form, nil)
+- settings_path_helper = local_assigns.fetch(:settings_path_helper, nil)
+- help_text = local_assigns.fetch(:help_text, nil)
+
+- return unless attribute && group && form && settings_path_helper
+
+- setting_locked = group.namespace_settings.public_send("#{attribute}_locked?")
+
+= form.label attribute, class: 'custom-control-label', aria: { disabled: setting_locked } do
+ %span.position-relative.gl-pr-6.gl-display-inline-flex
+ = yield
+ - if setting_locked
+ %button.position-absolute.gl-top-3.gl-right-0.gl-translate-y-n50.gl-cursor-default.btn.btn-default.btn-sm.gl-button.btn-default-tertiary.js-cascading-settings-lock-popover-target{ class: 'gl-p-1! gl-text-gray-600! gl-bg-transparent!',
+ type: 'button',
+ data: cascading_namespace_settings_popover_data(attribute, group, settings_path_helper) }
+ = sprite_icon('lock', size: 16)
+ - if help_text
+ %p.help-text
+ = help_text
diff --git a/app/views/shared/nav/_scope_menu.html.haml b/app/views/shared/nav/_scope_menu.html.haml
new file mode 100644
index 00000000000..270587f48a8
--- /dev/null
+++ b/app/views/shared/nav/_scope_menu.html.haml
@@ -0,0 +1,6 @@
+.context-header
+ = link_to scope_menu.link, **scope_menu.container_html_options do
+ .avatar-container.rect-avatar.s40.project-avatar
+ = source_icon(scope_menu.container, alt: scope_menu.title, class: 'avatar s40 avatar-tile', width: 40, height: 40)
+ .sidebar-context-title
+ = scope_menu.title
diff --git a/app/views/shared/nav/_sidebar.html.haml b/app/views/shared/nav/_sidebar.html.haml
new file mode 100644
index 00000000000..1c06fc9eebf
--- /dev/null
+++ b/app/views/shared/nav/_sidebar.html.haml
@@ -0,0 +1,14 @@
+%aside.nav-sidebar{ class: ('sidebar-collapsed-desktop' if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(sidebar.container), 'aria-label': sidebar.aria_label }
+ .nav-sidebar-inner-scroll
+ - if sidebar.scope_menu
+ = render partial: 'shared/nav/scope_menu', object: sidebar.scope_menu
+ - elsif sidebar.render_raw_scope_menu_partial
+ = render sidebar.render_raw_scope_menu_partial
+
+ %ul.sidebar-top-level-items.qa-project-sidebar
+ - if sidebar.renderable_menus.any?
+ = render partial: 'shared/nav/sidebar_menu', collection: sidebar.renderable_menus
+ - if sidebar.render_raw_menus_partial
+ = render sidebar.render_raw_menus_partial
+
+ = render 'shared/sidebar_toggle_button'
diff --git a/app/views/shared/nav/_sidebar_menu.html.haml b/app/views/shared/nav/_sidebar_menu.html.haml
new file mode 100644
index 00000000000..c6e86a90ba7
--- /dev/null
+++ b/app/views/shared/nav/_sidebar_menu.html.haml
@@ -0,0 +1,27 @@
+= nav_link(**sidebar_menu.all_active_routes, html_options: sidebar_menu.nav_link_html_options) do
+ = link_to sidebar_menu.link, **sidebar_menu.container_html_options, data: { qa_selector: 'sidebar_menu_link', qa_menu_item: sidebar_menu.title } do
+ - if sidebar_menu.icon_or_image?
+ .nav-icon-container
+ - if sidebar_menu.image_path
+ = image_tag(sidebar_menu.image_path, **sidebar_menu.image_html_options)
+ - elsif sidebar_menu.sprite_icon
+ = sprite_icon(sidebar_menu.sprite_icon, **sidebar_menu.sprite_icon_html_options)
+
+ %span.nav-item-name{ **sidebar_menu.title_html_options }
+ = sidebar_menu.title
+ - if sidebar_menu.has_pill?
+ %span.badge.badge-pill.count{ **sidebar_menu.pill_html_options }
+ = number_with_delimiter(sidebar_menu.pill_count)
+
+ %ul.sidebar-sub-level-items{ class: ('is-fly-out-only' unless sidebar_menu.has_items?) }
+ = nav_link(**sidebar_menu.all_active_routes, html_options: { class: 'fly-out-top-item' } ) do
+ = link_to sidebar_menu.link, aria: { label: sidebar_menu.title } do
+ %strong.fly-out-top-item-name
+ = sidebar_menu.title
+ - if sidebar_menu.has_pill?
+ %span.badge.badge-pill.count.fly-out-badge{ **sidebar_menu.pill_html_options }
+ = number_with_delimiter(sidebar_menu.pill_count)
+
+ - if sidebar_menu.has_renderable_items?
+ %li.divider.fly-out-top-item
+ = render partial: 'shared/nav/sidebar_menu_item', collection: sidebar_menu.renderable_items
diff --git a/app/views/shared/nav/_sidebar_menu_item.html.haml b/app/views/shared/nav/_sidebar_menu_item.html.haml
new file mode 100644
index 00000000000..0b0e4c7aec9
--- /dev/null
+++ b/app/views/shared/nav/_sidebar_menu_item.html.haml
@@ -0,0 +1,8 @@
+= nav_link(**sidebar_menu_item.active_routes) do
+ = link_to sidebar_menu_item.link, **sidebar_menu_item.container_html_options, data: { qa_selector: 'sidebar_menu_item_link', qa_menu_item: sidebar_menu_item.title } do
+ %span
+ = sidebar_menu_item.title
+ - if sidebar_menu_item.sprite_icon
+ = sprite_icon(sidebar_menu_item.sprite_icon, **sidebar_menu_item.sprite_icon_html_options)
+ - if sidebar_menu_item.show_hint?
+ .js-feature-highlight{ **sidebar_menu_item.hint_html_options }
diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml
index 4f1aa3a7b01..1129fed9c3b 100644
--- a/app/views/shared/notes/_comment_button.html.haml
+++ b/app/views/shared/notes/_comment_button.html.haml
@@ -1,15 +1,15 @@
- noteable_name = @note.noteable.human_class_name
.float-left.btn-group.gl-mr-3.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown
- %input.btn.gl-button.btn-success.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment'), data: { qa_selector: 'comment_button' } }
+ %input.btn.gl-button.btn-confirm.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment'), data: { qa_selector: 'comment_button' } }
- if @note.can_be_discussion_note?
- = button_tag type: 'button', class: 'gl-button btn dropdown-toggle btn-success js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => _('Open comment type dropdown') do
+ = button_tag type: 'button', class: 'gl-button btn dropdown-toggle btn-confirm js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => _('Open comment type dropdown') do
= sprite_icon('chevron-down')
%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.gl-button.btn-transparent
+ %button.btn.gl-button.btn-default-tertiary
= sprite_icon('check', css_class: 'icon')
.description
%strong= _("Comment")
@@ -19,7 +19,7 @@
%li.divider.droplab-item-ignore
%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.gl-button.btn-transparent
+ %button.btn.gl-button.btn-default-tertiary
= sprite_icon('check', css_class: 'icon')
.description
%strong= _("Start thread")
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index f1352be28e3..6549c86ab29 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -69,7 +69,7 @@
.note-attachment
- if note.attachment.image?
= link_to note.attachment.url, target: '_blank' do
- = image_tag note.attachment.url, class: 'note-image-attach'
+ = image_tag note.attachment.url, class: 'note-image-attach col-lg-4'
.attachment
= link_to note.attachment.url, target: '_blank' do
= sprite_icon('paperclip')
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index a33cd7c3b53..7466f360f67 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -24,7 +24,7 @@
.avatar-container.s48.flex-grow-0.flex-shrink-0{ class: avatar_container_class }
= link_to project_path(project), class: dom_class(project) do
- if project.creator && use_creator_avatar
- = image_tag avatar_icon_for_user(project.creator, 48), class: "avatar s48", alt:''
+ = image_tag avatar_icon_for_user(project.creator, 48), class: "avatar s48", alt: ''
- else
= project_icon(project, alt: '', class: 'avatar project-avatar s48', width: 48, height: 48)
.project-details.d-sm-flex.flex-sm-fill.align-items-center{ data: { qa_selector: 'project_content', qa_project_name: project.name } }
@@ -90,7 +90,7 @@
- if show_merge_request_count?(disabled: !merge_requests, compact_mode: compact_mode)
= link_to project_merge_requests_path(project),
class: "d-none d-xl-flex align-items-center icon-wrapper merge-requests has-tooltip",
- title: _('Merge Requests'), data: { container: 'body', placement: 'top' } do
+ title: _('Merge requests'), data: { container: 'body', placement: 'top' } do
= sprite_icon('git-merge', size: 14, css_class: 'gl-mr-2')
= number_with_delimiter(project.open_merge_requests_count)
- if show_issue_count?(disabled: !issues, compact_mode: compact_mode)
diff --git a/app/views/shared/projects/_search_bar.html.haml b/app/views/shared/projects/_search_bar.html.haml
index 6c3a6ce809f..8ec11d9cfbb 100644
--- a/app/views/shared/projects/_search_bar.html.haml
+++ b/app/views/shared/projects/_search_bar.html.haml
@@ -3,7 +3,7 @@
- flex_grow_and_shrink_xs = 'd-flex flex-xs-grow-1 flex-xs-shrink-1 flex-grow-0 flex-shrink-0'
.filtered-search-block.row-content-block.bt-0
- .filtered-search-wrapper.d-flex.flex-nowrap.flex-column.flex-sm-wrap.flex-sm-row.flex-xl-nowrap
+ .filtered-search-wrapper.d-flex.gl-flex-nowrap.flex-column.flex-sm-wrap.flex-sm-row.flex-xl-nowrap
- unless project_tab_filter == :starred
.filtered-search-nav.mb-2.mb-lg-0{ class: flex_grow_and_shrink_xs }
= render 'dashboard/projects/nav', project_tab_filter: project_tab_filter
diff --git a/app/views/shared/projects/_sort_dropdown.html.haml b/app/views/shared/projects/_sort_dropdown.html.haml
index 3e810dc6f08..f3aeaacbdb1 100644
--- a/app/views/shared/projects/_sort_dropdown.html.haml
+++ b/app/views/shared/projects/_sort_dropdown.html.haml
@@ -3,7 +3,7 @@
.btn-group.w-100{ role: "group" }
.btn-group.w-100.dropdown.js-project-filter-dropdown-wrap{ role: "group" }
- %button#sort-projects-dropdown.btn.btn-default.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } }
+ %button#sort-projects-dropdown.gl-button.btn.btn-default.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } }
= toggle_text
= sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
diff --git a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml
index 75d6d88fbc3..6a362866f41 100644
--- a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml
+++ b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml
@@ -33,6 +33,6 @@
%p.small
= _('Members of %{group} can also push to this branch: %{branch}') % { group: (group_push_access_levels.size > 1 ? 'these groups' : 'this group'), branch: group_push_access_levels.map(&:humanize).to_sentence }
-- if ::Feature.enabled?(:allow_force_push_to_protected_branches, @project)
+- if ::Feature.enabled?(:allow_force_push_to_protected_branches, @project, default_enabled: :yaml)
%td
= render "shared/buttons/project_feature_toggle", is_checked: protected_branch.allow_force_push, label: s_("ProtectedBranch|Toggle allow force push"), class_list: "js-force-push-toggle project-feature-toggle", data: { qa_selector: 'force_push_toggle_button', qa_branch_name: protected_branch.name }
diff --git a/app/views/shared/promotions/_promote_servicedesk.html.haml b/app/views/shared/promotions/_promote_servicedesk.html.haml
index 3a5123acdeb..a2da23de2b9 100644
--- a/app/views/shared/promotions/_promote_servicedesk.html.haml
+++ b/app/views/shared/promotions/_promote_servicedesk.html.haml
@@ -1,6 +1,6 @@
.user-callout.promotion-callout.js-service-desk-callout#promote_service_desk{ data: { uid: 'promote_service_desk_dismissed' } }
.bordered-box.content-block
- %button.btn.btn-default.close.js-close-callout{ type: 'button', 'aria-label' => 'Dismiss Service Desk promotion' }
+ %button.gl-button.btn.btn-default.close.js-close-callout{ type: 'button', 'aria-label' => 'Dismiss Service Desk promotion' }
= sprite_icon('close', size: 16, css_class: 'dismiss-icon')
.svg-container
= custom_icon('icon_service_desk')
diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml
index bb2aa93740e..8d0069a7664 100644
--- a/app/views/shared/runners/_form.html.haml
+++ b/app/views/shared/runners/_form.html.haml
@@ -59,4 +59,4 @@
.col-sm-10
= f.text_field :private_projects_minutes_cost_factor, class: 'form-control'
.form-actions
- = f.submit _('Save changes'), class: 'btn btn-success'
+ = f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/shared/runners/_runner_type_alert.html.haml b/app/views/shared/runners/_runner_type_alert.html.haml
new file mode 100644
index 00000000000..b83def8b802
--- /dev/null
+++ b/app/views/shared/runners/_runner_type_alert.html.haml
@@ -0,0 +1,20 @@
+.gl-alert.gl-alert-info.gl-my-5
+ = sprite_icon('information-o', css_class: 'gl-alert-icon')
+ - if runner.instance_type?
+ %h4.gl-alert-title
+ = s_('Runners|This runner is available to all groups and projects in your GitLab instance.')
+ .gl-alert-body
+ = s_('Runners|Shared runners are available to every project in a GitLab instance. If you want a runner to build only specific projects, restrict the project in the table below. After you restrict a runner to a project, you cannot change it back to a shared runner.')
+ = link_to _('Learn more.'), help_page_path('ci/runners/README', anchor: 'shared-runners'), target: '_blank', rel: 'noopener noreferrer'
+ - elsif runner.group_type?
+ %h4.gl-alert-title
+ = s_('Runners|This runner is available to all projects and subgroups in a group.')
+ .gl-alert-body
+ = s_('Runners|Use Group runners when you want all projects in a group to have access to a set of runners.')
+ = link_to _('Learn more.'), help_page_path('ci/runners/README', anchor: 'group-runners'), target: '_blank', rel: 'noopener noreferrer'
+ - else
+ %h4.gl-alert-title
+ = s_('Runners|This runner is associated with specific projects.')
+ .gl-alert-body
+ = s_('Runners|You can set up a specific runner to be used by multiple projects but you cannot make this a shared runner.')
+ = link_to _('Learn more.'), help_page_path('ci/runners/README', anchor: 'specific-runners'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/shared/runners/_runner_type_badge.html.haml b/app/views/shared/runners/_runner_type_badge.html.haml
new file mode 100644
index 00000000000..e0318006f09
--- /dev/null
+++ b/app/views/shared/runners/_runner_type_badge.html.haml
@@ -0,0 +1,10 @@
+
+- if runner.instance_type?
+ %span.badge.badge-pill.gl-badge.badge-success
+ = s_('Runners|shared')
+- elsif runner.group_type?
+ %span.badge.badge-pill.gl-badge.badge-success
+ = s_('Runners|group')
+- else
+ %span.badge.badge-pill.gl-badge.badge-info
+ = s_('Runners|specific')
diff --git a/app/views/shared/runners/show.html.haml b/app/views/shared/runners/show.html.haml
index 1af04b808bf..757ec870f79 100644
--- a/app/views/shared/runners/show.html.haml
+++ b/app/views/shared/runners/show.html.haml
@@ -1,17 +1,8 @@
- page_title "#{@runner.description} ##{@runner.id}", _("Runners")
-%h3.page-title
- = s_('Runners|Runner #%{id}' % { id: @runner.id })
- .float-right
- - if @runner.instance_type?
- %span.runner-state.runner-state-shared
- = s_('Runners|Shared')
- - elsif @runner.group_type?
- %span.runner-state.runner-state-shared
- = s_('Runners|Group')
- - else
- %span.runner-state.runner-state-specific
- = s_('Runners|Specific')
+%h2.page-title
+ = s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id })
+ = render 'shared/runners/runner_type_badge', runner: @runner
.table-holder
%table.table
diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml
index 82e32597c94..1c8e300fa8a 100644
--- a/app/views/shared/tokens/_scopes_form.html.haml
+++ b/app/views/shared/tokens/_scopes_form.html.haml
@@ -5,5 +5,5 @@
- scopes.each do |scope|
%fieldset.form-group.form-check
= check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", class: "form-check-input qa-#{scope}-radio"
- = label_tag ("#{prefix}_scopes_#{scope}"), scope, class: 'label-bold form-check-label'
+ = label_tag "#{prefix}_scopes_#{scope}", scope, class: 'label-bold form-check-label'
.text-secondary= t scope, scope: scope_description(prefix)
diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml
index 5437748a57e..abe23d0be78 100644
--- a/app/views/shared/web_hooks/_hook.html.haml
+++ b/app/views/shared/web_hooks/_hook.html.haml
@@ -11,6 +11,6 @@
= hook.enable_ssl_verification ? _('enabled') : _('disabled')
.col-md-4.col-lg-5.text-right-md.gl-mt-2
- %span>= render 'shared/web_hooks/test_button', hook: hook, button_class: 'btn-sm gl-mr-3'
- %span>= link_to _('Edit'), edit_hook_path(hook), class: 'gl-button btn btn-sm gl-mr-3'
- = link_to _('Delete'), destroy_hook_path(hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'gl-button btn btn-sm'
+ %span>= render 'shared/web_hooks/test_button', hook: hook, button_class: 'btn-sm btn-default gl-mr-3'
+ %span>= link_to _('Edit'), edit_hook_path(hook), class: 'btn gl-button btn-default btn-sm gl-mr-3'
+ = link_to _('Delete'), destroy_hook_path(hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn gl-button btn-default btn-sm'
diff --git a/app/views/shared/web_hooks/_test_button.html.haml b/app/views/shared/web_hooks/_test_button.html.haml
index a683f75c779..3ffa45f01be 100644
--- a/app/views/shared/web_hooks/_test_button.html.haml
+++ b/app/views/shared/web_hooks/_test_button.html.haml
@@ -2,11 +2,12 @@
- hook = local_assigns.fetch(:hook)
- triggers = hook.class.triggers
-.hook-test-button.dropdown.inline>
+.hook-test-button.dropdown.gl-new-dropdown.inline>
%button.btn.gl-button{ 'data-toggle' => 'dropdown', class: button_class }
= _('Test')
= sprite_icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right{ role: 'menu' }
- - triggers.each_value do |event|
- %li
- = link_to_test_hook(hook, event)
+ .gl-new-dropdown-inner
+ - triggers.each_value do |event|
+ %li.gl-new-dropdown-item
+ = link_to_test_hook(hook, event)
diff --git a/app/views/shared/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml
index d91e3c73c49..e121725b9af 100644
--- a/app/views/shared/wikis/_form.html.haml
+++ b/app/views/shared/wikis/_form.html.haml
@@ -1,79 +1,6 @@
-- form_classes = %w[wiki-form common-note-form gl-mt-3 js-quick-submit]
+- page_info = { last_commit_sha: @page.last_commit_sha, persisted: @page.persisted?, title: @page.title, content: @page.content || '', format: @page.format.to_s, uploads_path: uploads_path, path: wiki_page_path(@wiki, @page), wiki_path: wiki_path(@wiki), help_path: help_page_path('user/project/wiki/index'), markdown_help_path: help_page_path('user/markdown'), markdown_preview_path: wiki_page_path(@wiki, @page, action: :preview_markdown), create_path: wiki_path(@wiki, action: :create) }
-- if @page.persisted?
- - form_action = wiki_page_path(@wiki, @page)
- - form_method = :put
-- else
- - form_action = wiki_path(@wiki, action: :create)
- - form_method = :post
- - form_classes << 'js-new-wiki-page'
-
-= form_for @page, url: form_action, method: form_method,
- html: { class: form_classes },
- data: { uploads_path: uploads_path } do |f|
+.gl-mt-3
= form_errors(@page, truncate: :title)
- - if @page.persisted?
- = f.hidden_field :last_commit_sha, value: @page.last_commit_sha
-
- .form-group.row
- .col-sm-2.col-form-label= f.label :title, class: 'control-label-full-width'
- .col-sm-10
- = f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title, required: true, autofocus: !@page.persisted?, placeholder: s_('Wiki|Page title')
- %span.gl-display-inline-block.gl-max-w-full.gl-mt-2.gl-text-gray-600
- = sprite_icon('bulb', size: 12, css_class: 'gl-mr-n1')
- - if @page.persisted?
- = s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.")
- = link_to sprite_icon('question-o'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'),
- target: '_blank', rel: 'noopener noreferrer'
- - else
- = s_("WikiNewPageTip|Tip: You can specify the full path for the new file. We will automatically create any missing directories.")
- = succeed '.' do
- = link_to _('More information'), help_page_path('user/project/wiki/index', anchor: 'creating-a-new-wiki-page'),
- target: '_blank', rel: 'noopener noreferrer'
- .form-group.row
- .col-sm-2.col-form-label= f.label :format, class: 'control-label-full-width'
- .col-sm-10
- .select-wrapper
- = f.select :format, options_for_select(Wiki::MARKUPS, {selected: @page.format}), {}, class: 'form-control select-control'
- = sprite_icon('chevron-down', css_class: 'gl-absolute gl-top-3 gl-right-3 gl-text-gray-200')
-
- .form-group.row
- .col-sm-2.col-form-label= f.label :content, class: 'control-label-full-width'
- .col-sm-10
- = render layout: 'shared/md_preview', locals: { url: wiki_page_path(@wiki, @page, action: :preview_markdown) } do
- = render 'shared/zen', f: f, attr: :content, classes: 'note-textarea qa-wiki-content-textarea', placeholder: s_("WikiPage|Write your content or drag files here…"), autofocus: @page.persisted?
- = render 'shared/notes/hints'
-
- .clearfix
- .error-alert
-
- .form-text.gl-text-gray-600
- = succeed '.' do
- - case @page.format.to_s
- - when 'rdoc'
- - link_example = '{Link title}[link:page-slug]'
- - when 'asciidoc'
- - link_example = 'link:page-slug[Link title]'
- - when 'org'
- - link_example = '[[page-slug]]'
- - else
- - link_example = '[Link Title](page-slug)'
- = html_escape(s_('WikiMarkdownTip|To link to a (new) page, simply type %{link_example}')) % { link_example: tag.code(link_example, class: 'js-markup-link-example') }
- = succeed '.' do
- - markdown_link = link_to s_("WikiMarkdownDocs|documentation"), help_page_path('user/markdown', anchor: 'wiki-specific-markdown')
- = (s_("WikiMarkdownDocs|More examples are in the %{docs_link}") % { docs_link: markdown_link }).html_safe
-
- .form-group.row
- .col-sm-2.col-form-label= f.label :commit_message, class: 'control-label-full-width'
- .col-sm-10= f.text_field :message, class: 'form-control qa-wiki-message-textbox', rows: 18, value: nil
-
- .form-actions
- - if @page && @page.persisted?
- = f.submit _("Save changes"), class: 'btn gl-button btn-confirm qa-save-changes-button js-wiki-btn-submit', disabled: 'true'
- .float-right
- = link_to _("Cancel"), wiki_page_path(@wiki, @page), class: 'btn gl-button btn-cancel btn-default'
- - else
- = f.submit s_("Wiki|Create page"), class: 'btn-confirm gl-button btn qa-create-page-button rspec-create-page-button js-wiki-btn-submit', disabled: 'true'
- .float-right
- = link_to _("Cancel"), wiki_path(@wiki), class: 'btn gl-button btn-cancel btn-default'
+#js-wiki-form{ data: { page_info: page_info.to_json, format_options: Wiki::MARKUPS.to_json } }
diff --git a/app/views/shared/wikis/history.html.haml b/app/views/shared/wikis/history.html.haml
index b1dcd2cd400..079b9768730 100644
--- a/app/views/shared/wikis/history.html.haml
+++ b/app/views/shared/wikis/history.html.haml
@@ -28,7 +28,7 @@
%td
= commit.author_name
%td
- %span.str-truncated-60
+ .commit-content
= link_to wiki_page_path(@wiki, @page, action: :diff, version_id: commit.id), { title: commit.message } do
= commit.message
%td
diff --git a/app/views/shared/wikis/pages.html.haml b/app/views/shared/wikis/pages.html.haml
index f5ba1c83de4..1889b6501c9 100644
--- a/app/views/shared/wikis/pages.html.haml
+++ b/app/views/shared/wikis/pages.html.haml
@@ -5,8 +5,6 @@
- add_page_specific_style 'page_bundles/wiki'
.wiki-page-header.top-area.flex-column.flex-lg-row
-
-
%h3.page-title.gl-flex-fill-1
= s_("Wiki|Wiki Pages")
diff --git a/app/views/sherlock/queries/_general.html.haml b/app/views/sherlock/queries/_general.html.haml
index 92f4f16f453..cd810ae10ad 100644
--- a/app/views/sherlock/queries/_general.html.haml
+++ b/app/views/sherlock/queries/_general.html.haml
@@ -26,7 +26,7 @@
.card
.card-header
.float-right
- %button.js-clipboard-trigger.btn.btn-sm{ title: t('sherlock.copy_to_clipboard'), type: :button }
+ %button.js-clipboard-trigger.gl-button.btn.btn-default.btn-sm{ title: t('sherlock.copy_to_clipboard'), type: :button }
= sprite_icon('copy-to-clipboard')
%pre.hidden
= @query.formatted_query
@@ -41,7 +41,7 @@
.card
.card-header
.float-right
- %button.js-clipboard-trigger.btn.btn-sm{ title: t('sherlock.copy_to_clipboard'), type: :button }
+ %button.js-clipboard-trigger.gl-button.btn.btn-default.btn-sm{ title: t('sherlock.copy_to_clipboard'), type: :button }
= sprite_icon('copy-to-clipboard')
%pre.hidden
= @query.explain
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 51483df19d7..daa41e0ebfe 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -67,13 +67,13 @@
.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{ itemprop: 'address', itemscope: true, itemtype: 'https://schema.org/PostalAddress' }
- = sprite_icon('location', css_class: 'vertical-align-sub fgray')
- %span.vertical-align-middle{ itemprop: 'addressLocality' }
+ = sprite_icon('location', css_class: 'fgray')
+ %span{ 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
+ = sprite_icon('work', css_class: 'fgray')
+ %span
= work_information(@user, with_schema_markup: true)
.cover-desc.cgray.mb-1.mb-sm-2
- unless @user.skype.blank?
@@ -163,6 +163,7 @@
- if profile_tab?(:activity)
#activity.tab-pane
+ .flash-container
- if can?(current_user, :read_cross_project)
%h4.prepend-top-20
= s_('UserProfile|Most Recent Activity')
diff --git a/app/views/users/terms/index.html.haml b/app/views/users/terms/index.html.haml
index da8b73fd4fd..73d0f51f9ac 100644
--- a/app/views/users/terms/index.html.haml
+++ b/app/views/users/terms/index.html.haml
@@ -6,11 +6,11 @@
.card-footer.footer-block.clearfix
- if can?(current_user, :accept_terms, @term)
.float-right
- = button_to accept_term_path(@term, redirect_params), class: 'gl-button 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-confirm gl-ml-3', data: { qa_selector: 'accept_terms_button' } do
= _('Accept terms')
- else
.float-right
- = link_to root_path, class: 'gl-button btn btn-success gl-ml-3' do
+ = link_to root_path, class: 'gl-button btn btn-confirm gl-ml-3' do
= _('Continue')
- if can?(current_user, :decline_terms, @term)
.float-right
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index ff26aa7a4be..fa6ea54e342 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -25,7 +25,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent: true
+ :idempotent:
:tags: []
- :name: authorized_project_update:authorized_project_update_user_refresh_with_low_urgency
:feature_category: :authentication_and_authorization
@@ -187,6 +187,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: cronjob:database_batched_background_migration
+ :feature_category: :database
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:environments_auto_stop_cron
:feature_category: :continuous_delivery
:has_external_dependencies:
@@ -435,6 +443,22 @@
:weight: 1
:idempotent:
:tags: []
+- :name: cronjob:ssh_keys_expired_notification
+ :feature_category: :compliance_management
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: cronjob:ssh_keys_expiring_soon_notification
+ :feature_category: :compliance_management
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:stuck_ci_jobs
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -1083,6 +1107,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: package_repositories:packages_go_sync_packages
+ :feature_category: :package_registry
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: package_repositories:packages_maven_metadata_sync
:feature_category: :package_registry
:has_external_dependencies:
@@ -1099,6 +1131,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: package_repositories:packages_rubygems_extraction
+ :feature_category: :package_registry
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: pipeline_background:archive_trace
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -1187,6 +1227,14 @@
:weight: 4
:idempotent:
:tags: []
+- :name: pipeline_creation:merge_requests_create_pipeline
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :high
+ :resource_boundary: :cpu
+ :weight: 4
+ :idempotent: true
+ :tags: []
- :name: pipeline_creation:run_pipeline_schedule
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -1203,6 +1251,22 @@
:weight: 3
:idempotent:
:tags: []
+- :name: pipeline_default:ci_drop_pipeline
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 3
+ :idempotent: true
+ :tags: []
+- :name: pipeline_default:ci_merge_requests_add_todo_when_build_fails
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 3
+ :idempotent: true
+ :tags: []
- :name: pipeline_default:ci_pipeline_bridge_status
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -1283,6 +1347,14 @@
:weight: 5
:idempotent:
:tags: []
+- :name: pipeline_processing:ci_initial_pipeline_process
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :high
+ :resource_boundary: :unknown
+ :weight: 5
+ :idempotent: true
+ :tags: []
- :name: pipeline_processing:ci_resource_groups_assign_resource_from_resource_group
:feature_category: :continuous_delivery
:has_external_dependencies:
@@ -1355,6 +1427,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: todos_destroyer:todos_destroyer_destroyed_issuable
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: todos_destroyer:todos_destroyer_entity_leave
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -1475,6 +1555,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: bulk_imports_pipeline
+ :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
@@ -1781,9 +1869,9 @@
:idempotent: true
:tags: []
- :name: mailers
- :feature_category:
+ :feature_category: :issue_tracking
:has_external_dependencies:
- :urgency:
+ :urgency: low
:resource_boundary:
:weight: 2
:idempotent:
@@ -1812,6 +1900,14 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: merge_requests_assignees_change
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :urgency: :high
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: merge_requests_delete_source_branch
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1820,6 +1916,22 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: merge_requests_handle_assignees_change
+ :feature_category: :code_review
+ :has_external_dependencies:
+ :urgency: :high
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: merge_requests_resolve_todos
+ :feature_category: :code_review
+ :has_external_dependencies:
+ :urgency: :high
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: metrics_dashboard_prune_old_annotations
:feature_category: :metrics
:has_external_dependencies:
@@ -2056,6 +2168,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: projects_post_creation
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: projects_schedule_bulk_repository_shard_moves
:feature_category: :gitaly
:has_external_dependencies:
@@ -2273,7 +2393,7 @@
:idempotent:
:tags: []
- :name: update_highest_role
- :feature_category: :authentication_and_authorization
+ :feature_category: :utilization
:has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
diff --git a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
index 9bd1ad2ed30..6635c322ab8 100644
--- a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
+++ b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb
@@ -1,18 +1,49 @@
# frozen_string_literal: true
module AuthorizedProjectUpdate
- class UserRefreshOverUserRangeWorker
+ class UserRefreshOverUserRangeWorker # rubocop:disable Scalability/IdempotentWorker
+ # When the feature flag named `periodic_project_authorization_update_via_replica` is enabled,
+ # this worker checks if a specific user requires an update to their project_authorizations records.
+ # This check is done via the data read from the database replica (and not from the primary).
+ # If this check returns true, a completely new Sidekiq job is enqueued for this specific user
+ # so as to update its project_authorizations records.
+
+ # There is a possibility that the data in the replica is lagging behind the primary
+ # and hence it becomes very important that we check if an update is indeed required for this user
+ # once again via the primary database, which is the reason why we enqueue a completely new Sidekiq job
+ # via `UserRefreshWithLowUrgencyWorker` for this user.
+
include ApplicationWorker
feature_category :authentication_and_authorization
urgency :low
queue_namespace :authorized_project_update
+ # This job will not be deduplicated since it is marked with
+ # `data_consistency :delayed` and not `idempotent!`
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/325291
deduplicate :until_executing, including_scheduled: true
-
- idempotent!
+ data_consistency :delayed, feature_flag: :periodic_project_authorization_update_via_replica
def perform(start_user_id, end_user_id)
- AuthorizedProjectUpdate::RecalculateForUserRangeService.new(start_user_id, end_user_id).execute
+ if Feature.enabled?(:periodic_project_authorization_update_via_replica)
+ User.where(id: start_user_id..end_user_id).find_each do |user| # rubocop: disable CodeReuse/ActiveRecord
+ enqueue_project_authorizations_refresh(user) if project_authorizations_needs_refresh?(user)
+ end
+ else
+ AuthorizedProjectUpdate::RecalculateForUserRangeService.new(start_user_id, end_user_id).execute
+ end
+ end
+
+ private
+
+ def project_authorizations_needs_refresh?(user)
+ AuthorizedProjectUpdate::FindRecordsDueForRefreshService.new(user).needs_refresh?
+ end
+
+ def enqueue_project_authorizations_refresh(user)
+ with_context(user: user) do
+ AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker.perform_async(user.id)
+ end
end
end
end
diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb
index 3f99b30fdf7..aeda8d113ac 100644
--- a/app/workers/build_finished_worker.rb
+++ b/app/workers/build_finished_worker.rb
@@ -37,6 +37,10 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker
ExpirePipelineCacheWorker.perform_async(build.pipeline_id)
ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat?
+ if build.failed?
+ ::Ci::MergeRequests::AddTodoWhenBuildFailsWorker.perform_async(build.id)
+ end
+
##
# We want to delay sending a build trace to object storage operation to
# validate that this fixes a race condition between this and flushing live
diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb
index ce4aa7229aa..5e05063f058 100644
--- a/app/workers/build_hooks_worker.rb
+++ b/app/workers/build_hooks_worker.rb
@@ -7,6 +7,7 @@ class BuildHooksWorker # rubocop:disable Scalability/IdempotentWorker
queue_namespace :pipeline_hooks
feature_category :continuous_integration
urgency :high
+ data_consistency :delayed, feature_flag: :load_balancing_for_build_hooks_worker
# rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb
index e6bc54895a7..b4b9d9b05c1 100644
--- a/app/workers/bulk_import_worker.rb
+++ b/app/workers/bulk_import_worker.rb
@@ -21,9 +21,11 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker
@bulk_import.start! if @bulk_import.created?
created_entities.first(next_batch_size).each do |entity|
- entity.start!
+ create_pipeline_tracker_for(entity)
BulkImports::EntityWorker.perform_async(entity.id)
+
+ entity.start!
end
re_enqueue
@@ -65,4 +67,13 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker
def re_enqueue
BulkImportWorker.perform_in(PERFORM_DELAY, @bulk_import.id)
end
+
+ def create_pipeline_tracker_for(entity)
+ BulkImports::Stage.pipelines.each do |stage, pipeline|
+ entity.trackers.create!(
+ stage: stage,
+ pipeline_name: pipeline
+ )
+ end
+ end
end
diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb
index 5b41ccbdea1..7f173b738cf 100644
--- a/app/workers/bulk_imports/entity_worker.rb
+++ b/app/workers/bulk_imports/entity_worker.rb
@@ -10,24 +10,47 @@ module BulkImports
worker_has_external_dependencies!
- def perform(entity_id)
- entity = BulkImports::Entity.with_status(:started).find_by_id(entity_id)
+ def perform(entity_id, current_stage = nil)
+ return if stage_running?(entity_id, current_stage)
+
+ logger.info(
+ worker: self.class.name,
+ entity_id: entity_id,
+ current_stage: current_stage
+ )
+
+ next_pipeline_trackers_for(entity_id).each do |pipeline_tracker|
+ BulkImports::PipelineWorker.perform_async(
+ pipeline_tracker.id,
+ pipeline_tracker.stage,
+ entity_id
+ )
+ end
+ rescue => e
+ logger.error(
+ worker: self.class.name,
+ entity_id: entity_id,
+ current_stage: current_stage,
+ error_message: e.message
+ )
+
+ Gitlab::ErrorTracking.track_exception(e, entity_id: entity_id)
+ end
- if entity
- entity.update!(jid: jid)
+ private
- BulkImports::Importers::GroupImporter.new(entity).execute
- end
+ def stage_running?(entity_id, stage)
+ return unless stage
- rescue => e
- extra = {
- bulk_import_id: entity&.bulk_import&.id,
- entity_id: entity&.id
- }
+ BulkImports::Tracker.stage_running?(entity_id, stage)
+ end
- Gitlab::ErrorTracking.track_exception(e, extra)
+ def next_pipeline_trackers_for(entity_id)
+ BulkImports::Tracker.next_pipeline_trackers_for(entity_id)
+ end
- entity&.fail_op
+ def logger
+ @logger ||= Gitlab::Import::Logger.build
end
end
end
diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb
new file mode 100644
index 00000000000..a6de3c36205
--- /dev/null
+++ b/app/workers/bulk_imports/pipeline_worker.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class PipelineWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ feature_category :importers
+
+ sidekiq_options retry: false, dead: false
+
+ worker_has_external_dependencies!
+
+ def perform(pipeline_tracker_id, stage, entity_id)
+ pipeline_tracker = ::BulkImports::Tracker
+ .with_status(:created)
+ .find_by_id(pipeline_tracker_id)
+
+ if pipeline_tracker.present?
+ logger.info(
+ worker: self.class.name,
+ entity_id: pipeline_tracker.entity.id,
+ pipeline_name: pipeline_tracker.pipeline_name
+ )
+
+ run(pipeline_tracker)
+ else
+ logger.error(
+ worker: self.class.name,
+ entity_id: entity_id,
+ pipeline_tracker_id: pipeline_tracker_id,
+ message: 'Unstarted pipeline not found'
+ )
+ end
+
+ ensure
+ ::BulkImports::EntityWorker.perform_async(entity_id, stage)
+ end
+
+ private
+
+ def run(pipeline_tracker)
+ pipeline_tracker.update!(status_event: 'start', jid: jid)
+
+ context = ::BulkImports::Pipeline::Context.new(pipeline_tracker)
+
+ pipeline_tracker.pipeline_class.new(context).run
+
+ pipeline_tracker.finish!
+ rescue => e
+ pipeline_tracker.fail_op!
+
+ logger.error(
+ worker: self.class.name,
+ entity_id: pipeline_tracker.entity.id,
+ pipeline_name: pipeline_tracker.pipeline_name,
+ message: e.message
+ )
+
+ Gitlab::ErrorTracking.track_exception(
+ e,
+ entity_id: pipeline_tracker.entity.id,
+ pipeline_name: pipeline_tracker.pipeline_name
+ )
+ end
+
+ def logger
+ @logger ||= Gitlab::Import::Logger.build
+ end
+ end
+end
diff --git a/app/workers/chaos/kill_worker.rb b/app/workers/chaos/kill_worker.rb
index 3dedd47a1f9..4148c139d42 100644
--- a/app/workers/chaos/kill_worker.rb
+++ b/app/workers/chaos/kill_worker.rb
@@ -7,8 +7,8 @@ module Chaos
sidekiq_options retry: false
- def perform
- Gitlab::Chaos.kill
+ def perform(signal)
+ Gitlab::Chaos.kill(signal)
end
end
end
diff --git a/app/workers/ci/drop_pipeline_worker.rb b/app/workers/ci/drop_pipeline_worker.rb
new file mode 100644
index 00000000000..d19157a47e8
--- /dev/null
+++ b/app/workers/ci/drop_pipeline_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Ci
+ class DropPipelineWorker
+ include ApplicationWorker
+ include PipelineQueue
+
+ idempotent!
+
+ def perform(pipeline_id, failure_reason)
+ Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
+ Ci::DropPipelineService.new.execute(pipeline, failure_reason.to_sym)
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/initial_pipeline_process_worker.rb b/app/workers/ci/initial_pipeline_process_worker.rb
new file mode 100644
index 00000000000..f59726c87fb
--- /dev/null
+++ b/app/workers/ci/initial_pipeline_process_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Ci
+ class InitialPipelineProcessWorker
+ include ApplicationWorker
+ include PipelineQueue
+
+ queue_namespace :pipeline_processing
+ feature_category :continuous_integration
+ urgency :high
+ loggable_arguments 1
+ idempotent!
+
+ def perform(pipeline_id)
+ Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
+ Ci::ProcessPipelineService
+ .new(pipeline)
+ .execute
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb b/app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb
new file mode 100644
index 00000000000..d5e097dc2b5
--- /dev/null
+++ b/app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+module Ci
+ module MergeRequests
+ class AddTodoWhenBuildFailsWorker
+ include ApplicationWorker
+ include PipelineQueue
+
+ urgency :low
+ idempotent!
+
+ def perform(job_id)
+ job = ::CommitStatus.with_pipeline.find_by_id(job_id)
+ project = job&.project
+
+ return unless job && project
+
+ ::MergeRequests::AddTodoWhenBuildFailsService.new(job.project, nil).execute(job)
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb b/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb
index 0bb911bc6c8..fff979d95a9 100644
--- a/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb
+++ b/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb
@@ -14,7 +14,7 @@ module Ci
feature_category :continuous_integration
def perform
- service = ::Ci::PipelineArtifacts::DestroyExpiredArtifactsService.new
+ service = ::Ci::PipelineArtifacts::DestroyAllExpiredService.new
artifacts_count = service.execute
log_extra_metadata_on_done(:destroyed_pipeline_artifacts_count, artifacts_count)
end
diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
index d101ef100d8..0de26e27631 100644
--- a/app/workers/concerns/application_worker.rb
+++ b/app/workers/concerns/application_worker.rb
@@ -18,7 +18,7 @@ module ApplicationWorker
set_queue
def structured_payload(payload = {})
- context = Labkit::Context.current.to_h.merge(
+ context = Gitlab::ApplicationContext.current.merge(
'class' => self.class.name,
'job_status' => 'running',
'queue' => self.class.queue,
diff --git a/app/workers/concerns/cronjob_queue.rb b/app/workers/concerns/cronjob_queue.rb
index 955387b5ad4..b89d6bba72c 100644
--- a/app/workers/concerns/cronjob_queue.rb
+++ b/app/workers/concerns/cronjob_queue.rb
@@ -15,7 +15,7 @@ module CronjobQueue
# Cronjobs never get scheduled with arguments, so this is safe to
# override
def context_for_arguments(_args)
- return if Gitlab::ApplicationContext.current_context_include?('meta.caller_id')
+ return if Gitlab::ApplicationContext.current_context_include?(:caller_id)
Gitlab::ApplicationContext.new(caller_id: "Cronjob")
end
diff --git a/app/workers/concerns/each_shard_worker.rb b/app/workers/concerns/each_shard_worker.rb
index 00f589f957e..d1d558f55fe 100644
--- a/app/workers/concerns/each_shard_worker.rb
+++ b/app/workers/concerns/each_shard_worker.rb
@@ -24,7 +24,13 @@ module EachShardWorker
end
def healthy_ready_shards
- ready_shards.select(&:success)
+ success_checks, failed_checks = ready_shards.partition(&:success)
+
+ if failed_checks.any?
+ ::Gitlab::AppLogger.error(message: 'Excluding unhealthy shards', failed_checks: failed_checks.map(&:payload), class: self.class.name)
+ end
+
+ success_checks
end
def ready_shards
diff --git a/app/workers/concerns/reactive_cacheable_worker.rb b/app/workers/concerns/reactive_cacheable_worker.rb
index 189b0607605..9e882c8ac7a 100644
--- a/app/workers/concerns/reactive_cacheable_worker.rb
+++ b/app/workers/concerns/reactive_cacheable_worker.rb
@@ -17,10 +17,10 @@ module ReactiveCacheableWorker
def perform(class_name, id, *args)
klass = begin
- class_name.constantize
- rescue NameError
- nil
- end
+ class_name.constantize
+ rescue NameError
+ nil
+ end
return unless klass
diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb
index 042508d08f2..6f99fd089ac 100644
--- a/app/workers/concerns/worker_attributes.rb
+++ b/app/workers/concerns/worker_attributes.rb
@@ -11,6 +11,8 @@ module WorkerAttributes
# Urgencies that workers can declare through the `urgencies` attribute
VALID_URGENCIES = [:high, :low, :throttled].freeze
+ VALID_DATA_CONSISTENCIES = [:always, :sticky, :delayed].freeze
+
NAMESPACE_WEIGHTS = {
auto_devops: 2,
auto_merge: 3,
@@ -69,6 +71,35 @@ module WorkerAttributes
class_attributes[:urgency] || :low
end
+ def data_consistency(data_consistency, feature_flag: nil)
+ raise ArgumentError, "Invalid data consistency: #{data_consistency}" unless VALID_DATA_CONSISTENCIES.include?(data_consistency)
+ raise ArgumentError, 'Data consistency is already set' if class_attributes[:data_consistency]
+
+ class_attributes[:data_consistency_feature_flag] = feature_flag if feature_flag
+ class_attributes[:data_consistency] = data_consistency
+
+ validate_worker_attributes!
+ end
+
+ def validate_worker_attributes!
+ # Since the deduplication should always take into account the latest binary replication pointer into account,
+ # not the first one, the deduplication will not work with sticky or delayed.
+ # Follow up issue to improve this: https://gitlab.com/gitlab-org/gitlab/-/issues/325291
+ if idempotent? && get_data_consistency != :always
+ raise ArgumentError, "Class can't be marked as idempotent if data_consistency is not set to :always"
+ end
+ end
+
+ def get_data_consistency
+ class_attributes[:data_consistency] || :always
+ end
+
+ def get_data_consistency_feature_flag_enabled?
+ return true unless class_attributes[:data_consistency_feature_flag]
+
+ Feature.enabled?(class_attributes[:data_consistency_feature_flag], default_enabled: :yaml)
+ end
+
# Set this attribute on a job when it will call to services outside of the
# application, such as 3rd party applications, other k8s clusters etc See
# doc/development/sidekiq_style_guide.md#jobs-with-external-dependencies for
@@ -96,6 +127,8 @@ module WorkerAttributes
def idempotent!
class_attributes[:idempotent] = true
+
+ validate_worker_attributes!
end
def idempotent?
diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb
index 43dbea027f2..5ca89179099 100644
--- a/app/workers/container_expiration_policy_worker.rb
+++ b/app/workers/container_expiration_policy_worker.rb
@@ -9,7 +9,7 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo
InvalidPolicyError = Class.new(StandardError)
- BATCH_SIZE = 1000.freeze
+ BATCH_SIZE = 1000
def perform
throttling_enabled? ? perform_throttled : perform_unthrottled
@@ -29,13 +29,15 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo
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
+ unless loopless_enabled?
+ 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
end
@@ -75,6 +77,10 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo
Feature.enabled?(:container_registry_expiration_policies_throttling)
end
+ def loopless_enabled?
+ Feature.enabled?(:container_registry_expiration_policies_loopless)
+ end
+
def lease_timeout
5.hours
end
diff --git a/app/workers/database/batched_background_migration_worker.rb b/app/workers/database/batched_background_migration_worker.rb
new file mode 100644
index 00000000000..de274d58ad7
--- /dev/null
+++ b/app/workers/database/batched_background_migration_worker.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Database
+ class BatchedBackgroundMigrationWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ feature_category :database
+ idempotent!
+
+ LEASE_TIMEOUT_MULTIPLIER = 3
+ MINIMUM_LEASE_TIMEOUT = 10.minutes.freeze
+ INTERVAL_VARIANCE = 5.seconds.freeze
+
+ def perform
+ return unless Feature.enabled?(:execute_batched_migrations_on_schedule, type: :ops) && active_migration
+
+ with_exclusive_lease(active_migration.interval) do
+ # Now that we have the exclusive lease, reload migration in case another process has changed it.
+ # This is a temporary solution until we have better concurrency handling around job execution
+ #
+ # We also have to disable this cop, because ApplicationRecord aliases reset to reload, but our database
+ # models don't inherit from ApplicationRecord
+ active_migration.reload # rubocop:disable Cop/ActiveRecordAssociationReload
+
+ run_active_migration if active_migration.active? && active_migration.interval_elapsed?(variance: INTERVAL_VARIANCE)
+ end
+ end
+
+ private
+
+ def active_migration
+ @active_migration ||= Gitlab::Database::BackgroundMigration::BatchedMigration.active_migration
+ end
+
+ def run_active_migration
+ Gitlab::Database::BackgroundMigration::BatchedMigrationRunner.new.run_migration_job(active_migration)
+ end
+
+ def with_exclusive_lease(interval)
+ timeout = max(interval * LEASE_TIMEOUT_MULTIPLIER, MINIMUM_LEASE_TIMEOUT)
+ lease = Gitlab::ExclusiveLease.new(lease_key, timeout: timeout)
+
+ yield if lease.try_obtain
+ ensure
+ lease&.cancel
+ end
+
+ def max(left, right)
+ left >= right ? left : right
+ end
+
+ def lease_key
+ self.class.name.demodulize.underscore
+ end
+ end
+end
diff --git a/app/workers/delete_stored_files_worker.rb b/app/workers/delete_stored_files_worker.rb
index 689ac3dd0ce..9cf5631b7d8 100644
--- a/app/workers/delete_stored_files_worker.rb
+++ b/app/workers/delete_stored_files_worker.rb
@@ -9,8 +9,8 @@ class DeleteStoredFilesWorker # rubocop:disable Scalability/IdempotentWorker
def perform(class_name, keys)
klass = begin
class_name.constantize
- rescue NameError
- nil
+ rescue NameError
+ nil
end
unless klass
diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb
index 1a34bf50d87..978b65802dd 100644
--- a/app/workers/emails_on_push_worker.rb
+++ b/app/workers/emails_on_push_worker.rb
@@ -56,7 +56,7 @@ class EmailsOnPushWorker # rubocop:disable Scalability/IdempotentWorker
end
end
- valid_recipients(recipients).each do |recipient|
+ EmailsOnPushService.valid_recipients(recipients).each do |recipient|
send_email(
recipient,
project_id,
@@ -92,10 +92,4 @@ class EmailsOnPushWorker # rubocop:disable Scalability/IdempotentWorker
email.header[:skip_premailer] = true if skip_premailer
email.deliver_now
end
-
- def valid_recipients(recipients)
- recipients.split.select do |recipient|
- recipient.include?('@')
- end.uniq(&:downcase)
- end
end
diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb
index 5db9f0b67e0..50fdd046491 100644
--- a/app/workers/expire_build_artifacts_worker.rb
+++ b/app/workers/expire_build_artifacts_worker.rb
@@ -10,7 +10,7 @@ class ExpireBuildArtifactsWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :continuous_integration
def perform
- service = Ci::DestroyExpiredJobArtifactsService.new
+ service = Ci::JobArtifacts::DestroyAllExpiredService.new
artifacts_count = service.execute
log_extra_metadata_on_done(:destroyed_job_artifacts_count, artifacts_count)
end
diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb
index 77b0edfd7de..48bb1160ae8 100644
--- a/app/workers/expire_job_cache_worker.rb
+++ b/app/workers/expire_job_cache_worker.rb
@@ -10,7 +10,7 @@ class ExpireJobCacheWorker
# rubocop: disable CodeReuse/ActiveRecord
def perform(job_id)
- job = CommitStatus.joins(:pipeline, :project).find_by(id: job_id)
+ job = CommitStatus.eager_load_pipeline.find_by(id: job_id)
return unless job
pipeline = job.pipeline
diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb
index 02039e40e15..cbea46cdccd 100644
--- a/app/workers/expire_pipeline_cache_worker.rb
+++ b/app/workers/expire_pipeline_cache_worker.rb
@@ -12,7 +12,7 @@ class ExpirePipelineCacheWorker
# rubocop: disable CodeReuse/ActiveRecord
def perform(pipeline_id)
- pipeline = Ci::Pipeline.find_by(id: pipeline_id)
+ pipeline = Ci::Pipeline.eager_load_project.find_by(id: pipeline_id)
return unless pipeline
Ci::ExpirePipelineCacheService.new.execute(pipeline)
diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb
index 687fb1cd02a..c5bdb3e0970 100644
--- a/app/workers/irker_worker.rb
+++ b/app/workers/irker_worker.rb
@@ -147,8 +147,7 @@ class IrkerWorker # rubocop:disable Scalability/IdempotentWorker
def files_count(commit)
diff_size = commit.raw_deltas.size
- files = "#{diff_size} file".pluralize(diff_size)
- files
+ "#{diff_size} file".pluralize(diff_size)
end
def colorize_sha(sha)
diff --git a/app/workers/merge_requests/assignees_change_worker.rb b/app/workers/merge_requests/assignees_change_worker.rb
new file mode 100644
index 00000000000..9865563e357
--- /dev/null
+++ b/app/workers/merge_requests/assignees_change_worker.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class MergeRequests::AssigneesChangeWorker
+ include ApplicationWorker
+
+ feature_category :source_code_management
+ urgency :high
+ deduplicate :until_executed
+ idempotent!
+
+ def perform(merge_request_id, user_id, old_assignee_ids)
+ merge_request = MergeRequest.find(merge_request_id)
+ current_user = User.find(user_id)
+
+ # if a user was added and then removed, or removed and then added
+ # while waiting for this job to run, assume that nothing happened.
+ users = User.id_in(old_assignee_ids - merge_request.assignee_ids)
+
+ return if users.blank?
+
+ ::MergeRequests::HandleAssigneesChangeService
+ .new(merge_request.target_project, current_user)
+ .execute(merge_request, users, execute_hooks: true)
+ rescue ActiveRecord::RecordNotFound
+ end
+end
diff --git a/app/workers/merge_requests/create_pipeline_worker.rb b/app/workers/merge_requests/create_pipeline_worker.rb
new file mode 100644
index 00000000000..244ba1af300
--- /dev/null
+++ b/app/workers/merge_requests/create_pipeline_worker.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class CreatePipelineWorker
+ include ApplicationWorker
+ include PipelineQueue
+
+ queue_namespace :pipeline_creation
+ feature_category :continuous_integration
+ urgency :high
+ worker_resource_boundary :cpu
+ idempotent!
+
+ def perform(project_id, user_id, merge_request_id)
+ project = Project.find_by_id(project_id)
+ return unless project
+
+ user = User.find_by_id(user_id)
+ return unless user
+
+ merge_request = MergeRequest.find_by_id(merge_request_id)
+ return unless merge_request
+
+ MergeRequests::CreatePipelineService.new(project, user).execute(merge_request)
+ merge_request.update_head_pipeline
+ end
+ end
+end
diff --git a/app/workers/merge_requests/handle_assignees_change_worker.rb b/app/workers/merge_requests/handle_assignees_change_worker.rb
new file mode 100644
index 00000000000..e79d8293bae
--- /dev/null
+++ b/app/workers/merge_requests/handle_assignees_change_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class MergeRequests::HandleAssigneesChangeWorker
+ include ApplicationWorker
+
+ feature_category :code_review
+ urgency :high
+ deduplicate :until_executed
+ idempotent!
+
+ def perform(merge_request_id, user_id, old_assignee_ids, options = {})
+ merge_request = MergeRequest.find(merge_request_id)
+ user = User.find(user_id)
+
+ old_assignees = User.id_in(old_assignee_ids)
+
+ ::MergeRequests::HandleAssigneesChangeService
+ .new(merge_request.target_project, user)
+ .execute(merge_request, old_assignees, options)
+ rescue ActiveRecord::RecordNotFound
+ end
+end
diff --git a/app/workers/merge_requests/resolve_todos_worker.rb b/app/workers/merge_requests/resolve_todos_worker.rb
new file mode 100644
index 00000000000..2a5f742f809
--- /dev/null
+++ b/app/workers/merge_requests/resolve_todos_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class MergeRequests::ResolveTodosWorker
+ include ApplicationWorker
+
+ feature_category :code_review
+ urgency :high
+ deduplicate :until_executed
+ idempotent!
+
+ def perform(merge_request_id, user_id)
+ merge_request = MergeRequest.find(merge_request_id)
+ user = User.find(user_id)
+
+ MergeRequests::ResolveTodosService.new(merge_request, user).execute
+ rescue ActiveRecord::RecordNotFound
+ end
+end
diff --git a/app/workers/namespaces/in_product_marketing_emails_worker.rb b/app/workers/namespaces/in_product_marketing_emails_worker.rb
index f8fa393264a..3070afed3d6 100644
--- a/app/workers/namespaces/in_product_marketing_emails_worker.rb
+++ b/app/workers/namespaces/in_product_marketing_emails_worker.rb
@@ -9,10 +9,27 @@ module Namespaces
urgency :low
def perform
- return unless Gitlab::CurrentSettings.in_product_marketing_emails_enabled
- return unless Gitlab::Experimentation.active?(:in_product_marketing_emails)
+ return if paid_self_managed_instance?
+ return if setting_disabled?
+ return if experiment_inactive?
Namespaces::InProductMarketingEmailsService.send_for_all_tracks_and_intervals
end
+
+ private
+
+ def paid_self_managed_instance?
+ false
+ end
+
+ def setting_disabled?
+ !Gitlab::CurrentSettings.in_product_marketing_emails_enabled
+ end
+
+ def experiment_inactive?
+ Gitlab.com? && !Gitlab::Experimentation.active?(:in_product_marketing_emails)
+ end
end
end
+
+Namespaces::InProductMarketingEmailsWorker.prepend_if_ee('EE::Namespaces::InProductMarketingEmailsWorker')
diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb
index be9a168c3f6..c08f4b4cd75 100644
--- a/app/workers/new_issue_worker.rb
+++ b/app/workers/new_issue_worker.rb
@@ -14,7 +14,12 @@ class NewIssueWorker # rubocop:disable Scalability/IdempotentWorker
::EventCreateService.new.open_issue(issuable, user)
::NotificationService.new.new_issue(issuable, user)
+
issuable.create_cross_references!(user)
+
+ Issues::AfterCreateService
+ .new(issuable.project, user)
+ .execute(issuable)
end
def issuable_class
diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb
index f489e428e8d..666bacb0188 100644
--- a/app/workers/object_storage/migrate_uploads_worker.rb
+++ b/app/workers/object_storage/migrate_uploads_worker.rb
@@ -16,7 +16,8 @@ module ObjectStorage
attr_accessor :error
def initialize(upload, error = nil)
- @upload, @error = upload, error
+ @upload = upload
+ @error = error
end
def success?
diff --git a/app/workers/packages/go/sync_packages_worker.rb b/app/workers/packages/go/sync_packages_worker.rb
new file mode 100644
index 00000000000..e41f27f2252
--- /dev/null
+++ b/app/workers/packages/go/sync_packages_worker.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Packages
+ module Go
+ class SyncPackagesWorker
+ include ApplicationWorker
+ include Gitlab::Golang
+
+ queue_namespace :package_repositories
+ feature_category :package_registry
+
+ deduplicate :until_executing
+ idempotent!
+
+ def perform(project_id, ref_name, path)
+ project = Project.find_by_id(project_id)
+ return unless project && project.repository.find_tag(ref_name)
+
+ module_name = go_path(project, path)
+ mod = Packages::Go::ModuleFinder.new(project, module_name).execute
+ return unless mod
+
+ ver = Packages::Go::VersionFinder.new(mod).find(ref_name)
+ return unless ver
+
+ Packages::Go::CreatePackageService.new(project, nil, version: ver).execute
+
+ rescue ::Packages::Go::CreatePackageService::GoZipSizeError => ex
+ Gitlab::ErrorTracking.log_exception(ex)
+ end
+ end
+ end
+end
diff --git a/app/workers/packages/rubygems/extraction_worker.rb b/app/workers/packages/rubygems/extraction_worker.rb
new file mode 100644
index 00000000000..1e5cd0b54ce
--- /dev/null
+++ b/app/workers/packages/rubygems/extraction_worker.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Packages
+ module Rubygems
+ class ExtractionWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ queue_namespace :package_repositories
+ feature_category :package_registry
+ deduplicate :until_executing
+
+ idempotent!
+
+ def perform(package_file_id)
+ package_file = ::Packages::PackageFile.find_by_id(package_file_id)
+
+ return unless package_file
+
+ ::Packages::Rubygems::ProcessGemService.new(package_file).execute
+
+ rescue ::Packages::Rubygems::ProcessGemService::ExtractionError => e
+ Gitlab::ErrorTracking.log_exception(e, project_id: package_file.project_id)
+ package_file.package.destroy!
+ end
+ end
+ end
+end
diff --git a/app/workers/pages_update_configuration_worker.rb b/app/workers/pages_update_configuration_worker.rb
index 6e82e2099c7..ca5016dc782 100644
--- a/app/workers/pages_update_configuration_worker.rb
+++ b/app/workers/pages_update_configuration_worker.rb
@@ -7,7 +7,7 @@ class PagesUpdateConfigurationWorker
feature_category :pages
def self.perform_async(*args)
- return unless Feature.enabled?(:pages_update_legacy_storage, default_enabled: true)
+ return unless ::Settings.pages.local_store.enabled
super(*args)
end
diff --git a/app/workers/projects/post_creation_worker.rb b/app/workers/projects/post_creation_worker.rb
new file mode 100644
index 00000000000..2ca62e582b6
--- /dev/null
+++ b/app/workers/projects/post_creation_worker.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Projects
+ class PostCreationWorker
+ include ApplicationWorker
+
+ feature_category :source_code_management
+ idempotent!
+
+ def perform(project_id)
+ project = Project.find_by_id(project_id)
+
+ return unless project
+
+ create_prometheus_service(project)
+ end
+
+ private
+
+ def create_prometheus_service(project)
+ service = project.find_or_initialize_service(::PrometheusService.to_param)
+
+ # If the service has already been inserted in the database, that
+ # means it came from a template, and there's nothing more to do.
+ return if service.persisted?
+
+ return unless service.prometheus_available?
+
+ service.save!
+ rescue ActiveRecord::RecordInvalid => e
+ Gitlab::ErrorTracking.track_exception(e, extra: { project_id: project.id })
+ end
+ end
+end
diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb
index 35844fdf297..fc2ec047e1c 100644
--- a/app/workers/remove_expired_members_worker.rb
+++ b/app/workers/remove_expired_members_worker.rb
@@ -2,20 +2,29 @@
class RemoveExpiredMembersWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+ include CronjobQueue
feature_category :authentication_and_authorization
worker_resource_boundary :cpu
# rubocop: disable CodeReuse/ActiveRecord
def perform
- Member.expired.preload(:user).find_each do |member|
- Members::DestroyService.new.execute(member, skip_authorization: true)
+ Member.expired.preload(:user, :source).find_each do |member|
+ context = {
+ user: member.user,
+ # The ApplicationContext will reject type-mismatches. So a GroupMemeber will only populate `namespace`.
+ # while a `ProjectMember` will populate `project
+ project: member.source,
+ namespace: member.source
+ }
+ with_context(context) do
+ Members::DestroyService.new.execute(member, skip_authorization: true)
- expired_user = member.user
+ expired_user = member.user
- if expired_user.project_bot?
- Users::DestroyService.new(nil).execute(expired_user, skip_authorization: true)
+ if expired_user.project_bot?
+ Users::DestroyService.new(nil).execute(expired_user, skip_authorization: true)
+ end
end
rescue => ex
logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}")
diff --git a/app/workers/ssh_keys/expired_notification_worker.rb b/app/workers/ssh_keys/expired_notification_worker.rb
new file mode 100644
index 00000000000..ab6d1998773
--- /dev/null
+++ b/app/workers/ssh_keys/expired_notification_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module SshKeys
+ class ExpiredNotificationWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ feature_category :compliance_management
+ idempotent!
+
+ def perform
+ return unless ::Feature.enabled?(:ssh_key_expiration_email_notification, default_enabled: :yaml)
+
+ User.with_ssh_key_expired_today.find_each do |user|
+ with_context(user: user) do
+ Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about expired ssh key(s)"
+
+ keys = user.expired_today_and_unnotified_keys
+
+ Keys::ExpiryNotificationService.new(user, { keys: keys, expiring_soon: false }).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/ssh_keys/expiring_soon_notification_worker.rb b/app/workers/ssh_keys/expiring_soon_notification_worker.rb
new file mode 100644
index 00000000000..3214cd7a242
--- /dev/null
+++ b/app/workers/ssh_keys/expiring_soon_notification_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module SshKeys
+ class ExpiringSoonNotificationWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ feature_category :compliance_management
+ idempotent!
+
+ def perform
+ return unless ::Feature.enabled?(:ssh_key_expiration_email_notification, default_enabled: :yaml)
+
+ User.with_ssh_key_expiring_soon.find_each do |user|
+ with_context(user: user) do
+ Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about expiring soon ssh key(s)"
+
+ keys = user.expiring_soon_and_unnotified_keys
+
+ Keys::ExpiryNotificationService.new(user, { keys: keys, expiring_soon: true }).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/todos_destroyer/destroyed_issuable_worker.rb b/app/workers/todos_destroyer/destroyed_issuable_worker.rb
new file mode 100644
index 00000000000..6ca1959ff34
--- /dev/null
+++ b/app/workers/todos_destroyer/destroyed_issuable_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module TodosDestroyer
+ class DestroyedIssuableWorker
+ include ApplicationWorker
+ include TodosDestroyerQueue
+
+ idempotent!
+
+ def perform(target_id, target_type)
+ ::Todos::Destroy::DestroyedIssuableService.new(target_id, target_type).execute
+ end
+ end
+end
diff --git a/app/workers/update_highest_role_worker.rb b/app/workers/update_highest_role_worker.rb
index 1e2c974b6e5..952f1e511ea 100644
--- a/app/workers/update_highest_role_worker.rb
+++ b/app/workers/update_highest_role_worker.rb
@@ -3,7 +3,7 @@
class UpdateHighestRoleWorker
include ApplicationWorker
- feature_category :authentication_and_authorization
+ feature_category :utilization
urgency :high
weight 2